1use std::path::{Path, PathBuf};
22
23use serde::Deserialize;
24use serde_json::Value;
25
26use crate::root::find_root_from_markers;
27
28const PROJECT_DENYLIST: &[&str] = &["api_schema"];
31const EXTENDS_KEY: &str = "extends";
33
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct Settings {
38 pub model: Option<String>,
40 pub api_schema: Option<String>,
42 pub harness: Option<String>,
44 pub root_stop_pattern: Option<String>,
47 pub skills_extra: Vec<SkillsExtraEntry>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum SkillsExtraEntry {
54 Skill(PathBuf),
56 Folder(PathBuf),
59}
60
61#[derive(Debug, Clone, Default)]
64pub struct SettingsLoad {
65 pub settings: Settings,
67 pub extends_dirs: Vec<PathBuf>,
74 pub warnings: Vec<String>,
76}
77
78#[must_use]
84pub fn load_settings(cwd: &Path, flag: Option<&str>) -> SettingsLoad {
85 let mut warnings = Vec::new();
86 let user_dir = match crate::home::locode_home() {
87 Ok(dir) => Some(dir),
88 Err(e) => {
89 warnings.push(format!("settings: {e}; user layer skipped"));
90 None
91 }
92 };
93 if let Some(dir) = &user_dir
97 && let Some(notice) = scaffold_user_settings(dir)
98 {
99 warnings.push(notice);
100 }
101 let home_for_tilde = std::env::var_os("HOME")
102 .filter(|h| !h.is_empty())
103 .map(PathBuf::from);
104 let mut load = load_settings_from(user_dir.as_deref(), cwd, home_for_tilde.as_deref(), flag);
105 warnings.append(&mut load.warnings);
106 SettingsLoad {
107 settings: load.settings,
108 extends_dirs: load.extends_dirs,
109 warnings,
110 }
111}
112
113#[must_use]
116pub fn load_settings_from(
117 user_dir: Option<&Path>,
118 cwd: &Path,
119 home_for_tilde: Option<&Path>,
120 flag: Option<&str>,
121) -> SettingsLoad {
122 let mut warnings: Vec<String> = Vec::new();
123 let mut merged = Value::Object(serde_json::Map::new());
124 let mut extends_dirs: Vec<PathBuf> = Vec::new();
128
129 merge_user_and_extends_layers(
131 user_dir,
132 home_for_tilde,
133 &mut merged,
134 &mut extends_dirs,
135 &mut warnings,
136 );
137
138 let root = find_root_from_markers(cwd, &[".git".to_string()], None);
140 for name in ["settings.json", "settings.local.json"] {
141 let path = root.join(".locode").join(name);
142 if let Some(mut value) = read_layer(&path, &mut warnings) {
143 for key in PROJECT_DENYLIST.iter().copied().chain([EXTENDS_KEY]) {
144 if value.get(key).is_some() {
145 warnings.push(format!(
146 "settings: `{key}` in {} ignored (project layers may not set it, ADR-0024 §1.3)",
147 path.display()
148 ));
149 value = strip_key(value, key);
150 }
151 }
152 merge_values(&mut merged, value);
153 }
154 }
155
156 if let Some(flag) = flag {
158 let parsed = if matches!(flag.trim_start().chars().next(), Some('{' | '[')) {
161 serde_json::from_str::<Value>(flag)
162 .map_err(|e| format!("settings: --settings inline JSON: {e}"))
163 } else {
164 let path = expand_tilde(flag, home_for_tilde, cwd);
165 std::fs::read_to_string(&path)
166 .map_err(|e| format!("settings: --settings {}: {e}", path.display()))
167 .and_then(|text| {
168 serde_json::from_str::<Value>(&text)
169 .map_err(|e| format!("settings: --settings {}: {e}", path.display()))
170 })
171 };
172 match parsed {
173 Ok(value) if value.is_object() => merge_values(&mut merged, value),
174 Ok(_) => warnings.push("settings: --settings must be a JSON object".to_string()),
175 Err(e) => warnings.push(e),
176 }
177 }
178
179 let raw: RawSettings = serde_json::from_value(merged).unwrap_or_else(|e| {
181 warnings.push(format!("settings: merged settings did not decode: {e}"));
182 RawSettings::default()
183 });
184 if let Some(pattern) = &raw.instructions.root_stop_pattern
185 && let Err(e) = regex::Regex::new(pattern)
186 {
187 warnings.push(format!(
188 "settings: instructions.root_stop_pattern is not a valid regex ({e}); \
189 root detection will ignore it"
190 ));
191 }
192 let skills_extra = validate_skills_extra(
193 &raw.skills.extra,
194 home_for_tilde,
195 user_dir.unwrap_or(cwd),
196 &mut warnings,
197 );
198 SettingsLoad {
199 settings: Settings {
200 model: raw.model,
201 api_schema: raw.api_schema,
202 harness: raw.harness,
203 root_stop_pattern: raw.instructions.root_stop_pattern,
204 skills_extra,
205 },
206 extends_dirs,
207 warnings,
208 }
209}
210
211fn scaffold_user_settings(user_dir: &Path) -> Option<String> {
218 let path = user_dir.join("settings.json");
219 if path.exists() {
220 return None;
221 }
222 let body = serde_json::json!({
225 "api_schema": "anthropic",
226 "extends": [],
227 "harness": "claude",
228 "instructions": { "root_stop_pattern": Value::Null },
229 "model": "claude-sonnet-5",
230 "skills": { "extra": [] },
231 });
232 let text = serde_json::to_string_pretty(&body).ok()? + "\n";
233 crate::trace::create_dir_private(user_dir).ok()?;
234 let mut file = std::fs::OpenOptions::new()
235 .write(true)
236 .create_new(true)
237 .open(&path)
238 .ok()?;
239 std::io::Write::write_all(&mut file, text.as_bytes()).ok()?;
240 Some(format!(
241 "settings: created {} with the current defaults",
242 path.display()
243 ))
244}
245
246#[derive(Debug, Default, Deserialize)]
249struct RawSettings {
250 model: Option<String>,
251 api_schema: Option<String>,
252 harness: Option<String>,
253 #[serde(default)]
254 instructions: RawInstructions,
255 #[serde(default)]
256 skills: RawSkills,
257}
258
259#[derive(Debug, Default, Deserialize)]
260struct RawInstructions {
261 root_stop_pattern: Option<String>,
262}
263
264#[derive(Debug, Default, Deserialize)]
265struct RawSkills {
266 #[serde(default)]
267 extra: Vec<String>,
268}
269
270fn read_layer(path: &Path, warnings: &mut Vec<String>) -> Option<Value> {
273 if !path.is_file() {
274 return None;
275 }
276 let text = match std::fs::read_to_string(path) {
277 Ok(text) => text,
278 Err(e) => {
279 warnings.push(format!(
280 "settings: {} unreadable ({e}); skipped",
281 path.display()
282 ));
283 return None;
284 }
285 };
286 match serde_json::from_str::<Value>(&text) {
287 Ok(value) if value.is_object() => Some(value),
288 Ok(_) => {
289 warnings.push(format!(
290 "settings: {} is not a JSON object; skipped",
291 path.display()
292 ));
293 None
294 }
295 Err(e) => {
296 warnings.push(format!(
297 "settings: {} invalid ({e}); skipped",
298 path.display()
299 ));
300 None
301 }
302 }
303}
304
305fn merge_user_and_extends_layers(
313 user_dir: Option<&Path>,
314 home_for_tilde: Option<&Path>,
315 merged: &mut Value,
316 extends_dirs: &mut Vec<PathBuf>,
317 warnings: &mut Vec<String>,
318) {
319 let Some(user_dir) = user_dir else { return };
320 let user_file = user_dir.join("settings.json");
321 let Some(user_value) = read_layer(&user_file, warnings) else {
322 return;
323 };
324 let extends = extract_extends(&user_value, &user_file, warnings);
325 merge_values(merged, strip_key(user_value, EXTENDS_KEY));
326
327 for entry in extends {
328 let dir = expand_tilde(&entry, home_for_tilde, user_dir);
329 if dir.is_file() {
335 warnings.push(format!(
336 "settings: `extends` entry {} is a file; it must be a locode directory \
337 (point it at the folder holding settings.json)",
338 dir.display()
339 ));
340 continue;
341 }
342 if !dir.is_dir() {
345 warnings.push(format!(
346 "settings: extends directory {} not found; skipped",
347 dir.display()
348 ));
349 continue;
350 }
351 extends_dirs.push(dir.clone());
352
353 let path = dir.join("settings.json");
355 if !path.is_file() {
356 continue;
357 }
358 if let Some(mut value) = read_layer(&path, warnings) {
359 if value.get(EXTENDS_KEY).is_some() {
361 warnings.push(format!(
362 "settings: nested `extends` in {} ignored (extends does not recurse)",
363 path.display()
364 ));
365 value = strip_key(value, EXTENDS_KEY);
366 }
367 merge_values(merged, value);
368 }
369 }
370}
371
372fn extract_extends(
374 user_value: &Value,
375 user_file: &Path,
376 warnings: &mut Vec<String>,
377) -> Vec<String> {
378 match user_value.get(EXTENDS_KEY) {
379 None => Vec::new(),
380 Some(Value::Array(items)) => items
381 .iter()
382 .filter_map(|item| match item {
383 Value::String(s) => Some(s.clone()),
384 other => {
385 warnings.push(format!(
386 "settings: non-string `extends` entry {other} in {} ignored",
387 user_file.display()
388 ));
389 None
390 }
391 })
392 .collect(),
393 Some(_) => {
394 warnings.push(format!(
395 "settings: `extends` in {} must be an array of paths; ignored",
396 user_file.display()
397 ));
398 Vec::new()
399 }
400 }
401}
402
403fn validate_skills_extra(
406 entries: &[String],
407 home_for_tilde: Option<&Path>,
408 base: &Path,
409 warnings: &mut Vec<String>,
410) -> Vec<SkillsExtraEntry> {
411 entries
412 .iter()
413 .filter_map(|entry| {
414 let path = expand_tilde(entry, home_for_tilde, base);
415 if path.join("SKILL.md").is_file() {
416 return Some(SkillsExtraEntry::Skill(path));
417 }
418 let trimmed = entry.trim_end_matches('/');
419 if trimmed.ends_with("skills") {
420 return Some(SkillsExtraEntry::Folder(path));
421 }
422 warnings.push(format!(
423 "settings: skills.extra entry `{entry}` is neither a skill (no SKILL.md) \
424 nor a skills folder (path must end in `skills`); ignored"
425 ));
426 None
427 })
428 .collect()
429}
430
431fn expand_tilde(raw: &str, home: Option<&Path>, base: &Path) -> PathBuf {
434 if let Some(rest) = raw.strip_prefix("~/")
435 && let Some(home) = home
436 {
437 return home.join(rest);
438 }
439 if raw == "~"
440 && let Some(home) = home
441 {
442 return home.to_path_buf();
443 }
444 let path = PathBuf::from(raw);
445 if path.is_absolute() {
446 path
447 } else {
448 base.join(path)
449 }
450}
451
452fn strip_key(mut value: Value, key: &str) -> Value {
454 if let Value::Object(map) = &mut value {
455 map.remove(key);
456 }
457 value
458}
459
460fn merge_values(base: &mut Value, overlay: Value) {
463 match (base, overlay) {
464 (Value::Object(base_map), Value::Object(overlay_map)) => {
465 for (key, overlay_value) in overlay_map {
466 match base_map.get_mut(&key) {
467 Some(base_value) => merge_values(base_value, overlay_value),
468 None => {
469 base_map.insert(key, overlay_value);
470 }
471 }
472 }
473 }
474 (Value::Array(base_items), Value::Array(overlay_items)) => {
475 for item in overlay_items {
476 if !base_items.contains(&item) {
477 base_items.push(item);
478 }
479 }
480 }
481 (base_slot, overlay_value) => *base_slot = overlay_value,
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488 use serde_json::json;
489 use std::fs;
490
491 struct Fixture {
493 _guards: Vec<tempfile::TempDir>,
494 home: PathBuf, user_dir: PathBuf, repo: PathBuf, }
498
499 fn fixture() -> Fixture {
500 let home_guard = tempfile::tempdir().unwrap();
501 let repo_guard = tempfile::tempdir().unwrap();
502 let home = fs::canonicalize(home_guard.path()).unwrap();
503 let repo = fs::canonicalize(repo_guard.path()).unwrap();
504 let user_dir = home.join(".locode");
505 fs::create_dir_all(&user_dir).unwrap();
506 fs::create_dir(repo.join(".git")).unwrap();
507 Fixture {
508 _guards: vec![home_guard, repo_guard],
509 home,
510 user_dir,
511 repo,
512 }
513 }
514
515 fn write(path: &Path, value: &Value) {
516 if let Some(parent) = path.parent() {
517 fs::create_dir_all(parent).unwrap();
518 }
519 fs::write(path, serde_json::to_string_pretty(value).unwrap()).unwrap();
520 }
521
522 fn load(f: &Fixture, flag: Option<&str>) -> SettingsLoad {
523 load_settings_from(Some(&f.user_dir), &f.repo, Some(&f.home), flag)
524 }
525
526 #[test]
527 fn precedence_user_lt_extends_lt_project_lt_local_lt_flag() {
528 let f = fixture();
529 write(
530 &f.user_dir.join("settings.json"),
531 &json!({"model": "user", "harness": "user", "api_schema": "user",
532 "extends": ["team"]}),
533 );
534 write(
535 &f.user_dir.join("team/settings.json"),
536 &json!({"model": "team", "harness": "team"}),
537 );
538 write(
539 &f.repo.join(".locode/settings.json"),
540 &json!({"model": "project"}),
541 );
542 write(
543 &f.repo.join(".locode/settings.local.json"),
544 &json!({"model": "local"}),
545 );
546
547 let got = load(&f, None);
550 assert_eq!(got.settings.model.as_deref(), Some("local"));
551 assert_eq!(got.settings.harness.as_deref(), Some("team"));
552 assert_eq!(got.settings.api_schema.as_deref(), Some("user"));
553
554 let got = load(&f, Some(r#"{"model": "flag"}"#));
556 assert_eq!(got.settings.model.as_deref(), Some("flag"));
557 }
558
559 #[test]
560 fn extends_is_ordered_and_non_recursive() {
561 let f = fixture();
562 write(
563 &f.user_dir.join("settings.json"),
564 &json!({"extends": ["a", "b"]}),
565 );
566 write(&f.user_dir.join("a/settings.json"), &json!({"model": "a"}));
567 write(
568 &f.user_dir.join("b/settings.json"),
569 &json!({"model": "b", "extends": ["c"]}),
570 );
571 write(&f.user_dir.join("c/settings.json"), &json!({"model": "c"}));
572
573 let got = load(&f, None);
574 assert_eq!(got.settings.model.as_deref(), Some("b"));
576 assert!(
577 got.warnings.iter().any(|w| w.contains("nested `extends`")),
578 "{:?}",
579 got.warnings
580 );
581 assert_eq!(
582 got.extends_dirs,
583 vec![f.user_dir.join("a"), f.user_dir.join("b")],
584 "resolved dotfolders travel out in list order"
585 );
586 }
587
588 #[test]
591 fn extends_dotfolder_without_settings_json_is_silent() {
592 let f = fixture();
593 write(
594 &f.user_dir.join("settings.json"),
595 &json!({"extends": ["team"]}),
596 );
597 std::fs::create_dir_all(f.user_dir.join("team/skills")).unwrap();
598
599 let got = load(&f, None);
600 assert_eq!(got.extends_dirs, vec![f.user_dir.join("team")]);
601 assert!(
602 got.warnings.is_empty(),
603 "a dotfolder with no settings.json is normal: {:?}",
604 got.warnings
605 );
606 }
607
608 #[test]
612 fn extends_entry_pointing_at_a_file_is_refused_explicitly() {
613 let f = fixture();
614 write(
615 &f.user_dir.join("settings.json"),
616 &json!({"extends": ["team.json"]}),
617 );
618 write(&f.user_dir.join("team.json"), &json!({"model": "team"}));
619
620 let got = load(&f, None);
621 assert_eq!(got.settings.model, None, "the file must not be merged");
622 assert!(got.extends_dirs.is_empty());
623 let w = got.warnings.join(" | ");
624 assert!(w.contains("is a file"), "{w}");
625 assert!(w.contains("must be a locode directory"), "{w}");
626 }
627
628 #[test]
629 fn extends_missing_directory_warns_loudly() {
630 let f = fixture();
631 write(
632 &f.user_dir.join("settings.json"),
633 &json!({"extends": ["nope"]}),
634 );
635 let got = load(&f, None);
636 assert!(got.extends_dirs.is_empty());
637 assert!(
638 got.warnings.iter().any(|w| w.contains("not found")),
639 "{:?}",
640 got.warnings
641 );
642 }
643
644 #[test]
645 fn project_layers_cannot_set_denylisted_keys_or_extends() {
646 let f = fixture();
647 write(
648 &f.user_dir.join("settings.json"),
649 &json!({"api_schema": "user"}),
650 );
651 write(
652 &f.repo.join(".locode/settings.json"),
653 &json!({"api_schema": "evil", "extends": ["/tmp/evil.json"], "model": "ok"}),
654 );
655 let got = load(&f, None);
656 assert_eq!(
657 got.settings.api_schema.as_deref(),
658 Some("user"),
659 "denylisted"
660 );
661 assert_eq!(got.settings.model.as_deref(), Some("ok"), "other keys pass");
662 assert_eq!(
663 got.warnings
664 .iter()
665 .filter(|w| w.contains("project layers may not set"))
666 .count(),
667 2,
668 "{:?}",
669 got.warnings
670 );
671 }
672
673 #[test]
674 fn arrays_union_and_objects_deep_merge() {
675 let f = fixture();
676 write(
677 &f.user_dir.join("settings.json"),
678 &json!({"skills": {"extra": ["~/a-skills"]}, "instructions": {"root_stop_pattern": "u"}}),
679 );
680 write(
681 &f.repo.join(".locode/settings.json"),
682 &json!({"skills": {"extra": ["~/b-skills", "~/a-skills"]}}),
683 );
684 let got = load(&f, None);
685 assert_eq!(got.settings.root_stop_pattern.as_deref(), Some("u"));
687 let folders: Vec<_> = got
688 .settings
689 .skills_extra
690 .iter()
691 .map(|e| match e {
692 SkillsExtraEntry::Folder(p) | SkillsExtraEntry::Skill(p) => p.clone(),
693 })
694 .collect();
695 assert_eq!(
696 folders,
697 vec![f.home.join("a-skills"), f.home.join("b-skills")],
698 "union, first occurrence order, no duplicate"
699 );
700 }
701
702 #[test]
703 fn skills_extra_classifies_and_validates() {
704 let f = fixture();
705 let single = f.home.join("one-off");
706 fs::create_dir_all(&single).unwrap();
707 fs::write(single.join("SKILL.md"), "x").unwrap();
708 write(
709 &f.user_dir.join("settings.json"),
710 &json!({"skills": {"extra": ["~/one-off", "~/team-skills/", "~/random-dir"]}}),
711 );
712 let got = load(&f, None);
713 assert_eq!(
714 got.settings.skills_extra,
715 vec![
716 SkillsExtraEntry::Skill(single),
717 SkillsExtraEntry::Folder(f.home.join("team-skills/")),
718 ]
719 );
720 assert!(
721 got.warnings.iter().any(|w| w.contains("random-dir")),
722 "{:?}",
723 got.warnings
724 );
725 }
726
727 #[test]
728 fn malformed_layers_degrade_with_warnings() {
729 let f = fixture();
730 fs::write(f.user_dir.join("settings.json"), "{not json").unwrap();
731 write(
732 &f.repo.join(".locode/settings.json"),
733 &json!({"model": "p"}),
734 );
735 let got = load(&f, None);
736 assert_eq!(
737 got.settings.model.as_deref(),
738 Some("p"),
739 "good layers still load"
740 );
741 assert!(got.warnings.iter().any(|w| w.contains("invalid")));
742
743 write(
746 &f.user_dir.join("settings.json"),
747 &json!({"extends": ["missing.json"], "model": "u"}),
748 );
749 let got = load(&f, None);
750 assert_eq!(got.settings.model.as_deref(), Some("p"));
751 assert!(
752 got.warnings
753 .iter()
754 .any(|w| w.contains("missing.json") && w.contains("not found")),
755 "{:?}",
756 got.warnings
757 );
758 }
759
760 #[test]
761 fn unknown_keys_are_tolerated() {
762 let f = fixture();
763 write(
764 &f.user_dir.join("settings.json"),
765 &json!({"model": "m", "future_feature": {"x": 1}, "another": [1, 2]}),
766 );
767 let got = load(&f, None);
768 assert_eq!(got.settings.model.as_deref(), Some("m"));
769 assert!(got.warnings.is_empty(), "{:?}", got.warnings);
770 }
771
772 #[test]
773 fn no_git_root_uses_cwd_dot_locode() {
774 let dir = tempfile::tempdir().unwrap();
776 let cwd = fs::canonicalize(dir.path()).unwrap();
777 write(
778 &cwd.join(".locode/settings.json"),
779 &json!({"model": "here"}),
780 );
781 let got = load_settings_from(None, &cwd, None, None);
782 assert_eq!(got.settings.model.as_deref(), Some("here"));
783 }
784
785 #[test]
786 fn scaffold_writes_current_defaults_once() {
787 let dir = tempfile::tempdir().unwrap();
788 let user_dir = dir.path().join(".locode");
789 let notice = scaffold_user_settings(&user_dir).expect("scaffolded");
791 assert!(notice.contains("settings.json"));
792 let path = user_dir.join("settings.json");
793 let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
794 assert_eq!(value["harness"], "claude");
795 assert_eq!(value["api_schema"], "anthropic");
796 assert_eq!(value["model"], "claude-sonnet-5");
797 assert_eq!(value["skills"]["extra"], serde_json::json!([]));
798 let cwd = tempfile::tempdir().unwrap();
801 let got = load_settings_from(Some(&user_dir), cwd.path(), None, None);
802 assert_eq!(got.settings.harness.as_deref(), Some("claude"));
803 assert_eq!(got.settings.model.as_deref(), Some("claude-sonnet-5"));
804 assert!(got.warnings.is_empty(), "{:?}", got.warnings);
805 fs::write(&path, r#"{"harness":"claude"}"#).unwrap();
807 assert!(scaffold_user_settings(&user_dir).is_none());
808 let kept: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
809 assert_eq!(kept["harness"], "claude", "existing file untouched");
810 }
811
812 #[test]
813 fn invalid_root_stop_pattern_warns_but_survives() {
814 let f = fixture();
815 write(
816 &f.user_dir.join("settings.json"),
817 &json!({"instructions": {"root_stop_pattern": "[bad"}, "model": "m"}),
818 );
819 let got = load(&f, None);
820 assert_eq!(got.settings.model.as_deref(), Some("m"));
821 assert_eq!(got.settings.root_stop_pattern.as_deref(), Some("[bad"));
822 assert!(
823 got.warnings.iter().any(|w| w.contains("root_stop_pattern")),
824 "{:?}",
825 got.warnings
826 );
827 }
828
829 #[test]
830 fn inline_flag_json_and_non_object_rejection() {
831 let f = fixture();
832 let got = load(&f, Some(r#"{"harness": "codex"}"#));
833 assert_eq!(got.settings.harness.as_deref(), Some("codex"));
834 let got = load(&f, Some("[1,2]"));
835 assert!(got.warnings.iter().any(|w| w.contains("JSON object")));
836 }
837}