rusty_commit_saver/
config.rs

1use chrono::DateTime;
2use log::{error, info, warn};
3
4use std::{
5    fs,
6    path::{Path, PathBuf},
7};
8
9use clap::Parser;
10use configparser::ini::Ini;
11use dirs::home_dir;
12use once_cell::sync::OnceCell;
13
14/// Parses INI file content into a configuration object without file I/O.
15///
16/// This is a pure function that takes raw INI text and parses it into an `Ini` struct.
17/// It's useful for testing configuration parsing logic without reading from disk.
18///
19/// # Arguments
20///
21/// * `content` - The raw INI file content as a string
22///
23/// # Returns
24///
25/// - `Ok(Ini)` - Successfully parsed configuration
26/// - `Err(String)` - Parsing failed with error description
27///
28/// # INI Format
29///
30/// The INI format supported:
31/// ```text
32/// [section_name]
33/// key1 = value1
34/// key2 = value2
35///
36/// [another_section]
37/// key3 = value3
38/// ```
39///
40/// # Examples
41///
42/// ```ignore
43/// use rusty_commit_saver::config::parse_ini_content;
44///
45/// let ini_content = r#"
46/// [obsidian]
47/// root_path_dir = ~/Documents/Obsidian
48/// commit_path = Diaries/Commits
49///
50/// [templates]
51/// commit_date_path = %Y/%m-%B/%F.md
52/// commit_datetime = %Y-%m-%d %H:%M:%S
53/// "#;
54///
55/// let config = parse_ini_content(ini_content).unwrap();
56///
57/// // Access parsed values
58/// assert_eq!(
59///     config.get("obsidian", "root_path_dir"),
60///     Some("~/Documents/Obsidian".to_string())
61/// );
62/// assert_eq!(
63///     config.get("templates", "commit_date_path"),
64///     Some("%Y/%m-%B/%F.md".to_string())
65/// );
66/// ```
67///
68/// # Errors
69///
70/// Returns an error if:
71/// - INI syntax is invalid (malformed sections or key-value pairs)
72/// - The content cannot be parsed as valid UTF-8
73///
74/// # Testing
75///
76/// This function is particularly useful for unit testing without needing
77/// to create temporary files:
78///
79/// ```ignore
80/// use rusty_commit_saver::config::parse_ini_content;
81///
82/// fn test_config_parsing() {
83///     let test_config = "[section]\nkey=value\n";
84///     let result = parse_ini_content(test_config);
85///     assert!(result.is_ok());
86/// }
87/// ```
88pub fn parse_ini_content(content: &str) -> Result<Ini, String> {
89    let mut config = Ini::new();
90    config
91        .read(content.to_string())
92        .map_err(|e| format!("Failed to parse INI: {e:?}"))?;
93    Ok(config)
94}
95
96/// Thread-safe global configuration container for Rusty Commit Saver.
97///
98/// This struct holds all runtime configuration loaded from the INI file,
99/// using `OnceCell` for lazy initialization and thread safety. Configuration
100/// values are set once during initialization and remain immutable thereafter.
101///
102/// # Usage Pattern
103///
104/// ```ignore
105/// use rusty_commit_saver::config::GlobalVars;
106///
107/// // 1. Create instance
108/// let global_vars = GlobalVars::new();
109///
110/// // 2. Load configuration from INI file
111/// global_vars.set_all();
112///
113/// // 3. Access configuration values
114/// let obsidian_root = global_vars.get_obsidian_root_path_dir();
115/// let commit_path = global_vars.get_obsidian_commit_path();
116/// ```
117///
118/// # See Also
119///
120/// - [`GlobalVars::new()`] - Create new instance
121/// - [`GlobalVars::set_all()`] - Initialize from INI file
122/// - [`parse_ini_content()`] - Parse INI content
123#[derive(Debug, Default)]
124pub struct GlobalVars {
125    /// The parsed INI configuration file.
126    ///
127    /// Stores the complete parsed configuration from the INI file.
128    /// Initialized once by [`set_all()`](Self::set_all).
129    ///
130    /// # Thread Safety
131    ///
132    /// `OnceCell` ensures this is set exactly once and can be safely
133    /// accessed from multiple threads.
134    pub config: OnceCell<Ini>,
135
136    /// Path of the INI the configuration was read from.
137    ///
138    /// Retained so a configuration error can name the file to edit. Set by
139    /// [`set_all()`](Self::set_all); a `GlobalVars` handed a config directly
140    /// (as the tests do) leaves it unset.
141    pub config_path: OnceCell<String>,
142
143    /// Root directory of the Obsidian vault.
144    ///
145    /// The base directory where all Obsidian files are stored.
146    /// All diary entries are created under this directory.
147    ///
148    /// # Examples
149    ///
150    /// - `/home/user/Documents/Obsidian`
151    /// - `C:\Users\username\Documents\Obsidian` (Windows)
152    ///
153    /// # Configuration
154    ///
155    /// Loaded from INI file:
156    /// ```text
157    /// [obsidian]
158    /// root_path_dir = ~/Documents/Obsidian
159    /// ```
160    obsidian_root_path_dir: OnceCell<PathBuf>,
161
162    /// Subdirectory path for commit diary entries.
163    ///
164    /// Relative path under [`obsidian_root_path_dir`](Self::obsidian_root_path_dir)
165    /// where commit entries are organized.
166    ///
167    /// # Examples
168    ///
169    /// - `Diaries/Commits`
170    /// - `Journal/Git`
171    ///
172    /// # Full Path Construction
173    ///
174    /// Combined with root and date template:
175    /// ```text
176    /// {root_path_dir}/{commit_path}/{date_template}
177    /// /home/user/Obsidian/Diaries/Commits/2025/01-January/2025-01-14.md
178    /// ```
179    ///
180    /// # Configuration
181    ///
182    /// Loaded from INI file:
183    /// ```text
184    /// [obsidian]
185    /// commit_path = Diaries/Commits
186    /// ```
187    obsidian_commit_path: OnceCell<PathBuf>,
188
189    /// Chrono format string for date-based file paths.
190    ///
191    /// Controls the directory structure and filename for diary entries.
192    /// Uses Chrono format specifiers to create date-organized paths.
193    ///
194    /// # Format Specifiers
195    ///
196    /// - `%Y` - Year (e.g., `2025`)
197    /// - `%m` - Month number (e.g., `01`)
198    /// - `%B` - Full month name (e.g., `January`)
199    /// - `%F` - ISO 8601 date (e.g., `2025-01-14`)
200    /// - `%d` - Day of month (e.g., `14`)
201    ///
202    /// # Examples
203    ///
204    /// ```text
205    /// Format: %Y/%m-%B/%F.md
206    /// Result: 2025/01-January/2025-01-14.md
207    ///
208    /// Format: %Y/week-%W/%F.md
209    /// Result: 2025/week-02/2025-01-14.md
210    /// ```
211    ///
212    /// # Configuration
213    ///
214    /// Loaded from INI file:
215    /// ```text
216    /// [templates]
217    /// commit_date_path = %Y/%m-%B/%F.md
218    /// ```
219    template_commit_date_path: OnceCell<String>,
220
221    /// Chrono format string for datetime display in diary entries.
222    ///
223    /// Controls how commit timestamps appear in the diary table's TIME column.
224    ///
225    /// # Format Specifiers
226    ///
227    /// - `%Y` - Year (e.g., `2025`)
228    /// - `%m` - Month (e.g., `01`)
229    /// - `%d` - Day (e.g., `14`)
230    /// - `%H` - Hour, 24-hour (e.g., `14`)
231    /// - `%M` - Minute (e.g., `30`)
232    /// - `%S` - Second (e.g., `45`)
233    /// - `%T` - Time in HH:MM:SS format
234    ///
235    /// # Examples
236    ///
237    /// ```text
238    /// Format: %Y-%m-%d %H:%M:%S
239    /// Result: 2025-01-14 14:30:45
240    ///
241    /// Format: %H:%M:%S
242    /// Result: 14:30:45
243    /// ```
244    ///
245    /// # Configuration
246    ///
247    /// Loaded from INI file:
248    /// ```text
249    /// [templates]
250    /// commit_datetime = %Y-%m-%d %H:%M:%S
251    /// ```
252    template_commit_datetime: OnceCell<String>,
253
254    /// Repository names to exclude from commit capture.
255    ///
256    /// When the current repository's working-directory name matches an entry in
257    /// this list, the post-commit run skips cleanly and writes nothing to the
258    /// diary. Populated from the optional `[exclude]` section; empty when that
259    /// section is absent.
260    ///
261    /// # Configuration
262    ///
263    /// Loaded from the INI file (comma-separated, optional):
264    /// ```text
265    /// [exclude]
266    /// repos = claude-src, some-other-repo
267    /// ```
268    excluded_repos: OnceCell<Vec<String>>,
269}
270
271impl GlobalVars {
272    /// Creates a new uninitialized `GlobalVars` instance.
273    ///
274    /// This constructor initializes all fields as empty `OnceCell` values.
275    /// Use [`set_all()`](Self::set_all) to load configuration from the INI file.
276    ///
277    /// # Thread Safety
278    ///
279    /// `GlobalVars` uses `OnceCell` for thread-safe, lazy initialization.
280    /// Configuration values are set once and cannot be changed afterward.
281    ///
282    /// # Returns
283    ///
284    /// A new `GlobalVars` instance with all fields uninitialized
285    ///
286    /// # Fields
287    ///
288    /// - `config` - The parsed INI configuration file
289    /// - `obsidian_root_path_dir` - Root directory of Obsidian vault
290    /// - `obsidian_commit_path` - Subdirectory path for commit entries
291    /// - `template_commit_date_path` - Chrono format for date-based directory structure
292    /// - `template_commit_datetime` - Chrono format for datetime strings
293    ///
294    /// # Examples
295    ///
296    /// ```ignore
297    /// use rusty_commit_saver::config::GlobalVars;
298    ///
299    /// // Create new instance
300    /// let global_vars = GlobalVars::new();
301    ///
302    /// // Now call set_all() to initialize from config file
303    /// // global_vars.set_all();
304    /// ```
305    #[must_use]
306    pub fn new() -> Self {
307        info!("[GlobalVars::new()] Creating new GlobalVars with OnceCell default values.");
308        GlobalVars {
309            config: OnceCell::new(),
310            config_path: OnceCell::new(),
311
312            obsidian_root_path_dir: OnceCell::new(),
313            obsidian_commit_path: OnceCell::new(),
314
315            template_commit_date_path: OnceCell::new(),
316            template_commit_datetime: OnceCell::new(),
317
318            excluded_repos: OnceCell::new(),
319        }
320    }
321
322    /// Loads and initializes all configuration from the INI file.
323    ///
324    /// This is the main entry point for configuration setup. It:
325    /// 1. Reads the INI configuration file from disk (or CLI argument)
326    /// 2. Parses it into the `config` field
327    /// 3. Extracts and initializes all Obsidian and template variables
328    ///
329    /// Configuration is loaded from (in order of preference):
330    /// - `--config-ini <PATH>` CLI argument
331    /// - Default: `~/.config/rusty-commit-saver/rusty-commit-saver.ini`
332    ///
333    /// # Panics
334    ///
335    /// Panics if:
336    /// - Configuration file doesn't exist
337    /// - Configuration file cannot be read
338    /// - Configuration file has invalid INI format
339    /// - Required sections or keys are missing
340    /// - Section count is not exactly 2 (obsidian + templates)
341    ///
342    /// # Returns
343    ///
344    /// Returns `self` for method chaining
345    ///
346    /// # Required INI Structure
347    ///
348    /// ```text
349    /// [obsidian]
350    /// root_path_dir = ~/Documents/Obsidian
351    /// commit_path = Diaries/Commits
352    ///
353    /// [templates]
354    /// commit_date_path = %Y/%m-%B/%F.md
355    /// commit_datetime = %Y-%m-%d %H:%M:%S
356    /// ```
357    ///
358    /// # Examples
359    ///
360    /// ```ignore
361    /// use rusty_commit_saver::config::GlobalVars;
362    ///
363    /// let global_vars = GlobalVars::new();
364    /// global_vars.set_all(); // Reads from default or CLI config
365    ///
366    /// // Now all getters will return values
367    /// let root_path = global_vars.get_obsidian_root_path_dir();
368    /// let commit_path = global_vars.get_obsidian_commit_path();
369    /// ```
370    pub fn set_all(&self) -> &Self {
371        info!("[GlobalVars::set_all()] Setting all variables for GlobalVars");
372        let config_path = get_or_default_config_ini_path();
373        let config = get_ini_file_at(&config_path);
374
375        info!("[GlobalVars::set_all()]: Setting Config Ini file.");
376        self.config_path
377            .set(config_path)
378            .expect("Couldn't set config_path in GlobalVars");
379        self.config
380            .set(config)
381            .expect("Coulnd't set config in GlobalVars");
382
383        info!("[GlobalVars::set_all()]: Setting Obsidian variables from file.");
384        self.set_obsidian_vars();
385
386        self
387    }
388
389    /// Returns the root directory of the Obsidian vault.
390    ///
391    /// This is the base directory where all Obsidian vault files are stored.
392    /// All diary entries are created under this directory according to the
393    /// configured subdirectory structure.
394    ///
395    /// # Panics
396    ///
397    /// Panics if called before [`set_all()`](Self::set_all) has been invoked
398    ///
399    /// # Returns
400    ///
401    /// A `PathBuf` representing the Obsidian vault root directory
402    ///
403    /// # Examples
404    ///
405    /// ```ignore
406    /// use rusty_commit_saver::config::GlobalVars;
407    ///
408    /// let global_vars = GlobalVars::new();
409    /// global_vars.set_all();
410    ///
411    /// let root = global_vars.get_obsidian_root_path_dir();
412    /// println!("Obsidian vault root: {}", root.display());
413    /// // Output: Obsidian vault root: /home/user/Documents/Obsidian
414    /// ```
415    ///
416    /// # Configuration Source
417    ///
418    /// Read from INI file:
419    /// ```text
420    /// [obsidian]
421    /// root_path_dir = ~/Documents/Obsidian
422    /// ```
423    pub fn get_obsidian_root_path_dir(&self) -> PathBuf {
424        info!("[GlobalVars::get_obsidian_root_path_dir()]: Getting obsidian_root_path_dir.");
425        self.obsidian_root_path_dir
426            .get()
427            .expect("Could not get obsidian_root_path_dir")
428            .clone()
429    }
430
431    /// Returns the subdirectory path where commits are stored.
432    ///
433    /// This is a relative path under [`get_obsidian_root_path_dir()`](Self::get_obsidian_root_path_dir)
434    /// where commit diary entries will be organized. The full path is constructed by
435    /// combining this with the Obsidian root and the date-based directory structure.
436    ///
437    /// # Panics
438    ///
439    /// Panics if called before [`set_all()`](Self::set_all) has been invoked
440    ///
441    /// # Returns
442    ///
443    /// A `PathBuf` representing the commits subdirectory (relative path)
444    ///
445    /// # Examples
446    ///
447    /// ```ignore
448    /// use rusty_commit_saver::config::GlobalVars;
449    ///
450    /// let global_vars = GlobalVars::new();
451    /// global_vars.set_all();
452    ///
453    /// let commit_path = global_vars.get_obsidian_commit_path();
454    /// println!("Commit subdirectory: {}", commit_path.display());
455    /// // Output: Commit subdirectory: Diaries/Commits
456    ///
457    /// // Full path would be constructed as:
458    /// // /home/user/Documents/Obsidian/Diaries/Commits/2025/01-January/2025-01-14.md
459    /// ```
460    ///
461    /// # Configuration Source
462    ///
463    /// Read from INI file:
464    /// ```text
465    /// [obsidian]
466    /// commit_path = Diaries/Commits
467    /// ```
468    pub fn get_obsidian_commit_path(&self) -> PathBuf {
469        info!("[GlobalVars::get_obsidian_commit_path()]: Getting obsidian_commit_path.");
470        self.obsidian_commit_path
471            .get()
472            .expect("Could not get obsidian_commit_path")
473            .clone()
474    }
475
476    /// Returns the Chrono format string for diary file date hierarchies.
477    ///
478    /// This format string is used to create the directory structure and filename
479    /// for diary entries based on the commit timestamp. It controls how commits
480    /// are organized by date.
481    ///
482    /// # Chrono Format Specifiers
483    ///
484    /// - `%Y` - Full year (e.g., `2025`)
485    /// - `%m` - Month as zero-padded number (e.g., `01`)
486    /// - `%B` - Full month name (e.g., `January`)
487    /// - `%b` - Abbreviated month (e.g., `Jan`)
488    /// - `%d` - Day of month, zero-padded (e.g., `14`)
489    /// - `%F` - ISO 8601 date (equivalent to `%Y-%m-%d`, e.g., `2025-01-14`)
490    /// - `%H` - Hour in 24-hour format (e.g., `14`)
491    /// - `%M` - Minute (e.g., `30`)
492    /// - `%S` - Second (e.g., `45`)
493    ///
494    /// # Panics
495    ///
496    /// Panics if called before [`set_all()`](Self::set_all) has been invoked
497    ///
498    /// # Returns
499    ///
500    /// A `String` containing the Chrono format specifiers
501    ///
502    /// # Examples
503    ///
504    /// ```ignore
505    /// use rusty_commit_saver::config::GlobalVars;
506    ///
507    /// let global_vars = GlobalVars::new();
508    /// global_vars.set_all();
509    ///
510    /// let date_template = global_vars.get_template_commit_date_path();
511    /// println!("Date format: {}", date_template);
512    /// // Output: Date format: %Y/%m-%B/%F.md
513    ///
514    /// // This creates paths like:
515    /// // /home/user/Obsidian/Diaries/Commits/2025/01-January/2025-01-14.md
516    /// ```
517    ///
518    /// # Configuration Source
519    ///
520    /// Read from INI file:
521    /// ```text
522    /// [templates]
523    /// commit_date_path = %Y/%m-%B/%F.md
524    /// ```
525    pub fn get_template_commit_date_path(&self) -> String {
526        info!("[GlobalVars::get_template_commit_date_path()]: Getting template_commit_date_path.");
527        self.template_commit_date_path
528            .get()
529            .expect("Could not get template_commit_date_path")
530            .clone()
531    }
532
533    /// Returns the Chrono format string for commit timestamps in diary entries.
534    ///
535    /// This format string is used to display the commit time in the diary table.
536    /// It controls how timestamps appear in the commit entry rows.
537    ///
538    /// # Chrono Format Specifiers
539    ///
540    /// - `%Y` - Full year (e.g., `2025`)
541    /// - `%m` - Month as zero-padded number (e.g., `01`)
542    /// - `%B` - Full month name (e.g., `January`)
543    /// - `%d` - Day of month, zero-padded (e.g., `14`)
544    /// - `%H` - Hour in 24-hour format (e.g., `14`)
545    /// - `%M` - Minute, zero-padded (e.g., `30`)
546    /// - `%S` - Second, zero-padded (e.g., `45`)
547    /// - `%T` - Time in HH:MM:SS format (equivalent to `%H:%M:%S`)
548    ///
549    /// # Panics
550    ///
551    /// Panics if called before [`set_all()`](Self::set_all) has been invoked
552    ///
553    /// # Returns
554    ///
555    /// A `String` containing the Chrono format specifiers for datetime
556    ///
557    /// # Examples
558    ///
559    /// ```ignore
560    /// use rusty_commit_saver::config::GlobalVars;
561    ///
562    /// let global_vars = GlobalVars::new();
563    /// global_vars.set_all();
564    ///
565    /// let datetime_template = global_vars.get_template_commit_datetime();
566    /// println!("Datetime format: {}", datetime_template);
567    /// // Output: Datetime format: %Y-%m-%d %H:%M:%S
568    ///
569    /// // This renders timestamps like:
570    /// // 2025-01-14 14:30:45
571    /// ```
572    ///
573    /// # Diary Table Usage
574    ///
575    /// In the diary table, this format appears in the TIME column:
576    /// ```text
577    /// | FOLDER | TIME | COMMIT MESSAGE | REPOSITORY URL | BRANCH | COMMIT HASH |
578    /// |--------|------|----------------|----------------|--------|-------------|
579    /// | /work/project | 14:30:45 | feat: add feature | https://github.com/... | main | abc123... |
580    /// ```
581    ///
582    /// # Configuration Source
583    ///
584    /// Read from INI file:
585    /// ```text
586    /// [templates]
587    /// commit_datetime = %Y-%m-%d %H:%M:%S
588    /// ```
589    pub fn get_template_commit_datetime(&self) -> String {
590        info!("[GlobalVars::get_template_commit_datetime()]: Getting template_commit_datetime.");
591        self.template_commit_datetime
592            .get()
593            .expect("Could not get template_commit_datetime")
594            .clone()
595    }
596
597    /// Retrieves a clone of the parsed INI configuration.
598    ///
599    /// This is a private helper method that returns a copy of the configuration
600    /// object. Used internally by other helper methods to access sections and keys.
601    ///
602    /// # Panics
603    ///
604    /// Panics if called before [`set_all()`](Self::set_all) has initialized the config.
605    ///
606    /// # Returns
607    ///
608    /// A cloned `Ini` configuration object
609    fn get_config(&self) -> Ini {
610        info!("[GlobalVars::get_config()] Getting config");
611        self.config
612            .get()
613            .expect("Could not get Config. Config not initialized")
614            .clone()
615    }
616
617    fn get_key_from_section_from_ini(&self, section: &str, key: &str) -> Option<String> {
618        info!(
619            "[GlobalVars::get_key_from_section_from_ini()] Getting key: {key:} from section: {section:}."
620        );
621        self.config
622            .get()
623            .expect("Retrieving the config for commit_path")
624            .get(section, key)
625    }
626
627    /// The sections this binary understands. Adding a section to
628    /// `set_obsidian_vars`' dispatch means adding it here too, or the section
629    /// gets applied *and* reported as unrecognised.
630    const KNOWN_SECTIONS: [&'static str; 3] = ["obsidian", "templates", "exclude"];
631
632    /// The keys each known section understands. Adding a key to a setter means
633    /// adding it here too, or the key gets applied *and* reported as
634    /// unrecognised.
635    const KNOWN_KEYS: [(&'static str, &'static [&'static str]); 3] = [
636        ("obsidian", &["root_path_dir", "commit_path"]),
637        ("templates", &["commit_date_path", "commit_datetime"]),
638        ("exclude", &["repos"]),
639    ];
640
641    /// Lists the keys this binary does not understand, as sorted
642    /// `[section] key` labels.
643    ///
644    /// Only keys in a *known* section are listed: an unrecognised section is
645    /// already reported whole by [`get_sections_from_config()`](Self::get_sections_from_config),
646    /// and listing its keys as well would charge one mistake twice.
647    ///
648    /// Sorted because the parser holds keys in a hash map, whose iteration
649    /// order would otherwise vary from run to run.
650    fn unrecognised_keys(&self) -> Vec<String> {
651        let mut unknown = Vec::new();
652
653        for (section, _) in Self::KNOWN_KEYS {
654            for key in self.unrecognised_keys_in(section) {
655                unknown.push(format!("[{section}] {key}"));
656            }
657        }
658
659        unknown.sort();
660        unknown
661    }
662
663    /// The unrecognised keys of one known section, sorted, without the
664    /// `[section]` prefix. An unknown section has none by definition: the
665    /// binary has no idea what it should contain.
666    fn unrecognised_keys_in(&self, section: &str) -> Vec<String> {
667        let Some((_, known)) = Self::KNOWN_KEYS.iter().find(|(name, _)| *name == section) else {
668            return Vec::new();
669        };
670
671        let config = self.get_config();
672        let Some(present) = config.get_map_ref().get(section) else {
673            return Vec::new();
674        };
675
676        let mut unknown: Vec<String> = present
677            .keys()
678            .filter(|key| !known.contains(&key.as_str()))
679            .cloned()
680            .collect();
681
682        unknown.sort();
683        unknown
684    }
685
686    /// Names the configuration file for an error message.
687    ///
688    /// Falls back to a plain description rather than a guessed path when the
689    /// config was handed in directly instead of read from disk.
690    fn config_file_label(&self) -> String {
691        self.config_path
692            .get()
693            .cloned()
694            .unwrap_or_else(|| "the rusty-commit-saver config".to_string())
695    }
696
697    /// Reads a key the binary cannot work without.
698    ///
699    /// Fatal by design. Without it there is no destination to write to, and a
700    /// hook that quietly journals nothing is indistinguishable from a quiet
701    /// day - the diary would stop for weeks before anyone noticed. What the
702    /// fatal path owes the user is a message they can act on: it names the
703    /// resolved config file, the `[section] key`, and any unrecognised key in
704    /// that same section, because a misspelt `commit_paths` is the usual
705    /// reason `commit_path` is missing and naming both at once saves reading
706    /// the source.
707    ///
708    /// A present-but-blank value counts as missing: `commit_path =` used to
709    /// satisfy the old presence check and silently journal into the vault
710    /// root instead of the configured folder.
711    ///
712    /// # Panics
713    ///
714    /// Panics if the key is absent, or its value is empty or whitespace.
715    fn require_key(&self, section: &str, key: &str) -> String {
716        let value = self
717            .get_key_from_section_from_ini(section, key)
718            .filter(|value| !value.trim().is_empty());
719
720        if let Some(value) = value {
721            return value;
722        }
723
724        let file = self.config_file_label();
725        let typos = self.unrecognised_keys_in(section);
726        let hint = if typos.is_empty() {
727            String::new()
728        } else {
729            format!("; unrecognised in [{section}]: {}", typos.join(", "))
730        };
731
732        error!(
733            "[GlobalVars::require_key()] {file}: missing required key '{key}' in section [{section}]{hint}"
734        );
735        panic!(
736            "rusty-commit-saver: {file}: missing required key '{key}' in section [{section}]{hint}"
737        )
738    }
739
740    /// Reads a required key whose value must be a `chrono` format string.
741    ///
742    /// A format `chrono` cannot render is config skew like any other, but it
743    /// used to surface from deep inside the writer as `a formatting trait
744    /// implementation returned an error when the underlying stream did not`,
745    /// naming neither the file nor the key - and only after an empty diary
746    /// file had already been created. Checking it where the rest of the config
747    /// is checked keeps the message the same shape as every other config
748    /// fault, and stops the run before it writes anything.
749    ///
750    /// # Panics
751    ///
752    /// Panics if the key is missing (see [`require_key()`](Self::require_key)),
753    /// or if `chrono` cannot render its value.
754    fn require_time_format(&self, section: &str, key: &str) -> String {
755        let format = self.require_key(section, key);
756
757        if is_renderable_time_format(&format) {
758            return format;
759        }
760
761        let file = self.config_file_label();
762        error!(
763            "[GlobalVars::require_time_format()] {file}: key '{key}' in section [{section}] is not a format chrono can render: '{format}'"
764        );
765        panic!(
766            "rusty-commit-saver: {file}: key '{key}' in section [{section}] is not a format chrono can render: '{format}'"
767        )
768    }
769
770    /// Reports every unrecognised key, then carries on.
771    ///
772    /// An unknown key is never fatal, for the reason an unknown section is not:
773    /// one INI file is shared by every checkout on the machine, so a key
774    /// written for a newer release must not brick a binary that predates it.
775    /// Reporting it is what a silent skip failed to do - a misspelt
776    /// `commit_datetimes` used to apply nothing and say nothing.
777    fn report_unrecognised_keys(&self) {
778        let unknown = self.unrecognised_keys();
779        if unknown.is_empty() {
780            return;
781        }
782
783        let list = unknown.join(", ");
784        warn!(
785            "[GlobalVars::report_unrecognised_keys()] ignoring unrecognised config keys {list}; this binary may be older than the config"
786        );
787        // Also on stderr, for the same reason the section warning is: the git
788        // hook runs without RUST_LOG, where env_logger caps the level at Error
789        // and would swallow the warning entirely.
790        eprintln!(
791            "rusty-commit-saver: ignoring unrecognised config keys {list}; this binary may be older than the config"
792        );
793    }
794
795    fn get_sections_from_config(&self) -> Vec<String> {
796        info!("[GlobalVars::get_sections_from_config()] Getting sections from config");
797        let sections = self.get_config().sections();
798
799        info!("[GlobalVars::get_sections_from_config()] Checking validity of config sections.");
800        let has_required = ["obsidian", "templates"]
801            .iter()
802            .all(|required| sections.iter().any(|s| s == required));
803
804        if !has_required {
805            error!(
806                // LCOV_EXCL_START
807                "[GlobalVars::get_sections_from_config()] These are the sections found: {sections:?}"
808            ); // LCOV_EXCL_STOP
809            panic!(
810                "[GlobalVars::get_sections_from_config()] config must have [obsidian] and [templates]."
811            )
812        }
813
814        // An unrecognised section is ignored, never fatal: the config is shared
815        // by every checkout on the machine, so a section added for a newer
816        // release must not brick a binary that predates it. Adding [exclude]
817        // is exactly what killed every checkout older than 4.17.0.
818        let unknown: Vec<&String> = sections
819            .iter()
820            .filter(|s| !Self::KNOWN_SECTIONS.contains(&s.as_str()))
821            .collect();
822        if !unknown.is_empty() {
823            warn!(
824                "[GlobalVars::get_sections_from_config()] ignoring unrecognised config sections {unknown:?}; this binary may be older than the config"
825            );
826            // Also on stderr: the git hook runs without RUST_LOG, where
827            // env_logger caps the level at Error and would swallow the warning
828            // entirely. A misspelt section must never be silent - that is how
829            // an [excludes] typo would quietly journal the repos you excluded.
830            eprintln!(
831                "rusty-commit-saver: ignoring unrecognised config sections {unknown:?}; this binary may be older than the config"
832            );
833        }
834
835        sections
836    }
837
838    /// Loads all configuration variables from the "obsidian" and "templates" sections.
839    ///
840    /// This method iterates through all sections returned by `get_sections_from_config`.
841    /// For each recognized section, it initializes the corresponding runtime variables
842    /// by calling their dedicated setters:
843    ///
844    /// - For the **"obsidian"** section: calls `set_obsidian_root_path_dir` and `set_obsidian_commit_path`.
845    /// - For the **"templates"** section: calls `set_templates_commit_date_path` and `set_templates_datetime`.
846    /// - For the **"exclude"** section: calls `set_excluded_repos`.
847    ///
848    /// Any other section is skipped. Keys the binary does not understand are
849    /// reported on stderr and skipped too.
850    ///
851    /// # Panics
852    ///
853    /// Panics if the INI file is missing `[obsidian]` or `[templates]`; both are
854    /// required. An unrecognised section or key is not fatal.
855    ///
856    /// # Logging
857    ///
858    /// - Logs an info message when applying each section.
859    /// - Logs an error right before panicking on a missing required section.
860    ///
861    /// # Examples
862    ///
863    /// ```ignore
864    /// use rusty_commit_saver::config::GlobalVars;
865    /// let mut config = configparser::ini::Ini::new();
866    /// config.set("obsidian", "root_path_dir", Some("~/Obsidian".to_string()));
867    /// config.set("obsidian", "commit_path", Some("Diary/Commits".to_string()));
868    /// config.set("templates", "commit_date_path", Some("%Y-%m-%d.md".to_string()));
869    /// config.set("templates", "commit_datetime", Some("%Y-%m-%d %H:%M:%S".to_string()));
870    /// let global_vars = GlobalVars::new();
871    /// global_vars.config.set(config).unwrap();
872    /// global_vars.set_obsidian_vars();
873    /// ```
874    pub fn set_obsidian_vars(&self) {
875        let sections = self.get_sections_from_config();
876        self.report_unrecognised_keys();
877
878        for section in sections {
879            if section == "obsidian" {
880                info!("[GlobalVars::set_obsidian_vars()] Setting 'obsidian' section variables.");
881                self.set_obsidian_root_path_dir(&section);
882                self.set_obsidian_commit_path(&section);
883            } else if section == "templates" {
884                info!("[GlobalVars::set_obsidian_vars()] Setting 'templates' section variables.");
885                self.set_templates_commit_date_path(&section);
886                self.set_templates_datetime(&section);
887            } else if section == "exclude" {
888                info!("[GlobalVars::set_obsidian_vars()] Setting 'exclude' section variables.");
889                self.set_excluded_repos(&section);
890            }
891            // No `else`: an unrecognised section is deliberately skipped here.
892            // `get_sections_from_config()` returns it after warning about it,
893            // because a config written for a newer release must not be fatal.
894        }
895    }
896
897    /// Sets the `template_commit_datetime` field from the `[templates]` section.
898    ///
899    /// Reads the `commit_datetime` key from the INI file and stores it in the
900    /// `template_commit_datetime` `OnceCell`.
901    ///
902    /// # Arguments
903    ///
904    /// * `section` - Should be `"templates"` (validated by caller)
905    ///
906    /// # Panics
907    ///
908    /// Panics if:
909    /// - The `commit_datetime` key is missing, or its value is blank, or it is
910    ///   not a format `chrono` can render
911    /// - The `OnceCell` has already been set (called multiple times)
912    ///
913    /// # Expected INI Key
914    ///
915    /// ```text
916    /// [templates]
917    /// commit_datetime = %Y-%m-%d %H:%M:%S
918    /// ```
919    fn set_templates_datetime(&self, section: &str) {
920        info!("[GlobalVars::set_templates_datetime()]: Setting the templates_datetime.");
921        let key = self.require_time_format(section, "commit_datetime");
922
923        self.template_commit_datetime
924            .set(key)
925            .expect("Could not set the template_commit_datetime GlobalVars");
926    }
927
928    /// Sets the `excluded_repos` field from the optional `[exclude]` section.
929    ///
930    /// Reads the `repos` key, parses it as a comma-separated list of repository
931    /// names, and stores the result. A missing `repos` key yields an empty list.
932    ///
933    /// # Arguments
934    ///
935    /// * `section` - Should be `"exclude"` (validated by caller)
936    ///
937    /// # Expected INI Key
938    ///
939    /// ```text
940    /// [exclude]
941    /// repos = claude-src, some-other-repo
942    /// ```
943    fn set_excluded_repos(&self, section: &str) {
944        info!("[GlobalVars::set_excluded_repos()]: Setting the excluded repos list.");
945        let raw = self
946            .get_key_from_section_from_ini(section, "repos")
947            .unwrap_or_default();
948
949        self.excluded_repos
950            .set(parse_exclude_repos(&raw))
951            .expect("Could not set the excluded_repos in GlobalVars");
952    }
953
954    /// Returns the list of repository names excluded from commit capture.
955    ///
956    /// Reads the value populated from the optional `[exclude]` section. When that
957    /// section (or its `repos` key) is absent, returns an empty list — meaning no
958    /// repository is excluded.
959    ///
960    /// # Returns
961    ///
962    /// A `Vec<String>` of excluded repository names (working-directory names).
963    ///
964    /// # Configuration Source
965    ///
966    /// Read from the INI file:
967    /// ```text
968    /// [exclude]
969    /// repos = claude-src
970    /// ```
971    #[must_use]
972    pub fn get_excluded_repos(&self) -> Vec<String> {
973        info!("[GlobalVars::get_excluded_repos()]: Getting excluded repos list.");
974        self.excluded_repos.get().cloned().unwrap_or_default()
975    }
976
977    /// Sets the `template_commit_date_path` field from the `[templates]` section.
978    ///
979    /// Reads the `commit_date_path` key from the INI file and stores it in the
980    /// `template_commit_date_path` `OnceCell`.
981    ///
982    /// # Arguments
983    ///
984    /// * `section` - Should be `"templates"` (validated by caller)
985    ///
986    /// # Panics
987    ///
988    /// Panics if:
989    /// - The `commit_date_path` key is missing, or its value is blank, or it is
990    ///   not a format `chrono` can render
991    /// - The `OnceCell` has already been set (called multiple times)
992    ///
993    /// # Expected INI Key
994    ///
995    /// ```text
996    /// [templates]
997    /// commit_date_path = %Y/%m-%B/%F.md
998    /// ```
999    fn set_templates_commit_date_path(&self, section: &str) {
1000        info!(
1001            "[GlobalVars::set_templates_commit_date_path()]: Setting the template_commit_date_path."
1002        );
1003        let key = self.require_time_format(section, "commit_date_path");
1004
1005        self.template_commit_date_path
1006            .set(key)
1007            .expect("Could not set the template_commit_date_path in GlobalVars");
1008    }
1009
1010    /// Sets the `obsidian_commit_path` field from the `[obsidian]` section.
1011    ///
1012    /// Reads the `commit_path` key, expands tilde (`~`) to the home directory
1013    /// if present, splits the path by `/`, and constructs a `PathBuf`.
1014    ///
1015    /// # Arguments
1016    ///
1017    /// * `section` - Should be `"obsidian"` (validated by caller)
1018    ///
1019    /// # Tilde Expansion
1020    ///
1021    /// - `~/Diaries/Commits` → `/home/user/Diaries/Commits`
1022    /// - `/absolute/path` → `/absolute/path` (unchanged)
1023    ///
1024    /// # Panics
1025    ///
1026    /// Panics if:
1027    /// - The `commit_path` key is missing, or its value is blank
1028    /// - Home directory cannot be determined (when `~` is used)
1029    /// - The `OnceCell` has already been set
1030    ///
1031    /// # Expected INI Key
1032    ///
1033    /// ```text
1034    /// [obsidian]
1035    /// commit_path = ~/Documents/Obsidian/Diaries/Commits
1036    /// ```
1037    fn set_obsidian_commit_path(&self, section: &str) {
1038        let string_path = self.require_key(section, "commit_path");
1039
1040        let fixed_home = if string_path.contains('~') {
1041            info!("[GlobalVars::set_obsidian_commit_path()]: Path does contain: '~'.");
1042            set_proper_home_dir(&string_path)
1043        } else {
1044            info!("[GlobalVars::set_obsidian_commit_path()]: Path does NOT contain: '~'.");
1045            string_path
1046        };
1047
1048        let vec_str = fixed_home.split('/');
1049
1050        let mut path = PathBuf::new();
1051
1052        info!(
1053            "[GlobalVars::set_obsidian_commit_path()]: Pushing strings folders to create PathBuf."
1054        );
1055        for s in vec_str {
1056            path.push(s);
1057        }
1058        self.obsidian_commit_path
1059            .set(path)
1060            .expect("Could not set the path for obsidian_root_path_dir");
1061    }
1062
1063    /// Sets the `obsidian_root_path_dir` field from the `[obsidian]` section.
1064    ///
1065    /// Reads the `root_path_dir` key, expands tilde (`~`) to the home directory
1066    /// if present, prepends `/` for absolute paths, and constructs a `PathBuf`.
1067    ///
1068    /// # Arguments
1069    ///
1070    /// * `section` - Should be `"obsidian"` (validated by caller)
1071    ///
1072    /// # Path Construction
1073    ///
1074    /// - Starts with `/` to ensure absolute path
1075    /// - Expands `~` to home directory
1076    /// - Splits by `/` and constructs `PathBuf`
1077    ///
1078    /// # Tilde Expansion Examples
1079    ///
1080    /// - `~/Documents/Obsidian` → `/home/user/Documents/Obsidian`
1081    /// - `/absolute/path` → `/absolute/path`
1082    ///
1083    /// # Panics
1084    ///
1085    /// Panics if:
1086    /// - The `root_path_dir` key is missing, or its value is blank
1087    /// - Home directory cannot be determined (when `~` is used)
1088    /// - The `OnceCell` has already been set
1089    ///
1090    /// # Expected INI Key
1091    ///
1092    /// ```text
1093    /// [obsidian]
1094    /// root_path_dir = ~/Documents/Obsidian
1095    /// ```
1096    fn set_obsidian_root_path_dir(&self, section: &str) {
1097        let string_path = self.require_key(section, "root_path_dir");
1098
1099        let fixed_home = if string_path.contains('~') {
1100            info!("[GlobalVars::set_obsidian_root_path_dir()]: Does contain ~");
1101            set_proper_home_dir(&string_path)
1102        } else {
1103            info!("[GlobalVars::set_obsidian_root_path_dir()]: Does NOT contain ~");
1104            string_path
1105        };
1106
1107        let vec_str = fixed_home.split('/');
1108        let mut path = PathBuf::new();
1109
1110        info!(
1111            "[GlobalVars::set_obsidian_root_path_dir()]: Pushing '/' to PathBuf for proper path."
1112        );
1113        path.push("/");
1114
1115        info!(
1116            "[GlobalVars::set_obsidian_root_path_dir()]: Pushing strings folders to create PathBuf."
1117        );
1118        for s in vec_str {
1119            path.push(s);
1120        }
1121
1122        self.obsidian_root_path_dir
1123            .set(path)
1124            .expect("Could not set the path for obsidian_root_path_dir");
1125    }
1126}
1127
1128/// Command-line argument parser for configuration file path.
1129///
1130/// This struct uses `clap` to parse CLI arguments and provide configuration
1131/// options for the application. Currently supports specifying a custom INI
1132/// configuration file path.
1133///
1134/// # CLI Arguments
1135///
1136/// - `--config-ini <PATH>` - Optional path to a custom configuration file
1137///
1138/// # Examples
1139///
1140/// ```text
1141/// # Use default config (~/.config/rusty-commit-saver/rusty-commit-saver.ini)
1142/// rusty-commit-saver
1143///
1144/// # Use custom config file
1145/// rusty-commit-saver --config-ini /path/to/custom.ini
1146/// ```
1147///
1148/// # See Also
1149///
1150/// - [`retrieve_config_file_path()`] - Gets the config path from CLI or default
1151/// - [`get_ini_file()`] - Loads the INI file from the resolved path
1152#[derive(Parser, Debug, Clone)]
1153#[command(version, about, long_about = None)]
1154#[command(propagate_version = true)]
1155#[command(about = "Rusty Commit Saver config", long_about = None)]
1156pub struct UserInput {
1157    /// Path to a custom INI configuration file.
1158    ///
1159    /// If not provided, the default configuration file is used:
1160    /// `~/.config/rusty-commit-saver/rusty-commit-saver.ini`
1161    ///
1162    /// # CLI Usage
1163    ///
1164    /// ```text
1165    /// rusty-commit-saver --config-ini /custom/path/config.ini
1166    /// ```
1167    ///
1168    /// # Examples
1169    ///
1170    /// Valid paths:
1171    /// - `~/my-configs/commit-saver.ini`
1172    /// - `/etc/rusty-commit-saver/config.ini`
1173    /// - `./local-config.ini`
1174    #[arg(short, long)]
1175    pub config_ini: Option<String>,
1176}
1177
1178/// Retrieves the configuration file path from CLI arguments or returns the default.
1179///
1180/// This function parses command-line arguments and returns the path to the INI configuration file.
1181/// If no `--config-ini` argument is provided, returns the default path.
1182///
1183/// # Default Path
1184///
1185/// `~/.config/rusty-commit-saver/rusty-commit-saver.ini`
1186///
1187/// # Returns
1188///
1189/// A `String` containing the absolute path to the configuration file.
1190///
1191/// # CLI Usage
1192///
1193/// ```text
1194/// // Use default config
1195/// $ rusty-commit-saver
1196/// // Returns: ~/.config/rusty-commit-saver/rusty-commit-saver.ini
1197///
1198/// // Use custom config
1199/// $ rusty-commit-saver --config-ini /custom/path/config.ini
1200/// // Returns: /custom/path/config.ini
1201/// ```
1202///
1203/// # Panics
1204///
1205/// Panics if:
1206/// - The resolved configuration file does not exist on the filesystem
1207/// - The file cannot be read (permission denied, IO error)
1208/// - The file path cannot be converted to a valid string
1209///
1210/// # Examples
1211///
1212/// ```ignore
1213/// use rusty_commit_saver::config::retrieve_config_file_path;
1214///
1215/// let config_path = retrieve_config_file_path();
1216/// println!("Using config: {}", config_path);
1217/// ```
1218///
1219/// # See Also
1220///
1221/// - [`get_or_default_config_ini_path`] - Helper that implements the CLI parsing logic
1222/// - [`get_default_ini_path`] - Constructs the default configuration path
1223#[must_use]
1224pub fn retrieve_config_file_path() -> String {
1225    info!(
1226        "[UserInput::retrieve_config_file_path()]: retrieving the string path from CLI or default"
1227    );
1228    read_config_file(&get_or_default_config_ini_path())
1229}
1230
1231/// Reads the configuration file at `config_path` and returns its contents.
1232///
1233/// Split from [`retrieve_config_file_path()`] so a caller that needs the path
1234/// itself - to name the file in an error - resolves it once and passes it in,
1235/// rather than resolving it a second time behind the caller's back.
1236///
1237/// # Panics
1238///
1239/// Panics if the file does not exist, or cannot be read.
1240#[must_use]
1241fn read_config_file(config_path: &str) -> String {
1242    if Path::new(config_path).exists() {
1243        info!("[UserInput::retrieve_config_file_path()]: config_path exists {config_path:}");
1244    } else {
1245        error!(
1246            "[UserInput::retrieve_config_file_path()]: config_path DOES NOT exists {config_path:}"
1247        );
1248        panic!(
1249            "[UserInput::retrieve_config_file_path()]: config_path DOES NOT exists {config_path:}"
1250        );
1251    }
1252    info!("[UserInput::retrieve_config_file_path()] retrieved config path: {config_path:}");
1253    fs::read_to_string(config_path)
1254        .unwrap_or_else(|_| panic!("Should have been able to read the file: {config_path:}"))
1255}
1256
1257/// Returns the config path from CLI arguments or the default path.
1258///
1259/// Internal helper function that parses CLI arguments using `UserInput` and
1260/// returns either the provided `--config-ini` path or the default configuration
1261/// file location.
1262///
1263/// # Returns
1264///
1265/// - CLI path if `--config-ini` was provided
1266/// - Default path (`~/.config/rusty-commit-saver/rusty-commit-saver.ini`) otherwise
1267///
1268/// # Called By
1269///
1270/// This function is called internally by [`retrieve_config_file_path()`].
1271///
1272/// # See Also
1273///
1274/// - [`get_default_ini_path()`] - Constructs the default configuration path
1275#[must_use]
1276pub fn resolve_config_path(cli_arg: Option<String>, env_var: Option<String>) -> String {
1277    // Check env var first
1278    if let Some(env_path) = env_var {
1279        info!("[resolve_config_path]: Using config from env var.");
1280        return if env_path.contains('~') {
1281            set_proper_home_dir(&env_path)
1282        } else {
1283            env_path
1284        };
1285    }
1286
1287    // Check CLI arg
1288    if let Some(cfg_str) = cli_arg {
1289        if cfg_str.contains('~') {
1290            info!("[resolve_config_path]: CLI path contains '~'.");
1291            set_proper_home_dir(&cfg_str)
1292        } else {
1293            info!("[resolve_config_path]: CLI path without '~'.");
1294            cfg_str
1295        }
1296    } else {
1297        info!("[resolve_config_path]: Using default path.");
1298        get_default_ini_path()
1299    }
1300}
1301
1302#[must_use]
1303#[cfg_attr(coverage_nightly, coverage(off))]
1304pub fn get_or_default_config_ini_path() -> String {
1305    get_or_default_config_ini_path_with(std::env::var("RUSTY_COMMIT_SAVER_CONFIG").ok(), || {
1306        UserInput::parse().config_ini
1307    })
1308}
1309
1310#[must_use]
1311fn get_or_default_config_ini_path_with<F>(env_var: Option<String>, cli_parser: F) -> String
1312where
1313    F: FnOnce() -> Option<String>,
1314{
1315    info!("[get_or_default_config_ini_path()]: Parsing CLI inputs.");
1316
1317    let cli_arg = if env_var.is_some() {
1318        None // Skip parsing if env var is set
1319    } else {
1320        cli_parser()
1321    };
1322
1323    let config_path = resolve_config_path(cli_arg, env_var);
1324    info!("[get_or_default_config_ini_path()]: Config path found: {config_path:}");
1325    config_path
1326}
1327
1328/// Constructs the default configuration file path.
1329///
1330/// Builds the standard XDG configuration path for the application by combining
1331/// the user's home directory with the application-specific config directory.
1332///
1333/// # Returns
1334///
1335/// A `String` with the default INI file path:
1336/// `~/.config/rusty-commit-saver/rusty-commit-saver.ini`
1337///
1338/// # Directory Structure
1339///
1340/// ```text
1341/// ~/.config/
1342///   └── rusty-commit-saver/
1343///       └── rusty-commit-saver.ini
1344/// ```
1345///
1346/// # Panics
1347///
1348/// Panics if the user's home directory cannot be determined
1349/// (via the `dirs::home_dir()` function).
1350///
1351/// # Examples
1352///
1353/// ```ignore
1354/// // Internal usage
1355/// let default_path = get_default_ini_path();
1356/// // Returns: "/home/user/.config/rusty-commit-saver/rusty-commit-saver.ini"
1357/// ```
1358///
1359/// # See Also
1360///
1361/// - [`retrieve_config_file_path()`] - Public API for getting config path
1362#[must_use]
1363pub fn get_default_ini_path() -> String {
1364    info!("[get_default_ini_path()]: Getting default ini file.");
1365    let cfg_str = "~/.config/rusty-commit-saver/rusty-commit-saver.ini".to_string();
1366    set_proper_home_dir(&cfg_str)
1367}
1368
1369/// Loads and parses the INI configuration file from disk.
1370///
1371/// Reads the configuration file (from CLI argument or default location),
1372/// parses its contents using [`parse_ini_content()`], and returns the
1373/// parsed `Ini` object.
1374///
1375/// # Returns
1376///
1377/// A parsed `Ini` configuration object
1378///
1379/// # Panics
1380///
1381/// Panics if:
1382/// - The configuration file doesn't exist at the resolved path
1383/// - The file cannot be read (permission denied, I/O error)
1384/// - The file content is not valid UTF-8
1385/// - The INI syntax is invalid (malformed sections or key-value pairs)
1386///
1387/// # File Resolution Order
1388///
1389/// 1. Check for `--config-ini <PATH>` CLI argument
1390/// 2. Fall back to `~/.config/rusty-commit-saver/rusty-commit-saver.ini`
1391///
1392/// # Expected INI Structure
1393///
1394/// ```text
1395/// [obsidian]
1396/// root_path_dir = ~/Documents/Obsidian
1397/// commit_path = Diaries/Commits
1398///
1399/// [templates]
1400/// commit_date_path = %Y/%m-%B/%F.md
1401/// commit_datetime = %Y-%m-%d %H:%M:%S
1402/// ```
1403///
1404/// # Called By
1405///
1406/// This function is called internally by [`GlobalVars::set_all()`].
1407///
1408/// # See Also
1409///
1410/// - [`retrieve_config_file_path()`] - Resolves the config file path
1411/// - [`parse_ini_content()`] - Parses INI text into `Ini` struct
1412#[must_use]
1413pub fn get_ini_file() -> Ini {
1414    get_ini_file_at(&get_or_default_config_ini_path())
1415}
1416
1417/// Loads and parses the INI configuration file at `config_path`.
1418///
1419/// The path-taking half of [`get_ini_file()`], for a caller that has already
1420/// resolved the path and wants to keep it - [`GlobalVars::set_all()`] retains
1421/// it so a configuration error can name the file to edit.
1422///
1423/// # Panics
1424///
1425/// Panics under the same conditions as [`get_ini_file()`]: the file is
1426/// missing, unreadable, or not valid INI.
1427#[must_use]
1428pub fn get_ini_file_at(config_path: &str) -> Ini {
1429    info!("[get_ini_file()]: Retrieving the INI File");
1430    let content_ini = read_config_file(config_path);
1431    let mut config = Ini::new();
1432    config
1433        .read(content_ini)
1434        .expect("Could not read the INI file!");
1435
1436    info!("[get_ini_file()]: This is the INI File:\n\n{config:?}");
1437    config
1438}
1439
1440/// Expands the tilde (`~`) character to the user's home directory path.
1441///
1442/// Replaces the leading `~` in a path string with the absolute path to the
1443/// user's home directory. If no `~` is present, returns the string unchanged.
1444///
1445/// # Arguments
1446///
1447/// * `cfg_str` - A path string that may contain a leading `~`
1448///
1449/// # Returns
1450///
1451/// A `String` with `~` expanded to the full home directory path
1452///
1453/// # Panics
1454///
1455/// Panics if the user's home directory cannot be determined
1456/// (via the `dirs::home_dir()` function).
1457///
1458/// # Examples
1459///
1460/// ```ignore
1461/// // On Linux/macOS with home at /home/user
1462/// let expanded = set_proper_home_dir("~/Documents/Obsidian");
1463/// assert_eq!(expanded, "/home/user/Documents/Obsidian");
1464///
1465/// // Path without tilde is returned unchanged
1466/// let unchanged = set_proper_home_dir("/absolute/path");
1467/// assert_eq!(unchanged, "/absolute/path");
1468/// ```
1469///
1470/// # Platform Behavior
1471///
1472/// - **Linux/macOS**: Expands to `/home/username` or `/Users/username`
1473/// - **Windows**: Expands to `C:\Users\username`
1474///
1475/// # Used By
1476///
1477/// This function is called by:
1478/// - [`GlobalVars::set_obsidian_root_path_dir()`]
1479/// - [`GlobalVars::set_obsidian_commit_path()`]
1480fn set_proper_home_dir(cfg_str: &str) -> String {
1481    info!("[set_proper_home_dir()]: Changing the '~' to full home directory.");
1482    let home_dir = home_dir()
1483        .expect("Could not get home_dir")
1484        .into_os_string()
1485        .into_string()
1486        .expect("Could not convert home_dir from OsString to String");
1487
1488    cfg_str.replace('~', &home_dir)
1489}
1490
1491/// Whether `chrono` can render this format string.
1492///
1493/// `DateTime::format()` defers the work, and `to_string()` turns an invalid
1494/// specifier into a panic; writing into a `String` returns the error instead,
1495/// which is what makes the format checkable at all.
1496fn is_renderable_time_format(format: &str) -> bool {
1497    use std::fmt::Write;
1498
1499    let probe = DateTime::from_timestamp(0, 0).expect("the epoch is a valid timestamp");
1500    let mut rendered = String::new();
1501
1502    write!(rendered, "{}", probe.format(format)).is_ok()
1503}
1504
1505/// Parses a comma-separated list of repository names into a clean vector.
1506///
1507/// Each entry is trimmed of surrounding whitespace and empty entries are
1508/// dropped, so trailing commas and stray spaces are tolerated.
1509///
1510/// # Arguments
1511///
1512/// * `raw` - The raw comma-separated value (e.g. `"claude-src, other-repo"`)
1513///
1514/// # Returns
1515///
1516/// A `Vec<String>` with one entry per non-empty, trimmed repository name.
1517///
1518/// # Examples
1519///
1520/// ```ignore
1521/// use rusty_commit_saver::config::parse_exclude_repos;
1522///
1523/// assert_eq!(parse_exclude_repos("claude-src, foo"), vec!["claude-src", "foo"]);
1524/// assert_eq!(parse_exclude_repos("  "), Vec::<String>::new());
1525/// ```
1526#[must_use]
1527pub fn parse_exclude_repos(raw: &str) -> Vec<String> {
1528    raw.split(',')
1529        .map(str::trim)
1530        .filter(|s| !s.is_empty())
1531        .map(String::from)
1532        .collect()
1533}
1534
1535#[cfg(test)]
1536#[cfg_attr(coverage_nightly, coverage(off))]
1537mod global_vars_tests {
1538    use super::*;
1539    use std::panic::{self, AssertUnwindSafe};
1540
1541    #[test]
1542    fn test_global_vars_new() {
1543        let global_vars = GlobalVars::new();
1544
1545        assert!(global_vars.config.get().is_none());
1546    }
1547
1548    #[test]
1549    fn test_global_vars_default() {
1550        let global_vars = GlobalVars::default();
1551
1552        assert!(global_vars.config.get().is_none());
1553    }
1554
1555    #[test]
1556    fn test_get_sections_from_config_valid() {
1557        let mut config = Ini::new();
1558        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1559        config.set(
1560            "templates",
1561            "commit_date_path",
1562            Some("%Y-%m-%d".to_string()),
1563        );
1564
1565        let global_vars = GlobalVars::new();
1566        global_vars.config.set(config).unwrap();
1567
1568        let sections = global_vars.get_sections_from_config();
1569
1570        assert_eq!(sections.len(), 2);
1571        assert!(sections.contains(&"obsidian".to_string()));
1572        assert!(sections.contains(&"templates".to_string()));
1573    }
1574
1575    #[test]
1576    fn test_get_sections_from_config_rejects_a_lone_unknown_section() {
1577        let mut config = Ini::new();
1578        config.set("only_one_section", "key", Some("value".to_string()));
1579
1580        let global_vars = GlobalVars::new();
1581        global_vars.config.set(config).unwrap();
1582
1583        let result =
1584            panic::catch_unwind(AssertUnwindSafe(|| global_vars.get_sections_from_config()));
1585
1586        assert!(
1587            result.is_err(),
1588            "Expected panic: the required sections are missing"
1589        );
1590
1591        // Verify the panic message (panic! with string literal = &str)
1592        let panic_info = result.unwrap_err();
1593        let msg = panic_info
1594            .downcast_ref::<&str>()
1595            .expect("Panic message should be &str");
1596        assert!(
1597            msg.contains("must have [obsidian] and [templates]"),
1598            "Unexpected panic message: {msg}"
1599        );
1600    }
1601
1602    #[test]
1603    fn test_get_sections_from_config_panics_with_zero_sections() {
1604        let config = Ini::new();
1605
1606        let global_vars = GlobalVars::new();
1607        global_vars.config.set(config).unwrap();
1608
1609        let result =
1610            panic::catch_unwind(AssertUnwindSafe(|| global_vars.get_sections_from_config()));
1611
1612        assert!(result.is_err(), "Expected panic for zero sections");
1613    }
1614
1615    #[test]
1616    fn test_get_sections_from_config_keeps_an_extra_section() {
1617        let mut config = Ini::new();
1618        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1619        config.set("templates", "commit_date_path", Some("%Y.md".to_string()));
1620        config.set("extra", "key", Some("value".to_string()));
1621
1622        let global_vars = GlobalVars::new();
1623        global_vars.config.set(config).unwrap();
1624
1625        let result =
1626            panic::catch_unwind(AssertUnwindSafe(|| global_vars.get_sections_from_config()));
1627
1628        assert!(
1629            result.is_ok(),
1630            "An unrecognised section must be ignored, not fatal"
1631        );
1632        assert_eq!(result.unwrap().len(), 3);
1633    }
1634
1635    #[test]
1636    fn test_unrecognised_keys_names_a_typo_in_a_known_section() {
1637        let mut config = Ini::new();
1638        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1639        config.set("obsidian", "commit_path", Some("commits".to_string()));
1640        config.set("templates", "commit_date_path", Some("%F.md".to_string()));
1641        // The rename/typo case: the real key is still there, so nothing breaks
1642        // - which is exactly why this used to pass unnoticed.
1643        config.set("templates", "commit_datetime", Some("%T".to_string()));
1644        config.set("templates", "commit_datetimes", Some("%T".to_string()));
1645
1646        let global_vars = GlobalVars::new();
1647        global_vars.config.set(config).unwrap();
1648
1649        assert_eq!(
1650            global_vars.unrecognised_keys(),
1651            vec!["[templates] commit_datetimes".to_string()],
1652            "an unknown key must be named, not swallowed"
1653        );
1654    }
1655
1656    #[test]
1657    fn test_unrecognised_keys_is_empty_for_a_known_config() {
1658        let mut config = Ini::new();
1659        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1660        config.set("obsidian", "commit_path", Some("commits".to_string()));
1661        config.set("templates", "commit_date_path", Some("%F.md".to_string()));
1662        config.set("templates", "commit_datetime", Some("%T".to_string()));
1663        config.set("exclude", "repos", Some("claude-src".to_string()));
1664
1665        let global_vars = GlobalVars::new();
1666        global_vars.config.set(config).unwrap();
1667
1668        assert!(
1669            global_vars.unrecognised_keys().is_empty(),
1670            "a config this binary fully understands must warn about nothing"
1671        );
1672    }
1673
1674    #[test]
1675    fn test_unrecognised_keys_leaves_an_unknown_section_to_the_section_check() {
1676        let mut config = Ini::new();
1677        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1678        config.set("obsidian", "commit_path", Some("commits".to_string()));
1679        config.set("templates", "commit_date_path", Some("%F.md".to_string()));
1680        config.set("templates", "commit_datetime", Some("%T".to_string()));
1681        config.set("future_release", "whatever", Some("value".to_string()));
1682
1683        let global_vars = GlobalVars::new();
1684        global_vars.config.set(config).unwrap();
1685
1686        assert!(
1687            global_vars.unrecognised_keys().is_empty(),
1688            "the section is already reported whole; its keys must not double the noise"
1689        );
1690    }
1691
1692    #[test]
1693    fn test_unrecognised_keys_in_an_unknown_section_is_empty() {
1694        let mut config = Ini::new();
1695        config.set("future_release", "whatever", Some("value".to_string()));
1696
1697        let global_vars = GlobalVars::new();
1698        global_vars.config.set(config).unwrap();
1699
1700        assert!(
1701            global_vars
1702                .unrecognised_keys_in("future_release")
1703                .is_empty(),
1704            "the binary cannot know what an unknown section should contain"
1705        );
1706    }
1707
1708    #[test]
1709    fn test_unrecognised_keys_in_a_section_the_config_lacks_is_empty() {
1710        let mut config = Ini::new();
1711        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1712
1713        let global_vars = GlobalVars::new();
1714        global_vars.config.set(config).unwrap();
1715
1716        assert!(
1717            global_vars.unrecognised_keys_in("exclude").is_empty(),
1718            "a section that is not in the config has no keys to report"
1719        );
1720    }
1721
1722    #[test]
1723    fn test_unrecognised_keys_are_sorted_and_name_every_section() {
1724        let mut config = Ini::new();
1725        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1726        config.set("obsidian", "commit_path", Some("commits".to_string()));
1727        config.set("obsidian", "vault", Some("stale".to_string()));
1728        config.set("templates", "commit_date_path", Some("%F.md".to_string()));
1729        config.set("templates", "commit_datetime", Some("%T".to_string()));
1730        config.set("templates", "author", Some("stale".to_string()));
1731        config.set("exclude", "repos", Some("claude-src".to_string()));
1732        config.set("exclude", "branches", Some("stale".to_string()));
1733
1734        let global_vars = GlobalVars::new();
1735        global_vars.config.set(config).unwrap();
1736
1737        assert_eq!(
1738            global_vars.unrecognised_keys(),
1739            vec![
1740                "[exclude] branches".to_string(),
1741                "[obsidian] vault".to_string(),
1742                "[templates] author".to_string(),
1743            ],
1744            "hash-map order must not leak into the reported list"
1745        );
1746    }
1747
1748    #[test]
1749    fn test_set_obsidian_vars_survives_an_unrecognised_key() {
1750        let mut config = Ini::new();
1751        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1752        config.set("obsidian", "commit_path", Some("commits".to_string()));
1753        config.set("templates", "commit_date_path", Some("%F.md".to_string()));
1754        config.set("templates", "commit_datetime", Some("%T".to_string()));
1755        config.set("templates", "commit_datetimes", Some("%T".to_string()));
1756
1757        let global_vars = GlobalVars::new();
1758        global_vars.config.set(config).unwrap();
1759
1760        let result = panic::catch_unwind(AssertUnwindSafe(|| global_vars.set_obsidian_vars()));
1761
1762        assert!(
1763            result.is_ok(),
1764            "an unrecognised key must be reported, never fatal"
1765        );
1766        assert_eq!(global_vars.get_template_commit_datetime(), "%T");
1767    }
1768
1769    #[test]
1770    fn test_get_key_from_section_from_ini_exists() {
1771        let mut config = Ini::new();
1772        config.set(
1773            "obsidian",
1774            "root_path_dir",
1775            Some("/home/user/Obsidian".to_string()),
1776        );
1777
1778        let global_vars = GlobalVars::new();
1779        global_vars.config.set(config).unwrap();
1780
1781        let result = global_vars.get_key_from_section_from_ini("obsidian", "root_path_dir");
1782
1783        assert_eq!(result, Some("/home/user/Obsidian".to_string()));
1784    }
1785
1786    #[test]
1787    fn test_get_key_from_section_from_ini_not_exists() {
1788        let mut config = Ini::new();
1789        config.set("obsidian", "other_key", Some("value".to_string()));
1790
1791        let global_vars = GlobalVars::new();
1792        global_vars.config.set(config).unwrap();
1793
1794        let result = global_vars.get_key_from_section_from_ini("obsidian", "non_existent_key");
1795
1796        assert_eq!(result, None);
1797    }
1798
1799    #[test]
1800    fn test_get_config() {
1801        let mut config = Ini::new();
1802        config.set("test", "key", Some("value".to_string()));
1803
1804        let global_vars = GlobalVars::new();
1805        global_vars.config.set(config.clone()).unwrap();
1806
1807        let retrieved_config = global_vars.get_config();
1808
1809        assert_eq!(
1810            retrieved_config.get("test", "key"),
1811            Some("value".to_string())
1812        );
1813    }
1814
1815    #[test]
1816    fn test_set_obsidian_root_path_dir_with_tilde() {
1817        let mut config = Ini::new();
1818        config.set(
1819            "obsidian",
1820            "root_path_dir",
1821            Some("~/Documents/Obsidian".to_string()),
1822        );
1823        config.set(
1824            "templates",
1825            "commit_date_path",
1826            Some("%Y-%m-%d".to_string()),
1827        );
1828        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1829
1830        let global_vars = GlobalVars::new();
1831        global_vars.config.set(config).unwrap();
1832        global_vars.set_obsidian_root_path_dir("obsidian");
1833
1834        let result = global_vars.get_obsidian_root_path_dir();
1835
1836        // Should expand ~ to full home path
1837        assert!(!result.to_string_lossy().contains('~'));
1838        // Should start with /
1839        assert!(result.to_string_lossy().starts_with('/'));
1840        // Should end with Obsidian
1841        assert!(result.to_string_lossy().ends_with("Obsidian"));
1842    }
1843
1844    #[test]
1845    fn test_set_obsidian_root_path_dir_absolute_path() {
1846        let mut config = Ini::new();
1847        config.set(
1848            "obsidian",
1849            "root_path_dir",
1850            Some("/absolute/path/Obsidian".to_string()),
1851        );
1852        config.set(
1853            "templates",
1854            "commit_date_path",
1855            Some("%Y-%m-%d".to_string()),
1856        );
1857        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1858
1859        let global_vars = GlobalVars::new();
1860        global_vars.config.set(config).unwrap();
1861        global_vars.set_obsidian_root_path_dir("obsidian");
1862
1863        let result = global_vars.get_obsidian_root_path_dir();
1864
1865        // Should preserve absolute path
1866        assert!(result.to_string_lossy().contains("/absolute/path/Obsidian"));
1867    }
1868
1869    #[test]
1870    fn test_set_obsidian_commit_path_with_tilde() {
1871        let mut config = Ini::new();
1872        config.set(
1873            "obsidian",
1874            "commit_path",
1875            Some("~/Diaries/Commits".to_string()),
1876        );
1877        config.set(
1878            "templates",
1879            "commit_date_path",
1880            Some("%Y-%m-%d".to_string()),
1881        );
1882        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1883
1884        let global_vars = GlobalVars::new();
1885        global_vars.config.set(config).unwrap();
1886        global_vars.set_obsidian_commit_path("obsidian");
1887
1888        let result = global_vars.get_obsidian_commit_path();
1889
1890        // Should expand ~ to full home path
1891        assert!(!result.to_string_lossy().contains('~'));
1892        // Should end with Commits
1893        assert!(result.to_string_lossy().ends_with("Commits"));
1894    }
1895
1896    #[test]
1897    fn test_set_obsidian_commit_path_absolute_path() {
1898        let mut config = Ini::new();
1899        config.set(
1900            "obsidian",
1901            "commit_path",
1902            Some("absolute/Diaries/Commits".to_string()),
1903        );
1904        config.set(
1905            "templates",
1906            "commit_date_path",
1907            Some("%Y-%m-%d".to_string()),
1908        );
1909        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1910
1911        let global_vars = GlobalVars::new();
1912        global_vars.config.set(config).unwrap();
1913        global_vars.set_obsidian_commit_path("obsidian");
1914
1915        let result = global_vars.get_obsidian_commit_path();
1916
1917        // set_obsidian_commit_path() doesn't add leading / (unlike root_path_dir)
1918        // It just splits by / and rebuilds the PathBuf
1919        assert!(result.to_string_lossy().contains("absolute"));
1920        assert!(result.to_string_lossy().ends_with("Commits"));
1921    }
1922
1923    #[test]
1924    fn test_set_templates_commit_date_path() {
1925        let mut config = Ini::new();
1926        config.set(
1927            "templates",
1928            "commit_date_path",
1929            Some("%Y/%m-%B/%F.md".to_string()),
1930        );
1931        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1932
1933        let global_vars = GlobalVars::new();
1934        global_vars.config.set(config).unwrap();
1935        global_vars.set_templates_commit_date_path("templates");
1936
1937        let result = global_vars.get_template_commit_date_path();
1938
1939        assert_eq!(result, "%Y/%m-%B/%F.md");
1940    }
1941
1942    #[test]
1943    fn test_set_templates_datetime() {
1944        let mut config = Ini::new();
1945        config.set(
1946            "templates",
1947            "commit_datetime",
1948            Some("%Y-%m-%d %H:%M:%S".to_string()),
1949        );
1950
1951        let global_vars = GlobalVars::new();
1952        global_vars.config.set(config).unwrap();
1953        global_vars.set_templates_datetime("templates");
1954
1955        let result = global_vars.get_template_commit_datetime();
1956
1957        assert_eq!(result, "%Y-%m-%d %H:%M:%S");
1958    }
1959
1960    #[test]
1961    fn test_set_obsidian_vars_both_sections() {
1962        let mut config = Ini::new();
1963        config.set(
1964            "obsidian",
1965            "root_path_dir",
1966            Some("/home/user/Obsidian".to_string()),
1967        );
1968        config.set(
1969            "obsidian",
1970            "commit_path",
1971            Some("Diaries/Commits".to_string()),
1972        );
1973        config.set(
1974            "templates",
1975            "commit_date_path",
1976            Some("%Y-%m-%d.md".to_string()),
1977        );
1978        config.set(
1979            "templates",
1980            "commit_datetime",
1981            Some("%Y-%m-%d %H:%M:%S".to_string()),
1982        );
1983
1984        let global_vars = GlobalVars::new();
1985        global_vars.config.set(config).unwrap();
1986
1987        // Call the private method indirectly through set_obsidian_vars
1988        global_vars.set_obsidian_vars();
1989
1990        // Verify all getters work (meaning setters were called)
1991        let root_path = global_vars.get_obsidian_root_path_dir();
1992        let commit_path = global_vars.get_obsidian_commit_path();
1993        let date_path = global_vars.get_template_commit_date_path();
1994        let datetime = global_vars.get_template_commit_datetime();
1995
1996        assert!(root_path.to_string_lossy().contains("Obsidian"));
1997        assert!(commit_path.to_string_lossy().contains("Commits"));
1998        assert_eq!(date_path, "%Y-%m-%d.md");
1999        assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
2000    }
2001
2002    #[test]
2003    #[should_panic(expected = "must have [obsidian] and [templates]")]
2004    fn test_set_obsidian_vars_without_the_obsidian_section() {
2005        let mut config = Ini::new();
2006        // [templates] is present but [obsidian] is not, which is fatal. The
2007        // unrecognised section is incidental - it is not what makes this panic.
2008        config.set("invalid_section", "key", Some("value".to_string()));
2009        config.set(
2010            "templates",
2011            "commit_date_path",
2012            Some("%Y-%m-%d.md".to_string()),
2013        );
2014        config.set(
2015            "templates",
2016            "commit_datetime",
2017            Some("%Y-%m-%d %H:%M".to_string()),
2018        );
2019
2020        let global_vars = GlobalVars::new();
2021        global_vars.config.set(config).unwrap();
2022
2023        // Panics: [obsidian] is required and missing.
2024        global_vars.set_obsidian_vars();
2025    }
2026
2027    #[test]
2028    fn test_set_all_integration() {
2029        use std::io::Write;
2030        use tempfile::NamedTempFile;
2031
2032        // Create a temporary config file
2033        let mut temp_file = NamedTempFile::new().unwrap();
2034        writeln!(temp_file, "[obsidian]").unwrap();
2035        writeln!(temp_file, "root_path_dir=/tmp/test_obsidian").unwrap();
2036        writeln!(temp_file, "commit_path=TestDiaries/TestCommits").unwrap();
2037        writeln!(temp_file, "[templates]").unwrap();
2038        writeln!(temp_file, "commit_date_path=%Y-%m-%d.md").unwrap();
2039        writeln!(temp_file, "commit_datetime=%Y-%m-%d %H:%M:%S").unwrap();
2040        temp_file.flush().unwrap();
2041
2042        // Parse the config manually and test set_all
2043        let content = std::fs::read_to_string(temp_file.path()).unwrap();
2044        let config = parse_ini_content(&content).unwrap();
2045
2046        let global_vars = GlobalVars::new();
2047        global_vars.config.set(config).unwrap();
2048        global_vars.set_obsidian_vars();
2049
2050        // Verify all values were set
2051        let root = global_vars.get_obsidian_root_path_dir();
2052        let commit = global_vars.get_obsidian_commit_path();
2053        let date = global_vars.get_template_commit_date_path();
2054        let datetime = global_vars.get_template_commit_datetime();
2055
2056        assert!(root.to_string_lossy().contains("test_obsidian"));
2057        assert!(commit.to_string_lossy().contains("TestCommits"));
2058        assert_eq!(date, "%Y-%m-%d.md");
2059        assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
2060    }
2061
2062    #[test]
2063    #[should_panic(expected = "Could not get")]
2064    fn test_get_obsidian_root_path_dir_not_set() {
2065        let global_vars = GlobalVars::new();
2066        // Don't set any values
2067        // This should panic when trying to get
2068        global_vars.get_obsidian_root_path_dir();
2069    }
2070
2071    #[test]
2072    #[should_panic(expected = "Could not get")]
2073    fn test_get_obsidian_commit_path_not_set() {
2074        let global_vars = GlobalVars::new();
2075        global_vars.get_obsidian_commit_path();
2076    }
2077
2078    #[test]
2079    #[should_panic(expected = "Could not get")]
2080    fn test_get_template_commit_date_path_not_set() {
2081        let global_vars = GlobalVars::new();
2082        global_vars.get_template_commit_date_path();
2083    }
2084
2085    #[test]
2086    #[should_panic(expected = "Could not get")]
2087    fn test_get_template_commit_datetime_not_set() {
2088        let global_vars = GlobalVars::new();
2089        global_vars.get_template_commit_datetime();
2090    }
2091
2092    #[test]
2093    #[should_panic(expected = "Could not get Config")]
2094    fn test_get_config_not_initialized() {
2095        let global_vars = GlobalVars::new();
2096        // Config not set
2097        global_vars.get_config();
2098    }
2099
2100    #[test]
2101    fn test_set_config_twice_fails() {
2102        let global_vars = GlobalVars::new();
2103        let config1 = Ini::new();
2104        let config2 = Ini::new();
2105
2106        assert!(global_vars.config.set(config1).is_ok());
2107        // Second set should fail
2108        assert!(global_vars.config.set(config2).is_err());
2109    }
2110
2111    #[test]
2112    fn test_global_vars_set_all_end_to_end() {
2113        use std::io::Write;
2114        use tempfile::NamedTempFile;
2115
2116        // Create a real config file
2117        let mut temp_file = NamedTempFile::new().unwrap();
2118        writeln!(temp_file, "[obsidian]").unwrap();
2119        writeln!(temp_file, "root_path_dir=/tmp/obsidian_test").unwrap();
2120        writeln!(temp_file, "commit_path=TestDiaries/TestCommits").unwrap();
2121        writeln!(temp_file, "[templates]").unwrap();
2122        writeln!(temp_file, "commit_date_path=%Y/%m-%B/%F.md").unwrap();
2123        writeln!(temp_file, "commit_datetime=%Y-%m-%d %H:%M:%S").unwrap();
2124        temp_file.flush().unwrap();
2125
2126        // Read and parse the config
2127        let content = std::fs::read_to_string(temp_file.path()).unwrap();
2128        let mut config = Ini::new();
2129        config.read(content).unwrap();
2130
2131        // Now test set_all
2132        let global_vars = GlobalVars::new();
2133        let result = global_vars.config.set(config);
2134        assert!(result.is_ok());
2135
2136        // Call set_obsidian_vars (which set_all would call)
2137        global_vars.set_obsidian_vars();
2138
2139        // Verify everything is accessible
2140        let root = global_vars.get_obsidian_root_path_dir();
2141        let commit = global_vars.get_obsidian_commit_path();
2142        let date_path = global_vars.get_template_commit_date_path();
2143        let datetime = global_vars.get_template_commit_datetime();
2144
2145        assert!(root.to_string_lossy().contains("obsidian_test"));
2146        assert!(commit.to_string_lossy().contains("TestCommits"));
2147        assert_eq!(date_path, "%Y/%m-%B/%F.md");
2148        assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
2149    }
2150
2151    #[test]
2152    fn test_set_obsidian_root_path_dir_with_trailing_slash() {
2153        let mut config = Ini::new();
2154        config.set("obsidian", "root_path_dir", Some("/tmp/test/".to_string()));
2155        config.set(
2156            "templates",
2157            "commit_date_path",
2158            Some("%Y-%m-%d".to_string()),
2159        );
2160        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2161
2162        let global_vars = GlobalVars::new();
2163        global_vars.config.set(config).unwrap();
2164        global_vars.set_obsidian_root_path_dir("obsidian");
2165
2166        let result = global_vars.get_obsidian_root_path_dir();
2167
2168        // Should handle trailing slashes gracefully
2169        assert!(result.to_string_lossy().contains("test"));
2170    }
2171
2172    #[test]
2173    fn test_set_obsidian_commit_path_with_multiple_slashes() {
2174        let mut config = Ini::new();
2175        config.set(
2176            "obsidian",
2177            "commit_path",
2178            Some("Diaries//Commits///Nested".to_string()),
2179        );
2180        config.set(
2181            "templates",
2182            "commit_date_path",
2183            Some("%Y-%m-%d".to_string()),
2184        );
2185        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2186
2187        let global_vars = GlobalVars::new();
2188        global_vars.config.set(config).unwrap();
2189        global_vars.set_obsidian_commit_path("obsidian");
2190
2191        let result = global_vars.get_obsidian_commit_path();
2192
2193        // Path should be constructed despite multiple slashes
2194        assert!(result.to_string_lossy().contains("Nested"));
2195    }
2196
2197    #[test]
2198    fn test_set_obsidian_root_path_dir_empty_string() {
2199        // This used to assert the opposite - that an empty root_path_dir still
2200        // produced a usable PathBuf. It did: `/`. The vault root silently
2201        // became the filesystem root, and the run carried on at exit 0. A
2202        // blank value now counts as a missing key, which is the whole point of
2203        // the check; the old contract is deliberately withdrawn.
2204        let mut config = Ini::new();
2205        config.set("obsidian", "root_path_dir", Some(String::new()));
2206        config.set(
2207            "templates",
2208            "commit_date_path",
2209            Some("%Y-%m-%d".to_string()),
2210        );
2211        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2212
2213        let global_vars = GlobalVars::new();
2214        global_vars.config.set(config).unwrap();
2215
2216        let result = panic::catch_unwind(AssertUnwindSafe(|| {
2217            global_vars.set_obsidian_root_path_dir("obsidian")
2218        }));
2219
2220        let panic_info = result.expect_err("a blank root_path_dir must be fatal");
2221        let msg = panic_info
2222            .downcast_ref::<String>()
2223            .expect("panic message should be a formatted String");
2224
2225        assert!(
2226            msg.contains("missing required key 'root_path_dir' in section [obsidian]"),
2227            "a blank value must be reported as the missing key it is: {msg}"
2228        );
2229    }
2230
2231    #[test]
2232    #[should_panic(expected = "missing required key 'commit_path' in section [obsidian]")]
2233    fn test_set_obsidian_commit_path_missing_key() {
2234        let mut config = Ini::new();
2235        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
2236        config.set(
2237            "templates",
2238            "commit_date_path",
2239            Some("%Y-%m-%d".to_string()),
2240        );
2241        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2242
2243        let global_vars = GlobalVars::new();
2244        global_vars.config.set(config).unwrap();
2245
2246        global_vars.set_obsidian_commit_path("obsidian");
2247    }
2248
2249    #[test]
2250    #[should_panic(expected = "missing required key 'root_path_dir' in section [obsidian]")]
2251    fn test_set_obsidian_root_path_dir_missing_key() {
2252        let mut config = Ini::new();
2253        config.set("obsidian", "commit_path", Some("commits".to_string()));
2254        config.set(
2255            "templates",
2256            "commit_date_path",
2257            Some("%Y-%m-%d".to_string()),
2258        );
2259        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2260
2261        let global_vars = GlobalVars::new();
2262        global_vars.config.set(config).unwrap();
2263
2264        global_vars.set_obsidian_root_path_dir("obsidian");
2265    }
2266
2267    #[test]
2268    #[should_panic(expected = "missing required key 'commit_date_path' in section [templates]")]
2269    fn test_set_templates_commit_date_path_missing_key() {
2270        let mut config = Ini::new();
2271        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2272        config.set("obsidian", "root_path_dir", Some("/tmp".to_string()));
2273        config.set("obsidian", "commit_path", Some("commits".to_string()));
2274
2275        let global_vars = GlobalVars::new();
2276        global_vars.config.set(config).unwrap();
2277
2278        global_vars.set_templates_commit_date_path("templates");
2279    }
2280
2281    #[test]
2282    #[should_panic(expected = "missing required key 'commit_datetime' in section [templates]")]
2283    fn test_set_templates_datetime_missing_key() {
2284        let mut config = Ini::new();
2285        config.set(
2286            "templates",
2287            "commit_date_path",
2288            Some("%Y-%m-%d".to_string()),
2289        );
2290        config.set("obsidian", "root_path_dir", Some("/tmp".to_string()));
2291        config.set("obsidian", "commit_path", Some("commits".to_string()));
2292
2293        let global_vars = GlobalVars::new();
2294        global_vars.config.set(config).unwrap();
2295
2296        global_vars.set_templates_datetime("templates");
2297    }
2298
2299    #[test]
2300    #[should_panic(expected = "missing required key 'commit_path' in section [obsidian]")]
2301    fn test_require_key_treats_a_blank_value_as_missing() {
2302        // `commit_path =` used to satisfy the presence check and journal into
2303        // the vault root instead of the configured folder, at exit 0.
2304        let mut config = Ini::new();
2305        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
2306        config.set("obsidian", "commit_path", Some("   ".to_string()));
2307
2308        let global_vars = GlobalVars::new();
2309        global_vars.config.set(config).unwrap();
2310
2311        global_vars.set_obsidian_commit_path("obsidian");
2312    }
2313
2314    #[test]
2315    fn test_require_key_names_the_config_file_and_the_typo() {
2316        let mut config = Ini::new();
2317        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
2318        // The whole point: the missing key and the reason it is missing get
2319        // named together, so nobody has to read the source to connect them.
2320        config.set("obsidian", "commit_paths", Some("commits".to_string()));
2321
2322        let global_vars = GlobalVars::new();
2323        global_vars.config.set(config).unwrap();
2324        global_vars
2325            .config_path
2326            .set("/tmp/some/rusty-commit-saver.ini".to_string())
2327            .unwrap();
2328
2329        let result = panic::catch_unwind(AssertUnwindSafe(|| {
2330            global_vars.set_obsidian_commit_path("obsidian")
2331        }));
2332
2333        let panic_info = result.expect_err("a missing required key must be fatal");
2334        let msg = panic_info
2335            .downcast_ref::<String>()
2336            .expect("panic message should be a formatted String");
2337
2338        assert!(
2339            msg.contains("/tmp/some/rusty-commit-saver.ini"),
2340            "the message must name the file to edit: {msg}"
2341        );
2342        assert!(
2343            msg.contains("missing required key 'commit_path' in section [obsidian]"),
2344            "the message must name the key and its section: {msg}"
2345        );
2346        assert!(
2347            msg.contains("unrecognised in [obsidian]: commit_paths"),
2348            "the message must name the typo that explains the absence: {msg}"
2349        );
2350    }
2351
2352    #[test]
2353    fn test_require_time_format_rejects_what_chrono_cannot_render() {
2354        let mut config = Ini::new();
2355        config.set("templates", "commit_datetime", Some("%Q".to_string()));
2356
2357        let global_vars = GlobalVars::new();
2358        global_vars.config.set(config).unwrap();
2359        global_vars
2360            .config_path
2361            .set("/tmp/some/rusty-commit-saver.ini".to_string())
2362            .unwrap();
2363
2364        let result = panic::catch_unwind(AssertUnwindSafe(|| {
2365            global_vars.set_templates_datetime("templates")
2366        }));
2367
2368        let panic_info = result.expect_err("a format chrono cannot render must be fatal");
2369        let msg = panic_info
2370            .downcast_ref::<String>()
2371            .expect("panic message should be a formatted String");
2372
2373        assert!(
2374            msg.contains("/tmp/some/rusty-commit-saver.ini"),
2375            "the message must name the file to edit: {msg}"
2376        );
2377        assert!(
2378            msg.contains("key 'commit_datetime' in section [templates]"),
2379            "the message must name the key and its section: {msg}"
2380        );
2381        assert!(
2382            msg.contains("'%Q'"),
2383            "the message must quote the value that cannot be rendered: {msg}"
2384        );
2385    }
2386
2387    #[test]
2388    fn test_require_time_format_guards_the_date_path_too() {
2389        // Both format keys go through the same check. Without this, reverting
2390        // the date-path call site alone would leave every test green.
2391        let mut config = Ini::new();
2392        config.set(
2393            "templates",
2394            "commit_date_path",
2395            Some("%Y/%Q.md".to_string()),
2396        );
2397
2398        let global_vars = GlobalVars::new();
2399        global_vars.config.set(config).unwrap();
2400
2401        let result = panic::catch_unwind(AssertUnwindSafe(|| {
2402            global_vars.set_templates_commit_date_path("templates")
2403        }));
2404
2405        let panic_info = result.expect_err("a format chrono cannot render must be fatal");
2406        let msg = panic_info
2407            .downcast_ref::<String>()
2408            .expect("panic message should be a formatted String");
2409
2410        assert!(
2411            msg.contains("key 'commit_date_path' in section [templates]"),
2412            "the message must name the key and its section: {msg}"
2413        );
2414    }
2415
2416    #[test]
2417    fn test_is_renderable_time_format_accepts_the_shipped_defaults() {
2418        assert!(is_renderable_time_format("%H:%M:%S"));
2419        assert!(is_renderable_time_format("%Y/%m-%B/%F.md"));
2420        assert!(
2421            is_renderable_time_format("Commits"),
2422            "a format with no specifier at all is still renderable"
2423        );
2424        assert!(!is_renderable_time_format("%Q"));
2425    }
2426
2427    #[test]
2428    fn test_require_key_says_which_config_when_none_was_read() {
2429        let mut config = Ini::new();
2430        config.set("obsidian", "commit_path", Some("commits".to_string()));
2431
2432        let global_vars = GlobalVars::new();
2433        global_vars.config.set(config).unwrap();
2434
2435        let result = panic::catch_unwind(AssertUnwindSafe(|| {
2436            global_vars.set_obsidian_root_path_dir("obsidian")
2437        }));
2438
2439        let panic_info = result.expect_err("a missing required key must be fatal");
2440        let msg = panic_info
2441            .downcast_ref::<String>()
2442            .expect("panic message should be a formatted String");
2443
2444        assert!(
2445            msg.contains("the rusty-commit-saver config"),
2446            "with no file read, the message must say so rather than guess a path: {msg}"
2447        );
2448    }
2449
2450    #[test]
2451    fn test_global_vars_set_all_method() {
2452        use std::io::Write;
2453        use tempfile::NamedTempFile;
2454
2455        // Create a real config file
2456        let mut temp_file = NamedTempFile::new().unwrap();
2457        writeln!(temp_file, "[obsidian]").unwrap();
2458        writeln!(temp_file, "root_path_dir=/tmp/obsidian_full_test").unwrap();
2459        writeln!(temp_file, "commit_path=FullTest/Commits").unwrap();
2460        writeln!(temp_file, "[templates]").unwrap();
2461        writeln!(temp_file, "commit_date_path=%Y/%m/%d.md").unwrap();
2462        writeln!(temp_file, "commit_datetime=%Y-%m-%d %H:%M:%S").unwrap();
2463        temp_file.flush().unwrap();
2464
2465        // Parse config manually
2466        let content = std::fs::read_to_string(temp_file.path()).unwrap();
2467        let config = parse_ini_content(&content).unwrap();
2468
2469        // Test set_all workflow
2470        let global_vars = GlobalVars::new();
2471        global_vars.config.set(config).unwrap();
2472        global_vars.set_obsidian_vars();
2473
2474        // Verify all values accessible via set_all pattern
2475        let root = global_vars.get_obsidian_root_path_dir();
2476        let commit = global_vars.get_obsidian_commit_path();
2477        let date = global_vars.get_template_commit_date_path();
2478        let datetime = global_vars.get_template_commit_datetime();
2479
2480        assert!(root.to_string_lossy().contains("obsidian_full_test"));
2481        assert!(commit.to_string_lossy().contains("FullTest"));
2482        assert_eq!(date, "%Y/%m/%d.md");
2483        assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
2484    }
2485
2486    #[test]
2487    fn test_set_obsidian_vars_complete_workflow() {
2488        let mut config = Ini::new();
2489        config.set(
2490            "obsidian",
2491            "root_path_dir",
2492            Some("~/test/obsidian".to_string()),
2493        );
2494        config.set(
2495            "obsidian",
2496            "commit_path",
2497            Some("~/test/commits".to_string()),
2498        );
2499        config.set(
2500            "templates",
2501            "commit_date_path",
2502            Some("%Y/%m/%d.md".to_string()),
2503        );
2504        config.set(
2505            "templates",
2506            "commit_datetime",
2507            Some("%Y-%m-%d %H:%M:%S".to_string()),
2508        );
2509
2510        let global_vars = GlobalVars::new();
2511        global_vars.config.set(config).unwrap();
2512
2513        // This exercises the full set_obsidian_vars logic
2514        global_vars.set_obsidian_vars();
2515
2516        // Verify all paths were expanded
2517        let root = global_vars.get_obsidian_root_path_dir();
2518        let commit = global_vars.get_obsidian_commit_path();
2519
2520        // Both should have ~ expanded
2521        assert!(!root.to_string_lossy().contains('~'));
2522        assert!(!commit.to_string_lossy().contains('~'));
2523        assert!(root.to_string_lossy().contains("obsidian"));
2524        assert!(commit.to_string_lossy().contains("commits"));
2525    }
2526}
2527
2528#[cfg(test)]
2529#[cfg_attr(coverage_nightly, coverage(off))]
2530mod user_input_tests {
2531    use super::*;
2532    use clap::Parser;
2533    use std::sync::{Mutex, MutexGuard, PoisonError};
2534
2535    /// `RUSTY_COMMIT_SAVER_CONFIG` is process-global, and several tests here
2536    /// both write and read it. Under `cargo test` they share one process and
2537    /// race: a test reading the var can see the value another test just set.
2538    /// (`cargo nextest`, which the gate uses, runs each test in its own
2539    /// process and never sees this.)
2540    static CONFIG_ENV: Mutex<()> = Mutex::new(());
2541
2542    /// Takes the lock above, ignoring poisoning - several of these tests panic
2543    /// deliberately, and a poisoned lock is not a reason to fail the rest.
2544    fn lock_config_env() -> MutexGuard<'static, ()> {
2545        CONFIG_ENV.lock().unwrap_or_else(PoisonError::into_inner)
2546    }
2547
2548    #[test]
2549    fn test_user_input_parse_with_config() {
2550        let args = vec!["test_program", "--config-ini", "/path/to/config.ini"];
2551        let user_input = UserInput::try_parse_from(args).unwrap();
2552
2553        assert_eq!(
2554            user_input.config_ini,
2555            Some("/path/to/config.ini".to_string())
2556        );
2557    }
2558
2559    #[test]
2560    fn test_user_input_parse_without_config() {
2561        let args = vec!["test_program"];
2562        let user_input = UserInput::try_parse_from(args).unwrap();
2563
2564        assert_eq!(user_input.config_ini, None);
2565    }
2566
2567    #[test]
2568    fn test_user_input_parse_short_flag() {
2569        let args = vec!["test_program", "-c", "/short/path/config.ini"];
2570        let user_input = UserInput::try_parse_from(args).unwrap();
2571
2572        assert_eq!(
2573            user_input.config_ini,
2574            Some("/short/path/config.ini".to_string())
2575        );
2576    }
2577
2578    #[test]
2579    fn test_set_proper_home_dir_with_tilde() {
2580        let input = "~/test/path/file.ini";
2581        let result = set_proper_home_dir(input);
2582
2583        // Should replace ~ with actual home directory
2584        assert!(!result.contains('~'));
2585        assert!(result.ends_with("/test/path/file.ini"));
2586    }
2587
2588    #[test]
2589    fn test_set_proper_home_dir_without_tilde() {
2590        let input = "/absolute/path/file.ini";
2591        let result = set_proper_home_dir(input);
2592
2593        // Should remain unchanged
2594        assert_eq!(result, input);
2595    }
2596
2597    #[test]
2598    fn test_set_proper_home_dir_multiple_tildes() {
2599        let input = "~/path/~/file.ini";
2600        let result = set_proper_home_dir(input);
2601
2602        // Should replace ALL tildes
2603        assert!(!result.contains('~'));
2604    }
2605
2606    #[test]
2607    fn test_get_default_ini_path() {
2608        let result = get_default_ini_path();
2609
2610        // Should end with the expected config path
2611        assert!(result.ends_with(".config/rusty-commit-saver/rusty-commit-saver.ini"));
2612
2613        // Should NOT contain literal tilde
2614        assert!(!result.contains('~'));
2615
2616        // Should be an absolute path
2617        assert!(result.starts_with('/'));
2618    }
2619
2620    #[test]
2621    fn test_get_or_default_config_ini_path_with_config_and_tilde() {
2622        // Simulate CLI args: --config-ini ~/my/config.ini
2623        let args = vec!["test", "--config-ini", "~/my/config.ini"];
2624        let user_input = UserInput::try_parse_from(args).unwrap();
2625
2626        // We can't directly call get_or_default_config_ini_path() because it parses env args
2627        // Instead, test that UserInput correctly parses the config path
2628        assert_eq!(user_input.config_ini, Some("~/my/config.ini".to_string()));
2629    }
2630
2631    #[test]
2632    fn test_get_or_default_config_ini_path_with_config_absolute_path() {
2633        // Simulate CLI args: --config-ini /absolute/path/config.ini
2634        let args = vec!["test", "--config-ini", "/absolute/path/config.ini"];
2635        let user_input = UserInput::try_parse_from(args).unwrap();
2636
2637        assert_eq!(
2638            user_input.config_ini,
2639            Some("/absolute/path/config.ini".to_string())
2640        );
2641    }
2642
2643    #[test]
2644    fn test_get_or_default_config_ini_path_without_config() {
2645        // Simulate CLI args with no config specified
2646        let args = vec!["test"];
2647        let user_input = UserInput::try_parse_from(args).unwrap();
2648
2649        // Should default to None, and get_or_default_config_ini_path() will use get_default_ini_path()
2650        assert_eq!(user_input.config_ini, None);
2651    }
2652
2653    #[test]
2654    fn test_parse_ini_content_valid() {
2655        let content = r"
2656[obsidian]
2657root_path_dir=~/Documents/Obsidian
2658commit_path=Diaries/Commits
2659
2660[templates]
2661commit_date_path=%Y/%m-%B/%F.md
2662commit_datetime=%Y-%m-%d
2663";
2664
2665        let result = parse_ini_content(content);
2666        assert!(result.is_ok());
2667
2668        let ini = result.unwrap();
2669        assert_eq!(
2670            ini.get("obsidian", "root_path_dir"),
2671            Some("~/Documents/Obsidian".to_string())
2672        );
2673        assert_eq!(
2674            ini.get("templates", "commit_date_path"),
2675            Some("%Y/%m-%B/%F.md".to_string())
2676        );
2677    }
2678
2679    #[test]
2680    fn test_parse_ini_content_invalid() {
2681        let content = "this is not valid ini format [[[";
2682
2683        let result = parse_ini_content(content);
2684        // Should succeed because configparser is very lenient, but let's verify it doesn't panic
2685        assert!(result.is_ok() || result.is_err());
2686    }
2687
2688    #[test]
2689    fn test_parse_ini_content_empty() {
2690        let content = "";
2691
2692        let result = parse_ini_content(content);
2693        assert!(result.is_ok());
2694
2695        let ini = result.unwrap();
2696        assert_eq!(ini.sections().len(), 0);
2697    }
2698
2699    #[test]
2700    fn test_retrieve_config_file_path_with_temp_file() {
2701        use std::io::Write;
2702        use tempfile::NamedTempFile;
2703
2704        // Create a temporary config file
2705        let mut temp_file = NamedTempFile::new().unwrap();
2706        writeln!(temp_file, "[obsidian]").unwrap();
2707        writeln!(temp_file, "root_path_dir=/tmp/test").unwrap();
2708        writeln!(temp_file, "commit_path=commits").unwrap();
2709        writeln!(temp_file, "[templates]").unwrap();
2710        writeln!(temp_file, "commit_date_path=%Y-%m-%d.md").unwrap();
2711        writeln!(temp_file, "commit_datetime=%Y-%m-%d").unwrap();
2712        temp_file.flush().unwrap();
2713
2714        // Set CLI args to point to our temp file
2715        // We need to simulate CLI args via environment
2716        let path = temp_file.path().to_str().unwrap();
2717
2718        // Instead of testing retrieve_config_file_path directly (which reads from CLI),
2719        // test that we can read and parse a config file
2720        let content = std::fs::read_to_string(path).unwrap();
2721        let result = parse_ini_content(&content);
2722
2723        assert!(result.is_ok());
2724        let ini = result.unwrap();
2725        assert_eq!(
2726            ini.get("obsidian", "root_path_dir"),
2727            Some("/tmp/test".to_string())
2728        );
2729    }
2730
2731    #[test]
2732    fn test_ini_parsing_integration() {
2733        let content = r"
2734[obsidian]
2735root_path_dir=~/Documents/Obsidian
2736commit_path=Diaries/Commits
2737
2738[templates]
2739commit_date_path=%Y/%m-%B/%F.md
2740commit_datetime=%Y-%m-%d %H:%M:%S
2741";
2742
2743        let ini = parse_ini_content(content).unwrap();
2744
2745        // Verify all expected keys exist
2746        assert!(ini.get("obsidian", "root_path_dir").is_some());
2747        assert!(ini.get("obsidian", "commit_path").is_some());
2748        assert!(ini.get("templates", "commit_date_path").is_some());
2749        assert!(ini.get("templates", "commit_datetime").is_some());
2750
2751        // Verify sections count
2752        assert_eq!(ini.sections().len(), 2);
2753    }
2754
2755    // #[test]
2756    // fn debug_ini_sections_behavior() {
2757    //     let mut config = Ini::new();
2758    //     config.set("only_one_section", "key", Some("value".to_string()));
2759    //
2760    //     let sections = config.sections();
2761    //     println!("Sections count: {}", sections.len());
2762    //     println!("Sections: {:?}", sections);
2763    //
2764    //     // Force fail to see output
2765    //     assert!(false, "Debug: sections = {:?}", sections);
2766    // }
2767
2768    #[test]
2769    fn test_missing_required_sections_panic_message() {
2770        use std::panic;
2771
2772        let mut config = Ini::new();
2773        config.set("only_one_section", "key", Some("value".to_string()));
2774
2775        let global_vars = GlobalVars::new();
2776        global_vars.config.set(config).unwrap();
2777
2778        let result = panic::catch_unwind(|| global_vars.get_sections_from_config());
2779
2780        assert!(result.is_err(), "Should have panicked");
2781    }
2782
2783    #[test]
2784    fn test_set_all_loads_config_and_sets_vars() {
2785        use std::env;
2786        use std::fs;
2787        use tempfile::NamedTempFile;
2788
2789        let temp_file = NamedTempFile::new().expect("Failed to create temp file");
2790        let config_content = r"[obsidian]
2791root_path_dir = /tmp/test_obsidian
2792commit_path = Commits
2793
2794[templates]
2795commit_date_path = %Y/%m/%d.md
2796commit_datetime = %Y-%m-%d %H:%M:%S
2797";
2798        fs::write(temp_file.path(), config_content).expect("Failed to write temp config");
2799
2800        let _guard = lock_config_env();
2801        env::set_var(
2802            "RUSTY_COMMIT_SAVER_CONFIG",
2803            temp_file.path().to_str().unwrap(),
2804        );
2805
2806        let global_vars = GlobalVars::new();
2807        let result = global_vars.set_all();
2808
2809        // Verify method chaining
2810        assert!(std::ptr::eq(result, &raw const global_vars));
2811
2812        // Verify config was set
2813        assert!(global_vars.config.get().is_some());
2814
2815        env::remove_var("RUSTY_COMMIT_SAVER_CONFIG");
2816    }
2817
2818    #[test]
2819    #[should_panic(expected = "config_path DOES NOT exists")]
2820    fn test_retrieve_config_file_path_panics_on_missing_file() {
2821        let _guard = lock_config_env();
2822        std::env::set_var("RUSTY_COMMIT_SAVER_CONFIG", "/nonexistent/path/config.ini");
2823        let _ = retrieve_config_file_path();
2824    }
2825
2826    #[test]
2827    fn test_get_or_default_config_ini_path_env_var_with_tilde() {
2828        use std::env;
2829
2830        let _guard = lock_config_env();
2831        let var_name = "RUSTY_COMMIT_SAVER_CONFIG";
2832        let original = env::var(var_name).ok();
2833
2834        env::set_var(var_name, "~/some/config/path.ini");
2835
2836        let result = get_or_default_config_ini_path();
2837
2838        // Restore
2839        match original {
2840            Some(val) => env::set_var(var_name, val),
2841            None => env::remove_var(var_name),
2842        }
2843
2844        // Should have expanded ~ to home dir
2845        assert!(!result.contains('~'), "Tilde should be expanded");
2846        assert!(result.ends_with("/some/config/path.ini"));
2847    }
2848
2849    #[test]
2850    fn test_resolve_config_path_cli_with_tilde() {
2851        let result = resolve_config_path(Some("~/my/config.ini".to_string()), None);
2852        assert!(!result.contains('~'));
2853        assert!(result.ends_with("/my/config.ini"));
2854    }
2855
2856    #[test]
2857    fn test_resolve_config_path_cli_without_tilde() {
2858        let result = resolve_config_path(Some("/absolute/path.ini".to_string()), None);
2859        assert_eq!(result, "/absolute/path.ini");
2860    }
2861
2862    #[test]
2863    fn test_resolve_config_path_default() {
2864        let result = resolve_config_path(None, None);
2865        assert!(result.contains("rusty-commit-saver.ini"));
2866    }
2867
2868    #[test]
2869    fn test_resolve_config_path_env_takes_precedence() {
2870        let result = resolve_config_path(
2871            Some("/cli/path.ini".to_string()),
2872            Some("/env/path.ini".to_string()),
2873        );
2874        assert_eq!(result, "/env/path.ini");
2875    }
2876
2877    #[test]
2878    fn test_get_or_default_config_ini_path_with_no_env_calls_cli_parser() {
2879        let parser_called = std::cell::Cell::new(false);
2880
2881        let result = get_or_default_config_ini_path_with(None, || {
2882            parser_called.set(true);
2883            Some("/mock/cli/path.ini".to_string())
2884        });
2885
2886        assert!(
2887            parser_called.get(),
2888            "CLI parser should be called when no env var"
2889        );
2890        assert_eq!(result, "/mock/cli/path.ini");
2891    }
2892
2893    #[test]
2894    fn test_get_or_default_config_ini_path_with_env_skips_cli_parser() {
2895        let parser_called = std::cell::Cell::new(false);
2896
2897        let result = get_or_default_config_ini_path_with(Some("/env/path.ini".to_string()), || {
2898            parser_called.set(true);
2899            Some("/should/not/be/used.ini".to_string())
2900        });
2901
2902        assert!(
2903            !parser_called.get(),
2904            "CLI parser should NOT be called when env var is set"
2905        );
2906        assert_eq!(result, "/env/path.ini");
2907    }
2908
2909    #[test]
2910    fn test_get_or_default_config_ini_path_with_cli_returns_default_when_none() {
2911        let result = get_or_default_config_ini_path_with(None, || None);
2912
2913        // Should fall back to default path
2914        assert!(result.contains("rusty-commit-saver.ini"));
2915    }
2916
2917    #[test]
2918    fn test_get_or_default_config_ini_path_with_cli_tilde_expansion() {
2919        let result =
2920            get_or_default_config_ini_path_with(None, || Some("~/custom/config.ini".to_string()));
2921
2922        assert!(!result.contains('~'), "Tilde should be expanded");
2923        assert!(result.ends_with("/custom/config.ini"));
2924    }
2925
2926    #[test]
2927    fn test_parse_ini_content_find_invalid_case() {
2928        let cases = [
2929            "[unclosed",
2930            "no_section_key = value", // might actually work (goes to default section)
2931            "[section]\nweird line without equals",
2932        ];
2933
2934        for case in cases {
2935            let result = parse_ini_content(case);
2936            println!("{case:?} => {result:?}");
2937        }
2938    }
2939
2940    #[test]
2941    fn test_parse_ini_content_invalid_syntax() {
2942        let result = parse_ini_content("[unclosed");
2943
2944        assert!(result.is_err(), "Should fail on unclosed bracket");
2945        let err = result.unwrap_err();
2946        assert!(
2947            err.contains("Failed to parse INI"),
2948            "Error should contain expected prefix: {err}"
2949        );
2950    }
2951
2952    #[test]
2953    #[should_panic(expected = "Should have been able to read the file")]
2954    fn test_retrieve_config_file_path_panics_on_unreadable_file() {
2955        use std::fs::{self, File};
2956        use std::os::unix::fs::PermissionsExt;
2957        use tempfile::tempdir;
2958
2959        let dir = tempdir().unwrap();
2960        let file_path = dir.path().join("unreadable.ini");
2961
2962        // Create file with no read permissions
2963        File::create(&file_path).unwrap();
2964        fs::set_permissions(&file_path, fs::Permissions::from_mode(0o000)).unwrap();
2965
2966        let _guard = lock_config_env();
2967        std::env::set_var("RUSTY_COMMIT_SAVER_CONFIG", file_path.to_str().unwrap());
2968
2969        // This should panic because file exists but can't be read
2970        let _ = retrieve_config_file_path();
2971
2972        // Cleanup (won't run due to panic, but good practice)
2973        fs::set_permissions(&file_path, fs::Permissions::from_mode(0o644)).unwrap();
2974    }
2975
2976    #[test]
2977    fn test_get_ini_file_reads_the_configured_path() {
2978        use std::io::Write;
2979        use tempfile::NamedTempFile;
2980
2981        let mut temp_file = NamedTempFile::new().unwrap();
2982        writeln!(temp_file, "[obsidian]").unwrap();
2983        writeln!(temp_file, "root_path_dir=/tmp/test").unwrap();
2984        writeln!(temp_file, "commit_path=commits").unwrap();
2985        writeln!(temp_file, "[templates]").unwrap();
2986        writeln!(temp_file, "commit_date_path=%Y-%m-%d.md").unwrap();
2987        writeln!(temp_file, "commit_datetime=%H:%M:%S").unwrap();
2988        temp_file.flush().unwrap();
2989
2990        let _guard = lock_config_env();
2991        std::env::set_var("RUSTY_COMMIT_SAVER_CONFIG", temp_file.path());
2992
2993        // The convenience wrapper must resolve the path the same way
2994        // set_all() does, now that set_all() resolves it itself.
2995        let config = get_ini_file();
2996
2997        std::env::remove_var("RUSTY_COMMIT_SAVER_CONFIG");
2998
2999        assert_eq!(
3000            config.get("obsidian", "commit_path"),
3001            Some("commits".to_string())
3002        );
3003    }
3004
3005    #[test]
3006    fn test_parse_exclude_repos_basic() {
3007        assert_eq!(
3008            parse_exclude_repos("claude-src, other-repo"),
3009            vec!["claude-src".to_string(), "other-repo".to_string()]
3010        );
3011    }
3012
3013    #[test]
3014    fn test_parse_exclude_repos_trims_and_drops_empties() {
3015        // Extra spaces, trailing comma, and an empty middle entry are all cleaned.
3016        assert_eq!(
3017            parse_exclude_repos("  claude-src , , foo,"),
3018            vec!["claude-src".to_string(), "foo".to_string()]
3019        );
3020    }
3021
3022    #[test]
3023    fn test_parse_exclude_repos_empty_input() {
3024        assert!(parse_exclude_repos("   ").is_empty());
3025        assert!(parse_exclude_repos("").is_empty());
3026    }
3027
3028    #[test]
3029    fn test_parse_exclude_repos_single_entry() {
3030        assert_eq!(
3031            parse_exclude_repos("claude-src"),
3032            vec!["claude-src".to_string()]
3033        );
3034    }
3035
3036    #[test]
3037    fn test_get_excluded_repos_defaults_empty_when_unset() {
3038        // No [exclude] section set: getter yields an empty list, not a panic.
3039        let global_vars = GlobalVars::new();
3040        assert!(global_vars.get_excluded_repos().is_empty());
3041    }
3042
3043    #[test]
3044    fn test_set_and_get_excluded_repos() {
3045        let mut config = Ini::new();
3046        config.set("exclude", "repos", Some("claude-src, foo".to_string()));
3047
3048        let global_vars = GlobalVars::new();
3049        global_vars.config.set(config).unwrap();
3050        global_vars.set_excluded_repos("exclude");
3051
3052        assert_eq!(
3053            global_vars.get_excluded_repos(),
3054            vec!["claude-src".to_string(), "foo".to_string()]
3055        );
3056    }
3057
3058    #[test]
3059    fn test_set_excluded_repos_missing_key_is_empty() {
3060        // [exclude] present but no `repos` key -> empty list, no panic.
3061        let mut config = Ini::new();
3062        config.set("exclude", "unrelated", Some("value".to_string()));
3063
3064        let global_vars = GlobalVars::new();
3065        global_vars.config.set(config).unwrap();
3066        global_vars.set_excluded_repos("exclude");
3067
3068        assert!(global_vars.get_excluded_repos().is_empty());
3069    }
3070
3071    #[test]
3072    fn test_get_sections_accepts_optional_exclude_section() {
3073        // obsidian + templates + exclude is now valid (exclude is optional).
3074        let mut config = Ini::new();
3075        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
3076        config.set("templates", "commit_date_path", Some("%Y.md".to_string()));
3077        config.set("exclude", "repos", Some("claude-src".to_string()));
3078
3079        let global_vars = GlobalVars::new();
3080        global_vars.config.set(config).unwrap();
3081
3082        let sections = global_vars.get_sections_from_config();
3083        assert_eq!(sections.len(), 3);
3084        assert!(sections.contains(&"exclude".to_string()));
3085    }
3086
3087    #[test]
3088    fn test_set_obsidian_vars_populates_exclude_section() {
3089        // Full set_obsidian_vars path with all three sections present.
3090        let mut config = Ini::new();
3091        config.set(
3092            "obsidian",
3093            "root_path_dir",
3094            Some("/home/user/Obsidian".to_string()),
3095        );
3096        config.set(
3097            "obsidian",
3098            "commit_path",
3099            Some("Diaries/Commits".to_string()),
3100        );
3101        config.set(
3102            "templates",
3103            "commit_date_path",
3104            Some("%Y-%m-%d.md".to_string()),
3105        );
3106        config.set("templates", "commit_datetime", Some("%H:%M:%S".to_string()));
3107        config.set("exclude", "repos", Some("claude-src".to_string()));
3108
3109        let global_vars = GlobalVars::new();
3110        global_vars.config.set(config).unwrap();
3111        global_vars.set_obsidian_vars();
3112
3113        assert_eq!(
3114            global_vars.get_excluded_repos(),
3115            vec!["claude-src".to_string()]
3116        );
3117    }
3118
3119    #[test]
3120    fn test_get_sections_tolerates_an_unknown_section() {
3121        // A config written for a newer release must not brick an older binary:
3122        // an unrecognised section is ignored, not fatal. This is what made the
3123        // pre-4.17.0 binaries panic once [exclude] was added to the shared ini.
3124        let mut config = Ini::new();
3125        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
3126        config.set("templates", "commit_date_path", Some("%Y.md".to_string()));
3127        config.set("from_a_future_release", "key", Some("value".to_string()));
3128
3129        let global_vars = GlobalVars::new();
3130        global_vars.config.set(config).unwrap();
3131
3132        let sections = global_vars.get_sections_from_config();
3133        assert!(sections.contains(&"obsidian".to_string()));
3134        assert!(sections.contains(&"templates".to_string()));
3135    }
3136
3137    #[test]
3138    #[should_panic(expected = "must have [obsidian] and [templates]")]
3139    fn test_get_sections_rejects_obsidian_without_templates() {
3140        // The mirror of the case below: [obsidian] alone is just as fatal.
3141        let mut config = Ini::new();
3142        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
3143
3144        let global_vars = GlobalVars::new();
3145        global_vars.config.set(config).unwrap();
3146        let _ = global_vars.get_sections_from_config();
3147    }
3148
3149    #[test]
3150    #[should_panic(expected = "must have [obsidian] and [templates]")]
3151    fn test_get_sections_rejects_missing_required_section() {
3152        // exclude alone (no obsidian/templates) is still invalid.
3153        let mut config = Ini::new();
3154        config.set("exclude", "repos", Some("claude-src".to_string()));
3155        config.set("templates", "commit_date_path", Some("%Y.md".to_string()));
3156
3157        let global_vars = GlobalVars::new();
3158        global_vars.config.set(config).unwrap();
3159        let _ = global_vars.get_sections_from_config();
3160    }
3161}