Skip to main content

difflore_core/infra/
config.rs

1use std::path::Path;
2
3use crate::infra::paths;
4
5#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
6pub enum ThemeMode {
7    #[default]
8    Dark,
9    Light,
10}
11
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub struct DiffloreConfig {
14    pub theme: ThemeMode,
15}
16
17/// Read `<config_home>/config.toml`. Missing or unreadable files yield
18/// `DiffloreConfig::default()`. We deliberately avoid pulling in serde
19/// + toml just for one key; `parse_kv_pairs` understands the small
20///   `key = "value"` subset we ship.
21pub fn load() -> DiffloreConfig {
22    let Ok(path) = paths::config_file() else {
23        return DiffloreConfig::default();
24    };
25    load_from_path(&path)
26}
27
28/// Load a config from an explicit path. Returns default on any I/O or
29/// parse failure (including missing file). Exposed for tests that need
30/// to point at a specific tempdir without racing on the shared
31/// `DIFFLORE_HOME`.
32pub fn load_from_path(path: &Path) -> DiffloreConfig {
33    let Ok(raw) = std::fs::read_to_string(path) else {
34        return DiffloreConfig::default();
35    };
36    let mut cfg = DiffloreConfig::default();
37    for (key, value) in parse_kv_pairs(&raw) {
38        if key == "theme" {
39            cfg.theme = match value.as_str() {
40                "light" => ThemeMode::Light,
41                "dark" => ThemeMode::Dark,
42                _ => ThemeMode::default(),
43            };
44        }
45    }
46    cfg
47}
48
49fn parse_kv_pairs(src: &str) -> Vec<(String, String)> {
50    let mut out = Vec::new();
51    for line in src.lines() {
52        let line = line.trim_start();
53        if line.is_empty() || line.starts_with('#') {
54            continue;
55        }
56        let Some(eq) = line.find('=') else {
57            continue;
58        };
59        let key = line[..eq].trim().to_owned();
60        let rest = line[eq + 1..].trim_start();
61        let Some(rest) = rest.strip_prefix('"') else {
62            continue;
63        };
64        let Some(end) = rest.find('"') else {
65            continue;
66        };
67        out.push((key, rest[..end].to_owned()));
68    }
69    out
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use std::fs;
76
77    fn write_cfg(contents: &str) -> tempfile::TempDir {
78        let tmp = tempfile::tempdir().unwrap();
79        fs::write(tmp.path().join("config.toml"), contents).unwrap();
80        tmp
81    }
82
83    #[test]
84    fn load_from_path_returns_default_when_file_missing() {
85        let tmp = tempfile::tempdir().unwrap();
86        let cfg = load_from_path(&tmp.path().join("does-not-exist.toml"));
87        assert_eq!(cfg, DiffloreConfig::default());
88        assert_eq!(cfg.theme, ThemeMode::Dark);
89    }
90
91    #[test]
92    fn load_from_path_parses_theme_light() {
93        let tmp = write_cfg(r#"theme = "light""#);
94        assert_eq!(
95            load_from_path(&tmp.path().join("config.toml")).theme,
96            ThemeMode::Light
97        );
98    }
99
100    #[test]
101    fn load_from_path_parses_theme_dark() {
102        let tmp = write_cfg(r#"theme = "dark""#);
103        assert_eq!(
104            load_from_path(&tmp.path().join("config.toml")).theme,
105            ThemeMode::Dark
106        );
107    }
108
109    #[test]
110    fn load_from_path_malformed_theme_falls_back_to_default() {
111        // No quotes around `bogus` -> parser skips the line entirely.
112        let tmp = write_cfg("theme = bogus\n");
113        assert_eq!(
114            load_from_path(&tmp.path().join("config.toml")).theme,
115            ThemeMode::Dark
116        );
117    }
118
119    #[test]
120    fn load_from_path_tolerates_comments_and_extra_keys() {
121        let tmp = write_cfg("# leading comment\ntheme = \"light\"\nfoo = \"bar\"\n");
122        assert_eq!(
123            load_from_path(&tmp.path().join("config.toml")).theme,
124            ThemeMode::Light
125        );
126    }
127
128    #[test]
129    fn load_returns_default_when_file_missing_in_data_home() {
130        // The shared test home doesn't include a config.toml unless
131        // some other test has written one. Treat missing as default
132        // — that's the production guarantee we care about.
133        // (We don't write to the shared home here to avoid racing
134        //  with parallel tests.)
135        let _ = load(); // must not panic
136    }
137
138    #[test]
139    fn unrecognised_theme_value_falls_back_to_default() {
140        let tmp = write_cfg(r#"theme = "neon""#);
141        assert_eq!(
142            load_from_path(&tmp.path().join("config.toml")).theme,
143            ThemeMode::Dark
144        );
145    }
146}