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