rusty_commit_saver/
config.rs

1use log::{error, info};
2
3use std::{
4    fs,
5    path::{Path, PathBuf},
6};
7
8use clap::Parser;
9use configparser::ini::Ini;
10use dirs::home_dir;
11use once_cell::sync::OnceCell;
12
13/// Parses INI file content into a configuration object without file I/O.
14///
15/// This is a pure function that takes raw INI text and parses it into an `Ini` struct.
16/// It's useful for testing configuration parsing logic without reading from disk.
17///
18/// # Arguments
19///
20/// * `content` - The raw INI file content as a string
21///
22/// # Returns
23///
24/// - `Ok(Ini)` - Successfully parsed configuration
25/// - `Err(String)` - Parsing failed with error description
26///
27/// # INI Format
28///
29/// The INI format supported:
30/// ```text
31/// [section_name]
32/// key1 = value1
33/// key2 = value2
34///
35/// [another_section]
36/// key3 = value3
37/// ```
38///
39/// # Examples
40///
41/// ```ignore
42/// use rusty_commit_saver::config::parse_ini_content;
43///
44/// let ini_content = r#"
45/// [obsidian]
46/// root_path_dir = ~/Documents/Obsidian
47/// commit_path = Diaries/Commits
48///
49/// [templates]
50/// commit_date_path = %Y/%m-%B/%F.md
51/// commit_datetime = %Y-%m-%d %H:%M:%S
52/// "#;
53///
54/// let config = parse_ini_content(ini_content).unwrap();
55///
56/// // Access parsed values
57/// assert_eq!(
58///     config.get("obsidian", "root_path_dir"),
59///     Some("~/Documents/Obsidian".to_string())
60/// );
61/// assert_eq!(
62///     config.get("templates", "commit_date_path"),
63///     Some("%Y/%m-%B/%F.md".to_string())
64/// );
65/// ```
66///
67/// # Errors
68///
69/// Returns an error if:
70/// - INI syntax is invalid (malformed sections or key-value pairs)
71/// - The content cannot be parsed as valid UTF-8
72///
73/// # Testing
74///
75/// This function is particularly useful for unit testing without needing
76/// to create temporary files:
77///
78/// ```ignore
79/// use rusty_commit_saver::config::parse_ini_content;
80///
81/// fn test_config_parsing() {
82///     let test_config = "[section]\nkey=value\n";
83///     let result = parse_ini_content(test_config);
84///     assert!(result.is_ok());
85/// }
86/// ```
87pub fn parse_ini_content(content: &str) -> Result<Ini, String> {
88    let mut config = Ini::new();
89    config
90        .read(content.to_string())
91        .map_err(|e| format!("Failed to parse INI: {e:?}"))?;
92    Ok(config)
93}
94
95/// Thread-safe global configuration container for Rusty Commit Saver.
96///
97/// This struct holds all runtime configuration loaded from the INI file,
98/// using `OnceCell` for lazy initialization and thread safety. Configuration
99/// values are set once during initialization and remain immutable thereafter.
100///
101/// # Usage Pattern
102///
103/// ```ignore
104/// use rusty_commit_saver::config::GlobalVars;
105///
106/// // 1. Create instance
107/// let global_vars = GlobalVars::new();
108///
109/// // 2. Load configuration from INI file
110/// global_vars.set_all();
111///
112/// // 3. Access configuration values
113/// let obsidian_root = global_vars.get_obsidian_root_path_dir();
114/// let commit_path = global_vars.get_obsidian_commit_path();
115/// ```
116///
117/// # See Also
118///
119/// - [`GlobalVars::new()`] - Create new instance
120/// - [`GlobalVars::set_all()`] - Initialize from INI file
121/// - [`parse_ini_content()`] - Parse INI content
122#[derive(Debug, Default)]
123pub struct GlobalVars {
124    /// The parsed INI configuration file.
125    ///
126    /// Stores the complete parsed configuration from the INI file.
127    /// Initialized once by [`set_all()`](Self::set_all).
128    ///
129    /// # Thread Safety
130    ///
131    /// `OnceCell` ensures this is set exactly once and can be safely
132    /// accessed from multiple threads.
133    pub config: OnceCell<Ini>,
134
135    /// Root directory of the Obsidian vault.
136    ///
137    /// The base directory where all Obsidian files are stored.
138    /// All diary entries are created under this directory.
139    ///
140    /// # Examples
141    ///
142    /// - `/home/user/Documents/Obsidian`
143    /// - `C:\Users\username\Documents\Obsidian` (Windows)
144    ///
145    /// # Configuration
146    ///
147    /// Loaded from INI file:
148    /// ```text
149    /// [obsidian]
150    /// root_path_dir = ~/Documents/Obsidian
151    /// ```
152    obsidian_root_path_dir: OnceCell<PathBuf>,
153
154    /// Subdirectory path for commit diary entries.
155    ///
156    /// Relative path under [`obsidian_root_path_dir`](Self::obsidian_root_path_dir)
157    /// where commit entries are organized.
158    ///
159    /// # Examples
160    ///
161    /// - `Diaries/Commits`
162    /// - `Journal/Git`
163    ///
164    /// # Full Path Construction
165    ///
166    /// Combined with root and date template:
167    /// ```text
168    /// {root_path_dir}/{commit_path}/{date_template}
169    /// /home/user/Obsidian/Diaries/Commits/2025/01-January/2025-01-14.md
170    /// ```
171    ///
172    /// # Configuration
173    ///
174    /// Loaded from INI file:
175    /// ```text
176    /// [obsidian]
177    /// commit_path = Diaries/Commits
178    /// ```
179    obsidian_commit_path: OnceCell<PathBuf>,
180
181    /// Chrono format string for date-based file paths.
182    ///
183    /// Controls the directory structure and filename for diary entries.
184    /// Uses Chrono format specifiers to create date-organized paths.
185    ///
186    /// # Format Specifiers
187    ///
188    /// - `%Y` - Year (e.g., `2025`)
189    /// - `%m` - Month number (e.g., `01`)
190    /// - `%B` - Full month name (e.g., `January`)
191    /// - `%F` - ISO 8601 date (e.g., `2025-01-14`)
192    /// - `%d` - Day of month (e.g., `14`)
193    ///
194    /// # Examples
195    ///
196    /// ```text
197    /// Format: %Y/%m-%B/%F.md
198    /// Result: 2025/01-January/2025-01-14.md
199    ///
200    /// Format: %Y/week-%W/%F.md
201    /// Result: 2025/week-02/2025-01-14.md
202    /// ```
203    ///
204    /// # Configuration
205    ///
206    /// Loaded from INI file:
207    /// ```text
208    /// [templates]
209    /// commit_date_path = %Y/%m-%B/%F.md
210    /// ```
211    template_commit_date_path: OnceCell<String>,
212
213    /// Chrono format string for datetime display in diary entries.
214    ///
215    /// Controls how commit timestamps appear in the diary table's TIME column.
216    ///
217    /// # Format Specifiers
218    ///
219    /// - `%Y` - Year (e.g., `2025`)
220    /// - `%m` - Month (e.g., `01`)
221    /// - `%d` - Day (e.g., `14`)
222    /// - `%H` - Hour, 24-hour (e.g., `14`)
223    /// - `%M` - Minute (e.g., `30`)
224    /// - `%S` - Second (e.g., `45`)
225    /// - `%T` - Time in HH:MM:SS format
226    ///
227    /// # Examples
228    ///
229    /// ```text
230    /// Format: %Y-%m-%d %H:%M:%S
231    /// Result: 2025-01-14 14:30:45
232    ///
233    /// Format: %H:%M:%S
234    /// Result: 14:30:45
235    /// ```
236    ///
237    /// # Configuration
238    ///
239    /// Loaded from INI file:
240    /// ```text
241    /// [templates]
242    /// commit_datetime = %Y-%m-%d %H:%M:%S
243    /// ```
244    template_commit_datetime: OnceCell<String>,
245}
246
247impl GlobalVars {
248    /// Creates a new uninitialized `GlobalVars` instance.
249    ///
250    /// This constructor initializes all fields as empty `OnceCell` values.
251    /// Use [`set_all()`](Self::set_all) to load configuration from the INI file.
252    ///
253    /// # Thread Safety
254    ///
255    /// `GlobalVars` uses `OnceCell` for thread-safe, lazy initialization.
256    /// Configuration values are set once and cannot be changed afterward.
257    ///
258    /// # Returns
259    ///
260    /// A new `GlobalVars` instance with all fields uninitialized
261    ///
262    /// # Fields
263    ///
264    /// - `config` - The parsed INI configuration file
265    /// - `obsidian_root_path_dir` - Root directory of Obsidian vault
266    /// - `obsidian_commit_path` - Subdirectory path for commit entries
267    /// - `template_commit_date_path` - Chrono format for date-based directory structure
268    /// - `template_commit_datetime` - Chrono format for datetime strings
269    ///
270    /// # Examples
271    ///
272    /// ```ignore
273    /// use rusty_commit_saver::config::GlobalVars;
274    ///
275    /// // Create new instance
276    /// let global_vars = GlobalVars::new();
277    ///
278    /// // Now call set_all() to initialize from config file
279    /// // global_vars.set_all();
280    /// ```
281    #[must_use]
282    pub fn new() -> Self {
283        info!("[GlobalVars::new()] Creating new GlobalVars with OnceCell default values.");
284        GlobalVars {
285            config: OnceCell::new(),
286
287            obsidian_root_path_dir: OnceCell::new(),
288            obsidian_commit_path: OnceCell::new(),
289
290            template_commit_date_path: OnceCell::new(),
291            template_commit_datetime: OnceCell::new(),
292        }
293    }
294
295    /// Loads and initializes all configuration from the INI file.
296    ///
297    /// This is the main entry point for configuration setup. It:
298    /// 1. Reads the INI configuration file from disk (or CLI argument)
299    /// 2. Parses it into the `config` field
300    /// 3. Extracts and initializes all Obsidian and template variables
301    ///
302    /// Configuration is loaded from (in order of preference):
303    /// - `--config-ini <PATH>` CLI argument
304    /// - Default: `~/.config/rusty-commit-saver/rusty-commit-saver.ini`
305    ///
306    /// # Panics
307    ///
308    /// Panics if:
309    /// - Configuration file doesn't exist
310    /// - Configuration file cannot be read
311    /// - Configuration file has invalid INI format
312    /// - Required sections or keys are missing
313    /// - Section count is not exactly 2 (obsidian + templates)
314    ///
315    /// # Returns
316    ///
317    /// Returns `self` for method chaining
318    ///
319    /// # Required INI Structure
320    ///
321    /// ```text
322    /// [obsidian]
323    /// root_path_dir = ~/Documents/Obsidian
324    /// commit_path = Diaries/Commits
325    ///
326    /// [templates]
327    /// commit_date_path = %Y/%m-%B/%F.md
328    /// commit_datetime = %Y-%m-%d %H:%M:%S
329    /// ```
330    ///
331    /// # Examples
332    ///
333    /// ```ignore
334    /// use rusty_commit_saver::config::GlobalVars;
335    ///
336    /// let global_vars = GlobalVars::new();
337    /// global_vars.set_all(); // Reads from default or CLI config
338    ///
339    /// // Now all getters will return values
340    /// let root_path = global_vars.get_obsidian_root_path_dir();
341    /// let commit_path = global_vars.get_obsidian_commit_path();
342    /// ```
343    pub fn set_all(&self) -> &Self {
344        info!("[GlobalVars::set_all()] Setting all variables for GlobalVars");
345        let config = get_ini_file();
346
347        info!("[GlobalVars::set_all()]: Setting Config Ini file.");
348        self.config
349            .set(config)
350            .expect("Coulnd't set config in GlobalVars");
351
352        info!("[GlobalVars::set_all()]: Setting Obsidian variables from file.");
353        self.set_obsidian_vars();
354
355        self
356    }
357
358    /// Returns the root directory of the Obsidian vault.
359    ///
360    /// This is the base directory where all Obsidian vault files are stored.
361    /// All diary entries are created under this directory according to the
362    /// configured subdirectory structure.
363    ///
364    /// # Panics
365    ///
366    /// Panics if called before [`set_all()`](Self::set_all) has been invoked
367    ///
368    /// # Returns
369    ///
370    /// A `PathBuf` representing the Obsidian vault root directory
371    ///
372    /// # Examples
373    ///
374    /// ```ignore
375    /// use rusty_commit_saver::config::GlobalVars;
376    ///
377    /// let global_vars = GlobalVars::new();
378    /// global_vars.set_all();
379    ///
380    /// let root = global_vars.get_obsidian_root_path_dir();
381    /// println!("Obsidian vault root: {}", root.display());
382    /// // Output: Obsidian vault root: /home/user/Documents/Obsidian
383    /// ```
384    ///
385    /// # Configuration Source
386    ///
387    /// Read from INI file:
388    /// ```text
389    /// [obsidian]
390    /// root_path_dir = ~/Documents/Obsidian
391    /// ```
392    pub fn get_obsidian_root_path_dir(&self) -> PathBuf {
393        info!("[GlobalVars::get_obsidian_root_path_dir()]: Getting obsidian_root_path_dir.");
394        self.obsidian_root_path_dir
395            .get()
396            .expect("Could not get obsidian_root_path_dir")
397            .clone()
398    }
399
400    /// Returns the subdirectory path where commits are stored.
401    ///
402    /// This is a relative path under [`get_obsidian_root_path_dir()`](Self::get_obsidian_root_path_dir)
403    /// where commit diary entries will be organized. The full path is constructed by
404    /// combining this with the Obsidian root and the date-based directory structure.
405    ///
406    /// # Panics
407    ///
408    /// Panics if called before [`set_all()`](Self::set_all) has been invoked
409    ///
410    /// # Returns
411    ///
412    /// A `PathBuf` representing the commits subdirectory (relative path)
413    ///
414    /// # Examples
415    ///
416    /// ```ignore
417    /// use rusty_commit_saver::config::GlobalVars;
418    ///
419    /// let global_vars = GlobalVars::new();
420    /// global_vars.set_all();
421    ///
422    /// let commit_path = global_vars.get_obsidian_commit_path();
423    /// println!("Commit subdirectory: {}", commit_path.display());
424    /// // Output: Commit subdirectory: Diaries/Commits
425    ///
426    /// // Full path would be constructed as:
427    /// // /home/user/Documents/Obsidian/Diaries/Commits/2025/01-January/2025-01-14.md
428    /// ```
429    ///
430    /// # Configuration Source
431    ///
432    /// Read from INI file:
433    /// ```text
434    /// [obsidian]
435    /// commit_path = Diaries/Commits
436    /// ```
437    pub fn get_obsidian_commit_path(&self) -> PathBuf {
438        info!("[GlobalVars::get_obsidian_commit_path()]: Getting obsidian_commit_path.");
439        self.obsidian_commit_path
440            .get()
441            .expect("Could not get obsidian_commit_path")
442            .clone()
443    }
444
445    /// Returns the Chrono format string for diary file date hierarchies.
446    ///
447    /// This format string is used to create the directory structure and filename
448    /// for diary entries based on the commit timestamp. It controls how commits
449    /// are organized by date.
450    ///
451    /// # Chrono Format Specifiers
452    ///
453    /// - `%Y` - Full year (e.g., `2025`)
454    /// - `%m` - Month as zero-padded number (e.g., `01`)
455    /// - `%B` - Full month name (e.g., `January`)
456    /// - `%b` - Abbreviated month (e.g., `Jan`)
457    /// - `%d` - Day of month, zero-padded (e.g., `14`)
458    /// - `%F` - ISO 8601 date (equivalent to `%Y-%m-%d`, e.g., `2025-01-14`)
459    /// - `%H` - Hour in 24-hour format (e.g., `14`)
460    /// - `%M` - Minute (e.g., `30`)
461    /// - `%S` - Second (e.g., `45`)
462    ///
463    /// # Panics
464    ///
465    /// Panics if called before [`set_all()`](Self::set_all) has been invoked
466    ///
467    /// # Returns
468    ///
469    /// A `String` containing the Chrono format specifiers
470    ///
471    /// # Examples
472    ///
473    /// ```ignore
474    /// use rusty_commit_saver::config::GlobalVars;
475    ///
476    /// let global_vars = GlobalVars::new();
477    /// global_vars.set_all();
478    ///
479    /// let date_template = global_vars.get_template_commit_date_path();
480    /// println!("Date format: {}", date_template);
481    /// // Output: Date format: %Y/%m-%B/%F.md
482    ///
483    /// // This creates paths like:
484    /// // /home/user/Obsidian/Diaries/Commits/2025/01-January/2025-01-14.md
485    /// ```
486    ///
487    /// # Configuration Source
488    ///
489    /// Read from INI file:
490    /// ```text
491    /// [templates]
492    /// commit_date_path = %Y/%m-%B/%F.md
493    /// ```
494    pub fn get_template_commit_date_path(&self) -> String {
495        info!("[GlobalVars::get_template_commit_date_path()]: Getting template_commit_date_path.");
496        self.template_commit_date_path
497            .get()
498            .expect("Could not get template_commit_date_path")
499            .clone()
500    }
501
502    /// Returns the Chrono format string for commit timestamps in diary entries.
503    ///
504    /// This format string is used to display the commit time in the diary table.
505    /// It controls how timestamps appear in the commit entry rows.
506    ///
507    /// # Chrono Format Specifiers
508    ///
509    /// - `%Y` - Full year (e.g., `2025`)
510    /// - `%m` - Month as zero-padded number (e.g., `01`)
511    /// - `%B` - Full month name (e.g., `January`)
512    /// - `%d` - Day of month, zero-padded (e.g., `14`)
513    /// - `%H` - Hour in 24-hour format (e.g., `14`)
514    /// - `%M` - Minute, zero-padded (e.g., `30`)
515    /// - `%S` - Second, zero-padded (e.g., `45`)
516    /// - `%T` - Time in HH:MM:SS format (equivalent to `%H:%M:%S`)
517    ///
518    /// # Panics
519    ///
520    /// Panics if called before [`set_all()`](Self::set_all) has been invoked
521    ///
522    /// # Returns
523    ///
524    /// A `String` containing the Chrono format specifiers for datetime
525    ///
526    /// # Examples
527    ///
528    /// ```ignore
529    /// use rusty_commit_saver::config::GlobalVars;
530    ///
531    /// let global_vars = GlobalVars::new();
532    /// global_vars.set_all();
533    ///
534    /// let datetime_template = global_vars.get_template_commit_datetime();
535    /// println!("Datetime format: {}", datetime_template);
536    /// // Output: Datetime format: %Y-%m-%d %H:%M:%S
537    ///
538    /// // This renders timestamps like:
539    /// // 2025-01-14 14:30:45
540    /// ```
541    ///
542    /// # Diary Table Usage
543    ///
544    /// In the diary table, this format appears in the TIME column:
545    /// ```text
546    /// | FOLDER | TIME | COMMIT MESSAGE | REPOSITORY URL | BRANCH | COMMIT HASH |
547    /// |--------|------|----------------|----------------|--------|-------------|
548    /// | /work/project | 14:30:45 | feat: add feature | https://github.com/... | main | abc123... |
549    /// ```
550    ///
551    /// # Configuration Source
552    ///
553    /// Read from INI file:
554    /// ```text
555    /// [templates]
556    /// commit_datetime = %Y-%m-%d %H:%M:%S
557    /// ```
558    pub fn get_template_commit_datetime(&self) -> String {
559        info!("[GlobalVars::get_template_commit_datetime()]: Getting template_commit_datetime.");
560        self.template_commit_datetime
561            .get()
562            .expect("Could not get template_commit_datetime")
563            .clone()
564    }
565
566    /// Retrieves a clone of the parsed INI configuration.
567    ///
568    /// This is a private helper method that returns a copy of the configuration
569    /// object. Used internally by other helper methods to access sections and keys.
570    ///
571    /// # Panics
572    ///
573    /// Panics if called before [`set_all()`](Self::set_all) has initialized the config.
574    ///
575    /// # Returns
576    ///
577    /// A cloned `Ini` configuration object
578    fn get_config(&self) -> Ini {
579        info!("[GlobalVars::get_config()] Getting config");
580        self.config
581            .get()
582            .expect("Could not get Config. Config not initialized")
583            .clone()
584    }
585
586    fn get_key_from_section_from_ini(&self, section: &str, key: &str) -> Option<String> {
587        info!(
588            "[GlobalVars::get_key_from_section_from_ini()] Getting key: {key:} from section: {section:}."
589        );
590        self.config
591            .get()
592            .expect("Retrieving the config for commit_path")
593            .get(section, key)
594    }
595
596    fn get_sections_from_config(&self) -> Vec<String> {
597        info!("[GlobalVars::get_sections_from_config()] Getting sections from config");
598        let sections = self.get_config().sections();
599        let sections_len = sections.len(); // Extract to variable
600
601        info!("[GlobalVars::get_sections_from_config()] Checking validity of number of sections.");
602        if sections_len == 2 {
603            sections
604        } else {
605            error!(
606                // LCOV_EXCL_START
607                "[GlobalVars::get_sections_from_config()] Sections Len must be 2, we have: {sections_len:?}"
608            );
609            error!(
610                "[GlobalVars::get_sections_from_config()] These are the sections found: {sections:?}"
611            ); // LCOV_EXCL_STOP
612            panic!(
613                "[GlobalVars::get_sections_from_config()] config has the wrong number of sections."
614            )
615        }
616    }
617
618    /// Loads all configuration variables from the "obsidian" and "templates" sections.
619    ///
620    /// This method iterates through all sections returned by `get_sections_from_config`.
621    /// For each recognized section, it initializes the corresponding runtime variables
622    /// by calling their dedicated setters:
623    ///
624    /// - For the **"obsidian"** section: calls `set_obsidian_root_path_dir` and `set_obsidian_commit_path`.
625    /// - For the **"templates"** section: calls `set_templates_commit_date_path` and `set_templates_datetime`.
626    ///
627    /// # Panics
628    ///
629    /// Panics if the INI file contains a section other than "obsidian" or "templates", as only these two sections are supported.
630    ///
631    /// # Logging
632    ///
633    /// - Logs an info message when applying each section.
634    /// - Logs an error right before panicking on unsupported sections.
635    ///
636    /// # Examples
637    ///
638    /// ```ignore
639    /// use rusty_commit_saver::config::GlobalVars;
640    /// let mut config = configparser::ini::Ini::new();
641    /// config.set("obsidian", "root_path_dir", Some("~/Obsidian".to_string()));
642    /// config.set("obsidian", "commit_path", Some("Diary/Commits".to_string()));
643    /// config.set("templates", "commit_date_path", Some("%Y-%m-%d.md".to_string()));
644    /// config.set("templates", "commit_datetime", Some("%Y-%m-%d %H:%M:%S".to_string()));
645    /// let global_vars = GlobalVars::new();
646    /// global_vars.config.set(config).unwrap();
647    /// global_vars.set_obsidian_vars();
648    /// ```
649    pub fn set_obsidian_vars(&self) {
650        for section in self.get_sections_from_config() {
651            if section == "obsidian" {
652                info!("[GlobalVars::set_obsidian_vars()] Setting 'obsidian' section variables.");
653                self.set_obsidian_root_path_dir(&section);
654                self.set_obsidian_commit_path(&section);
655            } else if section == "templates" {
656                info!("[GlobalVars::set_obsidian_vars()] Setting 'templates' section variables.");
657                self.set_templates_commit_date_path(&section);
658                self.set_templates_datetime(&section);
659            } else {
660                error!(
661                    "[GlobalVars::set_obsidian_vars()] Trying to set other sections is not supported."
662                );
663                panic!(
664                    "[GlobalVars::set_obsidian_vars()] Trying to set other sections is not supported."
665                )
666            }
667        }
668    }
669
670    /// Sets the `template_commit_datetime` field from the `[templates]` section.
671    ///
672    /// Reads the `commit_datetime` key from the INI file and stores it in the
673    /// `template_commit_datetime` `OnceCell`.
674    ///
675    /// # Arguments
676    ///
677    /// * `section` - Should be `"templates"` (validated by caller)
678    ///
679    /// # Panics
680    ///
681    /// Panics if:
682    /// - The `commit_datetime` key is missing from the INI section
683    /// - The `OnceCell` has already been set (called multiple times)
684    ///
685    /// # Expected INI Key
686    ///
687    /// ```text
688    /// [templates]
689    /// commit_datetime = %Y-%m-%d %H:%M:%S
690    /// ```
691    fn set_templates_datetime(&self, section: &str) {
692        info!("[GlobalVars::set_templates_datetime()]: Setting the templates_datetime.");
693        let key = self
694            .get_key_from_section_from_ini(section, "commit_datetime")
695            .expect("Could not get the commit_datetime from INI");
696
697        self.template_commit_datetime
698            .set(key)
699            .expect("Could not set the template_commit_datetime GlobalVars");
700    }
701
702    /// Sets the `template_commit_date_path` field from the `[templates]` section.
703    ///
704    /// Reads the `commit_date_path` key from the INI file and stores it in the
705    /// `template_commit_date_path` `OnceCell`.
706    ///
707    /// # Arguments
708    ///
709    /// * `section` - Should be `"templates"` (validated by caller)
710    ///
711    /// # Panics
712    ///
713    /// Panics if:
714    /// - The `commit_date_path` key is missing from the INI section
715    /// - The `OnceCell` has already been set (called multiple times)
716    ///
717    /// # Expected INI Key
718    ///
719    /// ```text
720    /// [templates]
721    /// commit_date_path = %Y/%m-%B/%F.md
722    /// ```
723    fn set_templates_commit_date_path(&self, section: &str) {
724        info!(
725            "[GlobalVars::set_templates_commit_date_path()]: Setting the template_commit_date_path."
726        );
727        let key = self
728            .get_key_from_section_from_ini(section, "commit_date_path")
729            .expect("Could not get the commit_date_path from INI");
730
731        self.template_commit_date_path
732            .set(key)
733            .expect("Could not set the template_commit_date_path in GlobalVars");
734    }
735
736    /// Sets the `obsidian_commit_path` field from the `[obsidian]` section.
737    ///
738    /// Reads the `commit_path` key, expands tilde (`~`) to the home directory
739    /// if present, splits the path by `/`, and constructs a `PathBuf`.
740    ///
741    /// # Arguments
742    ///
743    /// * `section` - Should be `"obsidian"` (validated by caller)
744    ///
745    /// # Tilde Expansion
746    ///
747    /// - `~/Diaries/Commits` → `/home/user/Diaries/Commits`
748    /// - `/absolute/path` → `/absolute/path` (unchanged)
749    ///
750    /// # Panics
751    ///
752    /// Panics if:
753    /// - The `commit_path` key is missing from the INI section
754    /// - Home directory cannot be determined (when `~` is used)
755    /// - The `OnceCell` has already been set
756    ///
757    /// # Expected INI Key
758    ///
759    /// ```text
760    /// [obsidian]
761    /// commit_path = ~/Documents/Obsidian/Diaries/Commits
762    /// ```
763    fn set_obsidian_commit_path(&self, section: &str) {
764        let string_path = self
765            .get_key_from_section_from_ini(section, "commit_path")
766            .expect("Could not get commit_path from config");
767
768        let fixed_home = if string_path.contains('~') {
769            info!("[GlobalVars::set_obsidian_commit_path()]: Path does contain: '~'.");
770            set_proper_home_dir(&string_path)
771        } else {
772            info!("[GlobalVars::set_obsidian_commit_path()]: Path does NOT contain: '~'.");
773            string_path
774        };
775
776        let vec_str = fixed_home.split('/');
777
778        let mut path = PathBuf::new();
779
780        info!(
781            "[GlobalVars::set_obsidian_commit_path()]: Pushing strings folders to create PathBuf."
782        );
783        for s in vec_str {
784            path.push(s);
785        }
786        self.obsidian_commit_path
787            .set(path)
788            .expect("Could not set the path for obsidian_root_path_dir");
789    }
790
791    /// Sets the `obsidian_root_path_dir` field from the `[obsidian]` section.
792    ///
793    /// Reads the `root_path_dir` key, expands tilde (`~`) to the home directory
794    /// if present, prepends `/` for absolute paths, and constructs a `PathBuf`.
795    ///
796    /// # Arguments
797    ///
798    /// * `section` - Should be `"obsidian"` (validated by caller)
799    ///
800    /// # Path Construction
801    ///
802    /// - Starts with `/` to ensure absolute path
803    /// - Expands `~` to home directory
804    /// - Splits by `/` and constructs `PathBuf`
805    ///
806    /// # Tilde Expansion Examples
807    ///
808    /// - `~/Documents/Obsidian` → `/home/user/Documents/Obsidian`
809    /// - `/absolute/path` → `/absolute/path`
810    ///
811    /// # Panics
812    ///
813    /// Panics if:
814    /// - The `root_path_dir` key is missing from the INI section
815    /// - Home directory cannot be determined (when `~` is used)
816    /// - The `OnceCell` has already been set
817    ///
818    /// # Expected INI Key
819    ///
820    /// ```text
821    /// [obsidian]
822    /// root_path_dir = ~/Documents/Obsidian
823    /// ```
824    fn set_obsidian_root_path_dir(&self, section: &str) {
825        let string_path = self
826            .get_key_from_section_from_ini(section, "root_path_dir")
827            .expect("Could not get commit_path from config");
828
829        let fixed_home = if string_path.contains('~') {
830            info!("[GlobalVars::set_obsidian_root_path_dir()]: Does contain ~");
831            set_proper_home_dir(&string_path)
832        } else {
833            info!("[GlobalVars::set_obsidian_root_path_dir()]: Does NOT contain ~");
834            string_path
835        };
836
837        let vec_str = fixed_home.split('/');
838        let mut path = PathBuf::new();
839
840        info!(
841            "[GlobalVars::set_obsidian_root_path_dir()]: Pushing '/' to PathBuf for proper path."
842        );
843        path.push("/");
844
845        info!(
846            "[GlobalVars::set_obsidian_root_path_dir()]: Pushing strings folders to create PathBuf."
847        );
848        for s in vec_str {
849            path.push(s);
850        }
851
852        self.obsidian_root_path_dir
853            .set(path)
854            .expect("Could not set the path for obsidian_root_path_dir");
855    }
856}
857
858/// Command-line argument parser for configuration file path.
859///
860/// This struct uses `clap` to parse CLI arguments and provide configuration
861/// options for the application. Currently supports specifying a custom INI
862/// configuration file path.
863///
864/// # CLI Arguments
865///
866/// - `--config-ini <PATH>` - Optional path to a custom configuration file
867///
868/// # Examples
869///
870/// ```text
871/// # Use default config (~/.config/rusty-commit-saver/rusty-commit-saver.ini)
872/// rusty-commit-saver
873///
874/// # Use custom config file
875/// rusty-commit-saver --config-ini /path/to/custom.ini
876/// ```
877///
878/// # See Also
879///
880/// - [`retrieve_config_file_path()`] - Gets the config path from CLI or default
881/// - [`get_ini_file()`] - Loads the INI file from the resolved path
882#[derive(Parser, Debug, Clone)]
883#[command(version, about, long_about = None)]
884#[command(propagate_version = true)]
885#[command(about = "Rusty Commit Saver config", long_about = None)]
886pub struct UserInput {
887    /// Path to a custom INI configuration file.
888    ///
889    /// If not provided, the default configuration file is used:
890    /// `~/.config/rusty-commit-saver/rusty-commit-saver.ini`
891    ///
892    /// # CLI Usage
893    ///
894    /// ```text
895    /// rusty-commit-saver --config-ini /custom/path/config.ini
896    /// ```
897    ///
898    /// # Examples
899    ///
900    /// Valid paths:
901    /// - `~/my-configs/commit-saver.ini`
902    /// - `/etc/rusty-commit-saver/config.ini`
903    /// - `./local-config.ini`
904    #[arg(short, long)]
905    pub config_ini: Option<String>,
906}
907
908/// Retrieves the configuration file path from CLI arguments or returns the default.
909///
910/// This function parses command-line arguments and returns the path to the INI configuration file.
911/// If no `--config-ini` argument is provided, returns the default path.
912///
913/// # Default Path
914///
915/// `~/.config/rusty-commit-saver/rusty-commit-saver.ini`
916///
917/// # Returns
918///
919/// A `String` containing the absolute path to the configuration file.
920///
921/// # CLI Usage
922///
923/// ```text
924/// // Use default config
925/// $ rusty-commit-saver
926/// // Returns: ~/.config/rusty-commit-saver/rusty-commit-saver.ini
927///
928/// // Use custom config
929/// $ rusty-commit-saver --config-ini /custom/path/config.ini
930/// // Returns: /custom/path/config.ini
931/// ```
932///
933/// # Panics
934///
935/// Panics if:
936/// - The resolved configuration file does not exist on the filesystem
937/// - The file cannot be read (permission denied, IO error)
938/// - The file path cannot be converted to a valid string
939///
940/// # Examples
941///
942/// ```ignore
943/// use rusty_commit_saver::config::retrieve_config_file_path;
944///
945/// let config_path = retrieve_config_file_path();
946/// println!("Using config: {}", config_path);
947/// ```
948///
949/// # See Also
950///
951/// - [`get_or_default_config_ini_path`] - Helper that implements the CLI parsing logic
952/// - [`get_default_ini_path`] - Constructs the default configuration path
953#[must_use]
954pub fn retrieve_config_file_path() -> String {
955    info!(
956        "[UserInput::retrieve_config_file_path()]: retrieving the string path from CLI or default"
957    );
958    let config_path = get_or_default_config_ini_path();
959
960    if Path::new(&config_path).exists() {
961        info!("[UserInput::retrieve_config_file_path()]: config_path exists {config_path:}");
962    } else {
963        error!(
964            "[UserInput::retrieve_config_file_path()]: config_path DOES NOT exists {config_path:}"
965        );
966        panic!(
967            "[UserInput::retrieve_config_file_path()]: config_path DOES NOT exists {config_path:}"
968        );
969    }
970    info!("[UserInput::retrieve_config_file_path()] retrieved config path: {config_path:}");
971    fs::read_to_string(config_path.clone())
972        .unwrap_or_else(|_| panic!("Should have been able to read the file: {config_path:}"))
973}
974
975/// Returns the config path from CLI arguments or the default path.
976///
977/// Internal helper function that parses CLI arguments using `UserInput` and
978/// returns either the provided `--config-ini` path or the default configuration
979/// file location.
980///
981/// # Returns
982///
983/// - CLI path if `--config-ini` was provided
984/// - Default path (`~/.config/rusty-commit-saver/rusty-commit-saver.ini`) otherwise
985///
986/// # Called By
987///
988/// This function is called internally by [`retrieve_config_file_path()`].
989///
990/// # See Also
991///
992/// - [`get_default_ini_path()`] - Constructs the default configuration path
993#[must_use]
994pub fn resolve_config_path(cli_arg: Option<String>, env_var: Option<String>) -> String {
995    // Check env var first
996    if let Some(env_path) = env_var {
997        info!("[resolve_config_path]: Using config from env var.");
998        return if env_path.contains('~') {
999            set_proper_home_dir(&env_path)
1000        } else {
1001            env_path
1002        };
1003    }
1004
1005    // Check CLI arg
1006    if let Some(cfg_str) = cli_arg {
1007        if cfg_str.contains('~') {
1008            info!("[resolve_config_path]: CLI path contains '~'.");
1009            set_proper_home_dir(&cfg_str)
1010        } else {
1011            info!("[resolve_config_path]: CLI path without '~'.");
1012            cfg_str
1013        }
1014    } else {
1015        info!("[resolve_config_path]: Using default path.");
1016        get_default_ini_path()
1017    }
1018}
1019
1020#[must_use]
1021#[cfg_attr(coverage_nightly, coverage(off))]
1022pub fn get_or_default_config_ini_path() -> String {
1023    get_or_default_config_ini_path_with(std::env::var("RUSTY_COMMIT_SAVER_CONFIG").ok(), || {
1024        UserInput::parse().config_ini
1025    })
1026}
1027
1028#[must_use]
1029fn get_or_default_config_ini_path_with<F>(env_var: Option<String>, cli_parser: F) -> String
1030where
1031    F: FnOnce() -> Option<String>,
1032{
1033    info!("[get_or_default_config_ini_path()]: Parsing CLI inputs.");
1034
1035    let cli_arg = if env_var.is_some() {
1036        None // Skip parsing if env var is set
1037    } else {
1038        cli_parser()
1039    };
1040
1041    let config_path = resolve_config_path(cli_arg, env_var);
1042    info!("[get_or_default_config_ini_path()]: Config path found: {config_path:}");
1043    config_path
1044}
1045
1046/// Constructs the default configuration file path.
1047///
1048/// Builds the standard XDG configuration path for the application by combining
1049/// the user's home directory with the application-specific config directory.
1050///
1051/// # Returns
1052///
1053/// A `String` with the default INI file path:
1054/// `~/.config/rusty-commit-saver/rusty-commit-saver.ini`
1055///
1056/// # Directory Structure
1057///
1058/// ```text
1059/// ~/.config/
1060///   └── rusty-commit-saver/
1061///       └── rusty-commit-saver.ini
1062/// ```
1063///
1064/// # Panics
1065///
1066/// Panics if the user's home directory cannot be determined
1067/// (via the `dirs::home_dir()` function).
1068///
1069/// # Examples
1070///
1071/// ```ignore
1072/// // Internal usage
1073/// let default_path = get_default_ini_path();
1074/// // Returns: "/home/user/.config/rusty-commit-saver/rusty-commit-saver.ini"
1075/// ```
1076///
1077/// # See Also
1078///
1079/// - [`retrieve_config_file_path()`] - Public API for getting config path
1080#[must_use]
1081pub fn get_default_ini_path() -> String {
1082    info!("[get_default_ini_path()]: Getting default ini file.");
1083    let cfg_str = "~/.config/rusty-commit-saver/rusty-commit-saver.ini".to_string();
1084    set_proper_home_dir(&cfg_str)
1085}
1086
1087/// Loads and parses the INI configuration file from disk.
1088///
1089/// Reads the configuration file (from CLI argument or default location),
1090/// parses its contents using [`parse_ini_content()`], and returns the
1091/// parsed `Ini` object.
1092///
1093/// # Returns
1094///
1095/// A parsed `Ini` configuration object
1096///
1097/// # Panics
1098///
1099/// Panics if:
1100/// - The configuration file doesn't exist at the resolved path
1101/// - The file cannot be read (permission denied, I/O error)
1102/// - The file content is not valid UTF-8
1103/// - The INI syntax is invalid (malformed sections or key-value pairs)
1104///
1105/// # File Resolution Order
1106///
1107/// 1. Check for `--config-ini <PATH>` CLI argument
1108/// 2. Fall back to `~/.config/rusty-commit-saver/rusty-commit-saver.ini`
1109///
1110/// # Expected INI Structure
1111///
1112/// ```text
1113/// [obsidian]
1114/// root_path_dir = ~/Documents/Obsidian
1115/// commit_path = Diaries/Commits
1116///
1117/// [templates]
1118/// commit_date_path = %Y/%m-%B/%F.md
1119/// commit_datetime = %Y-%m-%d %H:%M:%S
1120/// ```
1121///
1122/// # Called By
1123///
1124/// This function is called internally by [`GlobalVars::set_all()`].
1125///
1126/// # See Also
1127///
1128/// - [`retrieve_config_file_path()`] - Resolves the config file path
1129/// - [`parse_ini_content()`] - Parses INI text into `Ini` struct
1130#[must_use]
1131pub fn get_ini_file() -> Ini {
1132    info!("[get_ini_file()]: Retrieving the INI File");
1133    let content_ini = retrieve_config_file_path();
1134    let mut config = Ini::new();
1135    config
1136        .read(content_ini)
1137        .expect("Could not read the INI file!");
1138
1139    info!("[get_ini_file()]: This is the INI File:\n\n{config:?}");
1140    config
1141}
1142
1143/// Expands the tilde (`~`) character to the user's home directory path.
1144///
1145/// Replaces the leading `~` in a path string with the absolute path to the
1146/// user's home directory. If no `~` is present, returns the string unchanged.
1147///
1148/// # Arguments
1149///
1150/// * `cfg_str` - A path string that may contain a leading `~`
1151///
1152/// # Returns
1153///
1154/// A `String` with `~` expanded to the full home directory path
1155///
1156/// # Panics
1157///
1158/// Panics if the user's home directory cannot be determined
1159/// (via the `dirs::home_dir()` function).
1160///
1161/// # Examples
1162///
1163/// ```ignore
1164/// // On Linux/macOS with home at /home/user
1165/// let expanded = set_proper_home_dir("~/Documents/Obsidian");
1166/// assert_eq!(expanded, "/home/user/Documents/Obsidian");
1167///
1168/// // Path without tilde is returned unchanged
1169/// let unchanged = set_proper_home_dir("/absolute/path");
1170/// assert_eq!(unchanged, "/absolute/path");
1171/// ```
1172///
1173/// # Platform Behavior
1174///
1175/// - **Linux/macOS**: Expands to `/home/username` or `/Users/username`
1176/// - **Windows**: Expands to `C:\Users\username`
1177///
1178/// # Used By
1179///
1180/// This function is called by:
1181/// - [`GlobalVars::set_obsidian_root_path_dir()`]
1182/// - [`GlobalVars::set_obsidian_commit_path()`]
1183fn set_proper_home_dir(cfg_str: &str) -> String {
1184    info!("[set_proper_home_dir()]: Changing the '~' to full home directory.");
1185    let home_dir = home_dir()
1186        .expect("Could not get home_dir")
1187        .into_os_string()
1188        .into_string()
1189        .expect("Could not convert home_dir from OsString to String");
1190
1191    cfg_str.replace('~', &home_dir)
1192}
1193
1194#[cfg(test)]
1195#[cfg_attr(coverage_nightly, coverage(off))]
1196mod global_vars_tests {
1197    use super::*;
1198    use std::panic::{self, AssertUnwindSafe};
1199
1200    #[test]
1201    fn test_global_vars_new() {
1202        let global_vars = GlobalVars::new();
1203
1204        assert!(global_vars.config.get().is_none());
1205    }
1206
1207    #[test]
1208    fn test_global_vars_default() {
1209        let global_vars = GlobalVars::default();
1210
1211        assert!(global_vars.config.get().is_none());
1212    }
1213
1214    #[test]
1215    fn test_get_sections_from_config_valid() {
1216        let mut config = Ini::new();
1217        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1218        config.set(
1219            "templates",
1220            "commit_date_path",
1221            Some("%Y-%m-%d".to_string()),
1222        );
1223
1224        let global_vars = GlobalVars::new();
1225        global_vars.config.set(config).unwrap();
1226
1227        let sections = global_vars.get_sections_from_config();
1228
1229        assert_eq!(sections.len(), 2);
1230        assert!(sections.contains(&"obsidian".to_string()));
1231        assert!(sections.contains(&"templates".to_string()));
1232    }
1233
1234    #[test]
1235    fn test_get_sections_from_config_invalid_count() {
1236        let mut config = Ini::new();
1237        config.set("only_one_section", "key", Some("value".to_string()));
1238
1239        let global_vars = GlobalVars::new();
1240        global_vars.config.set(config).unwrap();
1241
1242        let result =
1243            panic::catch_unwind(AssertUnwindSafe(|| global_vars.get_sections_from_config()));
1244
1245        assert!(result.is_err(), "Expected panic for invalid section count");
1246
1247        // Verify the panic message (panic! with string literal = &str)
1248        let panic_info = result.unwrap_err();
1249        let msg = panic_info
1250            .downcast_ref::<&str>()
1251            .expect("Panic message should be &str");
1252        assert!(
1253            msg.contains("wrong number of sections"),
1254            "Unexpected panic message: {msg}"
1255        );
1256    }
1257
1258    #[test]
1259    fn test_get_sections_from_config_panics_with_zero_sections() {
1260        let config = Ini::new();
1261
1262        let global_vars = GlobalVars::new();
1263        global_vars.config.set(config).unwrap();
1264
1265        let result =
1266            panic::catch_unwind(AssertUnwindSafe(|| global_vars.get_sections_from_config()));
1267
1268        assert!(result.is_err(), "Expected panic for zero sections");
1269    }
1270
1271    #[test]
1272    fn test_get_sections_from_config_panics_with_three_sections() {
1273        let mut config = Ini::new();
1274        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1275        config.set("templates", "commit_date_path", Some("%Y.md".to_string()));
1276        config.set("extra", "key", Some("value".to_string()));
1277
1278        let global_vars = GlobalVars::new();
1279        global_vars.config.set(config).unwrap();
1280
1281        let result =
1282            panic::catch_unwind(AssertUnwindSafe(|| global_vars.get_sections_from_config()));
1283
1284        assert!(result.is_err(), "Expected panic for three sections");
1285    }
1286
1287    #[test]
1288    fn test_get_key_from_section_from_ini_exists() {
1289        let mut config = Ini::new();
1290        config.set(
1291            "obsidian",
1292            "root_path_dir",
1293            Some("/home/user/Obsidian".to_string()),
1294        );
1295
1296        let global_vars = GlobalVars::new();
1297        global_vars.config.set(config).unwrap();
1298
1299        let result = global_vars.get_key_from_section_from_ini("obsidian", "root_path_dir");
1300
1301        assert_eq!(result, Some("/home/user/Obsidian".to_string()));
1302    }
1303
1304    #[test]
1305    fn test_get_key_from_section_from_ini_not_exists() {
1306        let mut config = Ini::new();
1307        config.set("obsidian", "other_key", Some("value".to_string()));
1308
1309        let global_vars = GlobalVars::new();
1310        global_vars.config.set(config).unwrap();
1311
1312        let result = global_vars.get_key_from_section_from_ini("obsidian", "non_existent_key");
1313
1314        assert_eq!(result, None);
1315    }
1316
1317    #[test]
1318    fn test_get_config() {
1319        let mut config = Ini::new();
1320        config.set("test", "key", Some("value".to_string()));
1321
1322        let global_vars = GlobalVars::new();
1323        global_vars.config.set(config.clone()).unwrap();
1324
1325        let retrieved_config = global_vars.get_config();
1326
1327        assert_eq!(
1328            retrieved_config.get("test", "key"),
1329            Some("value".to_string())
1330        );
1331    }
1332
1333    #[test]
1334    fn test_set_obsidian_root_path_dir_with_tilde() {
1335        let mut config = Ini::new();
1336        config.set(
1337            "obsidian",
1338            "root_path_dir",
1339            Some("~/Documents/Obsidian".to_string()),
1340        );
1341        config.set(
1342            "templates",
1343            "commit_date_path",
1344            Some("%Y-%m-%d".to_string()),
1345        );
1346        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1347
1348        let global_vars = GlobalVars::new();
1349        global_vars.config.set(config).unwrap();
1350        global_vars.set_obsidian_root_path_dir("obsidian");
1351
1352        let result = global_vars.get_obsidian_root_path_dir();
1353
1354        // Should expand ~ to full home path
1355        assert!(!result.to_string_lossy().contains('~'));
1356        // Should start with /
1357        assert!(result.to_string_lossy().starts_with('/'));
1358        // Should end with Obsidian
1359        assert!(result.to_string_lossy().ends_with("Obsidian"));
1360    }
1361
1362    #[test]
1363    fn test_set_obsidian_root_path_dir_absolute_path() {
1364        let mut config = Ini::new();
1365        config.set(
1366            "obsidian",
1367            "root_path_dir",
1368            Some("/absolute/path/Obsidian".to_string()),
1369        );
1370        config.set(
1371            "templates",
1372            "commit_date_path",
1373            Some("%Y-%m-%d".to_string()),
1374        );
1375        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1376
1377        let global_vars = GlobalVars::new();
1378        global_vars.config.set(config).unwrap();
1379        global_vars.set_obsidian_root_path_dir("obsidian");
1380
1381        let result = global_vars.get_obsidian_root_path_dir();
1382
1383        // Should preserve absolute path
1384        assert!(result.to_string_lossy().contains("/absolute/path/Obsidian"));
1385    }
1386
1387    #[test]
1388    fn test_set_obsidian_commit_path_with_tilde() {
1389        let mut config = Ini::new();
1390        config.set(
1391            "obsidian",
1392            "commit_path",
1393            Some("~/Diaries/Commits".to_string()),
1394        );
1395        config.set(
1396            "templates",
1397            "commit_date_path",
1398            Some("%Y-%m-%d".to_string()),
1399        );
1400        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1401
1402        let global_vars = GlobalVars::new();
1403        global_vars.config.set(config).unwrap();
1404        global_vars.set_obsidian_commit_path("obsidian");
1405
1406        let result = global_vars.get_obsidian_commit_path();
1407
1408        // Should expand ~ to full home path
1409        assert!(!result.to_string_lossy().contains('~'));
1410        // Should end with Commits
1411        assert!(result.to_string_lossy().ends_with("Commits"));
1412    }
1413
1414    #[test]
1415    fn test_set_obsidian_commit_path_absolute_path() {
1416        let mut config = Ini::new();
1417        config.set(
1418            "obsidian",
1419            "commit_path",
1420            Some("absolute/Diaries/Commits".to_string()),
1421        );
1422        config.set(
1423            "templates",
1424            "commit_date_path",
1425            Some("%Y-%m-%d".to_string()),
1426        );
1427        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1428
1429        let global_vars = GlobalVars::new();
1430        global_vars.config.set(config).unwrap();
1431        global_vars.set_obsidian_commit_path("obsidian");
1432
1433        let result = global_vars.get_obsidian_commit_path();
1434
1435        // set_obsidian_commit_path() doesn't add leading / (unlike root_path_dir)
1436        // It just splits by / and rebuilds the PathBuf
1437        assert!(result.to_string_lossy().contains("absolute"));
1438        assert!(result.to_string_lossy().ends_with("Commits"));
1439    }
1440
1441    #[test]
1442    fn test_set_templates_commit_date_path() {
1443        let mut config = Ini::new();
1444        config.set(
1445            "templates",
1446            "commit_date_path",
1447            Some("%Y/%m-%B/%F.md".to_string()),
1448        );
1449        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1450
1451        let global_vars = GlobalVars::new();
1452        global_vars.config.set(config).unwrap();
1453        global_vars.set_templates_commit_date_path("templates");
1454
1455        let result = global_vars.get_template_commit_date_path();
1456
1457        assert_eq!(result, "%Y/%m-%B/%F.md");
1458    }
1459
1460    #[test]
1461    fn test_set_templates_datetime() {
1462        let mut config = Ini::new();
1463        config.set(
1464            "templates",
1465            "commit_datetime",
1466            Some("%Y-%m-%d %H:%M:%S".to_string()),
1467        );
1468
1469        let global_vars = GlobalVars::new();
1470        global_vars.config.set(config).unwrap();
1471        global_vars.set_templates_datetime("templates");
1472
1473        let result = global_vars.get_template_commit_datetime();
1474
1475        assert_eq!(result, "%Y-%m-%d %H:%M:%S");
1476    }
1477
1478    #[test]
1479    fn test_set_obsidian_vars_both_sections() {
1480        let mut config = Ini::new();
1481        config.set(
1482            "obsidian",
1483            "root_path_dir",
1484            Some("/home/user/Obsidian".to_string()),
1485        );
1486        config.set(
1487            "obsidian",
1488            "commit_path",
1489            Some("Diaries/Commits".to_string()),
1490        );
1491        config.set(
1492            "templates",
1493            "commit_date_path",
1494            Some("%Y-%m-%d.md".to_string()),
1495        );
1496        config.set(
1497            "templates",
1498            "commit_datetime",
1499            Some("%Y-%m-%d %H:%M:%S".to_string()),
1500        );
1501
1502        let global_vars = GlobalVars::new();
1503        global_vars.config.set(config).unwrap();
1504
1505        // Call the private method indirectly through set_obsidian_vars
1506        global_vars.set_obsidian_vars();
1507
1508        // Verify all getters work (meaning setters were called)
1509        let root_path = global_vars.get_obsidian_root_path_dir();
1510        let commit_path = global_vars.get_obsidian_commit_path();
1511        let date_path = global_vars.get_template_commit_date_path();
1512        let datetime = global_vars.get_template_commit_datetime();
1513
1514        assert!(root_path.to_string_lossy().contains("Obsidian"));
1515        assert!(commit_path.to_string_lossy().contains("Commits"));
1516        assert_eq!(date_path, "%Y-%m-%d.md");
1517        assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
1518    }
1519
1520    #[test]
1521    #[should_panic(expected = "Trying to set other sections is not supported")]
1522    fn test_set_obsidian_vars_invalid_section() {
1523        let mut config = Ini::new();
1524        // Add correct number of sections (2) but with wrong name
1525        config.set("invalid_section", "key", Some("value".to_string()));
1526        config.set(
1527            "templates",
1528            "commit_date_path",
1529            Some("%Y-%m-%d.md".to_string()),
1530        );
1531        config.set(
1532            "templates",
1533            "commit_datetime",
1534            Some("%Y-%m-%d %H:%M".to_string()),
1535        );
1536
1537        let global_vars = GlobalVars::new();
1538        global_vars.config.set(config).unwrap();
1539
1540        // Should panic because "invalid_section" is not "obsidian" or "templates"
1541        global_vars.set_obsidian_vars();
1542    }
1543
1544    #[test]
1545    fn test_set_all_integration() {
1546        use std::io::Write;
1547        use tempfile::NamedTempFile;
1548
1549        // Create a temporary config file
1550        let mut temp_file = NamedTempFile::new().unwrap();
1551        writeln!(temp_file, "[obsidian]").unwrap();
1552        writeln!(temp_file, "root_path_dir=/tmp/test_obsidian").unwrap();
1553        writeln!(temp_file, "commit_path=TestDiaries/TestCommits").unwrap();
1554        writeln!(temp_file, "[templates]").unwrap();
1555        writeln!(temp_file, "commit_date_path=%Y-%m-%d.md").unwrap();
1556        writeln!(temp_file, "commit_datetime=%Y-%m-%d %H:%M:%S").unwrap();
1557        temp_file.flush().unwrap();
1558
1559        // Parse the config manually and test set_all
1560        let content = std::fs::read_to_string(temp_file.path()).unwrap();
1561        let config = parse_ini_content(&content).unwrap();
1562
1563        let global_vars = GlobalVars::new();
1564        global_vars.config.set(config).unwrap();
1565        global_vars.set_obsidian_vars();
1566
1567        // Verify all values were set
1568        let root = global_vars.get_obsidian_root_path_dir();
1569        let commit = global_vars.get_obsidian_commit_path();
1570        let date = global_vars.get_template_commit_date_path();
1571        let datetime = global_vars.get_template_commit_datetime();
1572
1573        assert!(root.to_string_lossy().contains("test_obsidian"));
1574        assert!(commit.to_string_lossy().contains("TestCommits"));
1575        assert_eq!(date, "%Y-%m-%d.md");
1576        assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
1577    }
1578
1579    #[test]
1580    #[should_panic(expected = "Could not get")]
1581    fn test_get_obsidian_root_path_dir_not_set() {
1582        let global_vars = GlobalVars::new();
1583        // Don't set any values
1584        // This should panic when trying to get
1585        global_vars.get_obsidian_root_path_dir();
1586    }
1587
1588    #[test]
1589    #[should_panic(expected = "Could not get")]
1590    fn test_get_obsidian_commit_path_not_set() {
1591        let global_vars = GlobalVars::new();
1592        global_vars.get_obsidian_commit_path();
1593    }
1594
1595    #[test]
1596    #[should_panic(expected = "Could not get")]
1597    fn test_get_template_commit_date_path_not_set() {
1598        let global_vars = GlobalVars::new();
1599        global_vars.get_template_commit_date_path();
1600    }
1601
1602    #[test]
1603    #[should_panic(expected = "Could not get")]
1604    fn test_get_template_commit_datetime_not_set() {
1605        let global_vars = GlobalVars::new();
1606        global_vars.get_template_commit_datetime();
1607    }
1608
1609    #[test]
1610    #[should_panic(expected = "Could not get Config")]
1611    fn test_get_config_not_initialized() {
1612        let global_vars = GlobalVars::new();
1613        // Config not set
1614        global_vars.get_config();
1615    }
1616
1617    #[test]
1618    fn test_set_config_twice_fails() {
1619        let global_vars = GlobalVars::new();
1620        let config1 = Ini::new();
1621        let config2 = Ini::new();
1622
1623        assert!(global_vars.config.set(config1).is_ok());
1624        // Second set should fail
1625        assert!(global_vars.config.set(config2).is_err());
1626    }
1627
1628    #[test]
1629    fn test_global_vars_set_all_end_to_end() {
1630        use std::io::Write;
1631        use tempfile::NamedTempFile;
1632
1633        // Create a real config file
1634        let mut temp_file = NamedTempFile::new().unwrap();
1635        writeln!(temp_file, "[obsidian]").unwrap();
1636        writeln!(temp_file, "root_path_dir=/tmp/obsidian_test").unwrap();
1637        writeln!(temp_file, "commit_path=TestDiaries/TestCommits").unwrap();
1638        writeln!(temp_file, "[templates]").unwrap();
1639        writeln!(temp_file, "commit_date_path=%Y/%m-%B/%F.md").unwrap();
1640        writeln!(temp_file, "commit_datetime=%Y-%m-%d %H:%M:%S").unwrap();
1641        temp_file.flush().unwrap();
1642
1643        // Read and parse the config
1644        let content = std::fs::read_to_string(temp_file.path()).unwrap();
1645        let mut config = Ini::new();
1646        config.read(content).unwrap();
1647
1648        // Now test set_all
1649        let global_vars = GlobalVars::new();
1650        let result = global_vars.config.set(config);
1651        assert!(result.is_ok());
1652
1653        // Call set_obsidian_vars (which set_all would call)
1654        global_vars.set_obsidian_vars();
1655
1656        // Verify everything is accessible
1657        let root = global_vars.get_obsidian_root_path_dir();
1658        let commit = global_vars.get_obsidian_commit_path();
1659        let date_path = global_vars.get_template_commit_date_path();
1660        let datetime = global_vars.get_template_commit_datetime();
1661
1662        assert!(root.to_string_lossy().contains("obsidian_test"));
1663        assert!(commit.to_string_lossy().contains("TestCommits"));
1664        assert_eq!(date_path, "%Y/%m-%B/%F.md");
1665        assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
1666    }
1667
1668    #[test]
1669    fn test_set_obsidian_root_path_dir_with_trailing_slash() {
1670        let mut config = Ini::new();
1671        config.set("obsidian", "root_path_dir", Some("/tmp/test/".to_string()));
1672        config.set(
1673            "templates",
1674            "commit_date_path",
1675            Some("%Y-%m-%d".to_string()),
1676        );
1677        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1678
1679        let global_vars = GlobalVars::new();
1680        global_vars.config.set(config).unwrap();
1681        global_vars.set_obsidian_root_path_dir("obsidian");
1682
1683        let result = global_vars.get_obsidian_root_path_dir();
1684
1685        // Should handle trailing slashes gracefully
1686        assert!(result.to_string_lossy().contains("test"));
1687    }
1688
1689    #[test]
1690    fn test_set_obsidian_commit_path_with_multiple_slashes() {
1691        let mut config = Ini::new();
1692        config.set(
1693            "obsidian",
1694            "commit_path",
1695            Some("Diaries//Commits///Nested".to_string()),
1696        );
1697        config.set(
1698            "templates",
1699            "commit_date_path",
1700            Some("%Y-%m-%d".to_string()),
1701        );
1702        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1703
1704        let global_vars = GlobalVars::new();
1705        global_vars.config.set(config).unwrap();
1706        global_vars.set_obsidian_commit_path("obsidian");
1707
1708        let result = global_vars.get_obsidian_commit_path();
1709
1710        // Path should be constructed despite multiple slashes
1711        assert!(result.to_string_lossy().contains("Nested"));
1712    }
1713
1714    #[test]
1715    fn test_set_obsidian_root_path_dir_empty_string() {
1716        let mut config = Ini::new();
1717        config.set("obsidian", "root_path_dir", Some(String::new()));
1718        config.set(
1719            "templates",
1720            "commit_date_path",
1721            Some("%Y-%m-%d".to_string()),
1722        );
1723        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1724
1725        let global_vars = GlobalVars::new();
1726        global_vars.config.set(config).unwrap();
1727        global_vars.set_obsidian_root_path_dir("obsidian");
1728
1729        let result = global_vars.get_obsidian_root_path_dir();
1730
1731        // Should at least create a PathBuf (even if empty or just "/")
1732        assert!(!result.to_string_lossy().is_empty());
1733    }
1734
1735    #[test]
1736    #[should_panic(expected = "Could not get commit_path from config")]
1737    fn test_set_obsidian_commit_path_missing_key() {
1738        let mut config = Ini::new();
1739        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1740        config.set(
1741            "templates",
1742            "commit_date_path",
1743            Some("%Y-%m-%d".to_string()),
1744        );
1745        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1746
1747        let global_vars = GlobalVars::new();
1748        global_vars.config.set(config).unwrap();
1749
1750        global_vars.set_obsidian_commit_path("obsidian");
1751    }
1752
1753    #[test]
1754    #[should_panic(expected = "Could not get")]
1755    fn test_set_obsidian_root_path_dir_missing_key() {
1756        let mut config = Ini::new();
1757        config.set("obsidian", "commit_path", Some("commits".to_string()));
1758        config.set(
1759            "templates",
1760            "commit_date_path",
1761            Some("%Y-%m-%d".to_string()),
1762        );
1763        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1764
1765        let global_vars = GlobalVars::new();
1766        global_vars.config.set(config).unwrap();
1767
1768        global_vars.set_obsidian_root_path_dir("obsidian");
1769    }
1770
1771    #[test]
1772    #[should_panic(expected = "Could not get the commit_date_path from INI")]
1773    fn test_set_templates_commit_date_path_missing_key() {
1774        let mut config = Ini::new();
1775        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1776        config.set("obsidian", "root_path_dir", Some("/tmp".to_string()));
1777        config.set("obsidian", "commit_path", Some("commits".to_string()));
1778
1779        let global_vars = GlobalVars::new();
1780        global_vars.config.set(config).unwrap();
1781
1782        global_vars.set_templates_commit_date_path("templates");
1783    }
1784
1785    #[test]
1786    #[should_panic(expected = "Could not get the commit_datetime from INI")]
1787    fn test_set_templates_datetime_missing_key() {
1788        let mut config = Ini::new();
1789        config.set(
1790            "templates",
1791            "commit_date_path",
1792            Some("%Y-%m-%d".to_string()),
1793        );
1794        config.set("obsidian", "root_path_dir", Some("/tmp".to_string()));
1795        config.set("obsidian", "commit_path", Some("commits".to_string()));
1796
1797        let global_vars = GlobalVars::new();
1798        global_vars.config.set(config).unwrap();
1799
1800        global_vars.set_templates_datetime("templates");
1801    }
1802
1803    #[test]
1804    fn test_global_vars_set_all_method() {
1805        use std::io::Write;
1806        use tempfile::NamedTempFile;
1807
1808        // Create a real config file
1809        let mut temp_file = NamedTempFile::new().unwrap();
1810        writeln!(temp_file, "[obsidian]").unwrap();
1811        writeln!(temp_file, "root_path_dir=/tmp/obsidian_full_test").unwrap();
1812        writeln!(temp_file, "commit_path=FullTest/Commits").unwrap();
1813        writeln!(temp_file, "[templates]").unwrap();
1814        writeln!(temp_file, "commit_date_path=%Y/%m/%d.md").unwrap();
1815        writeln!(temp_file, "commit_datetime=%Y-%m-%d %H:%M:%S").unwrap();
1816        temp_file.flush().unwrap();
1817
1818        // Parse config manually
1819        let content = std::fs::read_to_string(temp_file.path()).unwrap();
1820        let config = parse_ini_content(&content).unwrap();
1821
1822        // Test set_all workflow
1823        let global_vars = GlobalVars::new();
1824        global_vars.config.set(config).unwrap();
1825        global_vars.set_obsidian_vars();
1826
1827        // Verify all values accessible via set_all pattern
1828        let root = global_vars.get_obsidian_root_path_dir();
1829        let commit = global_vars.get_obsidian_commit_path();
1830        let date = global_vars.get_template_commit_date_path();
1831        let datetime = global_vars.get_template_commit_datetime();
1832
1833        assert!(root.to_string_lossy().contains("obsidian_full_test"));
1834        assert!(commit.to_string_lossy().contains("FullTest"));
1835        assert_eq!(date, "%Y/%m/%d.md");
1836        assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
1837    }
1838
1839    #[test]
1840    fn test_set_obsidian_vars_complete_workflow() {
1841        let mut config = Ini::new();
1842        config.set(
1843            "obsidian",
1844            "root_path_dir",
1845            Some("~/test/obsidian".to_string()),
1846        );
1847        config.set(
1848            "obsidian",
1849            "commit_path",
1850            Some("~/test/commits".to_string()),
1851        );
1852        config.set(
1853            "templates",
1854            "commit_date_path",
1855            Some("%Y/%m/%d.md".to_string()),
1856        );
1857        config.set(
1858            "templates",
1859            "commit_datetime",
1860            Some("%Y-%m-%d %H:%M:%S".to_string()),
1861        );
1862
1863        let global_vars = GlobalVars::new();
1864        global_vars.config.set(config).unwrap();
1865
1866        // This exercises the full set_obsidian_vars logic
1867        global_vars.set_obsidian_vars();
1868
1869        // Verify all paths were expanded
1870        let root = global_vars.get_obsidian_root_path_dir();
1871        let commit = global_vars.get_obsidian_commit_path();
1872
1873        // Both should have ~ expanded
1874        assert!(!root.to_string_lossy().contains('~'));
1875        assert!(!commit.to_string_lossy().contains('~'));
1876        assert!(root.to_string_lossy().contains("obsidian"));
1877        assert!(commit.to_string_lossy().contains("commits"));
1878    }
1879}
1880
1881#[cfg(test)]
1882#[cfg_attr(coverage_nightly, coverage(off))]
1883mod user_input_tests {
1884    use super::*;
1885    use clap::Parser;
1886
1887    #[test]
1888    fn test_user_input_parse_with_config() {
1889        let args = vec!["test_program", "--config-ini", "/path/to/config.ini"];
1890        let user_input = UserInput::try_parse_from(args).unwrap();
1891
1892        assert_eq!(
1893            user_input.config_ini,
1894            Some("/path/to/config.ini".to_string())
1895        );
1896    }
1897
1898    #[test]
1899    fn test_user_input_parse_without_config() {
1900        let args = vec!["test_program"];
1901        let user_input = UserInput::try_parse_from(args).unwrap();
1902
1903        assert_eq!(user_input.config_ini, None);
1904    }
1905
1906    #[test]
1907    fn test_user_input_parse_short_flag() {
1908        let args = vec!["test_program", "-c", "/short/path/config.ini"];
1909        let user_input = UserInput::try_parse_from(args).unwrap();
1910
1911        assert_eq!(
1912            user_input.config_ini,
1913            Some("/short/path/config.ini".to_string())
1914        );
1915    }
1916
1917    #[test]
1918    fn test_set_proper_home_dir_with_tilde() {
1919        let input = "~/test/path/file.ini";
1920        let result = set_proper_home_dir(input);
1921
1922        // Should replace ~ with actual home directory
1923        assert!(!result.contains('~'));
1924        assert!(result.ends_with("/test/path/file.ini"));
1925    }
1926
1927    #[test]
1928    fn test_set_proper_home_dir_without_tilde() {
1929        let input = "/absolute/path/file.ini";
1930        let result = set_proper_home_dir(input);
1931
1932        // Should remain unchanged
1933        assert_eq!(result, input);
1934    }
1935
1936    #[test]
1937    fn test_set_proper_home_dir_multiple_tildes() {
1938        let input = "~/path/~/file.ini";
1939        let result = set_proper_home_dir(input);
1940
1941        // Should replace ALL tildes
1942        assert!(!result.contains('~'));
1943    }
1944
1945    #[test]
1946    fn test_get_default_ini_path() {
1947        let result = get_default_ini_path();
1948
1949        // Should end with the expected config path
1950        assert!(result.ends_with(".config/rusty-commit-saver/rusty-commit-saver.ini"));
1951
1952        // Should NOT contain literal tilde
1953        assert!(!result.contains('~'));
1954
1955        // Should be an absolute path
1956        assert!(result.starts_with('/'));
1957    }
1958
1959    #[test]
1960    fn test_get_or_default_config_ini_path_with_config_and_tilde() {
1961        // Simulate CLI args: --config-ini ~/my/config.ini
1962        let args = vec!["test", "--config-ini", "~/my/config.ini"];
1963        let user_input = UserInput::try_parse_from(args).unwrap();
1964
1965        // We can't directly call get_or_default_config_ini_path() because it parses env args
1966        // Instead, test that UserInput correctly parses the config path
1967        assert_eq!(user_input.config_ini, Some("~/my/config.ini".to_string()));
1968    }
1969
1970    #[test]
1971    fn test_get_or_default_config_ini_path_with_config_absolute_path() {
1972        // Simulate CLI args: --config-ini /absolute/path/config.ini
1973        let args = vec!["test", "--config-ini", "/absolute/path/config.ini"];
1974        let user_input = UserInput::try_parse_from(args).unwrap();
1975
1976        assert_eq!(
1977            user_input.config_ini,
1978            Some("/absolute/path/config.ini".to_string())
1979        );
1980    }
1981
1982    #[test]
1983    fn test_get_or_default_config_ini_path_without_config() {
1984        // Simulate CLI args with no config specified
1985        let args = vec!["test"];
1986        let user_input = UserInput::try_parse_from(args).unwrap();
1987
1988        // Should default to None, and get_or_default_config_ini_path() will use get_default_ini_path()
1989        assert_eq!(user_input.config_ini, None);
1990    }
1991
1992    #[test]
1993    fn test_parse_ini_content_valid() {
1994        let content = r"
1995[obsidian]
1996root_path_dir=~/Documents/Obsidian
1997commit_path=Diaries/Commits
1998
1999[templates]
2000commit_date_path=%Y/%m-%B/%F.md
2001commit_datetime=%Y-%m-%d
2002";
2003
2004        let result = parse_ini_content(content);
2005        assert!(result.is_ok());
2006
2007        let ini = result.unwrap();
2008        assert_eq!(
2009            ini.get("obsidian", "root_path_dir"),
2010            Some("~/Documents/Obsidian".to_string())
2011        );
2012        assert_eq!(
2013            ini.get("templates", "commit_date_path"),
2014            Some("%Y/%m-%B/%F.md".to_string())
2015        );
2016    }
2017
2018    #[test]
2019    fn test_parse_ini_content_invalid() {
2020        let content = "this is not valid ini format [[[";
2021
2022        let result = parse_ini_content(content);
2023        // Should succeed because configparser is very lenient, but let's verify it doesn't panic
2024        assert!(result.is_ok() || result.is_err());
2025    }
2026
2027    #[test]
2028    fn test_parse_ini_content_empty() {
2029        let content = "";
2030
2031        let result = parse_ini_content(content);
2032        assert!(result.is_ok());
2033
2034        let ini = result.unwrap();
2035        assert_eq!(ini.sections().len(), 0);
2036    }
2037
2038    #[test]
2039    fn test_retrieve_config_file_path_with_temp_file() {
2040        use std::io::Write;
2041        use tempfile::NamedTempFile;
2042
2043        // Create a temporary config file
2044        let mut temp_file = NamedTempFile::new().unwrap();
2045        writeln!(temp_file, "[obsidian]").unwrap();
2046        writeln!(temp_file, "root_path_dir=/tmp/test").unwrap();
2047        writeln!(temp_file, "commit_path=commits").unwrap();
2048        writeln!(temp_file, "[templates]").unwrap();
2049        writeln!(temp_file, "commit_date_path=%Y-%m-%d.md").unwrap();
2050        writeln!(temp_file, "commit_datetime=%Y-%m-%d").unwrap();
2051        temp_file.flush().unwrap();
2052
2053        // Set CLI args to point to our temp file
2054        // We need to simulate CLI args via environment
2055        let path = temp_file.path().to_str().unwrap();
2056
2057        // Instead of testing retrieve_config_file_path directly (which reads from CLI),
2058        // test that we can read and parse a config file
2059        let content = std::fs::read_to_string(path).unwrap();
2060        let result = parse_ini_content(&content);
2061
2062        assert!(result.is_ok());
2063        let ini = result.unwrap();
2064        assert_eq!(
2065            ini.get("obsidian", "root_path_dir"),
2066            Some("/tmp/test".to_string())
2067        );
2068    }
2069
2070    #[test]
2071    fn test_ini_parsing_integration() {
2072        let content = r"
2073[obsidian]
2074root_path_dir=~/Documents/Obsidian
2075commit_path=Diaries/Commits
2076
2077[templates]
2078commit_date_path=%Y/%m-%B/%F.md
2079commit_datetime=%Y-%m-%d %H:%M:%S
2080";
2081
2082        let ini = parse_ini_content(content).unwrap();
2083
2084        // Verify all expected keys exist
2085        assert!(ini.get("obsidian", "root_path_dir").is_some());
2086        assert!(ini.get("obsidian", "commit_path").is_some());
2087        assert!(ini.get("templates", "commit_date_path").is_some());
2088        assert!(ini.get("templates", "commit_datetime").is_some());
2089
2090        // Verify sections count
2091        assert_eq!(ini.sections().len(), 2);
2092    }
2093
2094    // #[test]
2095    // fn debug_ini_sections_behavior() {
2096    //     let mut config = Ini::new();
2097    //     config.set("only_one_section", "key", Some("value".to_string()));
2098    //
2099    //     let sections = config.sections();
2100    //     println!("Sections count: {}", sections.len());
2101    //     println!("Sections: {:?}", sections);
2102    //
2103    //     // Force fail to see output
2104    //     assert!(false, "Debug: sections = {:?}", sections);
2105    // }
2106
2107    #[test]
2108    fn test_line_606_explicit_coverage() {
2109        use std::panic;
2110
2111        let mut config = Ini::new();
2112        config.set("only_one_section", "key", Some("value".to_string()));
2113
2114        let global_vars = GlobalVars::new();
2115        global_vars.config.set(config).unwrap();
2116
2117        let result = panic::catch_unwind(|| global_vars.get_sections_from_config());
2118
2119        assert!(result.is_err(), "Should have panicked");
2120    }
2121
2122    #[test]
2123    fn test_set_all_loads_config_and_sets_vars() {
2124        use std::env;
2125        use std::fs;
2126        use tempfile::NamedTempFile;
2127
2128        let temp_file = NamedTempFile::new().expect("Failed to create temp file");
2129        let config_content = r"[obsidian]
2130root_path_dir = /tmp/test_obsidian
2131commit_path = Commits
2132
2133[templates]
2134commit_date_path = %Y/%m/%d.md
2135commit_datetime = %Y-%m-%d %H:%M:%S
2136";
2137        fs::write(temp_file.path(), config_content).expect("Failed to write temp config");
2138
2139        env::set_var(
2140            "RUSTY_COMMIT_SAVER_CONFIG",
2141            temp_file.path().to_str().unwrap(),
2142        );
2143
2144        let global_vars = GlobalVars::new();
2145        let result = global_vars.set_all();
2146
2147        // Verify method chaining
2148        assert!(std::ptr::eq(result, &raw const global_vars));
2149
2150        // Verify config was set
2151        assert!(global_vars.config.get().is_some());
2152
2153        env::remove_var("RUSTY_COMMIT_SAVER_CONFIG");
2154    }
2155
2156    #[test]
2157    #[should_panic(expected = "config_path DOES NOT exists")]
2158    fn test_retrieve_config_file_path_panics_on_missing_file() {
2159        std::env::set_var("RUSTY_COMMIT_SAVER_CONFIG", "/nonexistent/path/config.ini");
2160        let _ = retrieve_config_file_path();
2161    }
2162
2163    #[test]
2164    fn test_get_or_default_config_ini_path_env_var_with_tilde() {
2165        use std::env;
2166
2167        let var_name = "RUSTY_COMMIT_SAVER_CONFIG";
2168        let original = env::var(var_name).ok();
2169
2170        env::set_var(var_name, "~/some/config/path.ini");
2171
2172        let result = get_or_default_config_ini_path();
2173
2174        // Restore
2175        match original {
2176            Some(val) => env::set_var(var_name, val),
2177            None => env::remove_var(var_name),
2178        }
2179
2180        // Should have expanded ~ to home dir
2181        assert!(!result.contains('~'), "Tilde should be expanded");
2182        assert!(result.ends_with("/some/config/path.ini"));
2183    }
2184
2185    #[test]
2186    fn test_resolve_config_path_cli_with_tilde() {
2187        let result = resolve_config_path(Some("~/my/config.ini".to_string()), None);
2188        assert!(!result.contains('~'));
2189        assert!(result.ends_with("/my/config.ini"));
2190    }
2191
2192    #[test]
2193    fn test_resolve_config_path_cli_without_tilde() {
2194        let result = resolve_config_path(Some("/absolute/path.ini".to_string()), None);
2195        assert_eq!(result, "/absolute/path.ini");
2196    }
2197
2198    #[test]
2199    fn test_resolve_config_path_default() {
2200        let result = resolve_config_path(None, None);
2201        assert!(result.contains("rusty-commit-saver.ini"));
2202    }
2203
2204    #[test]
2205    fn test_resolve_config_path_env_takes_precedence() {
2206        let result = resolve_config_path(
2207            Some("/cli/path.ini".to_string()),
2208            Some("/env/path.ini".to_string()),
2209        );
2210        assert_eq!(result, "/env/path.ini");
2211    }
2212
2213    #[test]
2214    fn test_get_or_default_config_ini_path_with_no_env_calls_cli_parser() {
2215        let parser_called = std::cell::Cell::new(false);
2216
2217        let result = get_or_default_config_ini_path_with(None, || {
2218            parser_called.set(true);
2219            Some("/mock/cli/path.ini".to_string())
2220        });
2221
2222        assert!(
2223            parser_called.get(),
2224            "CLI parser should be called when no env var"
2225        );
2226        assert_eq!(result, "/mock/cli/path.ini");
2227    }
2228
2229    #[test]
2230    fn test_get_or_default_config_ini_path_with_env_skips_cli_parser() {
2231        let parser_called = std::cell::Cell::new(false);
2232
2233        let result = get_or_default_config_ini_path_with(Some("/env/path.ini".to_string()), || {
2234            parser_called.set(true);
2235            Some("/should/not/be/used.ini".to_string())
2236        });
2237
2238        assert!(
2239            !parser_called.get(),
2240            "CLI parser should NOT be called when env var is set"
2241        );
2242        assert_eq!(result, "/env/path.ini");
2243    }
2244
2245    #[test]
2246    fn test_get_or_default_config_ini_path_with_cli_returns_default_when_none() {
2247        let result = get_or_default_config_ini_path_with(None, || None);
2248
2249        // Should fall back to default path
2250        assert!(result.contains("rusty-commit-saver.ini"));
2251    }
2252
2253    #[test]
2254    fn test_get_or_default_config_ini_path_with_cli_tilde_expansion() {
2255        let result =
2256            get_or_default_config_ini_path_with(None, || Some("~/custom/config.ini".to_string()));
2257
2258        assert!(!result.contains('~'), "Tilde should be expanded");
2259        assert!(result.ends_with("/custom/config.ini"));
2260    }
2261
2262    #[test]
2263    fn test_parse_ini_content_find_invalid_case() {
2264        let cases = [
2265            "[unclosed",
2266            "no_section_key = value", // might actually work (goes to default section)
2267            "[section]\nweird line without equals",
2268        ];
2269
2270        for case in cases {
2271            let result = parse_ini_content(case);
2272            println!("{case:?} => {result:?}");
2273        }
2274    }
2275
2276    #[test]
2277    fn test_parse_ini_content_invalid_syntax() {
2278        let result = parse_ini_content("[unclosed");
2279
2280        assert!(result.is_err(), "Should fail on unclosed bracket");
2281        let err = result.unwrap_err();
2282        assert!(
2283            err.contains("Failed to parse INI"),
2284            "Error should contain expected prefix: {err}"
2285        );
2286    }
2287
2288    #[test]
2289    #[should_panic(expected = "Should have been able to read the file")]
2290    fn test_retrieve_config_file_path_panics_on_unreadable_file() {
2291        use std::fs::{self, File};
2292        use std::os::unix::fs::PermissionsExt;
2293        use tempfile::tempdir;
2294
2295        let dir = tempdir().unwrap();
2296        let file_path = dir.path().join("unreadable.ini");
2297
2298        // Create file with no read permissions
2299        File::create(&file_path).unwrap();
2300        fs::set_permissions(&file_path, fs::Permissions::from_mode(0o000)).unwrap();
2301
2302        std::env::set_var("RUSTY_COMMIT_SAVER_CONFIG", file_path.to_str().unwrap());
2303
2304        // This should panic because file exists but can't be read
2305        let _ = retrieve_config_file_path();
2306
2307        // Cleanup (won't run due to panic, but good practice)
2308        fs::set_permissions(&file_path, fs::Permissions::from_mode(0o644)).unwrap();
2309    }
2310}