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
82pub 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; out.push(PathBuf::from("/etc/recon").join(name));
111 }
112
113 out
114}
115
116pub 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
126pub 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
169pub fn load_layered(name: &str, opts: &LayerOpts) -> Result<toml::Value> {
174 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
215fn 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 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 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}