1use 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 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 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
64pub fn init_global(opts: LayerOpts) -> &'static LayerOpts {
68 let _ = GLOBAL_OPTS.set(opts.clone());
69 GLOBAL_OPTS.get().unwrap_or_else(|| {
70 Box::leak(Box::new(opts))
73 })
74}
75
76pub 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; 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
113pub 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
156pub fn load_layered(name: &str, opts: &LayerOpts) -> Result<toml::Value> {
161 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
202fn 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 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 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}