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
1178#[must_use]
1224pub fn retrieve_config_file_path() -> String {
1225 info!(
1226 "[UserInput::retrieve_config_file_path()]: retrieving the string path from CLI or default"
1227 );
1228 read_config_file(&get_or_default_config_ini_path())
1229}
1230
1231#[must_use]
1241fn read_config_file(config_path: &str) -> String {
1242 if Path::new(config_path).exists() {
1243 info!("[UserInput::retrieve_config_file_path()]: config_path exists {config_path:}");
1244 } else {
1245 error!(
1246 "[UserInput::retrieve_config_file_path()]: config_path DOES NOT exists {config_path:}"
1247 );
1248 panic!(
1249 "[UserInput::retrieve_config_file_path()]: config_path DOES NOT exists {config_path:}"
1250 );
1251 }
1252 info!("[UserInput::retrieve_config_file_path()] retrieved config path: {config_path:}");
1253 fs::read_to_string(config_path)
1254 .unwrap_or_else(|_| panic!("Should have been able to read the file: {config_path:}"))
1255}
1256
1257#[must_use]
1276pub fn resolve_config_path(cli_arg: Option<String>, env_var: Option<String>) -> String {
1277 if let Some(env_path) = env_var {
1279 info!("[resolve_config_path]: Using config from env var.");
1280 return if env_path.contains('~') {
1281 set_proper_home_dir(&env_path)
1282 } else {
1283 env_path
1284 };
1285 }
1286
1287 if let Some(cfg_str) = cli_arg {
1289 if cfg_str.contains('~') {
1290 info!("[resolve_config_path]: CLI path contains '~'.");
1291 set_proper_home_dir(&cfg_str)
1292 } else {
1293 info!("[resolve_config_path]: CLI path without '~'.");
1294 cfg_str
1295 }
1296 } else {
1297 info!("[resolve_config_path]: Using default path.");
1298 get_default_ini_path()
1299 }
1300}
1301
1302#[must_use]
1303#[cfg_attr(coverage_nightly, coverage(off))]
1304pub fn get_or_default_config_ini_path() -> String {
1305 get_or_default_config_ini_path_with(std::env::var("RUSTY_COMMIT_SAVER_CONFIG").ok(), || {
1306 UserInput::parse().config_ini
1307 })
1308}
1309
1310#[must_use]
1311fn get_or_default_config_ini_path_with<F>(env_var: Option<String>, cli_parser: F) -> String
1312where
1313 F: FnOnce() -> Option<String>,
1314{
1315 info!("[get_or_default_config_ini_path()]: Parsing CLI inputs.");
1316
1317 let cli_arg = if env_var.is_some() {
1318 None } else {
1320 cli_parser()
1321 };
1322
1323 let config_path = resolve_config_path(cli_arg, env_var);
1324 info!("[get_or_default_config_ini_path()]: Config path found: {config_path:}");
1325 config_path
1326}
1327
1328#[must_use]
1363pub fn get_default_ini_path() -> String {
1364 info!("[get_default_ini_path()]: Getting default ini file.");
1365 let cfg_str = "~/.config/rusty-commit-saver/rusty-commit-saver.ini".to_string();
1366 set_proper_home_dir(&cfg_str)
1367}
1368
1369#[must_use]
1413pub fn get_ini_file() -> Ini {
1414 get_ini_file_at(&get_or_default_config_ini_path())
1415}
1416
1417#[must_use]
1428pub fn get_ini_file_at(config_path: &str) -> Ini {
1429 info!("[get_ini_file()]: Retrieving the INI File");
1430 let content_ini = read_config_file(config_path);
1431 let mut config = Ini::new();
1432 config
1433 .read(content_ini)
1434 .expect("Could not read the INI file!");
1435
1436 info!("[get_ini_file()]: This is the INI File:\n\n{config:?}");
1437 config
1438}
1439
1440fn set_proper_home_dir(cfg_str: &str) -> String {
1481 info!("[set_proper_home_dir()]: Changing the '~' to full home directory.");
1482 let home_dir = home_dir()
1483 .expect("Could not get home_dir")
1484 .into_os_string()
1485 .into_string()
1486 .expect("Could not convert home_dir from OsString to String");
1487
1488 cfg_str.replace('~', &home_dir)
1489}
1490
1491fn is_renderable_time_format(format: &str) -> bool {
1497 use std::fmt::Write;
1498
1499 let probe = DateTime::from_timestamp(0, 0).expect("the epoch is a valid timestamp");
1500 let mut rendered = String::new();
1501
1502 write!(rendered, "{}", probe.format(format)).is_ok()
1503}
1504
1505#[must_use]
1527pub fn parse_exclude_repos(raw: &str) -> Vec<String> {
1528 raw.split(',')
1529 .map(str::trim)
1530 .filter(|s| !s.is_empty())
1531 .map(String::from)
1532 .collect()
1533}
1534
1535#[cfg(test)]
1536#[cfg_attr(coverage_nightly, coverage(off))]
1537mod global_vars_tests {
1538 use super::*;
1539 use std::panic::{self, AssertUnwindSafe};
1540
1541 #[test]
1542 fn test_global_vars_new() {
1543 let global_vars = GlobalVars::new();
1544
1545 assert!(global_vars.config.get().is_none());
1546 }
1547
1548 #[test]
1549 fn test_global_vars_default() {
1550 let global_vars = GlobalVars::default();
1551
1552 assert!(global_vars.config.get().is_none());
1553 }
1554
1555 #[test]
1556 fn test_get_sections_from_config_valid() {
1557 let mut config = Ini::new();
1558 config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1559 config.set(
1560 "templates",
1561 "commit_date_path",
1562 Some("%Y-%m-%d".to_string()),
1563 );
1564
1565 let global_vars = GlobalVars::new();
1566 global_vars.config.set(config).unwrap();
1567
1568 let sections = global_vars.get_sections_from_config();
1569
1570 assert_eq!(sections.len(), 2);
1571 assert!(sections.contains(&"obsidian".to_string()));
1572 assert!(sections.contains(&"templates".to_string()));
1573 }
1574
1575 #[test]
1576 fn test_get_sections_from_config_rejects_a_lone_unknown_section() {
1577 let mut config = Ini::new();
1578 config.set("only_one_section", "key", Some("value".to_string()));
1579
1580 let global_vars = GlobalVars::new();
1581 global_vars.config.set(config).unwrap();
1582
1583 let result =
1584 panic::catch_unwind(AssertUnwindSafe(|| global_vars.get_sections_from_config()));
1585
1586 assert!(
1587 result.is_err(),
1588 "Expected panic: the required sections are missing"
1589 );
1590
1591 let panic_info = result.unwrap_err();
1593 let msg = panic_info
1594 .downcast_ref::<&str>()
1595 .expect("Panic message should be &str");
1596 assert!(
1597 msg.contains("must have [obsidian] and [templates]"),
1598 "Unexpected panic message: {msg}"
1599 );
1600 }
1601
1602 #[test]
1603 fn test_get_sections_from_config_panics_with_zero_sections() {
1604 let config = Ini::new();
1605
1606 let global_vars = GlobalVars::new();
1607 global_vars.config.set(config).unwrap();
1608
1609 let result =
1610 panic::catch_unwind(AssertUnwindSafe(|| global_vars.get_sections_from_config()));
1611
1612 assert!(result.is_err(), "Expected panic for zero sections");
1613 }
1614
1615 #[test]
1616 fn test_get_sections_from_config_keeps_an_extra_section() {
1617 let mut config = Ini::new();
1618 config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1619 config.set("templates", "commit_date_path", Some("%Y.md".to_string()));
1620 config.set("extra", "key", Some("value".to_string()));
1621
1622 let global_vars = GlobalVars::new();
1623 global_vars.config.set(config).unwrap();
1624
1625 let result =
1626 panic::catch_unwind(AssertUnwindSafe(|| global_vars.get_sections_from_config()));
1627
1628 assert!(
1629 result.is_ok(),
1630 "An unrecognised section must be ignored, not fatal"
1631 );
1632 assert_eq!(result.unwrap().len(), 3);
1633 }
1634
1635 #[test]
1636 fn test_unrecognised_keys_names_a_typo_in_a_known_section() {
1637 let mut config = Ini::new();
1638 config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1639 config.set("obsidian", "commit_path", Some("commits".to_string()));
1640 config.set("templates", "commit_date_path", Some("%F.md".to_string()));
1641 config.set("templates", "commit_datetime", Some("%T".to_string()));
1644 config.set("templates", "commit_datetimes", Some("%T".to_string()));
1645
1646 let global_vars = GlobalVars::new();
1647 global_vars.config.set(config).unwrap();
1648
1649 assert_eq!(
1650 global_vars.unrecognised_keys(),
1651 vec!["[templates] commit_datetimes".to_string()],
1652 "an unknown key must be named, not swallowed"
1653 );
1654 }
1655
1656 #[test]
1657 fn test_unrecognised_keys_is_empty_for_a_known_config() {
1658 let mut config = Ini::new();
1659 config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1660 config.set("obsidian", "commit_path", Some("commits".to_string()));
1661 config.set("templates", "commit_date_path", Some("%F.md".to_string()));
1662 config.set("templates", "commit_datetime", Some("%T".to_string()));
1663 config.set("exclude", "repos", Some("claude-src".to_string()));
1664
1665 let global_vars = GlobalVars::new();
1666 global_vars.config.set(config).unwrap();
1667
1668 assert!(
1669 global_vars.unrecognised_keys().is_empty(),
1670 "a config this binary fully understands must warn about nothing"
1671 );
1672 }
1673
1674 #[test]
1675 fn test_unrecognised_keys_leaves_an_unknown_section_to_the_section_check() {
1676 let mut config = Ini::new();
1677 config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1678 config.set("obsidian", "commit_path", Some("commits".to_string()));
1679 config.set("templates", "commit_date_path", Some("%F.md".to_string()));
1680 config.set("templates", "commit_datetime", Some("%T".to_string()));
1681 config.set("future_release", "whatever", Some("value".to_string()));
1682
1683 let global_vars = GlobalVars::new();
1684 global_vars.config.set(config).unwrap();
1685
1686 assert!(
1687 global_vars.unrecognised_keys().is_empty(),
1688 "the section is already reported whole; its keys must not double the noise"
1689 );
1690 }
1691
1692 #[test]
1693 fn test_unrecognised_keys_in_an_unknown_section_is_empty() {
1694 let mut config = Ini::new();
1695 config.set("future_release", "whatever", Some("value".to_string()));
1696
1697 let global_vars = GlobalVars::new();
1698 global_vars.config.set(config).unwrap();
1699
1700 assert!(
1701 global_vars
1702 .unrecognised_keys_in("future_release")
1703 .is_empty(),
1704 "the binary cannot know what an unknown section should contain"
1705 );
1706 }
1707
1708 #[test]
1709 fn test_unrecognised_keys_in_a_section_the_config_lacks_is_empty() {
1710 let mut config = Ini::new();
1711 config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1712
1713 let global_vars = GlobalVars::new();
1714 global_vars.config.set(config).unwrap();
1715
1716 assert!(
1717 global_vars.unrecognised_keys_in("exclude").is_empty(),
1718 "a section that is not in the config has no keys to report"
1719 );
1720 }
1721
1722 #[test]
1723 fn test_unrecognised_keys_are_sorted_and_name_every_section() {
1724 let mut config = Ini::new();
1725 config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1726 config.set("obsidian", "commit_path", Some("commits".to_string()));
1727 config.set("obsidian", "vault", Some("stale".to_string()));
1728 config.set("templates", "commit_date_path", Some("%F.md".to_string()));
1729 config.set("templates", "commit_datetime", Some("%T".to_string()));
1730 config.set("templates", "author", Some("stale".to_string()));
1731 config.set("exclude", "repos", Some("claude-src".to_string()));
1732 config.set("exclude", "branches", Some("stale".to_string()));
1733
1734 let global_vars = GlobalVars::new();
1735 global_vars.config.set(config).unwrap();
1736
1737 assert_eq!(
1738 global_vars.unrecognised_keys(),
1739 vec![
1740 "[exclude] branches".to_string(),
1741 "[obsidian] vault".to_string(),
1742 "[templates] author".to_string(),
1743 ],
1744 "hash-map order must not leak into the reported list"
1745 );
1746 }
1747
1748 #[test]
1749 fn test_set_obsidian_vars_survives_an_unrecognised_key() {
1750 let mut config = Ini::new();
1751 config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
1752 config.set("obsidian", "commit_path", Some("commits".to_string()));
1753 config.set("templates", "commit_date_path", Some("%F.md".to_string()));
1754 config.set("templates", "commit_datetime", Some("%T".to_string()));
1755 config.set("templates", "commit_datetimes", Some("%T".to_string()));
1756
1757 let global_vars = GlobalVars::new();
1758 global_vars.config.set(config).unwrap();
1759
1760 let result = panic::catch_unwind(AssertUnwindSafe(|| global_vars.set_obsidian_vars()));
1761
1762 assert!(
1763 result.is_ok(),
1764 "an unrecognised key must be reported, never fatal"
1765 );
1766 assert_eq!(global_vars.get_template_commit_datetime(), "%T");
1767 }
1768
1769 #[test]
1770 fn test_get_key_from_section_from_ini_exists() {
1771 let mut config = Ini::new();
1772 config.set(
1773 "obsidian",
1774 "root_path_dir",
1775 Some("/home/user/Obsidian".to_string()),
1776 );
1777
1778 let global_vars = GlobalVars::new();
1779 global_vars.config.set(config).unwrap();
1780
1781 let result = global_vars.get_key_from_section_from_ini("obsidian", "root_path_dir");
1782
1783 assert_eq!(result, Some("/home/user/Obsidian".to_string()));
1784 }
1785
1786 #[test]
1787 fn test_get_key_from_section_from_ini_not_exists() {
1788 let mut config = Ini::new();
1789 config.set("obsidian", "other_key", Some("value".to_string()));
1790
1791 let global_vars = GlobalVars::new();
1792 global_vars.config.set(config).unwrap();
1793
1794 let result = global_vars.get_key_from_section_from_ini("obsidian", "non_existent_key");
1795
1796 assert_eq!(result, None);
1797 }
1798
1799 #[test]
1800 fn test_get_config() {
1801 let mut config = Ini::new();
1802 config.set("test", "key", Some("value".to_string()));
1803
1804 let global_vars = GlobalVars::new();
1805 global_vars.config.set(config.clone()).unwrap();
1806
1807 let retrieved_config = global_vars.get_config();
1808
1809 assert_eq!(
1810 retrieved_config.get("test", "key"),
1811 Some("value".to_string())
1812 );
1813 }
1814
1815 #[test]
1816 fn test_set_obsidian_root_path_dir_with_tilde() {
1817 let mut config = Ini::new();
1818 config.set(
1819 "obsidian",
1820 "root_path_dir",
1821 Some("~/Documents/Obsidian".to_string()),
1822 );
1823 config.set(
1824 "templates",
1825 "commit_date_path",
1826 Some("%Y-%m-%d".to_string()),
1827 );
1828 config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1829
1830 let global_vars = GlobalVars::new();
1831 global_vars.config.set(config).unwrap();
1832 global_vars.set_obsidian_root_path_dir("obsidian");
1833
1834 let result = global_vars.get_obsidian_root_path_dir();
1835
1836 assert!(!result.to_string_lossy().contains('~'));
1838 assert!(result.to_string_lossy().starts_with('/'));
1840 assert!(result.to_string_lossy().ends_with("Obsidian"));
1842 }
1843
1844 #[test]
1845 fn test_set_obsidian_root_path_dir_absolute_path() {
1846 let mut config = Ini::new();
1847 config.set(
1848 "obsidian",
1849 "root_path_dir",
1850 Some("/absolute/path/Obsidian".to_string()),
1851 );
1852 config.set(
1853 "templates",
1854 "commit_date_path",
1855 Some("%Y-%m-%d".to_string()),
1856 );
1857 config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1858
1859 let global_vars = GlobalVars::new();
1860 global_vars.config.set(config).unwrap();
1861 global_vars.set_obsidian_root_path_dir("obsidian");
1862
1863 let result = global_vars.get_obsidian_root_path_dir();
1864
1865 assert!(result.to_string_lossy().contains("/absolute/path/Obsidian"));
1867 }
1868
1869 #[test]
1870 fn test_set_obsidian_commit_path_with_tilde() {
1871 let mut config = Ini::new();
1872 config.set(
1873 "obsidian",
1874 "commit_path",
1875 Some("~/Diaries/Commits".to_string()),
1876 );
1877 config.set(
1878 "templates",
1879 "commit_date_path",
1880 Some("%Y-%m-%d".to_string()),
1881 );
1882 config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1883
1884 let global_vars = GlobalVars::new();
1885 global_vars.config.set(config).unwrap();
1886 global_vars.set_obsidian_commit_path("obsidian");
1887
1888 let result = global_vars.get_obsidian_commit_path();
1889
1890 assert!(!result.to_string_lossy().contains('~'));
1892 assert!(result.to_string_lossy().ends_with("Commits"));
1894 }
1895
1896 #[test]
1897 fn test_set_obsidian_commit_path_absolute_path() {
1898 let mut config = Ini::new();
1899 config.set(
1900 "obsidian",
1901 "commit_path",
1902 Some("absolute/Diaries/Commits".to_string()),
1903 );
1904 config.set(
1905 "templates",
1906 "commit_date_path",
1907 Some("%Y-%m-%d".to_string()),
1908 );
1909 config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1910
1911 let global_vars = GlobalVars::new();
1912 global_vars.config.set(config).unwrap();
1913 global_vars.set_obsidian_commit_path("obsidian");
1914
1915 let result = global_vars.get_obsidian_commit_path();
1916
1917 assert!(result.to_string_lossy().contains("absolute"));
1920 assert!(result.to_string_lossy().ends_with("Commits"));
1921 }
1922
1923 #[test]
1924 fn test_set_templates_commit_date_path() {
1925 let mut config = Ini::new();
1926 config.set(
1927 "templates",
1928 "commit_date_path",
1929 Some("%Y/%m-%B/%F.md".to_string()),
1930 );
1931 config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
1932
1933 let global_vars = GlobalVars::new();
1934 global_vars.config.set(config).unwrap();
1935 global_vars.set_templates_commit_date_path("templates");
1936
1937 let result = global_vars.get_template_commit_date_path();
1938
1939 assert_eq!(result, "%Y/%m-%B/%F.md");
1940 }
1941
1942 #[test]
1943 fn test_set_templates_datetime() {
1944 let mut config = Ini::new();
1945 config.set(
1946 "templates",
1947 "commit_datetime",
1948 Some("%Y-%m-%d %H:%M:%S".to_string()),
1949 );
1950
1951 let global_vars = GlobalVars::new();
1952 global_vars.config.set(config).unwrap();
1953 global_vars.set_templates_datetime("templates");
1954
1955 let result = global_vars.get_template_commit_datetime();
1956
1957 assert_eq!(result, "%Y-%m-%d %H:%M:%S");
1958 }
1959
1960 #[test]
1961 fn test_set_obsidian_vars_both_sections() {
1962 let mut config = Ini::new();
1963 config.set(
1964 "obsidian",
1965 "root_path_dir",
1966 Some("/home/user/Obsidian".to_string()),
1967 );
1968 config.set(
1969 "obsidian",
1970 "commit_path",
1971 Some("Diaries/Commits".to_string()),
1972 );
1973 config.set(
1974 "templates",
1975 "commit_date_path",
1976 Some("%Y-%m-%d.md".to_string()),
1977 );
1978 config.set(
1979 "templates",
1980 "commit_datetime",
1981 Some("%Y-%m-%d %H:%M:%S".to_string()),
1982 );
1983
1984 let global_vars = GlobalVars::new();
1985 global_vars.config.set(config).unwrap();
1986
1987 global_vars.set_obsidian_vars();
1989
1990 let root_path = global_vars.get_obsidian_root_path_dir();
1992 let commit_path = global_vars.get_obsidian_commit_path();
1993 let date_path = global_vars.get_template_commit_date_path();
1994 let datetime = global_vars.get_template_commit_datetime();
1995
1996 assert!(root_path.to_string_lossy().contains("Obsidian"));
1997 assert!(commit_path.to_string_lossy().contains("Commits"));
1998 assert_eq!(date_path, "%Y-%m-%d.md");
1999 assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
2000 }
2001
2002 #[test]
2003 #[should_panic(expected = "must have [obsidian] and [templates]")]
2004 fn test_set_obsidian_vars_without_the_obsidian_section() {
2005 let mut config = Ini::new();
2006 config.set("invalid_section", "key", Some("value".to_string()));
2009 config.set(
2010 "templates",
2011 "commit_date_path",
2012 Some("%Y-%m-%d.md".to_string()),
2013 );
2014 config.set(
2015 "templates",
2016 "commit_datetime",
2017 Some("%Y-%m-%d %H:%M".to_string()),
2018 );
2019
2020 let global_vars = GlobalVars::new();
2021 global_vars.config.set(config).unwrap();
2022
2023 global_vars.set_obsidian_vars();
2025 }
2026
2027 #[test]
2028 fn test_set_all_integration() {
2029 use std::io::Write;
2030 use tempfile::NamedTempFile;
2031
2032 let mut temp_file = NamedTempFile::new().unwrap();
2034 writeln!(temp_file, "[obsidian]").unwrap();
2035 writeln!(temp_file, "root_path_dir=/tmp/test_obsidian").unwrap();
2036 writeln!(temp_file, "commit_path=TestDiaries/TestCommits").unwrap();
2037 writeln!(temp_file, "[templates]").unwrap();
2038 writeln!(temp_file, "commit_date_path=%Y-%m-%d.md").unwrap();
2039 writeln!(temp_file, "commit_datetime=%Y-%m-%d %H:%M:%S").unwrap();
2040 temp_file.flush().unwrap();
2041
2042 let content = std::fs::read_to_string(temp_file.path()).unwrap();
2044 let config = parse_ini_content(&content).unwrap();
2045
2046 let global_vars = GlobalVars::new();
2047 global_vars.config.set(config).unwrap();
2048 global_vars.set_obsidian_vars();
2049
2050 let root = global_vars.get_obsidian_root_path_dir();
2052 let commit = global_vars.get_obsidian_commit_path();
2053 let date = global_vars.get_template_commit_date_path();
2054 let datetime = global_vars.get_template_commit_datetime();
2055
2056 assert!(root.to_string_lossy().contains("test_obsidian"));
2057 assert!(commit.to_string_lossy().contains("TestCommits"));
2058 assert_eq!(date, "%Y-%m-%d.md");
2059 assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
2060 }
2061
2062 #[test]
2063 #[should_panic(expected = "Could not get")]
2064 fn test_get_obsidian_root_path_dir_not_set() {
2065 let global_vars = GlobalVars::new();
2066 global_vars.get_obsidian_root_path_dir();
2069 }
2070
2071 #[test]
2072 #[should_panic(expected = "Could not get")]
2073 fn test_get_obsidian_commit_path_not_set() {
2074 let global_vars = GlobalVars::new();
2075 global_vars.get_obsidian_commit_path();
2076 }
2077
2078 #[test]
2079 #[should_panic(expected = "Could not get")]
2080 fn test_get_template_commit_date_path_not_set() {
2081 let global_vars = GlobalVars::new();
2082 global_vars.get_template_commit_date_path();
2083 }
2084
2085 #[test]
2086 #[should_panic(expected = "Could not get")]
2087 fn test_get_template_commit_datetime_not_set() {
2088 let global_vars = GlobalVars::new();
2089 global_vars.get_template_commit_datetime();
2090 }
2091
2092 #[test]
2093 #[should_panic(expected = "Could not get Config")]
2094 fn test_get_config_not_initialized() {
2095 let global_vars = GlobalVars::new();
2096 global_vars.get_config();
2098 }
2099
2100 #[test]
2101 fn test_set_config_twice_fails() {
2102 let global_vars = GlobalVars::new();
2103 let config1 = Ini::new();
2104 let config2 = Ini::new();
2105
2106 assert!(global_vars.config.set(config1).is_ok());
2107 assert!(global_vars.config.set(config2).is_err());
2109 }
2110
2111 #[test]
2112 fn test_global_vars_set_all_end_to_end() {
2113 use std::io::Write;
2114 use tempfile::NamedTempFile;
2115
2116 let mut temp_file = NamedTempFile::new().unwrap();
2118 writeln!(temp_file, "[obsidian]").unwrap();
2119 writeln!(temp_file, "root_path_dir=/tmp/obsidian_test").unwrap();
2120 writeln!(temp_file, "commit_path=TestDiaries/TestCommits").unwrap();
2121 writeln!(temp_file, "[templates]").unwrap();
2122 writeln!(temp_file, "commit_date_path=%Y/%m-%B/%F.md").unwrap();
2123 writeln!(temp_file, "commit_datetime=%Y-%m-%d %H:%M:%S").unwrap();
2124 temp_file.flush().unwrap();
2125
2126 let content = std::fs::read_to_string(temp_file.path()).unwrap();
2128 let mut config = Ini::new();
2129 config.read(content).unwrap();
2130
2131 let global_vars = GlobalVars::new();
2133 let result = global_vars.config.set(config);
2134 assert!(result.is_ok());
2135
2136 global_vars.set_obsidian_vars();
2138
2139 let root = global_vars.get_obsidian_root_path_dir();
2141 let commit = global_vars.get_obsidian_commit_path();
2142 let date_path = global_vars.get_template_commit_date_path();
2143 let datetime = global_vars.get_template_commit_datetime();
2144
2145 assert!(root.to_string_lossy().contains("obsidian_test"));
2146 assert!(commit.to_string_lossy().contains("TestCommits"));
2147 assert_eq!(date_path, "%Y/%m-%B/%F.md");
2148 assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
2149 }
2150
2151 #[test]
2152 fn test_set_obsidian_root_path_dir_with_trailing_slash() {
2153 let mut config = Ini::new();
2154 config.set("obsidian", "root_path_dir", Some("/tmp/test/".to_string()));
2155 config.set(
2156 "templates",
2157 "commit_date_path",
2158 Some("%Y-%m-%d".to_string()),
2159 );
2160 config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2161
2162 let global_vars = GlobalVars::new();
2163 global_vars.config.set(config).unwrap();
2164 global_vars.set_obsidian_root_path_dir("obsidian");
2165
2166 let result = global_vars.get_obsidian_root_path_dir();
2167
2168 assert!(result.to_string_lossy().contains("test"));
2170 }
2171
2172 #[test]
2173 fn test_set_obsidian_commit_path_with_multiple_slashes() {
2174 let mut config = Ini::new();
2175 config.set(
2176 "obsidian",
2177 "commit_path",
2178 Some("Diaries//Commits///Nested".to_string()),
2179 );
2180 config.set(
2181 "templates",
2182 "commit_date_path",
2183 Some("%Y-%m-%d".to_string()),
2184 );
2185 config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2186
2187 let global_vars = GlobalVars::new();
2188 global_vars.config.set(config).unwrap();
2189 global_vars.set_obsidian_commit_path("obsidian");
2190
2191 let result = global_vars.get_obsidian_commit_path();
2192
2193 assert!(result.to_string_lossy().contains("Nested"));
2195 }
2196
2197 #[test]
2198 fn test_set_obsidian_root_path_dir_empty_string() {
2199 let mut config = Ini::new();
2205 config.set("obsidian", "root_path_dir", Some(String::new()));
2206 config.set(
2207 "templates",
2208 "commit_date_path",
2209 Some("%Y-%m-%d".to_string()),
2210 );
2211 config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2212
2213 let global_vars = GlobalVars::new();
2214 global_vars.config.set(config).unwrap();
2215
2216 let result = panic::catch_unwind(AssertUnwindSafe(|| {
2217 global_vars.set_obsidian_root_path_dir("obsidian")
2218 }));
2219
2220 let panic_info = result.expect_err("a blank root_path_dir must be fatal");
2221 let msg = panic_info
2222 .downcast_ref::<String>()
2223 .expect("panic message should be a formatted String");
2224
2225 assert!(
2226 msg.contains("missing required key 'root_path_dir' in section [obsidian]"),
2227 "a blank value must be reported as the missing key it is: {msg}"
2228 );
2229 }
2230
2231 #[test]
2232 #[should_panic(expected = "missing required key 'commit_path' in section [obsidian]")]
2233 fn test_set_obsidian_commit_path_missing_key() {
2234 let mut config = Ini::new();
2235 config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
2236 config.set(
2237 "templates",
2238 "commit_date_path",
2239 Some("%Y-%m-%d".to_string()),
2240 );
2241 config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2242
2243 let global_vars = GlobalVars::new();
2244 global_vars.config.set(config).unwrap();
2245
2246 global_vars.set_obsidian_commit_path("obsidian");
2247 }
2248
2249 #[test]
2250 #[should_panic(expected = "missing required key 'root_path_dir' in section [obsidian]")]
2251 fn test_set_obsidian_root_path_dir_missing_key() {
2252 let mut config = Ini::new();
2253 config.set("obsidian", "commit_path", Some("commits".to_string()));
2254 config.set(
2255 "templates",
2256 "commit_date_path",
2257 Some("%Y-%m-%d".to_string()),
2258 );
2259 config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2260
2261 let global_vars = GlobalVars::new();
2262 global_vars.config.set(config).unwrap();
2263
2264 global_vars.set_obsidian_root_path_dir("obsidian");
2265 }
2266
2267 #[test]
2268 #[should_panic(expected = "missing required key 'commit_date_path' in section [templates]")]
2269 fn test_set_templates_commit_date_path_missing_key() {
2270 let mut config = Ini::new();
2271 config.set("templates", "commit_datetime", Some("%Y-%m-%d".to_string()));
2272 config.set("obsidian", "root_path_dir", Some("/tmp".to_string()));
2273 config.set("obsidian", "commit_path", Some("commits".to_string()));
2274
2275 let global_vars = GlobalVars::new();
2276 global_vars.config.set(config).unwrap();
2277
2278 global_vars.set_templates_commit_date_path("templates");
2279 }
2280
2281 #[test]
2282 #[should_panic(expected = "missing required key 'commit_datetime' in section [templates]")]
2283 fn test_set_templates_datetime_missing_key() {
2284 let mut config = Ini::new();
2285 config.set(
2286 "templates",
2287 "commit_date_path",
2288 Some("%Y-%m-%d".to_string()),
2289 );
2290 config.set("obsidian", "root_path_dir", Some("/tmp".to_string()));
2291 config.set("obsidian", "commit_path", Some("commits".to_string()));
2292
2293 let global_vars = GlobalVars::new();
2294 global_vars.config.set(config).unwrap();
2295
2296 global_vars.set_templates_datetime("templates");
2297 }
2298
2299 #[test]
2300 #[should_panic(expected = "missing required key 'commit_path' in section [obsidian]")]
2301 fn test_require_key_treats_a_blank_value_as_missing() {
2302 let mut config = Ini::new();
2305 config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
2306 config.set("obsidian", "commit_path", Some(" ".to_string()));
2307
2308 let global_vars = GlobalVars::new();
2309 global_vars.config.set(config).unwrap();
2310
2311 global_vars.set_obsidian_commit_path("obsidian");
2312 }
2313
2314 #[test]
2315 fn test_require_key_names_the_config_file_and_the_typo() {
2316 let mut config = Ini::new();
2317 config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
2318 config.set("obsidian", "commit_paths", Some("commits".to_string()));
2321
2322 let global_vars = GlobalVars::new();
2323 global_vars.config.set(config).unwrap();
2324 global_vars
2325 .config_path
2326 .set("/tmp/some/rusty-commit-saver.ini".to_string())
2327 .unwrap();
2328
2329 let result = panic::catch_unwind(AssertUnwindSafe(|| {
2330 global_vars.set_obsidian_commit_path("obsidian")
2331 }));
2332
2333 let panic_info = result.expect_err("a missing required key must be fatal");
2334 let msg = panic_info
2335 .downcast_ref::<String>()
2336 .expect("panic message should be a formatted String");
2337
2338 assert!(
2339 msg.contains("/tmp/some/rusty-commit-saver.ini"),
2340 "the message must name the file to edit: {msg}"
2341 );
2342 assert!(
2343 msg.contains("missing required key 'commit_path' in section [obsidian]"),
2344 "the message must name the key and its section: {msg}"
2345 );
2346 assert!(
2347 msg.contains("unrecognised in [obsidian]: commit_paths"),
2348 "the message must name the typo that explains the absence: {msg}"
2349 );
2350 }
2351
2352 #[test]
2353 fn test_require_time_format_rejects_what_chrono_cannot_render() {
2354 let mut config = Ini::new();
2355 config.set("templates", "commit_datetime", Some("%Q".to_string()));
2356
2357 let global_vars = GlobalVars::new();
2358 global_vars.config.set(config).unwrap();
2359 global_vars
2360 .config_path
2361 .set("/tmp/some/rusty-commit-saver.ini".to_string())
2362 .unwrap();
2363
2364 let result = panic::catch_unwind(AssertUnwindSafe(|| {
2365 global_vars.set_templates_datetime("templates")
2366 }));
2367
2368 let panic_info = result.expect_err("a format chrono cannot render must be fatal");
2369 let msg = panic_info
2370 .downcast_ref::<String>()
2371 .expect("panic message should be a formatted String");
2372
2373 assert!(
2374 msg.contains("/tmp/some/rusty-commit-saver.ini"),
2375 "the message must name the file to edit: {msg}"
2376 );
2377 assert!(
2378 msg.contains("key 'commit_datetime' in section [templates]"),
2379 "the message must name the key and its section: {msg}"
2380 );
2381 assert!(
2382 msg.contains("'%Q'"),
2383 "the message must quote the value that cannot be rendered: {msg}"
2384 );
2385 }
2386
2387 #[test]
2388 fn test_require_time_format_guards_the_date_path_too() {
2389 let mut config = Ini::new();
2392 config.set(
2393 "templates",
2394 "commit_date_path",
2395 Some("%Y/%Q.md".to_string()),
2396 );
2397
2398 let global_vars = GlobalVars::new();
2399 global_vars.config.set(config).unwrap();
2400
2401 let result = panic::catch_unwind(AssertUnwindSafe(|| {
2402 global_vars.set_templates_commit_date_path("templates")
2403 }));
2404
2405 let panic_info = result.expect_err("a format chrono cannot render must be fatal");
2406 let msg = panic_info
2407 .downcast_ref::<String>()
2408 .expect("panic message should be a formatted String");
2409
2410 assert!(
2411 msg.contains("key 'commit_date_path' in section [templates]"),
2412 "the message must name the key and its section: {msg}"
2413 );
2414 }
2415
2416 #[test]
2417 fn test_is_renderable_time_format_accepts_the_shipped_defaults() {
2418 assert!(is_renderable_time_format("%H:%M:%S"));
2419 assert!(is_renderable_time_format("%Y/%m-%B/%F.md"));
2420 assert!(
2421 is_renderable_time_format("Commits"),
2422 "a format with no specifier at all is still renderable"
2423 );
2424 assert!(!is_renderable_time_format("%Q"));
2425 }
2426
2427 #[test]
2428 fn test_require_key_says_which_config_when_none_was_read() {
2429 let mut config = Ini::new();
2430 config.set("obsidian", "commit_path", Some("commits".to_string()));
2431
2432 let global_vars = GlobalVars::new();
2433 global_vars.config.set(config).unwrap();
2434
2435 let result = panic::catch_unwind(AssertUnwindSafe(|| {
2436 global_vars.set_obsidian_root_path_dir("obsidian")
2437 }));
2438
2439 let panic_info = result.expect_err("a missing required key must be fatal");
2440 let msg = panic_info
2441 .downcast_ref::<String>()
2442 .expect("panic message should be a formatted String");
2443
2444 assert!(
2445 msg.contains("the rusty-commit-saver config"),
2446 "with no file read, the message must say so rather than guess a path: {msg}"
2447 );
2448 }
2449
2450 #[test]
2451 fn test_global_vars_set_all_method() {
2452 use std::io::Write;
2453 use tempfile::NamedTempFile;
2454
2455 let mut temp_file = NamedTempFile::new().unwrap();
2457 writeln!(temp_file, "[obsidian]").unwrap();
2458 writeln!(temp_file, "root_path_dir=/tmp/obsidian_full_test").unwrap();
2459 writeln!(temp_file, "commit_path=FullTest/Commits").unwrap();
2460 writeln!(temp_file, "[templates]").unwrap();
2461 writeln!(temp_file, "commit_date_path=%Y/%m/%d.md").unwrap();
2462 writeln!(temp_file, "commit_datetime=%Y-%m-%d %H:%M:%S").unwrap();
2463 temp_file.flush().unwrap();
2464
2465 let content = std::fs::read_to_string(temp_file.path()).unwrap();
2467 let config = parse_ini_content(&content).unwrap();
2468
2469 let global_vars = GlobalVars::new();
2471 global_vars.config.set(config).unwrap();
2472 global_vars.set_obsidian_vars();
2473
2474 let root = global_vars.get_obsidian_root_path_dir();
2476 let commit = global_vars.get_obsidian_commit_path();
2477 let date = global_vars.get_template_commit_date_path();
2478 let datetime = global_vars.get_template_commit_datetime();
2479
2480 assert!(root.to_string_lossy().contains("obsidian_full_test"));
2481 assert!(commit.to_string_lossy().contains("FullTest"));
2482 assert_eq!(date, "%Y/%m/%d.md");
2483 assert_eq!(datetime, "%Y-%m-%d %H:%M:%S");
2484 }
2485
2486 #[test]
2487 fn test_set_obsidian_vars_complete_workflow() {
2488 let mut config = Ini::new();
2489 config.set(
2490 "obsidian",
2491 "root_path_dir",
2492 Some("~/test/obsidian".to_string()),
2493 );
2494 config.set(
2495 "obsidian",
2496 "commit_path",
2497 Some("~/test/commits".to_string()),
2498 );
2499 config.set(
2500 "templates",
2501 "commit_date_path",
2502 Some("%Y/%m/%d.md".to_string()),
2503 );
2504 config.set(
2505 "templates",
2506 "commit_datetime",
2507 Some("%Y-%m-%d %H:%M:%S".to_string()),
2508 );
2509
2510 let global_vars = GlobalVars::new();
2511 global_vars.config.set(config).unwrap();
2512
2513 global_vars.set_obsidian_vars();
2515
2516 let root = global_vars.get_obsidian_root_path_dir();
2518 let commit = global_vars.get_obsidian_commit_path();
2519
2520 assert!(!root.to_string_lossy().contains('~'));
2522 assert!(!commit.to_string_lossy().contains('~'));
2523 assert!(root.to_string_lossy().contains("obsidian"));
2524 assert!(commit.to_string_lossy().contains("commits"));
2525 }
2526}
2527
2528#[cfg(test)]
2529#[cfg_attr(coverage_nightly, coverage(off))]
2530mod user_input_tests {
2531 use super::*;
2532 use clap::Parser;
2533 use std::sync::{Mutex, MutexGuard, PoisonError};
2534
2535 static CONFIG_ENV: Mutex<()> = Mutex::new(());
2541
2542 fn lock_config_env() -> MutexGuard<'static, ()> {
2545 CONFIG_ENV.lock().unwrap_or_else(PoisonError::into_inner)
2546 }
2547
2548 #[test]
2549 fn test_user_input_parse_with_config() {
2550 let args = vec!["test_program", "--config-ini", "/path/to/config.ini"];
2551 let user_input = UserInput::try_parse_from(args).unwrap();
2552
2553 assert_eq!(
2554 user_input.config_ini,
2555 Some("/path/to/config.ini".to_string())
2556 );
2557 }
2558
2559 #[test]
2560 fn test_user_input_parse_without_config() {
2561 let args = vec!["test_program"];
2562 let user_input = UserInput::try_parse_from(args).unwrap();
2563
2564 assert_eq!(user_input.config_ini, None);
2565 }
2566
2567 #[test]
2568 fn test_user_input_parse_short_flag() {
2569 let args = vec!["test_program", "-c", "/short/path/config.ini"];
2570 let user_input = UserInput::try_parse_from(args).unwrap();
2571
2572 assert_eq!(
2573 user_input.config_ini,
2574 Some("/short/path/config.ini".to_string())
2575 );
2576 }
2577
2578 #[test]
2579 fn test_set_proper_home_dir_with_tilde() {
2580 let input = "~/test/path/file.ini";
2581 let result = set_proper_home_dir(input);
2582
2583 assert!(!result.contains('~'));
2585 assert!(result.ends_with("/test/path/file.ini"));
2586 }
2587
2588 #[test]
2589 fn test_set_proper_home_dir_without_tilde() {
2590 let input = "/absolute/path/file.ini";
2591 let result = set_proper_home_dir(input);
2592
2593 assert_eq!(result, input);
2595 }
2596
2597 #[test]
2598 fn test_set_proper_home_dir_multiple_tildes() {
2599 let input = "~/path/~/file.ini";
2600 let result = set_proper_home_dir(input);
2601
2602 assert!(!result.contains('~'));
2604 }
2605
2606 #[test]
2607 fn test_get_default_ini_path() {
2608 let result = get_default_ini_path();
2609
2610 assert!(result.ends_with(".config/rusty-commit-saver/rusty-commit-saver.ini"));
2612
2613 assert!(!result.contains('~'));
2615
2616 assert!(result.starts_with('/'));
2618 }
2619
2620 #[test]
2621 fn test_get_or_default_config_ini_path_with_config_and_tilde() {
2622 let args = vec!["test", "--config-ini", "~/my/config.ini"];
2624 let user_input = UserInput::try_parse_from(args).unwrap();
2625
2626 assert_eq!(user_input.config_ini, Some("~/my/config.ini".to_string()));
2629 }
2630
2631 #[test]
2632 fn test_get_or_default_config_ini_path_with_config_absolute_path() {
2633 let args = vec!["test", "--config-ini", "/absolute/path/config.ini"];
2635 let user_input = UserInput::try_parse_from(args).unwrap();
2636
2637 assert_eq!(
2638 user_input.config_ini,
2639 Some("/absolute/path/config.ini".to_string())
2640 );
2641 }
2642
2643 #[test]
2644 fn test_get_or_default_config_ini_path_without_config() {
2645 let args = vec!["test"];
2647 let user_input = UserInput::try_parse_from(args).unwrap();
2648
2649 assert_eq!(user_input.config_ini, None);
2651 }
2652
2653 #[test]
2654 fn test_parse_ini_content_valid() {
2655 let content = r"
2656[obsidian]
2657root_path_dir=~/Documents/Obsidian
2658commit_path=Diaries/Commits
2659
2660[templates]
2661commit_date_path=%Y/%m-%B/%F.md
2662commit_datetime=%Y-%m-%d
2663";
2664
2665 let result = parse_ini_content(content);
2666 assert!(result.is_ok());
2667
2668 let ini = result.unwrap();
2669 assert_eq!(
2670 ini.get("obsidian", "root_path_dir"),
2671 Some("~/Documents/Obsidian".to_string())
2672 );
2673 assert_eq!(
2674 ini.get("templates", "commit_date_path"),
2675 Some("%Y/%m-%B/%F.md".to_string())
2676 );
2677 }
2678
2679 #[test]
2680 fn test_parse_ini_content_invalid() {
2681 let content = "this is not valid ini format [[[";
2682
2683 let result = parse_ini_content(content);
2684 assert!(result.is_ok() || result.is_err());
2686 }
2687
2688 #[test]
2689 fn test_parse_ini_content_empty() {
2690 let content = "";
2691
2692 let result = parse_ini_content(content);
2693 assert!(result.is_ok());
2694
2695 let ini = result.unwrap();
2696 assert_eq!(ini.sections().len(), 0);
2697 }
2698
2699 #[test]
2700 fn test_retrieve_config_file_path_with_temp_file() {
2701 use std::io::Write;
2702 use tempfile::NamedTempFile;
2703
2704 let mut temp_file = NamedTempFile::new().unwrap();
2706 writeln!(temp_file, "[obsidian]").unwrap();
2707 writeln!(temp_file, "root_path_dir=/tmp/test").unwrap();
2708 writeln!(temp_file, "commit_path=commits").unwrap();
2709 writeln!(temp_file, "[templates]").unwrap();
2710 writeln!(temp_file, "commit_date_path=%Y-%m-%d.md").unwrap();
2711 writeln!(temp_file, "commit_datetime=%Y-%m-%d").unwrap();
2712 temp_file.flush().unwrap();
2713
2714 let path = temp_file.path().to_str().unwrap();
2717
2718 let content = std::fs::read_to_string(path).unwrap();
2721 let result = parse_ini_content(&content);
2722
2723 assert!(result.is_ok());
2724 let ini = result.unwrap();
2725 assert_eq!(
2726 ini.get("obsidian", "root_path_dir"),
2727 Some("/tmp/test".to_string())
2728 );
2729 }
2730
2731 #[test]
2732 fn test_ini_parsing_integration() {
2733 let content = r"
2734[obsidian]
2735root_path_dir=~/Documents/Obsidian
2736commit_path=Diaries/Commits
2737
2738[templates]
2739commit_date_path=%Y/%m-%B/%F.md
2740commit_datetime=%Y-%m-%d %H:%M:%S
2741";
2742
2743 let ini = parse_ini_content(content).unwrap();
2744
2745 assert!(ini.get("obsidian", "root_path_dir").is_some());
2747 assert!(ini.get("obsidian", "commit_path").is_some());
2748 assert!(ini.get("templates", "commit_date_path").is_some());
2749 assert!(ini.get("templates", "commit_datetime").is_some());
2750
2751 assert_eq!(ini.sections().len(), 2);
2753 }
2754
2755 #[test]
2769 fn test_missing_required_sections_panic_message() {
2770 use std::panic;
2771
2772 let mut config = Ini::new();
2773 config.set("only_one_section", "key", Some("value".to_string()));
2774
2775 let global_vars = GlobalVars::new();
2776 global_vars.config.set(config).unwrap();
2777
2778 let result = panic::catch_unwind(|| global_vars.get_sections_from_config());
2779
2780 assert!(result.is_err(), "Should have panicked");
2781 }
2782
2783 #[test]
2784 fn test_set_all_loads_config_and_sets_vars() {
2785 use std::env;
2786 use std::fs;
2787 use tempfile::NamedTempFile;
2788
2789 let temp_file = NamedTempFile::new().expect("Failed to create temp file");
2790 let config_content = r"[obsidian]
2791root_path_dir = /tmp/test_obsidian
2792commit_path = Commits
2793
2794[templates]
2795commit_date_path = %Y/%m/%d.md
2796commit_datetime = %Y-%m-%d %H:%M:%S
2797";
2798 fs::write(temp_file.path(), config_content).expect("Failed to write temp config");
2799
2800 let _guard = lock_config_env();
2801 env::set_var(
2802 "RUSTY_COMMIT_SAVER_CONFIG",
2803 temp_file.path().to_str().unwrap(),
2804 );
2805
2806 let global_vars = GlobalVars::new();
2807 let result = global_vars.set_all();
2808
2809 assert!(std::ptr::eq(result, &raw const global_vars));
2811
2812 assert!(global_vars.config.get().is_some());
2814
2815 env::remove_var("RUSTY_COMMIT_SAVER_CONFIG");
2816 }
2817
2818 #[test]
2819 #[should_panic(expected = "config_path DOES NOT exists")]
2820 fn test_retrieve_config_file_path_panics_on_missing_file() {
2821 let _guard = lock_config_env();
2822 std::env::set_var("RUSTY_COMMIT_SAVER_CONFIG", "/nonexistent/path/config.ini");
2823 let _ = retrieve_config_file_path();
2824 }
2825
2826 #[test]
2827 fn test_get_or_default_config_ini_path_env_var_with_tilde() {
2828 use std::env;
2829
2830 let _guard = lock_config_env();
2831 let var_name = "RUSTY_COMMIT_SAVER_CONFIG";
2832 let original = env::var(var_name).ok();
2833
2834 env::set_var(var_name, "~/some/config/path.ini");
2835
2836 let result = get_or_default_config_ini_path();
2837
2838 match original {
2840 Some(val) => env::set_var(var_name, val),
2841 None => env::remove_var(var_name),
2842 }
2843
2844 assert!(!result.contains('~'), "Tilde should be expanded");
2846 assert!(result.ends_with("/some/config/path.ini"));
2847 }
2848
2849 #[test]
2850 fn test_resolve_config_path_cli_with_tilde() {
2851 let result = resolve_config_path(Some("~/my/config.ini".to_string()), None);
2852 assert!(!result.contains('~'));
2853 assert!(result.ends_with("/my/config.ini"));
2854 }
2855
2856 #[test]
2857 fn test_resolve_config_path_cli_without_tilde() {
2858 let result = resolve_config_path(Some("/absolute/path.ini".to_string()), None);
2859 assert_eq!(result, "/absolute/path.ini");
2860 }
2861
2862 #[test]
2863 fn test_resolve_config_path_default() {
2864 let result = resolve_config_path(None, None);
2865 assert!(result.contains("rusty-commit-saver.ini"));
2866 }
2867
2868 #[test]
2869 fn test_resolve_config_path_env_takes_precedence() {
2870 let result = resolve_config_path(
2871 Some("/cli/path.ini".to_string()),
2872 Some("/env/path.ini".to_string()),
2873 );
2874 assert_eq!(result, "/env/path.ini");
2875 }
2876
2877 #[test]
2878 fn test_get_or_default_config_ini_path_with_no_env_calls_cli_parser() {
2879 let parser_called = std::cell::Cell::new(false);
2880
2881 let result = get_or_default_config_ini_path_with(None, || {
2882 parser_called.set(true);
2883 Some("/mock/cli/path.ini".to_string())
2884 });
2885
2886 assert!(
2887 parser_called.get(),
2888 "CLI parser should be called when no env var"
2889 );
2890 assert_eq!(result, "/mock/cli/path.ini");
2891 }
2892
2893 #[test]
2894 fn test_get_or_default_config_ini_path_with_env_skips_cli_parser() {
2895 let parser_called = std::cell::Cell::new(false);
2896
2897 let result = get_or_default_config_ini_path_with(Some("/env/path.ini".to_string()), || {
2898 parser_called.set(true);
2899 Some("/should/not/be/used.ini".to_string())
2900 });
2901
2902 assert!(
2903 !parser_called.get(),
2904 "CLI parser should NOT be called when env var is set"
2905 );
2906 assert_eq!(result, "/env/path.ini");
2907 }
2908
2909 #[test]
2910 fn test_get_or_default_config_ini_path_with_cli_returns_default_when_none() {
2911 let result = get_or_default_config_ini_path_with(None, || None);
2912
2913 assert!(result.contains("rusty-commit-saver.ini"));
2915 }
2916
2917 #[test]
2918 fn test_get_or_default_config_ini_path_with_cli_tilde_expansion() {
2919 let result =
2920 get_or_default_config_ini_path_with(None, || Some("~/custom/config.ini".to_string()));
2921
2922 assert!(!result.contains('~'), "Tilde should be expanded");
2923 assert!(result.ends_with("/custom/config.ini"));
2924 }
2925
2926 #[test]
2927 fn test_parse_ini_content_find_invalid_case() {
2928 let cases = [
2929 "[unclosed",
2930 "no_section_key = value", "[section]\nweird line without equals",
2932 ];
2933
2934 for case in cases {
2935 let result = parse_ini_content(case);
2936 println!("{case:?} => {result:?}");
2937 }
2938 }
2939
2940 #[test]
2941 fn test_parse_ini_content_invalid_syntax() {
2942 let result = parse_ini_content("[unclosed");
2943
2944 assert!(result.is_err(), "Should fail on unclosed bracket");
2945 let err = result.unwrap_err();
2946 assert!(
2947 err.contains("Failed to parse INI"),
2948 "Error should contain expected prefix: {err}"
2949 );
2950 }
2951
2952 #[test]
2953 #[should_panic(expected = "Should have been able to read the file")]
2954 fn test_retrieve_config_file_path_panics_on_unreadable_file() {
2955 use std::fs::{self, File};
2956 use std::os::unix::fs::PermissionsExt;
2957 use tempfile::tempdir;
2958
2959 let dir = tempdir().unwrap();
2960 let file_path = dir.path().join("unreadable.ini");
2961
2962 File::create(&file_path).unwrap();
2964 fs::set_permissions(&file_path, fs::Permissions::from_mode(0o000)).unwrap();
2965
2966 let _guard = lock_config_env();
2967 std::env::set_var("RUSTY_COMMIT_SAVER_CONFIG", file_path.to_str().unwrap());
2968
2969 let _ = retrieve_config_file_path();
2971
2972 fs::set_permissions(&file_path, fs::Permissions::from_mode(0o644)).unwrap();
2974 }
2975
2976 #[test]
2977 fn test_get_ini_file_reads_the_configured_path() {
2978 use std::io::Write;
2979 use tempfile::NamedTempFile;
2980
2981 let mut temp_file = NamedTempFile::new().unwrap();
2982 writeln!(temp_file, "[obsidian]").unwrap();
2983 writeln!(temp_file, "root_path_dir=/tmp/test").unwrap();
2984 writeln!(temp_file, "commit_path=commits").unwrap();
2985 writeln!(temp_file, "[templates]").unwrap();
2986 writeln!(temp_file, "commit_date_path=%Y-%m-%d.md").unwrap();
2987 writeln!(temp_file, "commit_datetime=%H:%M:%S").unwrap();
2988 temp_file.flush().unwrap();
2989
2990 let _guard = lock_config_env();
2991 std::env::set_var("RUSTY_COMMIT_SAVER_CONFIG", temp_file.path());
2992
2993 let config = get_ini_file();
2996
2997 std::env::remove_var("RUSTY_COMMIT_SAVER_CONFIG");
2998
2999 assert_eq!(
3000 config.get("obsidian", "commit_path"),
3001 Some("commits".to_string())
3002 );
3003 }
3004
3005 #[test]
3006 fn test_parse_exclude_repos_basic() {
3007 assert_eq!(
3008 parse_exclude_repos("claude-src, other-repo"),
3009 vec!["claude-src".to_string(), "other-repo".to_string()]
3010 );
3011 }
3012
3013 #[test]
3014 fn test_parse_exclude_repos_trims_and_drops_empties() {
3015 assert_eq!(
3017 parse_exclude_repos(" claude-src , , foo,"),
3018 vec!["claude-src".to_string(), "foo".to_string()]
3019 );
3020 }
3021
3022 #[test]
3023 fn test_parse_exclude_repos_empty_input() {
3024 assert!(parse_exclude_repos(" ").is_empty());
3025 assert!(parse_exclude_repos("").is_empty());
3026 }
3027
3028 #[test]
3029 fn test_parse_exclude_repos_single_entry() {
3030 assert_eq!(
3031 parse_exclude_repos("claude-src"),
3032 vec!["claude-src".to_string()]
3033 );
3034 }
3035
3036 #[test]
3037 fn test_get_excluded_repos_defaults_empty_when_unset() {
3038 let global_vars = GlobalVars::new();
3040 assert!(global_vars.get_excluded_repos().is_empty());
3041 }
3042
3043 #[test]
3044 fn test_set_and_get_excluded_repos() {
3045 let mut config = Ini::new();
3046 config.set("exclude", "repos", Some("claude-src, foo".to_string()));
3047
3048 let global_vars = GlobalVars::new();
3049 global_vars.config.set(config).unwrap();
3050 global_vars.set_excluded_repos("exclude");
3051
3052 assert_eq!(
3053 global_vars.get_excluded_repos(),
3054 vec!["claude-src".to_string(), "foo".to_string()]
3055 );
3056 }
3057
3058 #[test]
3059 fn test_set_excluded_repos_missing_key_is_empty() {
3060 let mut config = Ini::new();
3062 config.set("exclude", "unrelated", Some("value".to_string()));
3063
3064 let global_vars = GlobalVars::new();
3065 global_vars.config.set(config).unwrap();
3066 global_vars.set_excluded_repos("exclude");
3067
3068 assert!(global_vars.get_excluded_repos().is_empty());
3069 }
3070
3071 #[test]
3072 fn test_get_sections_accepts_optional_exclude_section() {
3073 let mut config = Ini::new();
3075 config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
3076 config.set("templates", "commit_date_path", Some("%Y.md".to_string()));
3077 config.set("exclude", "repos", Some("claude-src".to_string()));
3078
3079 let global_vars = GlobalVars::new();
3080 global_vars.config.set(config).unwrap();
3081
3082 let sections = global_vars.get_sections_from_config();
3083 assert_eq!(sections.len(), 3);
3084 assert!(sections.contains(&"exclude".to_string()));
3085 }
3086
3087 #[test]
3088 fn test_set_obsidian_vars_populates_exclude_section() {
3089 let mut config = Ini::new();
3091 config.set(
3092 "obsidian",
3093 "root_path_dir",
3094 Some("/home/user/Obsidian".to_string()),
3095 );
3096 config.set(
3097 "obsidian",
3098 "commit_path",
3099 Some("Diaries/Commits".to_string()),
3100 );
3101 config.set(
3102 "templates",
3103 "commit_date_path",
3104 Some("%Y-%m-%d.md".to_string()),
3105 );
3106 config.set("templates", "commit_datetime", Some("%H:%M:%S".to_string()));
3107 config.set("exclude", "repos", Some("claude-src".to_string()));
3108
3109 let global_vars = GlobalVars::new();
3110 global_vars.config.set(config).unwrap();
3111 global_vars.set_obsidian_vars();
3112
3113 assert_eq!(
3114 global_vars.get_excluded_repos(),
3115 vec!["claude-src".to_string()]
3116 );
3117 }
3118
3119 #[test]
3120 fn test_get_sections_tolerates_an_unknown_section() {
3121 let mut config = Ini::new();
3125 config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
3126 config.set("templates", "commit_date_path", Some("%Y.md".to_string()));
3127 config.set("from_a_future_release", "key", Some("value".to_string()));
3128
3129 let global_vars = GlobalVars::new();
3130 global_vars.config.set(config).unwrap();
3131
3132 let sections = global_vars.get_sections_from_config();
3133 assert!(sections.contains(&"obsidian".to_string()));
3134 assert!(sections.contains(&"templates".to_string()));
3135 }
3136
3137 #[test]
3138 #[should_panic(expected = "must have [obsidian] and [templates]")]
3139 fn test_get_sections_rejects_obsidian_without_templates() {
3140 let mut config = Ini::new();
3142 config.set("obsidian", "root_path_dir", Some("/tmp/test".to_string()));
3143
3144 let global_vars = GlobalVars::new();
3145 global_vars.config.set(config).unwrap();
3146 let _ = global_vars.get_sections_from_config();
3147 }
3148
3149 #[test]
3150 #[should_panic(expected = "must have [obsidian] and [templates]")]
3151 fn test_get_sections_rejects_missing_required_section() {
3152 let mut config = Ini::new();
3154 config.set("exclude", "repos", Some("claude-src".to_string()));
3155 config.set("templates", "commit_date_path", Some("%Y.md".to_string()));
3156
3157 let global_vars = GlobalVars::new();
3158 global_vars.config.set(config).unwrap();
3159 let _ = global_vars.get_sections_from_config();
3160 }
3161}