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
14pub 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#[derive(Debug, Default)]
124pub struct GlobalVars {
125 pub config: OnceCell<Ini>,
135
136 pub config_path: OnceCell<String>,
142
143 obsidian_root_path_dir: OnceCell<PathBuf>,
161
162 obsidian_commit_path: OnceCell<PathBuf>,
188
189 template_commit_date_path: OnceCell<String>,
220
221 template_commit_datetime: OnceCell<String>,
253
254 excluded_repos: OnceCell<Vec<String>>,
269}
270
271impl GlobalVars {
272 #[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 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 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 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 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 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 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 const KNOWN_SECTIONS: [&'static str; 3] = ["obsidian", "templates", "exclude"];
631
632 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 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 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 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 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 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 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 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 "[GlobalVars::get_sections_from_config()] These are the sections found: {sections:?}"
808 ); panic!(
810 "[GlobalVars::get_sections_from_config()] config must have [obsidian] and [templates]."
811 )
812 }
813
814 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 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 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(§ion);
882 self.set_obsidian_commit_path(§ion);
883 } else if section == "templates" {
884 info!("[GlobalVars::set_obsidian_vars()] Setting 'templates' section variables.");
885 self.set_templates_commit_date_path(§ion);
886 self.set_templates_datetime(§ion);
887 } else if section == "exclude" {
888 info!("[GlobalVars::set_obsidian_vars()] Setting 'exclude' section variables.");
889 self.set_excluded_repos(§ion);
890 }
891 }
895 }
896
897 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 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 #[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 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 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 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#[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 #[arg(short, long)]
1175 pub config_ini: Option<String>,
1176
1177 #[arg(long, value_name = "PATH")]
1192 pub reconcile: Vec<PathBuf>,
1193
1194 #[arg(long, value_name = "DATE")]
1205 pub since: Option<String>,
1206}
1207
1208pub 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#[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#[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#[must_use]
1325pub fn resolve_config_path(cli_arg: Option<String>, env_var: Option<String>) -> String {
1326 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 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 } 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#[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#[must_use]
1462pub fn get_ini_file() -> Ini {
1463 get_ini_file_at(&get_or_default_config_ini_path())
1464}
1465
1466#[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
1489fn 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
1540fn 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#[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 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 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 assert!(!result.to_string_lossy().contains('~'));
1887 assert!(result.to_string_lossy().starts_with('/'));
1889 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 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 assert!(!result.to_string_lossy().contains('~'));
1941 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 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 global_vars.set_obsidian_vars();
2038
2039 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 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 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 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 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 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 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 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 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 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 let content = std::fs::read_to_string(temp_file.path()).unwrap();
2177 let mut config = Ini::new();
2178 config.read(content).unwrap();
2179
2180 let global_vars = GlobalVars::new();
2182 let result = global_vars.config.set(config);
2183 assert!(result.is_ok());
2184
2185 global_vars.set_obsidian_vars();
2187
2188 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 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 assert!(result.to_string_lossy().contains("Nested"));
2244 }
2245
2246 #[test]
2247 fn test_set_obsidian_root_path_dir_empty_string() {
2248 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 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 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 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 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 let content = std::fs::read_to_string(temp_file.path()).unwrap();
2516 let config = parse_ini_content(&content).unwrap();
2517
2518 let global_vars = GlobalVars::new();
2520 global_vars.config.set(config).unwrap();
2521 global_vars.set_obsidian_vars();
2522
2523 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 global_vars.set_obsidian_vars();
2564
2565 let root = global_vars.get_obsidian_root_path_dir();
2567 let commit = global_vars.get_obsidian_commit_path();
2568
2569 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 static CONFIG_ENV: Mutex<()> = Mutex::new(());
2590
2591 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 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 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 assert!(!result.contains('~'));
2653 }
2654
2655 #[test]
2656 fn test_get_default_ini_path() {
2657 let result = get_default_ini_path();
2658
2659 assert!(result.ends_with(".config/rusty-commit-saver/rusty-commit-saver.ini"));
2661
2662 assert!(!result.contains('~'));
2664
2665 assert!(result.starts_with('/'));
2667 }
2668
2669 #[test]
2670 fn test_get_or_default_config_ini_path_with_config_and_tilde() {
2671 let args = vec!["test", "--config-ini", "~/my/config.ini"];
2673 let user_input = UserInput::try_parse_from(args).unwrap();
2674
2675 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 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 let args = vec!["test"];
2696 let user_input = UserInput::try_parse_from(args).unwrap();
2697
2698 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 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 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 let path = temp_file.path().to_str().unwrap();
2766
2767 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 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 assert_eq!(ini.sections().len(), 2);
2802 }
2803
2804 #[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 assert!(std::ptr::eq(result, &raw const global_vars));
2860
2861 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 match original {
2889 Some(val) => env::set_var(var_name, val),
2890 None => env::remove_var(var_name),
2891 }
2892
2893 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 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", "[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 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 let _ = retrieve_config_file_path();
3020
3021 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 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 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 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 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 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 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 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 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 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 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}