Skip to main content

ev/
config.rs

1//! The .evolving/config reader: one typed Config parsed once from the flat `key = value`
2//! file. Defaults match DEFAULT_CONFIG. No TOML dependency — the file is ev-authored and
3//! fixed-shape, so a whole-token line scan is enough.
4use crate::store::Store;
5
6#[derive(Debug, Clone, PartialEq)]
7pub struct Config {
8    pub staleness_days: u64,
9    pub green_exit_code: i32,
10    pub staleness_ref: String, // "live-origin" | "local-head" | "none"
11    pub brief_limit: usize,
12}
13
14impl Default for Config {
15    fn default() -> Self {
16        Config {
17            staleness_days: 7,
18            green_exit_code: 0,
19            staleness_ref: "live-origin".into(),
20            brief_limit: 10,
21        }
22    }
23}
24
25/// The value of a `key = value` line (exact whole-key match), trimmed; None if absent.
26fn value_of<'a>(text: &'a str, key: &str) -> Option<&'a str> {
27    text.lines().find_map(|line| {
28        let (k, v) = line.split_once('=')?;
29        (k.trim() == key).then_some(v.trim())
30    })
31}
32
33fn unquote(s: &str) -> &str {
34    s.strip_prefix('"')
35        .and_then(|x| x.strip_suffix('"'))
36        .unwrap_or(s)
37}
38
39/// Parse the store's config; any missing or malformed key falls back to its default.
40pub fn read(store: &Store) -> Config {
41    let text = std::fs::read_to_string(store.config_path()).unwrap_or_default();
42    let d = Config::default();
43    Config {
44        staleness_days: value_of(&text, "staleness_days")
45            .and_then(|v| v.parse().ok())
46            .unwrap_or(d.staleness_days),
47        green_exit_code: value_of(&text, "green_exit_code")
48            .and_then(|v| v.parse().ok())
49            .unwrap_or(d.green_exit_code),
50        staleness_ref: value_of(&text, "staleness_ref")
51            .map(|v| unquote(v).to_string())
52            .unwrap_or(d.staleness_ref),
53        brief_limit: value_of(&text, "brief_limit")
54            .and_then(|v| v.parse().ok())
55            .unwrap_or(d.brief_limit),
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::store::Store;
63
64    fn store() -> (std::path::PathBuf, Store) {
65        use std::sync::atomic::{AtomicU64, Ordering};
66        static N: AtomicU64 = AtomicU64::new(0);
67        let p = std::env::temp_dir().join(format!(
68            "ev-config-{}-{}",
69            std::process::id(),
70            N.fetch_add(1, Ordering::Relaxed)
71        ));
72        let _ = std::fs::remove_dir_all(&p);
73        std::fs::create_dir_all(&p).unwrap();
74        let s = Store::at(&p);
75        s.init().unwrap();
76        (p, s)
77    }
78
79    #[test]
80    fn read_should_parse_every_key_when_the_config_sets_them() {
81        // given: a config that overrides all three keys
82        let (_p, s) = store();
83        std::fs::write(
84            s.config_path(),
85            "[runner]\ngreen_exit_code = 1\n\n[liveness]\nstaleness_days = 3\nstaleness_ref = \"local-head\"\n",
86        )
87        .unwrap();
88
89        // when: the config is read
90        let c = read(&s);
91
92        // then: each typed field reflects the file
93        assert_eq!(c.staleness_days, 3);
94        assert_eq!(c.green_exit_code, 1);
95        assert_eq!(c.staleness_ref, "local-head");
96    }
97
98    #[test]
99    fn read_should_parse_brief_limit_when_present() {
100        // given: a config that sets brief_limit
101        let (_p, s) = store();
102        std::fs::write(s.config_path(), "brief_limit = 5\n").unwrap();
103
104        // when: the config is read
105        let c = read(&s);
106
107        // then: the typed field reflects the file
108        assert_eq!(c.brief_limit, 5);
109    }
110
111    #[test]
112    fn read_should_use_defaults_when_the_keys_are_absent() {
113        // given: a config with none of the keys
114        let (_p, s) = store();
115        std::fs::write(s.config_path(), "schema_version = 1\n").unwrap();
116
117        // when: the config is read
118        let c = read(&s);
119
120        // then: it falls back to the defaults
121        assert_eq!(c, Config::default());
122    }
123
124    #[test]
125    fn read_should_not_match_a_longer_key_when_a_prefix_collides() {
126        // given: a config with only a longer key that shares the staleness_days prefix
127        let (_p, s) = store();
128        std::fs::write(s.config_path(), "staleness_days_extra = 99\n").unwrap();
129
130        // when: the config is read
131        let c = read(&s);
132
133        // then: staleness_days is the default, not 99 (whole-token match)
134        assert_eq!(c.staleness_days, 7);
135    }
136
137    #[test]
138    fn read_should_equal_the_defaults_for_a_freshly_initialized_store() {
139        // given: a store carrying the canonical DEFAULT_CONFIG that `init` writes
140        let (_p, s) = store();
141
142        // when: that default config is read back
143        let c = read(&s);
144
145        // then: it matches Config::default() — pins DEFAULT_CONFIG and Config::default() in lockstep
146        assert_eq!(c, Config::default());
147    }
148}