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