Skip to main content

recon_cli/
config_resolver.rs

1//! Layered TOML config resolver โ€” reads `/etc/recon/<name>.toml` (system)
2//! and `~/.recon/<name>.toml` (user), deep-merges them with user winning,
3//! and returns a single `toml::Value`. Used by `src/config.rs` and the gh
4//! script binding.
5//!
6//! See `docs/MANUAL.md` "Configuration files" for the public model;
7//! see `~/Development/Starweb/superpowers/recon/specs/2026-05-25-layered-config-design.md`
8//! for design rationale.
9
10use anyhow::{anyhow, Context, Result};
11use std::path::PathBuf;
12use std::sync::OnceLock;
13
14#[derive(Debug, Clone, Default)]
15pub struct LayerOpts {
16    pub skip_system:     bool,
17    pub skip_user:       bool,
18    pub system_override: Option<PathBuf>,
19    pub user_override:   Option<PathBuf>,
20}
21
22#[derive(Debug, Clone, Default)]
23pub struct Resolved {
24    pub system: Option<PathBuf>,
25    pub user:   Option<PathBuf>,
26}
27
28static GLOBAL_OPTS: OnceLock<LayerOpts> = OnceLock::new();
29
30impl LayerOpts {
31    /// Build options purely from env vars; the CLI-flag overlay
32    /// (`merge_cli_flags`) is applied separately in main.rs.
33    pub fn from_env() -> Self {
34        Self::from_env_with(|k| std::env::var(k).ok())
35    }
36
37    fn from_env_with(read: impl Fn(&str) -> Option<String>) -> Self {
38        LayerOpts {
39            skip_system:     false,
40            skip_user:       false,
41            system_override: read("RECON_SYSTEM_CONFIG").map(PathBuf::from),
42            user_override:   read("RECON_CONFIG").map(PathBuf::from),
43        }
44    }
45
46    /// Apply the three CLI flags onto an existing LayerOpts. Flag wins
47    /// over env-var override (see spec ยง"Path resolution").
48    pub fn merge_cli_flags(
49        mut self,
50        no_config: bool,
51        no_system_config: bool,
52        no_user_config: bool,
53    ) -> Self {
54        if no_config || no_system_config {
55            self.skip_system = true;
56        }
57        if no_config || no_user_config {
58            self.skip_user = true;
59        }
60        self
61    }
62}
63
64/// Set the process-wide `LayerOpts` once. Subsequent calls are
65/// silently ignored โ€” the first call wins, matching `OnceLock`
66/// semantics. Returns the stored opts.
67pub fn init_global(opts: LayerOpts) -> &'static LayerOpts {
68    let _ = GLOBAL_OPTS.set(opts.clone());
69    GLOBAL_OPTS.get().unwrap_or_else(|| {
70        // Unreachable in practice (we just set it), but cope if a
71        // concurrent caller raced us.
72        Box::leak(Box::new(opts))
73    })
74}
75
76/// Return the process-wide `LayerOpts`, or a default if `init_global`
77/// was never called (test paths, REPL).
78pub fn global() -> LayerOpts {
79    GLOBAL_OPTS.get().cloned().unwrap_or_default()
80}
81
82/// Return the candidate paths for the system layer in priority order
83/// (first match wins). Uses real env vars; for tests, see
84/// `system_candidates_with_env`.
85pub fn system_candidates() -> Vec<PathBuf> {
86    system_candidates_for("config.toml")
87}
88
89fn system_candidates_for(name: &str) -> Vec<PathBuf> {
90    let brew_prefix = std::env::var("HOMEBREW_PREFIX").ok();
91    system_candidates_with_env(name, brew_prefix.as_deref())
92}
93
94fn system_candidates_with_env(name: &str, brew_prefix: Option<&str>) -> Vec<PathBuf> {
95    let mut out = Vec::new();
96
97    #[cfg(target_os = "macos")]
98    {
99        if let Some(p) = brew_prefix {
100            out.push(PathBuf::from(p).join("etc/recon").join(name));
101        }
102        out.push(PathBuf::from("/opt/homebrew/etc/recon").join(name));
103        out.push(PathBuf::from("/usr/local/etc/recon").join(name));
104        out.push(PathBuf::from("/etc/recon").join(name));
105    }
106
107    #[cfg(not(target_os = "macos"))]
108    {
109        let _ = brew_prefix; // silence unused on non-mac
110        out.push(PathBuf::from("/etc/recon").join(name));
111    }
112
113    out
114}
115
116/// Return the user-layer path for `config.toml` (no existence check). Returns
117/// None when $HOME is unset.
118pub fn user_path() -> Option<PathBuf> {
119    user_path_with_home(std::env::var("HOME").ok().as_deref(), "config.toml")
120}
121
122fn user_path_with_home(home: Option<&str>, name: &str) -> Option<PathBuf> {
123    Some(PathBuf::from(home?).join(".recon").join(name))
124}
125
126/// Resolve which system+user paths actually exist for the given config
127/// name. Returns `None` for layers that are skipped or have no match.
128/// `--*-config` flags always beat env-var overrides.
129pub fn resolve_paths(name: &str, opts: &LayerOpts) -> Resolved {
130    let system_candidates = system_candidates_for(name);
131    let user_candidate = user_path_with_home(
132        std::env::var("HOME").ok().as_deref(),
133        name,
134    );
135    resolve_paths_with(name, opts, &system_candidates, user_candidate)
136}
137
138fn resolve_paths_with(
139    name: &str,
140    opts: &LayerOpts,
141    system_candidates: &[PathBuf],
142    user_candidate: Option<PathBuf>,
143) -> Resolved {
144    let system = if opts.skip_system {
145        None
146    } else if let Some(p) = &opts.system_override {
147        Some(resolve_override(p, name))
148    } else {
149        system_candidates.iter().find(|p| p.is_file()).cloned()
150    };
151    let user = if opts.skip_user {
152        None
153    } else if let Some(p) = &opts.user_override {
154        Some(resolve_override(p, name))
155    } else {
156        user_candidate.filter(|p| p.is_file())
157    };
158    Resolved { system, user }
159}
160
161fn resolve_override(p: &std::path::Path, default_name: &str) -> PathBuf {
162    if p.is_dir() {
163        p.join(default_name)
164    } else {
165        p.to_path_buf()
166    }
167}
168
169/// Load both layers for `name`, deep-merge them (user wins), and return
170/// the effective `toml::Value`. Missing files at default paths are
171/// silent (returns empty table); missing files at env-var override
172/// paths are a hard error.
173pub fn load_layered(name: &str, opts: &LayerOpts) -> Result<toml::Value> {
174    // If an override is set and points at a nonexistent path, error
175    // before consulting resolve_paths (which silently filters non-existent).
176    if let Some(p) = opts.system_override.as_ref().filter(|_| !opts.skip_system) {
177        let resolved = resolve_override(p, "config.toml");
178        if !resolved.exists() {
179            return Err(anyhow!(
180                "$RECON_SYSTEM_CONFIG points at {} but the file/dir does not exist",
181                p.display(),
182            ));
183        }
184    }
185    if let Some(p) = opts.user_override.as_ref().filter(|_| !opts.skip_user) {
186        let resolved = resolve_override(p, "config.toml");
187        if !resolved.exists() {
188            return Err(anyhow!(
189                "$RECON_CONFIG points at {} but the file/dir does not exist",
190                p.display(),
191            ));
192        }
193    }
194
195    let r = resolve_paths(name, opts);
196    let mut effective = toml::Value::Table(Default::default());
197    if let Some(p) = r.system {
198        let v = read_and_parse(&p)?;
199        deep_merge(&mut effective, v);
200    }
201    if let Some(p) = r.user {
202        let v = read_and_parse(&p)?;
203        deep_merge(&mut effective, v);
204    }
205    Ok(effective)
206}
207
208fn read_and_parse(path: &std::path::Path) -> Result<toml::Value> {
209    let text = std::fs::read_to_string(path)
210        .with_context(|| format!("config_resolver: cannot read {}", path.display()))?;
211    text.parse::<toml::Value>()
212        .map_err(|e| anyhow!("config_resolver: invalid TOML in {}: {e}", path.display()))
213}
214
215/// Deep-merge `overlay` onto `base`. Tables merge recursively; arrays
216/// and leaves are replaced by overlay. Type clashes (table vs. leaf)
217/// resolve with overlay winning silently โ€” schema enforcement happens
218/// in the downstream serde deserialize.
219fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
220    use toml::Value;
221    match (base, overlay) {
222        (Value::Table(b), Value::Table(o)) => {
223            for (k, v) in o {
224                match b.get_mut(&k) {
225                    Some(existing) => deep_merge(existing, v),
226                    None => {
227                        b.insert(k, v);
228                    }
229                }
230            }
231        }
232        (slot, overlay) => {
233            *slot = overlay;
234        }
235    }
236}
237
238#[cfg(test)]
239mod merge_tests {
240    use super::*;
241    use toml::Value;
242
243    fn v(s: &str) -> Value {
244        s.parse().unwrap()
245    }
246
247    #[test]
248    fn overlay_leaf_replaces_base_leaf() {
249        let mut base = v(r#"x = "old""#);
250        let overlay = v(r#"x = "new""#);
251        deep_merge(&mut base, overlay);
252        assert_eq!(base, v(r#"x = "new""#));
253    }
254
255    #[test]
256    fn overlay_table_merges_sibling_keys_preserved() {
257        let mut base = v("[t]\na = 1\nb = 2\n");
258        let overlay = v("[t]\nb = 20\nc = 30\n");
259        deep_merge(&mut base, overlay);
260        assert_eq!(base, v("[t]\na = 1\nb = 20\nc = 30\n"));
261    }
262
263    #[test]
264    fn overlay_array_replaces_base_array_no_concat() {
265        let mut base = v(r#"items = ["a", "b"]"#);
266        let overlay = v(r#"items = ["c"]"#);
267        deep_merge(&mut base, overlay);
268        assert_eq!(base, v(r#"items = ["c"]"#));
269    }
270
271    #[test]
272    fn overlay_empty_array_replaces_non_empty_base() {
273        let mut base = v(r#"items = ["a", "b"]"#);
274        let overlay = v("items = []");
275        deep_merge(&mut base, overlay);
276        assert_eq!(base, v("items = []"));
277    }
278
279    #[test]
280    fn overlay_table_replaces_base_leaf_of_same_key() {
281        let mut base = v(r#"x = "string""#);
282        let overlay = v("[x]\na = 1\n");
283        deep_merge(&mut base, overlay);
284        assert_eq!(base, v("[x]\na = 1\n"));
285    }
286
287    #[test]
288    fn overlay_leaf_replaces_base_table_of_same_key() {
289        let mut base = v("[x]\na = 1\n");
290        let overlay = v(r#"x = "string""#);
291        deep_merge(&mut base, overlay);
292        assert_eq!(base, v(r#"x = "string""#));
293    }
294
295    #[test]
296    fn empty_overlay_leaves_base_unchanged() {
297        let mut base = v("a = 1\nb = 2\n");
298        let original = base.clone();
299        deep_merge(&mut base, v(""));
300        assert_eq!(base, original);
301    }
302
303    #[test]
304    fn deeply_nested_table_merges_correctly() {
305        let mut base = v(r#"
306            [a.b.c]
307            x = 1
308            y = 2
309        "#);
310        let overlay = v(r#"
311            [a.b.c]
312            y = 20
313            z = 30
314            [a.b.d]
315            new = "table"
316        "#);
317        deep_merge(&mut base, overlay);
318        assert_eq!(
319            base,
320            v(r#"
321                [a.b.c]
322                x = 1
323                y = 20
324                z = 30
325                [a.b.d]
326                new = "table"
327            "#)
328        );
329    }
330}
331
332#[cfg(test)]
333mod system_candidates_tests {
334    use super::*;
335
336    #[test]
337    fn includes_etc_recon_on_every_platform() {
338        let paths = system_candidates_for("config.toml");
339        assert!(
340            paths.iter().any(|p| p == &PathBuf::from("/etc/recon/config.toml")),
341            "missing /etc/recon/config.toml in {paths:?}",
342        );
343    }
344
345    #[test]
346    #[cfg(target_os = "macos")]
347    fn macos_includes_homebrew_paths() {
348        let paths = system_candidates_for("config.toml");
349        assert!(paths.iter().any(|p| p == &PathBuf::from("/opt/homebrew/etc/recon/config.toml")));
350        assert!(paths.iter().any(|p| p == &PathBuf::from("/usr/local/etc/recon/config.toml")));
351    }
352
353    #[test]
354    #[cfg(target_os = "macos")]
355    fn macos_homebrew_prefix_env_var_wins_when_set() {
356        let paths = system_candidates_with_env("config.toml", Some("/tmp/brewy"));
357        assert_eq!(paths.first(), Some(&PathBuf::from("/tmp/brewy/etc/recon/config.toml")));
358    }
359
360    #[test]
361    #[cfg(target_os = "linux")]
362    fn linux_only_etc_recon() {
363        let paths = system_candidates_for("config.toml");
364        assert_eq!(paths, vec![PathBuf::from("/etc/recon/config.toml")]);
365    }
366}
367
368#[cfg(test)]
369mod user_path_tests {
370    use super::*;
371
372    #[test]
373    fn user_path_with_home_returns_dot_recon() {
374        let p = user_path_with_home(Some("/home/test"), "config.toml");
375        assert_eq!(p, Some(PathBuf::from("/home/test/.recon/config.toml")));
376    }
377
378    #[test]
379    fn user_path_without_home_returns_none() {
380        let p = user_path_with_home(None, "config.toml");
381        assert_eq!(p, None);
382    }
383}
384
385#[cfg(test)]
386mod resolve_paths_tests {
387    use super::*;
388    use tempfile::TempDir;
389
390    fn touch(path: &std::path::Path) {
391        if let Some(parent) = path.parent() {
392            std::fs::create_dir_all(parent).unwrap();
393        }
394        std::fs::write(path, b"").unwrap();
395    }
396
397    #[test]
398    fn default_opts_with_env_overrides_picks_those() {
399        let dir = TempDir::new().unwrap();
400        let sys = dir.path().join("sys.toml");
401        let usr = dir.path().join("usr.toml");
402        touch(&sys);
403        touch(&usr);
404        let opts = LayerOpts {
405            system_override: Some(sys.clone()),
406            user_override:   Some(usr.clone()),
407            ..LayerOpts::default()
408        };
409        let r = resolve_paths_with("config.toml", &opts, &[], None);
410        assert_eq!(r.system, Some(sys));
411        assert_eq!(r.user,   Some(usr));
412    }
413
414    #[test]
415    fn skip_flags_yield_none() {
416        let dir = TempDir::new().unwrap();
417        let sys = dir.path().join("sys.toml");
418        let usr = dir.path().join("usr.toml");
419        touch(&sys);
420        touch(&usr);
421        let opts = LayerOpts {
422            skip_system:     true,
423            skip_user:       true,
424            system_override: Some(sys),
425            user_override:   Some(usr),
426        };
427        let r = resolve_paths_with("config.toml", &opts, &[], None);
428        assert_eq!(r.system, None);
429        assert_eq!(r.user,   None);
430    }
431
432    #[test]
433    fn picks_first_existing_system_candidate() {
434        let dir = TempDir::new().unwrap();
435        let a = dir.path().join("a.toml");
436        let b = dir.path().join("b.toml");
437        let c = dir.path().join("c.toml");
438        touch(&b);
439        touch(&c);
440        // Only b and c exist; a doesn't. Candidate order is [a, b, c].
441        let opts = LayerOpts::default();
442        let r = resolve_paths_with("config.toml", &opts, &[a, b.clone(), c], None);
443        assert_eq!(r.system, Some(b));
444    }
445
446    #[test]
447    fn returns_none_when_no_candidate_exists() {
448        let dir = TempDir::new().unwrap();
449        let a = dir.path().join("does-not-exist.toml");
450        let opts = LayerOpts::default();
451        let r = resolve_paths_with("config.toml", &opts, &[a], None);
452        assert_eq!(r.system, None);
453    }
454
455    #[test]
456    fn env_var_pointing_at_directory_appends_name() {
457        let dir = TempDir::new().unwrap();
458        let cfg = dir.path().join("config.toml");
459        touch(&cfg);
460        let opts = LayerOpts {
461            system_override: Some(dir.path().to_path_buf()),
462            ..LayerOpts::default()
463        };
464        let r = resolve_paths_with("config.toml", &opts, &[], None);
465        assert_eq!(r.system, Some(cfg));
466    }
467
468    #[test]
469    fn env_var_pointing_at_missing_file_returns_error_path() {
470        let dir = TempDir::new().unwrap();
471        let missing = dir.path().join("nope.toml");
472        let opts = LayerOpts {
473            system_override: Some(missing.clone()),
474            ..LayerOpts::default()
475        };
476        // resolve_paths_with returns the (missing) path; load_layered is
477        // the layer that turns this into a hard error.
478        let r = resolve_paths_with("config.toml", &opts, &[], None);
479        assert_eq!(r.system, Some(missing));
480    }
481
482    #[test]
483    fn skip_flag_wins_over_env_var_override() {
484        let dir = TempDir::new().unwrap();
485        let sys = dir.path().join("sys.toml");
486        touch(&sys);
487        let opts = LayerOpts {
488            skip_system:     true,
489            system_override: Some(sys),
490            ..LayerOpts::default()
491        };
492        let r = resolve_paths_with("config.toml", &opts, &[], None);
493        assert_eq!(r.system, None);
494    }
495}
496
497#[cfg(test)]
498mod load_layered_tests {
499    use super::*;
500    use tempfile::TempDir;
501
502    fn write(path: &std::path::Path, body: &str) {
503        if let Some(parent) = path.parent() {
504            std::fs::create_dir_all(parent).unwrap();
505        }
506        std::fs::write(path, body).unwrap();
507    }
508
509    fn opts_for(sys: Option<&std::path::Path>, usr: Option<&std::path::Path>) -> LayerOpts {
510        LayerOpts {
511            system_override: sys.map(|p| p.to_path_buf()),
512            user_override:   usr.map(|p| p.to_path_buf()),
513            ..LayerOpts::default()
514        }
515    }
516
517    #[test]
518    fn both_layers_missing_yields_empty_table() {
519        let opts = LayerOpts {
520            skip_system: true,
521            skip_user:   true,
522            ..LayerOpts::default()
523        };
524        let v = load_layered("config.toml", &opts).unwrap();
525        assert_eq!(v, toml::Value::Table(Default::default()));
526    }
527
528    #[test]
529    fn system_only_loads_cleanly() {
530        let dir = TempDir::new().unwrap();
531        let sys = dir.path().join("sys.toml");
532        write(&sys, r#"[a]
533x = 1
534"#);
535        let opts = opts_for(Some(&sys), None);
536        let opts = LayerOpts { skip_user: true, ..opts };
537        let v = load_layered("config.toml", &opts).unwrap();
538        assert_eq!(v.get("a").and_then(|t| t.get("x")).and_then(|x| x.as_integer()), Some(1));
539    }
540
541    #[test]
542    fn user_only_loads_cleanly() {
543        let dir = TempDir::new().unwrap();
544        let usr = dir.path().join("usr.toml");
545        write(&usr, r#"[a]
546y = 2
547"#);
548        let opts = opts_for(None, Some(&usr));
549        let opts = LayerOpts { skip_system: true, ..opts };
550        let v = load_layered("config.toml", &opts).unwrap();
551        assert_eq!(v.get("a").and_then(|t| t.get("y")).and_then(|y| y.as_integer()), Some(2));
552    }
553
554    #[test]
555    fn both_layers_merge_with_user_winning() {
556        let dir = TempDir::new().unwrap();
557        let sys = dir.path().join("sys.toml");
558        let usr = dir.path().join("usr.toml");
559        write(&sys, r#"[editor]
560default = "vim"
561[ai.backends.work]
562cmd = "/opt/claude"
563"#);
564        write(&usr, r#"[editor]
565default = "zed"
566[ai.backends.scratch]
567cmd = "claude"
568"#);
569        let opts = opts_for(Some(&sys), Some(&usr));
570        let v = load_layered("config.toml", &opts).unwrap();
571        assert_eq!(
572            v.get("editor").and_then(|t| t.get("default")).and_then(|d| d.as_str()),
573            Some("zed"),
574        );
575        assert_eq!(
576            v.get("ai").and_then(|t| t.get("backends"))
577                .and_then(|t| t.get("work")).and_then(|t| t.get("cmd"))
578                .and_then(|c| c.as_str()),
579            Some("/opt/claude"),
580        );
581        assert_eq!(
582            v.get("ai").and_then(|t| t.get("backends"))
583                .and_then(|t| t.get("scratch")).and_then(|t| t.get("cmd"))
584                .and_then(|c| c.as_str()),
585            Some("claude"),
586        );
587    }
588
589    #[test]
590    fn malformed_toml_errors_with_path() {
591        let dir = TempDir::new().unwrap();
592        let usr = dir.path().join("usr.toml");
593        write(&usr, "this is = not valid = toml\n");
594        let opts = opts_for(None, Some(&usr));
595        let opts = LayerOpts { skip_system: true, ..opts };
596        let err = load_layered("config.toml", &opts).unwrap_err().to_string();
597        assert!(err.contains("invalid TOML"), "got: {err}");
598        assert!(err.contains(usr.display().to_string().as_str()), "got: {err}");
599    }
600
601    #[test]
602    fn env_override_missing_file_errors_loudly() {
603        let opts = LayerOpts {
604            system_override: Some(PathBuf::from("/nonexistent/path/here.toml")),
605            ..LayerOpts::default()
606        };
607        let opts = LayerOpts { skip_user: true, ..opts };
608        let err = load_layered("config.toml", &opts).unwrap_err().to_string();
609        assert!(err.contains("does not exist") || err.contains("cannot read"), "got: {err}");
610    }
611}
612
613#[cfg(test)]
614mod layer_opts_tests {
615    use super::*;
616
617    #[test]
618    fn from_env_with_no_vars_set_yields_empty_overrides() {
619        let opts = LayerOpts::from_env_with(|_| None);
620        assert!(opts.system_override.is_none());
621        assert!(opts.user_override.is_none());
622    }
623
624    #[test]
625    fn from_env_picks_up_recon_system_config() {
626        let opts = LayerOpts::from_env_with(|k| match k {
627            "RECON_SYSTEM_CONFIG" => Some("/tmp/sys.toml".into()),
628            _ => None,
629        });
630        assert_eq!(opts.system_override, Some(PathBuf::from("/tmp/sys.toml")));
631        assert!(opts.user_override.is_none());
632    }
633
634    #[test]
635    fn from_env_picks_up_recon_config() {
636        let opts = LayerOpts::from_env_with(|k| match k {
637            "RECON_CONFIG" => Some("/tmp/usr.toml".into()),
638            _ => None,
639        });
640        assert_eq!(opts.user_override, Some(PathBuf::from("/tmp/usr.toml")));
641        assert!(opts.system_override.is_none());
642    }
643}