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    /// Reconcile these repositories against their day notes, instead of
1178    /// journalling the commit that just happened.
1179    ///
1180    /// Repeatable. Each value is a path inside a git repository; the
1181    /// repository is discovered from it the same way the hook discovers its
1182    /// own, and the history walked is what that checkout's `HEAD` can reach.
1183    /// Given at least once, the binary does not journal the repository it was
1184    /// started in and does not behave as a hook at all.
1185    ///
1186    /// # CLI Usage
1187    ///
1188    /// ```text
1189    /// rusty-commit-saver --reconcile ~/src/one --reconcile ~/src/two
1190    /// ```
1191    #[arg(long, value_name = "PATH")]
1192    pub reconcile: Vec<PathBuf>,
1193
1194    /// Ignore commits older than this date when reconciling, as `YYYY-MM-DD`.
1195    ///
1196    /// Without it every commit reachable from `HEAD` is considered, which is
1197    /// what a first backfill wants and what a nightly pass does not.
1198    ///
1199    /// # CLI Usage
1200    ///
1201    /// ```text
1202    /// rusty-commit-saver --reconcile ~/src/one --since 2026-08-01
1203    /// ```
1204    #[arg(long, value_name = "DATE")]
1205    pub since: Option<String>,
1206}
1207
1208/// Parses a `--since` value into the instant commits are compared against.
1209///
1210/// A date rather than a timestamp, because the journal is organised by day and
1211/// an operator writing this into a timer unit thinks in days. Midnight UTC is
1212/// the floor, matching the UTC the rest of the tool records commits in.
1213///
1214/// # Errors
1215///
1216/// Returns a message naming the value and the expected shape when the date
1217/// cannot be parsed.
1218pub fn parse_since(value: &str) -> Result<chrono::DateTime<chrono::Utc>, String> {
1219    let day = chrono::NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d")
1220        .map_err(|_| format!("--since expects a date as YYYY-MM-DD, not '{value}'"))?;
1221
1222    day.and_hms_opt(0, 0, 0)
1223        .map(|naive| naive.and_utc())
1224        .ok_or_else(|| format!("--since could not be read as an instant: '{value}'"))
1225}
1226
1227/// Retrieves the configuration file path from CLI arguments or returns the default.
1228///
1229/// This function parses command-line arguments and returns the path to the INI configuration file.
1230/// If no `--config-ini` argument is provided, returns the default path.
1231///
1232/// # Default Path
1233///
1234/// `~/.config/rusty-commit-saver/rusty-commit-saver.ini`
1235///
1236/// # Returns
1237///
1238/// A `String` containing the absolute path to the configuration file.
1239///
1240/// # CLI Usage
1241///
1242/// ```text
1243/// // Use default config
1244/// $ rusty-commit-saver
1245/// // Returns: ~/.config/rusty-commit-saver/rusty-commit-saver.ini
1246///
1247/// // Use custom config
1248/// $ rusty-commit-saver --config-ini /custom/path/config.ini
1249/// // Returns: /custom/path/config.ini
1250/// ```
1251///
1252/// # Panics
1253///
1254/// Panics if:
1255/// - The resolved configuration file does not exist on the filesystem
1256/// - The file cannot be read (permission denied, IO error)
1257/// - The file path cannot be converted to a valid string
1258///
1259/// # Examples
1260///
1261/// ```ignore
1262/// use rusty_commit_saver::config::retrieve_config_file_path;
1263///
1264/// let config_path = retrieve_config_file_path();
1265/// println!("Using config: {}", config_path);
1266/// ```
1267///
1268/// # See Also
1269///
1270/// - [`get_or_default_config_ini_path`] - Helper that implements the CLI parsing logic
1271/// - [`get_default_ini_path`] - Constructs the default configuration path
1272#[must_use]
1273pub fn retrieve_config_file_path() -> String {
1274    info!(
1275        "[UserInput::retrieve_config_file_path()]: retrieving the string path from CLI or default"
1276    );
1277    read_config_file(&get_or_default_config_ini_path())
1278}
1279
1280/// Reads the configuration file at `config_path` and returns its contents.
1281///
1282/// Split from [`retrieve_config_file_path()`] so a caller that needs the path
1283/// itself - to name the file in an error - resolves it once and passes it in,
1284/// rather than resolving it a second time behind the caller's back.
1285///
1286/// # Panics
1287///
1288/// Panics if the file does not exist, or cannot be read.
1289#[must_use]
1290fn read_config_file(config_path: &str) -> String {
1291    if Path::new(config_path).exists() {
1292        info!("[UserInput::retrieve_config_file_path()]: config_path exists {config_path:}");
1293    } else {
1294        error!(
1295            "[UserInput::retrieve_config_file_path()]: config_path DOES NOT exists {config_path:}"
1296        );
1297        panic!(
1298            "[UserInput::retrieve_config_file_path()]: config_path DOES NOT exists {config_path:}"
1299        );
1300    }
1301    info!("[UserInput::retrieve_config_file_path()] retrieved config path: {config_path:}");
1302    fs::read_to_string(config_path)
1303        .unwrap_or_else(|_| panic!("Should have been able to read the file: {config_path:}"))
1304}
1305
1306/// Returns the config path from CLI arguments or the default path.
1307///
1308/// Internal helper function that parses CLI arguments using `UserInput` and
1309/// returns either the provided `--config-ini` path or the default configuration
1310/// file location.
1311///
1312/// # Returns
1313///
1314/// - CLI path if `--config-ini` was provided
1315/// - Default path (`~/.config/rusty-commit-saver/rusty-commit-saver.ini`) otherwise
1316///
1317/// # Called By
1318///
1319/// This function is called internally by [`retrieve_config_file_path()`].
1320///
1321/// # See Also
1322///
1323/// - [`get_default_ini_path()`] - Constructs the default configuration path
1324#[must_use]
1325pub fn resolve_config_path(cli_arg: Option<String>, env_var: Option<String>) -> String {
1326    // Check env var first
1327    if let Some(env_path) = env_var {
1328        info!("[resolve_config_path]: Using config from env var.");
1329        return if env_path.contains('~') {
1330            set_proper_home_dir(&env_path)
1331        } else {
1332            env_path
1333        };
1334    }
1335
1336    // Check CLI arg
1337    if let Some(cfg_str) = cli_arg {
1338        if cfg_str.contains('~') {
1339            info!("[resolve_config_path]: CLI path contains '~'.");
1340            set_proper_home_dir(&cfg_str)
1341        } else {
1342            info!("[resolve_config_path]: CLI path without '~'.");
1343            cfg_str
1344        }
1345    } else {
1346        info!("[resolve_config_path]: Using default path.");
1347        get_default_ini_path()
1348    }
1349}
1350
1351#[must_use]
1352#[cfg_attr(coverage_nightly, coverage(off))]
1353pub fn get_or_default_config_ini_path() -> String {
1354    get_or_default_config_ini_path_with(std::env::var("RUSTY_COMMIT_SAVER_CONFIG").ok(), || {
1355        UserInput::parse().config_ini
1356    })
1357}
1358
1359#[must_use]
1360fn get_or_default_config_ini_path_with<F>(env_var: Option<String>, cli_parser: F) -> String
1361where
1362    F: FnOnce() -> Option<String>,
1363{
1364    info!("[get_or_default_config_ini_path()]: Parsing CLI inputs.");
1365
1366    let cli_arg = if env_var.is_some() {
1367        None // Skip parsing if env var is set
1368    } else {
1369        cli_parser()
1370    };
1371
1372    let config_path = resolve_config_path(cli_arg, env_var);
1373    info!("[get_or_default_config_ini_path()]: Config path found: {config_path:}");
1374    config_path
1375}
1376
1377/// Constructs the default configuration file path.
1378///
1379/// Builds the standard XDG configuration path for the application by combining
1380/// the user's home directory with the application-specific config directory.
1381///
1382/// # Returns
1383///
1384/// A `String` with the default INI file path:
1385/// `~/.config/rusty-commit-saver/rusty-commit-saver.ini`
1386///
1387/// # Directory Structure
1388///
1389/// ```text
1390/// ~/.config/
1391///   └── rusty-commit-saver/
1392///       └── rusty-commit-saver.ini
1393/// ```
1394///
1395/// # Panics
1396///
1397/// Panics if the user's home directory cannot be determined
1398/// (via the `dirs::home_dir()` function).
1399///
1400/// # Examples
1401///
1402/// ```ignore
1403/// // Internal usage
1404/// let default_path = get_default_ini_path();
1405/// // Returns: "/home/user/.config/rusty-commit-saver/rusty-commit-saver.ini"
1406/// ```
1407///
1408/// # See Also
1409///
1410/// - [`retrieve_config_file_path()`] - Public API for getting config path
1411#[must_use]
1412pub fn get_default_ini_path() -> String {
1413    info!("[get_default_ini_path()]: Getting default ini file.");
1414    let cfg_str = "~/.config/rusty-commit-saver/rusty-commit-saver.ini".to_string();
1415    set_proper_home_dir(&cfg_str)
1416}
1417
1418/// Loads and parses the INI configuration file from disk.
1419///
1420/// Reads the configuration file (from CLI argument or default location),
1421/// parses its contents using [`parse_ini_content()`], and returns the
1422/// parsed `Ini` object.
1423///
1424/// # Returns
1425///
1426/// A parsed `Ini` configuration object
1427///
1428/// # Panics
1429///
1430/// Panics if:
1431/// - The configuration file doesn't exist at the resolved path
1432/// - The file cannot be read (permission denied, I/O error)
1433/// - The file content is not valid UTF-8
1434/// - The INI syntax is invalid (malformed sections or key-value pairs)
1435///
1436/// # File Resolution Order
1437///
1438/// 1. Check for `--config-ini <PATH>` CLI argument
1439/// 2. Fall back to `~/.config/rusty-commit-saver/rusty-commit-saver.ini`
1440///
1441/// # Expected INI Structure
1442///
1443/// ```text
1444/// [obsidian]
1445/// root_path_dir = ~/Documents/Obsidian
1446/// commit_path = Diaries/Commits
1447///
1448/// [templates]
1449/// commit_date_path = %Y/%m-%B/%F.md
1450/// commit_datetime = %Y-%m-%d %H:%M:%S
1451/// ```
1452///
1453/// # Called By
1454///
1455/// This function is called internally by [`GlobalVars::set_all()`].
1456///
1457/// # See Also
1458///
1459/// - [`retrieve_config_file_path()`] - Resolves the config file path
1460/// - [`parse_ini_content()`] - Parses INI text into `Ini` struct
1461#[must_use]
1462pub fn get_ini_file() -> Ini {
1463    get_ini_file_at(&get_or_default_config_ini_path())
1464}
1465
1466/// Loads and parses the INI configuration file at `config_path`.
1467///
1468/// The path-taking half of [`get_ini_file()`], for a caller that has already
1469/// resolved the path and wants to keep it - [`GlobalVars::set_all()`] retains
1470/// it so a configuration error can name the file to edit.
1471///
1472/// # Panics
1473///
1474/// Panics under the same conditions as [`get_ini_file()`]: the file is
1475/// missing, unreadable, or not valid INI.
1476#[must_use]
1477pub fn get_ini_file_at(config_path: &str) -> Ini {
1478    info!("[get_ini_file()]: Retrieving the INI File");
1479    let content_ini = read_config_file(config_path);
1480    let mut config = Ini::new();
1481    config
1482        .read(content_ini)
1483        .expect("Could not read the INI file!");
1484
1485    info!("[get_ini_file()]: This is the INI File:\n\n{config:?}");
1486    config
1487}
1488
1489/// Expands the tilde (`~`) character to the user's home directory path.
1490///
1491/// Replaces the leading `~` in a path string with the absolute path to the
1492/// user's home directory. If no `~` is present, returns the string unchanged.
1493///
1494/// # Arguments
1495///
1496/// * `cfg_str` - A path string that may contain a leading `~`
1497///
1498/// # Returns
1499///
1500/// A `String` with `~` expanded to the full home directory path
1501///
1502/// # Panics
1503///
1504/// Panics if the user's home directory cannot be determined
1505/// (via the `dirs::home_dir()` function).
1506///
1507/// # Examples
1508///
1509/// ```ignore
1510/// // On Linux/macOS with home at /home/user
1511/// let expanded = set_proper_home_dir("~/Documents/Obsidian");
1512/// assert_eq!(expanded, "/home/user/Documents/Obsidian");
1513///
1514/// // Path without tilde is returned unchanged
1515/// let unchanged = set_proper_home_dir("/absolute/path");
1516/// assert_eq!(unchanged, "/absolute/path");
1517/// ```
1518///
1519/// # Platform Behavior
1520///
1521/// - **Linux/macOS**: Expands to `/home/username` or `/Users/username`
1522/// - **Windows**: Expands to `C:\Users\username`
1523///
1524/// # Used By
1525///
1526/// This function is called by:
1527/// - [`GlobalVars::set_obsidian_root_path_dir()`]
1528/// - [`GlobalVars::set_obsidian_commit_path()`]
1529fn set_proper_home_dir(cfg_str: &str) -> String {
1530    info!("[set_proper_home_dir()]: Changing the '~' to full home directory.");
1531    let home_dir = home_dir()
1532        .expect("Could not get home_dir")
1533        .into_os_string()
1534        .into_string()
1535        .expect("Could not convert home_dir from OsString to String");
1536
1537    cfg_str.replace('~', &home_dir)
1538}
1539
1540/// Whether `chrono` can render this format string.
1541///
1542/// `DateTime::format()` defers the work, and `to_string()` turns an invalid
1543/// specifier into a panic; writing into a `String` returns the error instead,
1544/// which is what makes the format checkable at all.
1545fn is_renderable_time_format(format: &str) -> bool {
1546    use std::fmt::Write;
1547
1548    let probe = DateTime::from_timestamp(0, 0).expect("the epoch is a valid timestamp");
1549    let mut rendered = String::new();
1550
1551    write!(rendered, "{}", probe.format(format)).is_ok()
1552}
1553
1554/// Parses a comma-separated list of repository names into a clean vector.
1555///
1556/// Each entry is trimmed of surrounding whitespace and empty entries are
1557/// dropped, so trailing commas and stray spaces are tolerated.
1558///
1559/// # Arguments
1560///
1561/// * `raw` - The raw comma-separated value (e.g. `"claude-src, other-repo"`)
1562///
1563/// # Returns
1564///
1565/// A `Vec<String>` with one entry per non-empty, trimmed repository name.
1566///
1567/// # Examples
1568///
1569/// ```ignore
1570/// use rusty_commit_saver::config::parse_exclude_repos;
1571///
1572/// assert_eq!(parse_exclude_repos("claude-src, foo"), vec!["claude-src", "foo"]);
1573/// assert_eq!(parse_exclude_repos("  "), Vec::<String>::new());
1574/// ```
1575#[must_use]
1576pub fn parse_exclude_repos(raw: &str) -> Vec<String> {
1577    raw.split(',')
1578        .map(str::trim)
1579        .filter(|s| !s.is_empty())
1580        .map(String::from)
1581        .collect()
1582}
1583
1584#[cfg(test)]
1585#[cfg_attr(coverage_nightly, coverage(off))]
1586mod global_vars_tests {
1587    use super::*;
1588    use std::panic::{self, AssertUnwindSafe};
1589
1590    #[test]
1591    fn test_global_vars_new() {
1592        let global_vars = GlobalVars::new();
1593
1594        assert!(global_vars.config.get().is_none());
1595    }
1596
1597    #[test]
1598    fn test_global_vars_default() {
1599        let global_vars = GlobalVars::default();
1600
1601        assert!(global_vars.config.get().is_none());
1602    }
1603
1604    #[test]
1605    fn test_get_sections_from_config_valid() {
1606        let mut config = Ini::new();
1607        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1608        config.set(
1609            "templates",
1610            "commit_date_path",
1611            Some("%Y-%m-%d".to_string()),
1612        );
1613
1614        let global_vars = GlobalVars::new();
1615        global_vars.config.set(config).unwrap();
1616
1617        let sections = global_vars.get_sections_from_config();
1618
1619        assert_eq!(sections.len(), 2);
1620        assert!(sections.contains(&"obsidian".to_string()));
1621        assert!(sections.contains(&"templates".to_string()));
1622    }
1623
1624    #[test]
1625    fn test_get_sections_from_config_rejects_a_lone_unknown_section() {
1626        let mut config = Ini::new();
1627        config.set("only_one_section", "key", Some("value".to_string()));
1628
1629        let global_vars = GlobalVars::new();
1630        global_vars.config.set(config).unwrap();
1631
1632        let result =
1633            panic::catch_unwind(AssertUnwindSafe(|| global_vars.get_sections_from_config()));
1634
1635        assert!(
1636            result.is_err(),
1637            "Expected panic: the required sections are missing"
1638        );
1639
1640        // Verify the panic message (panic! with string literal = &str)
1641        let panic_info = result.unwrap_err();
1642        let msg = panic_info
1643            .downcast_ref::<&str>()
1644            .expect("Panic message should be &str");
1645        assert!(
1646            msg.contains("must have [obsidian] and [templates]"),
1647            "Unexpected panic message: {msg}"
1648        );
1649    }
1650
1651    #[test]
1652    fn test_get_sections_from_config_panics_with_zero_sections() {
1653        let config = Ini::new();
1654
1655        let global_vars = GlobalVars::new();
1656        global_vars.config.set(config).unwrap();
1657
1658        let result =
1659            panic::catch_unwind(AssertUnwindSafe(|| global_vars.get_sections_from_config()));
1660
1661        assert!(result.is_err(), "Expected panic for zero sections");
1662    }
1663
1664    #[test]
1665    fn test_get_sections_from_config_keeps_an_extra_section() {
1666        let mut config = Ini::new();
1667        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1668        config.set("templates", "commit_date_path", Some("%Y.md".to_string()));
1669        config.set("extra", "key", Some("value".to_string()));
1670
1671        let global_vars = GlobalVars::new();
1672        global_vars.config.set(config).unwrap();
1673
1674        let result =
1675            panic::catch_unwind(AssertUnwindSafe(|| global_vars.get_sections_from_config()));
1676
1677        assert!(
1678            result.is_ok(),
1679            "An unrecognised section must be ignored, not fatal"
1680        );
1681        assert_eq!(result.unwrap().len(), 3);
1682    }
1683
1684    #[test]
1685    fn test_unrecognised_keys_names_a_typo_in_a_known_section() {
1686        let mut config = Ini::new();
1687        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1688        config.set("obsidian", "commit_path", Some("commits".to_string()));
1689        config.set("templates", "commit_date_path", Some("%F.md".to_string()));
1690        // The rename/typo case: the real key is still there, so nothing breaks
1691        // - which is exactly why this used to pass unnoticed.
1692        config.set("templates", "commit_datetime", Some("%T".to_string()));
1693        config.set("templates", "commit_datetimes", Some("%T".to_string()));
1694
1695        let global_vars = GlobalVars::new();
1696        global_vars.config.set(config).unwrap();
1697
1698        assert_eq!(
1699            global_vars.unrecognised_keys(),
1700            vec!["[templates] commit_datetimes".to_string()],
1701            "an unknown key must be named, not swallowed"
1702        );
1703    }
1704
1705    #[test]
1706    fn test_unrecognised_keys_is_empty_for_a_known_config() {
1707        let mut config = Ini::new();
1708        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1709        config.set("obsidian", "commit_path", Some("commits".to_string()));
1710        config.set("templates", "commit_date_path", Some("%F.md".to_string()));
1711        config.set("templates", "commit_datetime", Some("%T".to_string()));
1712        config.set("exclude", "repos", Some("claude-src".to_string()));
1713
1714        let global_vars = GlobalVars::new();
1715        global_vars.config.set(config).unwrap();
1716
1717        assert!(
1718            global_vars.unrecognised_keys().is_empty(),
1719            "a config this binary fully understands must warn about nothing"
1720        );
1721    }
1722
1723    #[test]
1724    fn test_unrecognised_keys_leaves_an_unknown_section_to_the_section_check() {
1725        let mut config = Ini::new();
1726        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1727        config.set("obsidian", "commit_path", Some("commits".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("future_release", "whatever", Some("value".to_string()));
1731
1732        let global_vars = GlobalVars::new();
1733        global_vars.config.set(config).unwrap();
1734
1735        assert!(
1736            global_vars.unrecognised_keys().is_empty(),
1737            "the section is already reported whole; its keys must not double the noise"
1738        );
1739    }
1740
1741    #[test]
1742    fn test_unrecognised_keys_in_an_unknown_section_is_empty() {
1743        let mut config = Ini::new();
1744        config.set("future_release", "whatever", Some("value".to_string()));
1745
1746        let global_vars = GlobalVars::new();
1747        global_vars.config.set(config).unwrap();
1748
1749        assert!(
1750            global_vars
1751                .unrecognised_keys_in("future_release")
1752                .is_empty(),
1753            "the binary cannot know what an unknown section should contain"
1754        );
1755    }
1756
1757    #[test]
1758    fn test_unrecognised_keys_in_a_section_the_config_lacks_is_empty() {
1759        let mut config = Ini::new();
1760        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1761
1762        let global_vars = GlobalVars::new();
1763        global_vars.config.set(config).unwrap();
1764
1765        assert!(
1766            global_vars.unrecognised_keys_in("exclude").is_empty(),
1767            "a section that is not in the config has no keys to report"
1768        );
1769    }
1770
1771    #[test]
1772    fn test_unrecognised_keys_are_sorted_and_name_every_section() {
1773        let mut config = Ini::new();
1774        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1775        config.set("obsidian", "commit_path", Some("commits".to_string()));
1776        config.set("obsidian", "vault", Some("stale".to_string()));
1777        config.set("templates", "commit_date_path", Some("%F.md".to_string()));
1778        config.set("templates", "commit_datetime", Some("%T".to_string()));
1779        config.set("templates", "author", Some("stale".to_string()));
1780        config.set("exclude", "repos", Some("claude-src".to_string()));
1781        config.set("exclude", "branches", Some("stale".to_string()));
1782
1783        let global_vars = GlobalVars::new();
1784        global_vars.config.set(config).unwrap();
1785
1786        assert_eq!(
1787            global_vars.unrecognised_keys(),
1788            vec![
1789                "[exclude] branches".to_string(),
1790                "[obsidian] vault".to_string(),
1791                "[templates] author".to_string(),
1792            ],
1793            "hash-map order must not leak into the reported list"
1794        );
1795    }
1796
1797    #[test]
1798    fn test_set_obsidian_vars_survives_an_unrecognised_key() {
1799        let mut config = Ini::new();
1800        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1801        config.set("obsidian", "commit_path", Some("commits".to_string()));
1802        config.set("templates", "commit_date_path", Some("%F.md".to_string()));
1803        config.set("templates", "commit_datetime", Some("%T".to_string()));
1804        config.set("templates", "commit_datetimes", Some("%T".to_string()));
1805
1806        let global_vars = GlobalVars::new();
1807        global_vars.config.set(config).unwrap();
1808
1809        let result = panic::catch_unwind(AssertUnwindSafe(|| global_vars.set_obsidian_vars()));
1810
1811        assert!(
1812            result.is_ok(),
1813            "an unrecognised key must be reported, never fatal"
1814        );
1815        assert_eq!(global_vars.get_template_commit_datetime(), "%T");
1816    }
1817
1818    #[test]
1819    fn test_get_key_from_section_from_ini_exists() {
1820        let mut config = Ini::new();
1821        config.set(
1822            "obsidian",
1823            "root_path_dir",
1824            Some("/home/user/Obsidian".to_string()),
1825        );
1826
1827        let global_vars = GlobalVars::new();
1828        global_vars.config.set(config).unwrap();
1829
1830        let result = global_vars.get_key_from_section_from_ini("obsidian", "root_path_dir");
1831
1832        assert_eq!(result, Some("/home/user/Obsidian".to_string()));
1833    }
1834
1835    #[test]
1836    fn test_get_key_from_section_from_ini_not_exists() {
1837        let mut config = Ini::new();
1838        config.set("obsidian", "other_key", Some("value".to_string()));
1839
1840        let global_vars = GlobalVars::new();
1841        global_vars.config.set(config).unwrap();
1842
1843        let result = global_vars.get_key_from_section_from_ini("obsidian", "non_existent_key");
1844
1845        assert_eq!(result, None);
1846    }
1847
1848    #[test]
1849    fn test_get_config() {
1850        let mut config = Ini::new();
1851        config.set("test", "key", Some("value".to_string()));
1852
1853        let global_vars = GlobalVars::new();
1854        global_vars.config.set(config.clone()).unwrap();
1855
1856        let retrieved_config = global_vars.get_config();
1857
1858        assert_eq!(
1859            retrieved_config.get("test", "key"),
1860            Some("value".to_string())
1861        );
1862    }
1863
1864    #[test]
1865    fn test_set_obsidian_root_path_dir_with_tilde() {
1866        let mut config = Ini::new();
1867        config.set(
1868            "obsidian",
1869            "root_path_dir",
1870            Some("~/Documents/Obsidian".to_string()),
1871        );
1872        config.set(
1873            "templates",
1874            "commit_date_path",
1875            Some("%Y-%m-%d".to_string()),
1876        );
1877        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1878
1879        let global_vars = GlobalVars::new();
1880        global_vars.config.set(config).unwrap();
1881        global_vars.set_obsidian_root_path_dir("obsidian");
1882
1883        let result = global_vars.get_obsidian_root_path_dir();
1884
1885        // Should expand ~ to full home path
1886        assert!(!result.to_string_lossy().contains('~'));
1887        // Should start with /
1888        assert!(result.to_string_lossy().starts_with('/'));
1889        // Should end with Obsidian
1890        assert!(result.to_string_lossy().ends_with("Obsidian"));
1891    }
1892
1893    #[test]
1894    fn test_set_obsidian_root_path_dir_absolute_path() {
1895        let mut config = Ini::new();
1896        config.set(
1897            "obsidian",
1898            "root_path_dir",
1899            Some("/absolute/path/Obsidian".to_string()),
1900        );
1901        config.set(
1902            "templates",
1903            "commit_date_path",
1904            Some("%Y-%m-%d".to_string()),
1905        );
1906        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1907
1908        let global_vars = GlobalVars::new();
1909        global_vars.config.set(config).unwrap();
1910        global_vars.set_obsidian_root_path_dir("obsidian");
1911
1912        let result = global_vars.get_obsidian_root_path_dir();
1913
1914        // Should preserve absolute path
1915        assert!(result.to_string_lossy().contains("/absolute/path/Obsidian"));
1916    }
1917
1918    #[test]
1919    fn test_set_obsidian_commit_path_with_tilde() {
1920        let mut config = Ini::new();
1921        config.set(
1922            "obsidian",
1923            "commit_path",
1924            Some("~/Diaries/Commits".to_string()),
1925        );
1926        config.set(
1927            "templates",
1928            "commit_date_path",
1929            Some("%Y-%m-%d".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_obsidian_commit_path("obsidian");
1936
1937        let result = global_vars.get_obsidian_commit_path();
1938
1939        // Should expand ~ to full home path
1940        assert!(!result.to_string_lossy().contains('~'));
1941        // Should end with Commits
1942        assert!(result.to_string_lossy().ends_with("Commits"));
1943    }
1944
1945    #[test]
1946    fn test_set_obsidian_commit_path_absolute_path() {
1947        let mut config = Ini::new();
1948        config.set(
1949            "obsidian",
1950            "commit_path",
1951            Some("absolute/Diaries/Commits".to_string()),
1952        );
1953        config.set(
1954            "templates",
1955            "commit_date_path",
1956            Some("%Y-%m-%d".to_string()),
1957        );
1958        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1959
1960        let global_vars = GlobalVars::new();
1961        global_vars.config.set(config).unwrap();
1962        global_vars.set_obsidian_commit_path("obsidian");
1963
1964        let result = global_vars.get_obsidian_commit_path();
1965
1966        // set_obsidian_commit_path() doesn't add leading / (unlike root_path_dir)
1967        // It just splits by / and rebuilds the PathBuf
1968        assert!(result.to_string_lossy().contains("absolute"));
1969        assert!(result.to_string_lossy().ends_with("Commits"));
1970    }
1971
1972    #[test]
1973    fn test_set_templates_commit_date_path() {
1974        let mut config = Ini::new();
1975        config.set(
1976            "templates",
1977            "commit_date_path",
1978            Some("%Y/%m-%B/%F.md".to_string()),
1979        );
1980        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1981
1982        let global_vars = GlobalVars::new();
1983        global_vars.config.set(config).unwrap();
1984        global_vars.set_templates_commit_date_path("templates");
1985
1986        let result = global_vars.get_template_commit_date_path();
1987
1988        assert_eq!(result, "%Y/%m-%B/%F.md");
1989    }
1990
1991    #[test]
1992    fn test_set_templates_datetime() {
1993        let mut config = Ini::new();
1994        config.set(
1995            "templates",
1996            "commit_datetime",
1997            Some("%Y-%m-%d %H:%M:%S".to_string()),
1998        );
1999
2000        let global_vars = GlobalVars::new();
2001        global_vars.config.set(config).unwrap();
2002        global_vars.set_templates_datetime("templates");
2003
2004        let result = global_vars.get_template_commit_datetime();
2005
2006        assert_eq!(result, "%Y-%m-%d %H:%M:%S");
2007    }
2008
2009    #[test]
2010    fn test_set_obsidian_vars_both_sections() {
2011        let mut config = Ini::new();
2012        config.set(
2013            "obsidian",
2014            "root_path_dir",
2015            Some("/home/user/Obsidian".to_string()),
2016        );
2017        config.set(
2018            "obsidian",
2019            "commit_path",
2020            Some("Diaries/Commits".to_string()),
2021        );
2022        config.set(
2023            "templates",
2024            "commit_date_path",
2025            Some("%Y-%m-%d.md".to_string()),
2026        );
2027        config.set(
2028            "templates",
2029            "commit_datetime",
2030            Some("%Y-%m-%d %H:%M:%S".to_string()),
2031        );
2032
2033        let global_vars = GlobalVars::new();
2034        global_vars.config.set(config).unwrap();
2035
2036        // Call the private method indirectly through set_obsidian_vars
2037        global_vars.set_obsidian_vars();
2038
2039        // Verify all getters work (meaning setters were called)
2040        let root_path = global_vars.get_obsidian_root_path_dir();
2041        let commit_path = global_vars.get_obsidian_commit_path();
2042        let date_path = global_vars.get_template_commit_date_path();
2043        let datetime = global_vars.get_template_commit_datetime();
2044
2045        assert!(root_path.to_string_lossy().contains("Obsidian"));
2046        assert!(commit_path.to_string_lossy().contains("Commits"));
2047        assert_eq!(date_path, "%Y-%m-%d.md");
2048        assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
2049    }
2050
2051    #[test]
2052    #[should_panic(expected = "must have [obsidian] and [templates]")]
2053    fn test_set_obsidian_vars_without_the_obsidian_section() {
2054        let mut config = Ini::new();
2055        // [templates] is present but [obsidian] is not, which is fatal. The
2056        // unrecognised section is incidental - it is not what makes this panic.
2057        config.set("invalid_section", "key", Some("value".to_string()));
2058        config.set(
2059            "templates",
2060            "commit_date_path",
2061            Some("%Y-%m-%d.md".to_string()),
2062        );
2063        config.set(
2064            "templates",
2065            "commit_datetime",
2066            Some("%Y-%m-%d %H:%M".to_string()),
2067        );
2068
2069        let global_vars = GlobalVars::new();
2070        global_vars.config.set(config).unwrap();
2071
2072        // Panics: [obsidian] is required and missing.
2073        global_vars.set_obsidian_vars();
2074    }
2075
2076    #[test]
2077    fn test_set_all_integration() {
2078        use std::io::Write;
2079        use tempfile::NamedTempFile;
2080
2081        // Create a temporary config file
2082        let mut temp_file = NamedTempFile::new().unwrap();
2083        writeln!(temp_file, "[obsidian]").unwrap();
2084        writeln!(temp_file, "root_path_dir=/tmp/test_obsidian").unwrap();
2085        writeln!(temp_file, "commit_path=TestDiaries/TestCommits").unwrap();
2086        writeln!(temp_file, "[templates]").unwrap();
2087        writeln!(temp_file, "commit_date_path=%Y-%m-%d.md").unwrap();
2088        writeln!(temp_file, "commit_datetime=%Y-%m-%d %H:%M:%S").unwrap();
2089        temp_file.flush().unwrap();
2090
2091        // Parse the config manually and test set_all
2092        let content = std::fs::read_to_string(temp_file.path()).unwrap();
2093        let config = parse_ini_content(&content).unwrap();
2094
2095        let global_vars = GlobalVars::new();
2096        global_vars.config.set(config).unwrap();
2097        global_vars.set_obsidian_vars();
2098
2099        // Verify all values were set
2100        let root = global_vars.get_obsidian_root_path_dir();
2101        let commit = global_vars.get_obsidian_commit_path();
2102        let date = global_vars.get_template_commit_date_path();
2103        let datetime = global_vars.get_template_commit_datetime();
2104
2105        assert!(root.to_string_lossy().contains("test_obsidian"));
2106        assert!(commit.to_string_lossy().contains("TestCommits"));
2107        assert_eq!(date, "%Y-%m-%d.md");
2108        assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
2109    }
2110
2111    #[test]
2112    #[should_panic(expected = "Could not get")]
2113    fn test_get_obsidian_root_path_dir_not_set() {
2114        let global_vars = GlobalVars::new();
2115        // Don't set any values
2116        // This should panic when trying to get
2117        global_vars.get_obsidian_root_path_dir();
2118    }
2119
2120    #[test]
2121    #[should_panic(expected = "Could not get")]
2122    fn test_get_obsidian_commit_path_not_set() {
2123        let global_vars = GlobalVars::new();
2124        global_vars.get_obsidian_commit_path();
2125    }
2126
2127    #[test]
2128    #[should_panic(expected = "Could not get")]
2129    fn test_get_template_commit_date_path_not_set() {
2130        let global_vars = GlobalVars::new();
2131        global_vars.get_template_commit_date_path();
2132    }
2133
2134    #[test]
2135    #[should_panic(expected = "Could not get")]
2136    fn test_get_template_commit_datetime_not_set() {
2137        let global_vars = GlobalVars::new();
2138        global_vars.get_template_commit_datetime();
2139    }
2140
2141    #[test]
2142    #[should_panic(expected = "Could not get Config")]
2143    fn test_get_config_not_initialized() {
2144        let global_vars = GlobalVars::new();
2145        // Config not set
2146        global_vars.get_config();
2147    }
2148
2149    #[test]
2150    fn test_set_config_twice_fails() {
2151        let global_vars = GlobalVars::new();
2152        let config1 = Ini::new();
2153        let config2 = Ini::new();
2154
2155        assert!(global_vars.config.set(config1).is_ok());
2156        // Second set should fail
2157        assert!(global_vars.config.set(config2).is_err());
2158    }
2159
2160    #[test]
2161    fn test_global_vars_set_all_end_to_end() {
2162        use std::io::Write;
2163        use tempfile::NamedTempFile;
2164
2165        // Create a real config file
2166        let mut temp_file = NamedTempFile::new().unwrap();
2167        writeln!(temp_file, "[obsidian]").unwrap();
2168        writeln!(temp_file, "root_path_dir=/tmp/obsidian_test").unwrap();
2169        writeln!(temp_file, "commit_path=TestDiaries/TestCommits").unwrap();
2170        writeln!(temp_file, "[templates]").unwrap();
2171        writeln!(temp_file, "commit_date_path=%Y/%m-%B/%F.md").unwrap();
2172        writeln!(temp_file, "commit_datetime=%Y-%m-%d %H:%M:%S").unwrap();
2173        temp_file.flush().unwrap();
2174
2175        // Read and parse the config
2176        let content = std::fs::read_to_string(temp_file.path()).unwrap();
2177        let mut config = Ini::new();
2178        config.read(content).unwrap();
2179
2180        // Now test set_all
2181        let global_vars = GlobalVars::new();
2182        let result = global_vars.config.set(config);
2183        assert!(result.is_ok());
2184
2185        // Call set_obsidian_vars (which set_all would call)
2186        global_vars.set_obsidian_vars();
2187
2188        // Verify everything is accessible
2189        let root = global_vars.get_obsidian_root_path_dir();
2190        let commit = global_vars.get_obsidian_commit_path();
2191        let date_path = global_vars.get_template_commit_date_path();
2192        let datetime = global_vars.get_template_commit_datetime();
2193
2194        assert!(root.to_string_lossy().contains("obsidian_test"));
2195        assert!(commit.to_string_lossy().contains("TestCommits"));
2196        assert_eq!(date_path, "%Y/%m-%B/%F.md");
2197        assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
2198    }
2199
2200    #[test]
2201    fn test_set_obsidian_root_path_dir_with_trailing_slash() {
2202        let mut config = Ini::new();
2203        config.set("obsidian", "root_path_dir", Some("/tmp/test/".to_string()));
2204        config.set(
2205            "templates",
2206            "commit_date_path",
2207            Some("%Y-%m-%d".to_string()),
2208        );
2209        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2210
2211        let global_vars = GlobalVars::new();
2212        global_vars.config.set(config).unwrap();
2213        global_vars.set_obsidian_root_path_dir("obsidian");
2214
2215        let result = global_vars.get_obsidian_root_path_dir();
2216
2217        // Should handle trailing slashes gracefully
2218        assert!(result.to_string_lossy().contains("test"));
2219    }
2220
2221    #[test]
2222    fn test_set_obsidian_commit_path_with_multiple_slashes() {
2223        let mut config = Ini::new();
2224        config.set(
2225            "obsidian",
2226            "commit_path",
2227            Some("Diaries//Commits///Nested".to_string()),
2228        );
2229        config.set(
2230            "templates",
2231            "commit_date_path",
2232            Some("%Y-%m-%d".to_string()),
2233        );
2234        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2235
2236        let global_vars = GlobalVars::new();
2237        global_vars.config.set(config).unwrap();
2238        global_vars.set_obsidian_commit_path("obsidian");
2239
2240        let result = global_vars.get_obsidian_commit_path();
2241
2242        // Path should be constructed despite multiple slashes
2243        assert!(result.to_string_lossy().contains("Nested"));
2244    }
2245
2246    #[test]
2247    fn test_set_obsidian_root_path_dir_empty_string() {
2248        // This used to assert the opposite - that an empty root_path_dir still
2249        // produced a usable PathBuf. It did: `/`. The vault root silently
2250        // became the filesystem root, and the run carried on at exit 0. A
2251        // blank value now counts as a missing key, which is the whole point of
2252        // the check; the old contract is deliberately withdrawn.
2253        let mut config = Ini::new();
2254        config.set("obsidian", "root_path_dir", Some(String::new()));
2255        config.set(
2256            "templates",
2257            "commit_date_path",
2258            Some("%Y-%m-%d".to_string()),
2259        );
2260        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2261
2262        let global_vars = GlobalVars::new();
2263        global_vars.config.set(config).unwrap();
2264
2265        let result = panic::catch_unwind(AssertUnwindSafe(|| {
2266            global_vars.set_obsidian_root_path_dir("obsidian")
2267        }));
2268
2269        let panic_info = result.expect_err("a blank root_path_dir must be fatal");
2270        let msg = panic_info
2271            .downcast_ref::<String>()
2272            .expect("panic message should be a formatted String");
2273
2274        assert!(
2275            msg.contains("missing required key 'root_path_dir' in section [obsidian]"),
2276            "a blank value must be reported as the missing key it is: {msg}"
2277        );
2278    }
2279
2280    #[test]
2281    #[should_panic(expected = "missing required key 'commit_path' in section [obsidian]")]
2282    fn test_set_obsidian_commit_path_missing_key() {
2283        let mut config = Ini::new();
2284        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
2285        config.set(
2286            "templates",
2287            "commit_date_path",
2288            Some("%Y-%m-%d".to_string()),
2289        );
2290        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2291
2292        let global_vars = GlobalVars::new();
2293        global_vars.config.set(config).unwrap();
2294
2295        global_vars.set_obsidian_commit_path("obsidian");
2296    }
2297
2298    #[test]
2299    #[should_panic(expected = "missing required key 'root_path_dir' in section [obsidian]")]
2300    fn test_set_obsidian_root_path_dir_missing_key() {
2301        let mut config = Ini::new();
2302        config.set("obsidian", "commit_path", Some("commits".to_string()));
2303        config.set(
2304            "templates",
2305            "commit_date_path",
2306            Some("%Y-%m-%d".to_string()),
2307        );
2308        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2309
2310        let global_vars = GlobalVars::new();
2311        global_vars.config.set(config).unwrap();
2312
2313        global_vars.set_obsidian_root_path_dir("obsidian");
2314    }
2315
2316    #[test]
2317    #[should_panic(expected = "missing required key 'commit_date_path' in section [templates]")]
2318    fn test_set_templates_commit_date_path_missing_key() {
2319        let mut config = Ini::new();
2320        config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2321        config.set("obsidian", "root_path_dir", Some("/tmp".to_string()));
2322        config.set("obsidian", "commit_path", Some("commits".to_string()));
2323
2324        let global_vars = GlobalVars::new();
2325        global_vars.config.set(config).unwrap();
2326
2327        global_vars.set_templates_commit_date_path("templates");
2328    }
2329
2330    #[test]
2331    #[should_panic(expected = "missing required key 'commit_datetime' in section [templates]")]
2332    fn test_set_templates_datetime_missing_key() {
2333        let mut config = Ini::new();
2334        config.set(
2335            "templates",
2336            "commit_date_path",
2337            Some("%Y-%m-%d".to_string()),
2338        );
2339        config.set("obsidian", "root_path_dir", Some("/tmp".to_string()));
2340        config.set("obsidian", "commit_path", Some("commits".to_string()));
2341
2342        let global_vars = GlobalVars::new();
2343        global_vars.config.set(config).unwrap();
2344
2345        global_vars.set_templates_datetime("templates");
2346    }
2347
2348    #[test]
2349    #[should_panic(expected = "missing required key 'commit_path' in section [obsidian]")]
2350    fn test_require_key_treats_a_blank_value_as_missing() {
2351        // `commit_path =` used to satisfy the presence check and journal into
2352        // the vault root instead of the configured folder, at exit 0.
2353        let mut config = Ini::new();
2354        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
2355        config.set("obsidian", "commit_path", Some("   ".to_string()));
2356
2357        let global_vars = GlobalVars::new();
2358        global_vars.config.set(config).unwrap();
2359
2360        global_vars.set_obsidian_commit_path("obsidian");
2361    }
2362
2363    #[test]
2364    fn test_require_key_names_the_config_file_and_the_typo() {
2365        let mut config = Ini::new();
2366        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
2367        // The whole point: the missing key and the reason it is missing get
2368        // named together, so nobody has to read the source to connect them.
2369        config.set("obsidian", "commit_paths", Some("commits".to_string()));
2370
2371        let global_vars = GlobalVars::new();
2372        global_vars.config.set(config).unwrap();
2373        global_vars
2374            .config_path
2375            .set("/tmp/some/rusty-commit-saver.ini".to_string())
2376            .unwrap();
2377
2378        let result = panic::catch_unwind(AssertUnwindSafe(|| {
2379            global_vars.set_obsidian_commit_path("obsidian")
2380        }));
2381
2382        let panic_info = result.expect_err("a missing required key must be fatal");
2383        let msg = panic_info
2384            .downcast_ref::<String>()
2385            .expect("panic message should be a formatted String");
2386
2387        assert!(
2388            msg.contains("/tmp/some/rusty-commit-saver.ini"),
2389            "the message must name the file to edit: {msg}"
2390        );
2391        assert!(
2392            msg.contains("missing required key 'commit_path' in section [obsidian]"),
2393            "the message must name the key and its section: {msg}"
2394        );
2395        assert!(
2396            msg.contains("unrecognised in [obsidian]: commit_paths"),
2397            "the message must name the typo that explains the absence: {msg}"
2398        );
2399    }
2400
2401    #[test]
2402    fn test_require_time_format_rejects_what_chrono_cannot_render() {
2403        let mut config = Ini::new();
2404        config.set("templates", "commit_datetime", Some("%Q".to_string()));
2405
2406        let global_vars = GlobalVars::new();
2407        global_vars.config.set(config).unwrap();
2408        global_vars
2409            .config_path
2410            .set("/tmp/some/rusty-commit-saver.ini".to_string())
2411            .unwrap();
2412
2413        let result = panic::catch_unwind(AssertUnwindSafe(|| {
2414            global_vars.set_templates_datetime("templates")
2415        }));
2416
2417        let panic_info = result.expect_err("a format chrono cannot render must be fatal");
2418        let msg = panic_info
2419            .downcast_ref::<String>()
2420            .expect("panic message should be a formatted String");
2421
2422        assert!(
2423            msg.contains("/tmp/some/rusty-commit-saver.ini"),
2424            "the message must name the file to edit: {msg}"
2425        );
2426        assert!(
2427            msg.contains("key 'commit_datetime' in section [templates]"),
2428            "the message must name the key and its section: {msg}"
2429        );
2430        assert!(
2431            msg.contains("'%Q'"),
2432            "the message must quote the value that cannot be rendered: {msg}"
2433        );
2434    }
2435
2436    #[test]
2437    fn test_require_time_format_guards_the_date_path_too() {
2438        // Both format keys go through the same check. Without this, reverting
2439        // the date-path call site alone would leave every test green.
2440        let mut config = Ini::new();
2441        config.set(
2442            "templates",
2443            "commit_date_path",
2444            Some("%Y/%Q.md".to_string()),
2445        );
2446
2447        let global_vars = GlobalVars::new();
2448        global_vars.config.set(config).unwrap();
2449
2450        let result = panic::catch_unwind(AssertUnwindSafe(|| {
2451            global_vars.set_templates_commit_date_path("templates")
2452        }));
2453
2454        let panic_info = result.expect_err("a format chrono cannot render must be fatal");
2455        let msg = panic_info
2456            .downcast_ref::<String>()
2457            .expect("panic message should be a formatted String");
2458
2459        assert!(
2460            msg.contains("key 'commit_date_path' in section [templates]"),
2461            "the message must name the key and its section: {msg}"
2462        );
2463    }
2464
2465    #[test]
2466    fn test_is_renderable_time_format_accepts_the_shipped_defaults() {
2467        assert!(is_renderable_time_format("%H:%M:%S"));
2468        assert!(is_renderable_time_format("%Y/%m-%B/%F.md"));
2469        assert!(
2470            is_renderable_time_format("Commits"),
2471            "a format with no specifier at all is still renderable"
2472        );
2473        assert!(!is_renderable_time_format("%Q"));
2474    }
2475
2476    #[test]
2477    fn test_require_key_says_which_config_when_none_was_read() {
2478        let mut config = Ini::new();
2479        config.set("obsidian", "commit_path", Some("commits".to_string()));
2480
2481        let global_vars = GlobalVars::new();
2482        global_vars.config.set(config).unwrap();
2483
2484        let result = panic::catch_unwind(AssertUnwindSafe(|| {
2485            global_vars.set_obsidian_root_path_dir("obsidian")
2486        }));
2487
2488        let panic_info = result.expect_err("a missing required key must be fatal");
2489        let msg = panic_info
2490            .downcast_ref::<String>()
2491            .expect("panic message should be a formatted String");
2492
2493        assert!(
2494            msg.contains("the rusty-commit-saver config"),
2495            "with no file read, the message must say so rather than guess a path: {msg}"
2496        );
2497    }
2498
2499    #[test]
2500    fn test_global_vars_set_all_method() {
2501        use std::io::Write;
2502        use tempfile::NamedTempFile;
2503
2504        // Create a real config file
2505        let mut temp_file = NamedTempFile::new().unwrap();
2506        writeln!(temp_file, "[obsidian]").unwrap();
2507        writeln!(temp_file, "root_path_dir=/tmp/obsidian_full_test").unwrap();
2508        writeln!(temp_file, "commit_path=FullTest/Commits").unwrap();
2509        writeln!(temp_file, "[templates]").unwrap();
2510        writeln!(temp_file, "commit_date_path=%Y/%m/%d.md").unwrap();
2511        writeln!(temp_file, "commit_datetime=%Y-%m-%d %H:%M:%S").unwrap();
2512        temp_file.flush().unwrap();
2513
2514        // Parse config manually
2515        let content = std::fs::read_to_string(temp_file.path()).unwrap();
2516        let config = parse_ini_content(&content).unwrap();
2517
2518        // Test set_all workflow
2519        let global_vars = GlobalVars::new();
2520        global_vars.config.set(config).unwrap();
2521        global_vars.set_obsidian_vars();
2522
2523        // Verify all values accessible via set_all pattern
2524        let root = global_vars.get_obsidian_root_path_dir();
2525        let commit = global_vars.get_obsidian_commit_path();
2526        let date = global_vars.get_template_commit_date_path();
2527        let datetime = global_vars.get_template_commit_datetime();
2528
2529        assert!(root.to_string_lossy().contains("obsidian_full_test"));
2530        assert!(commit.to_string_lossy().contains("FullTest"));
2531        assert_eq!(date, "%Y/%m/%d.md");
2532        assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
2533    }
2534
2535    #[test]
2536    fn test_set_obsidian_vars_complete_workflow() {
2537        let mut config = Ini::new();
2538        config.set(
2539            "obsidian",
2540            "root_path_dir",
2541            Some("~/test/obsidian".to_string()),
2542        );
2543        config.set(
2544            "obsidian",
2545            "commit_path",
2546            Some("~/test/commits".to_string()),
2547        );
2548        config.set(
2549            "templates",
2550            "commit_date_path",
2551            Some("%Y/%m/%d.md".to_string()),
2552        );
2553        config.set(
2554            "templates",
2555            "commit_datetime",
2556            Some("%Y-%m-%d %H:%M:%S".to_string()),
2557        );
2558
2559        let global_vars = GlobalVars::new();
2560        global_vars.config.set(config).unwrap();
2561
2562        // This exercises the full set_obsidian_vars logic
2563        global_vars.set_obsidian_vars();
2564
2565        // Verify all paths were expanded
2566        let root = global_vars.get_obsidian_root_path_dir();
2567        let commit = global_vars.get_obsidian_commit_path();
2568
2569        // Both should have ~ expanded
2570        assert!(!root.to_string_lossy().contains('~'));
2571        assert!(!commit.to_string_lossy().contains('~'));
2572        assert!(root.to_string_lossy().contains("obsidian"));
2573        assert!(commit.to_string_lossy().contains("commits"));
2574    }
2575}
2576
2577#[cfg(test)]
2578#[cfg_attr(coverage_nightly, coverage(off))]
2579mod user_input_tests {
2580    use super::*;
2581    use clap::Parser;
2582    use std::sync::{Mutex, MutexGuard, PoisonError};
2583
2584    /// `RUSTY_COMMIT_SAVER_CONFIG` is process-global, and several tests here
2585    /// both write and read it. Under `cargo test` they share one process and
2586    /// race: a test reading the var can see the value another test just set.
2587    /// (`cargo nextest`, which the gate uses, runs each test in its own
2588    /// process and never sees this.)
2589    static CONFIG_ENV: Mutex<()> = Mutex::new(());
2590
2591    /// Takes the lock above, ignoring poisoning - several of these tests panic
2592    /// deliberately, and a poisoned lock is not a reason to fail the rest.
2593    fn lock_config_env() -> MutexGuard<'static, ()> {
2594        CONFIG_ENV.lock().unwrap_or_else(PoisonError::into_inner)
2595    }
2596
2597    #[test]
2598    fn test_user_input_parse_with_config() {
2599        let args = vec!["test_program", "--config-ini", "/path/to/config.ini"];
2600        let user_input = UserInput::try_parse_from(args).unwrap();
2601
2602        assert_eq!(
2603            user_input.config_ini,
2604            Some("/path/to/config.ini".to_string())
2605        );
2606    }
2607
2608    #[test]
2609    fn test_user_input_parse_without_config() {
2610        let args = vec!["test_program"];
2611        let user_input = UserInput::try_parse_from(args).unwrap();
2612
2613        assert_eq!(user_input.config_ini, None);
2614    }
2615
2616    #[test]
2617    fn test_user_input_parse_short_flag() {
2618        let args = vec!["test_program", "-c", "/short/path/config.ini"];
2619        let user_input = UserInput::try_parse_from(args).unwrap();
2620
2621        assert_eq!(
2622            user_input.config_ini,
2623            Some("/short/path/config.ini".to_string())
2624        );
2625    }
2626
2627    #[test]
2628    fn test_set_proper_home_dir_with_tilde() {
2629        let input = "~/test/path/file.ini";
2630        let result = set_proper_home_dir(input);
2631
2632        // Should replace ~ with actual home directory
2633        assert!(!result.contains('~'));
2634        assert!(result.ends_with("/test/path/file.ini"));
2635    }
2636
2637    #[test]
2638    fn test_set_proper_home_dir_without_tilde() {
2639        let input = "/absolute/path/file.ini";
2640        let result = set_proper_home_dir(input);
2641
2642        // Should remain unchanged
2643        assert_eq!(result, input);
2644    }
2645
2646    #[test]
2647    fn test_set_proper_home_dir_multiple_tildes() {
2648        let input = "~/path/~/file.ini";
2649        let result = set_proper_home_dir(input);
2650
2651        // Should replace ALL tildes
2652        assert!(!result.contains('~'));
2653    }
2654
2655    #[test]
2656    fn test_get_default_ini_path() {
2657        let result = get_default_ini_path();
2658
2659        // Should end with the expected config path
2660        assert!(result.ends_with(".config/rusty-commit-saver/rusty-commit-saver.ini"));
2661
2662        // Should NOT contain literal tilde
2663        assert!(!result.contains('~'));
2664
2665        // Should be an absolute path
2666        assert!(result.starts_with('/'));
2667    }
2668
2669    #[test]
2670    fn test_get_or_default_config_ini_path_with_config_and_tilde() {
2671        // Simulate CLI args: --config-ini ~/my/config.ini
2672        let args = vec!["test", "--config-ini", "~/my/config.ini"];
2673        let user_input = UserInput::try_parse_from(args).unwrap();
2674
2675        // We can't directly call get_or_default_config_ini_path() because it parses env args
2676        // Instead, test that UserInput correctly parses the config path
2677        assert_eq!(user_input.config_ini, Some("~/my/config.ini".to_string()));
2678    }
2679
2680    #[test]
2681    fn test_get_or_default_config_ini_path_with_config_absolute_path() {
2682        // Simulate CLI args: --config-ini /absolute/path/config.ini
2683        let args = vec!["test", "--config-ini", "/absolute/path/config.ini"];
2684        let user_input = UserInput::try_parse_from(args).unwrap();
2685
2686        assert_eq!(
2687            user_input.config_ini,
2688            Some("/absolute/path/config.ini".to_string())
2689        );
2690    }
2691
2692    #[test]
2693    fn test_get_or_default_config_ini_path_without_config() {
2694        // Simulate CLI args with no config specified
2695        let args = vec!["test"];
2696        let user_input = UserInput::try_parse_from(args).unwrap();
2697
2698        // Should default to None, and get_or_default_config_ini_path() will use get_default_ini_path()
2699        assert_eq!(user_input.config_ini, None);
2700    }
2701
2702    #[test]
2703    fn test_parse_ini_content_valid() {
2704        let content = r"
2705[obsidian]
2706root_path_dir=~/Documents/Obsidian
2707commit_path=Diaries/Commits
2708
2709[templates]
2710commit_date_path=%Y/%m-%B/%F.md
2711commit_datetime=%Y-%m-%d
2712";
2713
2714        let result = parse_ini_content(content);
2715        assert!(result.is_ok());
2716
2717        let ini = result.unwrap();
2718        assert_eq!(
2719            ini.get("obsidian", "root_path_dir"),
2720            Some("~/Documents/Obsidian".to_string())
2721        );
2722        assert_eq!(
2723            ini.get("templates", "commit_date_path"),
2724            Some("%Y/%m-%B/%F.md".to_string())
2725        );
2726    }
2727
2728    #[test]
2729    fn test_parse_ini_content_invalid() {
2730        let content = "this is not valid ini format [[[";
2731
2732        let result = parse_ini_content(content);
2733        // Should succeed because configparser is very lenient, but let's verify it doesn't panic
2734        assert!(result.is_ok() || result.is_err());
2735    }
2736
2737    #[test]
2738    fn test_parse_ini_content_empty() {
2739        let content = "";
2740
2741        let result = parse_ini_content(content);
2742        assert!(result.is_ok());
2743
2744        let ini = result.unwrap();
2745        assert_eq!(ini.sections().len(), 0);
2746    }
2747
2748    #[test]
2749    fn test_retrieve_config_file_path_with_temp_file() {
2750        use std::io::Write;
2751        use tempfile::NamedTempFile;
2752
2753        // Create a temporary config file
2754        let mut temp_file = NamedTempFile::new().unwrap();
2755        writeln!(temp_file, "[obsidian]").unwrap();
2756        writeln!(temp_file, "root_path_dir=/tmp/test").unwrap();
2757        writeln!(temp_file, "commit_path=commits").unwrap();
2758        writeln!(temp_file, "[templates]").unwrap();
2759        writeln!(temp_file, "commit_date_path=%Y-%m-%d.md").unwrap();
2760        writeln!(temp_file, "commit_datetime=%Y-%m-%d").unwrap();
2761        temp_file.flush().unwrap();
2762
2763        // Set CLI args to point to our temp file
2764        // We need to simulate CLI args via environment
2765        let path = temp_file.path().to_str().unwrap();
2766
2767        // Instead of testing retrieve_config_file_path directly (which reads from CLI),
2768        // test that we can read and parse a config file
2769        let content = std::fs::read_to_string(path).unwrap();
2770        let result = parse_ini_content(&content);
2771
2772        assert!(result.is_ok());
2773        let ini = result.unwrap();
2774        assert_eq!(
2775            ini.get("obsidian", "root_path_dir"),
2776            Some("/tmp/test".to_string())
2777        );
2778    }
2779
2780    #[test]
2781    fn test_ini_parsing_integration() {
2782        let content = r"
2783[obsidian]
2784root_path_dir=~/Documents/Obsidian
2785commit_path=Diaries/Commits
2786
2787[templates]
2788commit_date_path=%Y/%m-%B/%F.md
2789commit_datetime=%Y-%m-%d %H:%M:%S
2790";
2791
2792        let ini = parse_ini_content(content).unwrap();
2793
2794        // Verify all expected keys exist
2795        assert!(ini.get("obsidian", "root_path_dir").is_some());
2796        assert!(ini.get("obsidian", "commit_path").is_some());
2797        assert!(ini.get("templates", "commit_date_path").is_some());
2798        assert!(ini.get("templates", "commit_datetime").is_some());
2799
2800        // Verify sections count
2801        assert_eq!(ini.sections().len(), 2);
2802    }
2803
2804    // #[test]
2805    // fn debug_ini_sections_behavior() {
2806    //     let mut config = Ini::new();
2807    //     config.set("only_one_section", "key", Some("value".to_string()));
2808    //
2809    //     let sections = config.sections();
2810    //     println!("Sections count: {}", sections.len());
2811    //     println!("Sections: {:?}", sections);
2812    //
2813    //     // Force fail to see output
2814    //     assert!(false, "Debug: sections = {:?}", sections);
2815    // }
2816
2817    #[test]
2818    fn test_missing_required_sections_panic_message() {
2819        use std::panic;
2820
2821        let mut config = Ini::new();
2822        config.set("only_one_section", "key", Some("value".to_string()));
2823
2824        let global_vars = GlobalVars::new();
2825        global_vars.config.set(config).unwrap();
2826
2827        let result = panic::catch_unwind(|| global_vars.get_sections_from_config());
2828
2829        assert!(result.is_err(), "Should have panicked");
2830    }
2831
2832    #[test]
2833    fn test_set_all_loads_config_and_sets_vars() {
2834        use std::env;
2835        use std::fs;
2836        use tempfile::NamedTempFile;
2837
2838        let temp_file = NamedTempFile::new().expect("Failed to create temp file");
2839        let config_content = r"[obsidian]
2840root_path_dir = /tmp/test_obsidian
2841commit_path = Commits
2842
2843[templates]
2844commit_date_path = %Y/%m/%d.md
2845commit_datetime = %Y-%m-%d %H:%M:%S
2846";
2847        fs::write(temp_file.path(), config_content).expect("Failed to write temp config");
2848
2849        let _guard = lock_config_env();
2850        env::set_var(
2851            "RUSTY_COMMIT_SAVER_CONFIG",
2852            temp_file.path().to_str().unwrap(),
2853        );
2854
2855        let global_vars = GlobalVars::new();
2856        let result = global_vars.set_all();
2857
2858        // Verify method chaining
2859        assert!(std::ptr::eq(result, &raw const global_vars));
2860
2861        // Verify config was set
2862        assert!(global_vars.config.get().is_some());
2863
2864        env::remove_var("RUSTY_COMMIT_SAVER_CONFIG");
2865    }
2866
2867    #[test]
2868    #[should_panic(expected = "config_path DOES NOT exists")]
2869    fn test_retrieve_config_file_path_panics_on_missing_file() {
2870        let _guard = lock_config_env();
2871        std::env::set_var("RUSTY_COMMIT_SAVER_CONFIG", "/nonexistent/path/config.ini");
2872        let _ = retrieve_config_file_path();
2873    }
2874
2875    #[test]
2876    fn test_get_or_default_config_ini_path_env_var_with_tilde() {
2877        use std::env;
2878
2879        let _guard = lock_config_env();
2880        let var_name = "RUSTY_COMMIT_SAVER_CONFIG";
2881        let original = env::var(var_name).ok();
2882
2883        env::set_var(var_name, "~/some/config/path.ini");
2884
2885        let result = get_or_default_config_ini_path();
2886
2887        // Restore
2888        match original {
2889            Some(val) => env::set_var(var_name, val),
2890            None => env::remove_var(var_name),
2891        }
2892
2893        // Should have expanded ~ to home dir
2894        assert!(!result.contains('~'), "Tilde should be expanded");
2895        assert!(result.ends_with("/some/config/path.ini"));
2896    }
2897
2898    #[test]
2899    fn test_resolve_config_path_cli_with_tilde() {
2900        let result = resolve_config_path(Some("~/my/config.ini".to_string()), None);
2901        assert!(!result.contains('~'));
2902        assert!(result.ends_with("/my/config.ini"));
2903    }
2904
2905    #[test]
2906    fn test_resolve_config_path_cli_without_tilde() {
2907        let result = resolve_config_path(Some("/absolute/path.ini".to_string()), None);
2908        assert_eq!(result, "/absolute/path.ini");
2909    }
2910
2911    #[test]
2912    fn test_resolve_config_path_default() {
2913        let result = resolve_config_path(None, None);
2914        assert!(result.contains("rusty-commit-saver.ini"));
2915    }
2916
2917    #[test]
2918    fn test_resolve_config_path_env_takes_precedence() {
2919        let result = resolve_config_path(
2920            Some("/cli/path.ini".to_string()),
2921            Some("/env/path.ini".to_string()),
2922        );
2923        assert_eq!(result, "/env/path.ini");
2924    }
2925
2926    #[test]
2927    fn test_get_or_default_config_ini_path_with_no_env_calls_cli_parser() {
2928        let parser_called = std::cell::Cell::new(false);
2929
2930        let result = get_or_default_config_ini_path_with(None, || {
2931            parser_called.set(true);
2932            Some("/mock/cli/path.ini".to_string())
2933        });
2934
2935        assert!(
2936            parser_called.get(),
2937            "CLI parser should be called when no env var"
2938        );
2939        assert_eq!(result, "/mock/cli/path.ini");
2940    }
2941
2942    #[test]
2943    fn test_get_or_default_config_ini_path_with_env_skips_cli_parser() {
2944        let parser_called = std::cell::Cell::new(false);
2945
2946        let result = get_or_default_config_ini_path_with(Some("/env/path.ini".to_string()), || {
2947            parser_called.set(true);
2948            Some("/should/not/be/used.ini".to_string())
2949        });
2950
2951        assert!(
2952            !parser_called.get(),
2953            "CLI parser should NOT be called when env var is set"
2954        );
2955        assert_eq!(result, "/env/path.ini");
2956    }
2957
2958    #[test]
2959    fn test_get_or_default_config_ini_path_with_cli_returns_default_when_none() {
2960        let result = get_or_default_config_ini_path_with(None, || None);
2961
2962        // Should fall back to default path
2963        assert!(result.contains("rusty-commit-saver.ini"));
2964    }
2965
2966    #[test]
2967    fn test_get_or_default_config_ini_path_with_cli_tilde_expansion() {
2968        let result =
2969            get_or_default_config_ini_path_with(None, || Some("~/custom/config.ini".to_string()));
2970
2971        assert!(!result.contains('~'), "Tilde should be expanded");
2972        assert!(result.ends_with("/custom/config.ini"));
2973    }
2974
2975    #[test]
2976    fn test_parse_ini_content_find_invalid_case() {
2977        let cases = [
2978            "[unclosed",
2979            "no_section_key = value", // might actually work (goes to default section)
2980            "[section]\nweird line without equals",
2981        ];
2982
2983        for case in cases {
2984            let result = parse_ini_content(case);
2985            println!("{case:?} => {result:?}");
2986        }
2987    }
2988
2989    #[test]
2990    fn test_parse_ini_content_invalid_syntax() {
2991        let result = parse_ini_content("[unclosed");
2992
2993        assert!(result.is_err(), "Should fail on unclosed bracket");
2994        let err = result.unwrap_err();
2995        assert!(
2996            err.contains("Failed to parse INI"),
2997            "Error should contain expected prefix: {err}"
2998        );
2999    }
3000
3001    #[test]
3002    #[should_panic(expected = "Should have been able to read the file")]
3003    fn test_retrieve_config_file_path_panics_on_unreadable_file() {
3004        use std::fs::{self, File};
3005        use std::os::unix::fs::PermissionsExt;
3006        use tempfile::tempdir;
3007
3008        let dir = tempdir().unwrap();
3009        let file_path = dir.path().join("unreadable.ini");
3010
3011        // Create file with no read permissions
3012        File::create(&file_path).unwrap();
3013        fs::set_permissions(&file_path, fs::Permissions::from_mode(0o000)).unwrap();
3014
3015        let _guard = lock_config_env();
3016        std::env::set_var("RUSTY_COMMIT_SAVER_CONFIG", file_path.to_str().unwrap());
3017
3018        // This should panic because file exists but can't be read
3019        let _ = retrieve_config_file_path();
3020
3021        // Cleanup (won't run due to panic, but good practice)
3022        fs::set_permissions(&file_path, fs::Permissions::from_mode(0o644)).unwrap();
3023    }
3024
3025    #[test]
3026    fn test_get_ini_file_reads_the_configured_path() {
3027        use std::io::Write;
3028        use tempfile::NamedTempFile;
3029
3030        let mut temp_file = NamedTempFile::new().unwrap();
3031        writeln!(temp_file, "[obsidian]").unwrap();
3032        writeln!(temp_file, "root_path_dir=/tmp/test").unwrap();
3033        writeln!(temp_file, "commit_path=commits").unwrap();
3034        writeln!(temp_file, "[templates]").unwrap();
3035        writeln!(temp_file, "commit_date_path=%Y-%m-%d.md").unwrap();
3036        writeln!(temp_file, "commit_datetime=%H:%M:%S").unwrap();
3037        temp_file.flush().unwrap();
3038
3039        let _guard = lock_config_env();
3040        std::env::set_var("RUSTY_COMMIT_SAVER_CONFIG", temp_file.path());
3041
3042        // The convenience wrapper must resolve the path the same way
3043        // set_all() does, now that set_all() resolves it itself.
3044        let config = get_ini_file();
3045
3046        std::env::remove_var("RUSTY_COMMIT_SAVER_CONFIG");
3047
3048        assert_eq!(
3049            config.get("obsidian", "commit_path"),
3050            Some("commits".to_string())
3051        );
3052    }
3053
3054    #[test]
3055    fn test_parse_exclude_repos_basic() {
3056        assert_eq!(
3057            parse_exclude_repos("claude-src, other-repo"),
3058            vec!["claude-src".to_string(), "other-repo".to_string()]
3059        );
3060    }
3061
3062    #[test]
3063    fn test_parse_exclude_repos_trims_and_drops_empties() {
3064        // Extra spaces, trailing comma, and an empty middle entry are all cleaned.
3065        assert_eq!(
3066            parse_exclude_repos("  claude-src , , foo,"),
3067            vec!["claude-src".to_string(), "foo".to_string()]
3068        );
3069    }
3070
3071    #[test]
3072    fn test_parse_exclude_repos_empty_input() {
3073        assert!(parse_exclude_repos("   ").is_empty());
3074        assert!(parse_exclude_repos("").is_empty());
3075    }
3076
3077    #[test]
3078    fn test_parse_exclude_repos_single_entry() {
3079        assert_eq!(
3080            parse_exclude_repos("claude-src"),
3081            vec!["claude-src".to_string()]
3082        );
3083    }
3084
3085    #[test]
3086    fn test_get_excluded_repos_defaults_empty_when_unset() {
3087        // No [exclude] section set: getter yields an empty list, not a panic.
3088        let global_vars = GlobalVars::new();
3089        assert!(global_vars.get_excluded_repos().is_empty());
3090    }
3091
3092    #[test]
3093    fn test_set_and_get_excluded_repos() {
3094        let mut config = Ini::new();
3095        config.set("exclude", "repos", Some("claude-src, foo".to_string()));
3096
3097        let global_vars = GlobalVars::new();
3098        global_vars.config.set(config).unwrap();
3099        global_vars.set_excluded_repos("exclude");
3100
3101        assert_eq!(
3102            global_vars.get_excluded_repos(),
3103            vec!["claude-src".to_string(), "foo".to_string()]
3104        );
3105    }
3106
3107    #[test]
3108    fn test_set_excluded_repos_missing_key_is_empty() {
3109        // [exclude] present but no `repos` key -> empty list, no panic.
3110        let mut config = Ini::new();
3111        config.set("exclude", "unrelated", Some("value".to_string()));
3112
3113        let global_vars = GlobalVars::new();
3114        global_vars.config.set(config).unwrap();
3115        global_vars.set_excluded_repos("exclude");
3116
3117        assert!(global_vars.get_excluded_repos().is_empty());
3118    }
3119
3120    #[test]
3121    fn test_get_sections_accepts_optional_exclude_section() {
3122        // obsidian + templates + exclude is now valid (exclude is optional).
3123        let mut config = Ini::new();
3124        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
3125        config.set("templates", "commit_date_path", Some("%Y.md".to_string()));
3126        config.set("exclude", "repos", Some("claude-src".to_string()));
3127
3128        let global_vars = GlobalVars::new();
3129        global_vars.config.set(config).unwrap();
3130
3131        let sections = global_vars.get_sections_from_config();
3132        assert_eq!(sections.len(), 3);
3133        assert!(sections.contains(&"exclude".to_string()));
3134    }
3135
3136    #[test]
3137    fn test_set_obsidian_vars_populates_exclude_section() {
3138        // Full set_obsidian_vars path with all three sections present.
3139        let mut config = Ini::new();
3140        config.set(
3141            "obsidian",
3142            "root_path_dir",
3143            Some("/home/user/Obsidian".to_string()),
3144        );
3145        config.set(
3146            "obsidian",
3147            "commit_path",
3148            Some("Diaries/Commits".to_string()),
3149        );
3150        config.set(
3151            "templates",
3152            "commit_date_path",
3153            Some("%Y-%m-%d.md".to_string()),
3154        );
3155        config.set("templates", "commit_datetime", Some("%H:%M:%S".to_string()));
3156        config.set("exclude", "repos", Some("claude-src".to_string()));
3157
3158        let global_vars = GlobalVars::new();
3159        global_vars.config.set(config).unwrap();
3160        global_vars.set_obsidian_vars();
3161
3162        assert_eq!(
3163            global_vars.get_excluded_repos(),
3164            vec!["claude-src".to_string()]
3165        );
3166    }
3167
3168    #[test]
3169    fn test_get_sections_tolerates_an_unknown_section() {
3170        // A config written for a newer release must not brick an older binary:
3171        // an unrecognised section is ignored, not fatal. This is what made the
3172        // pre-4.17.0 binaries panic once [exclude] was added to the shared ini.
3173        let mut config = Ini::new();
3174        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
3175        config.set("templates", "commit_date_path", Some("%Y.md".to_string()));
3176        config.set("from_a_future_release", "key", Some("value".to_string()));
3177
3178        let global_vars = GlobalVars::new();
3179        global_vars.config.set(config).unwrap();
3180
3181        let sections = global_vars.get_sections_from_config();
3182        assert!(sections.contains(&"obsidian".to_string()));
3183        assert!(sections.contains(&"templates".to_string()));
3184    }
3185
3186    #[test]
3187    #[should_panic(expected = "must have [obsidian] and [templates]")]
3188    fn test_get_sections_rejects_obsidian_without_templates() {
3189        // The mirror of the case below: [obsidian] alone is just as fatal.
3190        let mut config = Ini::new();
3191        config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
3192
3193        let global_vars = GlobalVars::new();
3194        global_vars.config.set(config).unwrap();
3195        let _ = global_vars.get_sections_from_config();
3196    }
3197
3198    #[test]
3199    #[should_panic(expected = "must have [obsidian] and [templates]")]
3200    fn test_get_sections_rejects_missing_required_section() {
3201        // exclude alone (no obsidian/templates) is still invalid.
3202        let mut config = Ini::new();
3203        config.set("exclude", "repos", Some("claude-src".to_string()));
3204        config.set("templates", "commit_date_path", Some("%Y.md".to_string()));
3205
3206        let global_vars = GlobalVars::new();
3207        global_vars.config.set(config).unwrap();
3208        let _ = global_vars.get_sections_from_config();
3209    }
3210}
3211
3212#[cfg(test)]
3213#[cfg_attr(coverage_nightly, coverage(off))]
3214mod since_tests {
3215    use super::*;
3216
3217    #[test]
3218    fn a_date_becomes_midnight_utc() {
3219        let floor = parse_since("2026-08-01").expect("a real date");
3220
3221        assert_eq!(floor.to_rfc3339(), "2026-08-01T00:00:00+00:00");
3222    }
3223
3224    #[test]
3225    fn surrounding_whitespace_is_tolerated() {
3226        // A timer unit's ExecStart is edited by hand; a stray space must not
3227        // turn into a backfill of everything HEAD can reach.
3228        assert_eq!(
3229            parse_since("  2026-08-01 ").expect("a real date"),
3230            parse_since("2026-08-01").expect("a real date")
3231        );
3232    }
3233
3234    #[test]
3235    fn an_unreadable_value_names_itself_and_the_shape() {
3236        let error = parse_since("yesterday").expect_err("not a date");
3237
3238        assert!(error.contains("YYYY-MM-DD"), "must name the shape: {error}");
3239        assert!(error.contains("yesterday"), "must quote the value: {error}");
3240    }
3241
3242    #[test]
3243    fn a_date_that_does_not_exist_is_refused() {
3244        assert!(parse_since("2026-02-30").is_err());
3245    }
3246}