1use std::path::{Path, PathBuf};
22
23use serde::Deserialize;
24use serde_json::{Map, 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 effort: Option<String>,
48 pub root_stop_pattern: Option<String>,
51 pub skills_extra: Vec<SkillsExtraEntry>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum SkillsExtraEntry {
58 Skill(PathBuf),
60 Folder(PathBuf),
63}
64
65#[derive(Debug, Clone, Default)]
68pub struct SettingsLoad {
69 pub settings: Settings,
71 pub extends_dirs: Vec<PathBuf>,
78 pub warnings: Vec<String>,
80}
81
82#[must_use]
88pub fn load_settings(cwd: &Path, flag: Option<&str>) -> SettingsLoad {
89 let mut warnings = Vec::new();
90 let user_dir = match crate::home::locode_home() {
91 Ok(dir) => Some(dir),
92 Err(e) => {
93 warnings.push(format!("settings: {e}; user layer skipped"));
94 None
95 }
96 };
97 if let Some(dir) = &user_dir
101 && let Some(notice) = scaffold_user_settings(dir)
102 {
103 warnings.push(notice);
104 }
105 let home_for_tilde = std::env::var_os("HOME")
106 .filter(|h| !h.is_empty())
107 .map(PathBuf::from);
108 let mut load = load_settings_from(user_dir.as_deref(), cwd, home_for_tilde.as_deref(), flag);
109 warnings.append(&mut load.warnings);
110 SettingsLoad {
111 settings: load.settings,
112 extends_dirs: load.extends_dirs,
113 warnings,
114 }
115}
116
117#[must_use]
120pub fn load_settings_from(
121 user_dir: Option<&Path>,
122 cwd: &Path,
123 home_for_tilde: Option<&Path>,
124 flag: Option<&str>,
125) -> SettingsLoad {
126 let mut warnings: Vec<String> = Vec::new();
127 let mut merged = Value::Object(serde_json::Map::new());
128 let mut extends_dirs: Vec<PathBuf> = Vec::new();
132
133 merge_user_and_extends_layers(
135 user_dir,
136 home_for_tilde,
137 &mut merged,
138 &mut extends_dirs,
139 &mut warnings,
140 );
141
142 let root = find_root_from_markers(cwd, &[".git".to_string()], None);
144 for name in ["settings.json", "settings.local.json"] {
145 let path = root.join(".locode").join(name);
146 if let Some(mut value) = read_layer(&path, &mut warnings) {
147 for key in PROJECT_DENYLIST.iter().copied().chain([EXTENDS_KEY]) {
148 if value.get(key).is_some() {
149 warnings.push(format!(
150 "settings: `{key}` in {} ignored (project layers may not set it, ADR-0024 §1.3)",
151 path.display()
152 ));
153 value = strip_key(value, key);
154 }
155 }
156 merge_values(&mut merged, value);
157 }
158 }
159
160 if let Some(flag) = flag {
162 let parsed = if matches!(flag.trim_start().chars().next(), Some('{' | '[')) {
165 serde_json::from_str::<Value>(flag)
166 .map_err(|e| format!("settings: --settings inline JSON: {e}"))
167 } else {
168 let path = expand_tilde(flag, home_for_tilde, cwd);
169 std::fs::read_to_string(&path)
170 .map_err(|e| format!("settings: --settings {}: {e}", path.display()))
171 .and_then(|text| {
172 serde_json::from_str::<Value>(&text)
173 .map_err(|e| format!("settings: --settings {}: {e}", path.display()))
174 })
175 };
176 match parsed {
177 Ok(value) if value.is_object() => merge_values(&mut merged, value),
178 Ok(_) => warnings.push("settings: --settings must be a JSON object".to_string()),
179 Err(e) => warnings.push(e),
180 }
181 }
182
183 let raw: RawSettings = serde_json::from_value(merged).unwrap_or_else(|e| {
185 warnings.push(format!("settings: merged settings did not decode: {e}"));
186 RawSettings::default()
187 });
188 if let Some(pattern) = &raw.instructions.root_stop_pattern
189 && let Err(e) = regex::Regex::new(pattern)
190 {
191 warnings.push(format!(
192 "settings: instructions.root_stop_pattern is not a valid regex ({e}); \
193 root detection will ignore it"
194 ));
195 }
196 let skills_extra = validate_skills_extra(
197 &raw.skills.extra,
198 home_for_tilde,
199 user_dir.unwrap_or(cwd),
200 &mut warnings,
201 );
202 SettingsLoad {
203 settings: Settings {
204 model: raw.model,
205 api_schema: raw.api_schema,
206 harness: raw.harness,
207 effort: raw.effort,
208 root_stop_pattern: raw.instructions.root_stop_pattern,
209 skills_extra,
210 },
211 extends_dirs,
212 warnings,
213 }
214}
215
216pub fn update_user_setting(user_dir: &Path, key: &str, value: Value) -> Result<PathBuf, String> {
236 let path = user_dir.join("settings.json");
237 let mut root = match std::fs::read_to_string(&path) {
241 Ok(text) => {
242 serde_json::from_str::<Value>(&text).unwrap_or_else(|_| Value::Object(Map::new()))
243 }
244 Err(_) => Value::Object(Map::new()),
245 };
246 if !root.is_object() {
247 root = Value::Object(Map::new());
248 }
249 let Some(object) = root.as_object_mut() else {
250 unreachable!("just forced to an object");
251 };
252 match value {
253 Value::Null => {
256 object.remove(key);
257 }
258 value => {
259 object.insert(key.to_string(), value);
260 }
261 }
262 let text = serde_json::to_string_pretty(&root).map_err(|e| format!("serialize: {e}"))? + "\n";
263 crate::trace::create_dir_private(user_dir)?;
264 write_atomically(&path, text.as_bytes())?;
265 Ok(path)
266}
267
268fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
273 let dir = path.parent().unwrap_or(Path::new("."));
274 let name = path.file_name().map_or_else(
275 || std::ffi::OsString::from("settings.json"),
276 std::ffi::OsStr::to_os_string,
277 );
278 let mut temp_name = name;
279 temp_name.push(format!(".tmp.{}", std::process::id()));
280 let temp = dir.join(temp_name);
281
282 let write = || -> std::io::Result<()> {
283 let mut file = std::fs::File::create(&temp)?;
284 std::io::Write::write_all(&mut file, bytes)?;
285 file.sync_all()?;
288 Ok(())
289 };
290 if let Err(e) = write() {
291 let _ = std::fs::remove_file(&temp);
292 return Err(format!("write {}: {e}", temp.display()));
293 }
294 std::fs::rename(&temp, path).map_err(|e| {
295 let _ = std::fs::remove_file(&temp);
296 format!("replace {}: {e}", path.display())
297 })
298}
299
300fn scaffold_user_settings(user_dir: &Path) -> Option<String> {
307 let path = user_dir.join("settings.json");
308 if path.exists() {
309 return None;
310 }
311 let body = serde_json::json!({
314 "api_schema": "anthropic",
315 "extends": [],
316 "harness": "claude",
317 "instructions": { "root_stop_pattern": Value::Null },
318 "model": "claude-sonnet-5",
319 "skills": { "extra": [] },
320 });
321 let text = serde_json::to_string_pretty(&body).ok()? + "\n";
322 crate::trace::create_dir_private(user_dir).ok()?;
323 let mut file = std::fs::OpenOptions::new()
324 .write(true)
325 .create_new(true)
326 .open(&path)
327 .ok()?;
328 std::io::Write::write_all(&mut file, text.as_bytes()).ok()?;
329 Some(format!(
330 "settings: created {} with the current defaults",
331 path.display()
332 ))
333}
334
335#[derive(Debug, Default, Deserialize)]
338struct RawSettings {
339 model: Option<String>,
340 api_schema: Option<String>,
341 harness: Option<String>,
342 effort: Option<String>,
343 #[serde(default)]
344 instructions: RawInstructions,
345 #[serde(default)]
346 skills: RawSkills,
347}
348
349#[derive(Debug, Default, Deserialize)]
350struct RawInstructions {
351 root_stop_pattern: Option<String>,
352}
353
354#[derive(Debug, Default, Deserialize)]
355struct RawSkills {
356 #[serde(default)]
357 extra: Vec<String>,
358}
359
360fn read_layer(path: &Path, warnings: &mut Vec<String>) -> Option<Value> {
363 if !path.is_file() {
364 return None;
365 }
366 let text = match std::fs::read_to_string(path) {
367 Ok(text) => text,
368 Err(e) => {
369 warnings.push(format!(
370 "settings: {} unreadable ({e}); skipped",
371 path.display()
372 ));
373 return None;
374 }
375 };
376 match serde_json::from_str::<Value>(&text) {
377 Ok(value) if value.is_object() => Some(value),
378 Ok(_) => {
379 warnings.push(format!(
380 "settings: {} is not a JSON object; skipped",
381 path.display()
382 ));
383 None
384 }
385 Err(e) => {
386 warnings.push(format!(
387 "settings: {} invalid ({e}); skipped",
388 path.display()
389 ));
390 None
391 }
392 }
393}
394
395fn merge_user_and_extends_layers(
403 user_dir: Option<&Path>,
404 home_for_tilde: Option<&Path>,
405 merged: &mut Value,
406 extends_dirs: &mut Vec<PathBuf>,
407 warnings: &mut Vec<String>,
408) {
409 let Some(user_dir) = user_dir else { return };
410 let user_file = user_dir.join("settings.json");
411 let Some(user_value) = read_layer(&user_file, warnings) else {
412 return;
413 };
414 let extends = extract_extends(&user_value, &user_file, warnings);
415 merge_values(merged, strip_key(user_value, EXTENDS_KEY));
416
417 for entry in extends {
418 let dir = expand_tilde(&entry, home_for_tilde, user_dir);
419 if dir.is_file() {
425 warnings.push(format!(
426 "settings: `extends` entry {} is a file; it must be a locode directory \
427 (point it at the folder holding settings.json)",
428 dir.display()
429 ));
430 continue;
431 }
432 if !dir.is_dir() {
435 warnings.push(format!(
436 "settings: extends directory {} not found; skipped",
437 dir.display()
438 ));
439 continue;
440 }
441 extends_dirs.push(dir.clone());
442
443 let path = dir.join("settings.json");
445 if !path.is_file() {
446 continue;
447 }
448 if let Some(mut value) = read_layer(&path, warnings) {
449 if value.get(EXTENDS_KEY).is_some() {
451 warnings.push(format!(
452 "settings: nested `extends` in {} ignored (extends does not recurse)",
453 path.display()
454 ));
455 value = strip_key(value, EXTENDS_KEY);
456 }
457 merge_values(merged, value);
458 }
459 }
460}
461
462fn extract_extends(
464 user_value: &Value,
465 user_file: &Path,
466 warnings: &mut Vec<String>,
467) -> Vec<String> {
468 match user_value.get(EXTENDS_KEY) {
469 None => Vec::new(),
470 Some(Value::Array(items)) => items
471 .iter()
472 .filter_map(|item| match item {
473 Value::String(s) => Some(s.clone()),
474 other => {
475 warnings.push(format!(
476 "settings: non-string `extends` entry {other} in {} ignored",
477 user_file.display()
478 ));
479 None
480 }
481 })
482 .collect(),
483 Some(_) => {
484 warnings.push(format!(
485 "settings: `extends` in {} must be an array of paths; ignored",
486 user_file.display()
487 ));
488 Vec::new()
489 }
490 }
491}
492
493fn validate_skills_extra(
496 entries: &[String],
497 home_for_tilde: Option<&Path>,
498 base: &Path,
499 warnings: &mut Vec<String>,
500) -> Vec<SkillsExtraEntry> {
501 entries
502 .iter()
503 .filter_map(|entry| {
504 let path = expand_tilde(entry, home_for_tilde, base);
505 if path.join("SKILL.md").is_file() {
506 return Some(SkillsExtraEntry::Skill(path));
507 }
508 let trimmed = entry.trim_end_matches('/');
509 if trimmed.ends_with("skills") {
510 return Some(SkillsExtraEntry::Folder(path));
511 }
512 warnings.push(format!(
513 "settings: skills.extra entry `{entry}` is neither a skill (no SKILL.md) \
514 nor a skills folder (path must end in `skills`); ignored"
515 ));
516 None
517 })
518 .collect()
519}
520
521fn expand_tilde(raw: &str, home: Option<&Path>, base: &Path) -> PathBuf {
524 if let Some(rest) = raw.strip_prefix("~/")
525 && let Some(home) = home
526 {
527 return home.join(rest);
528 }
529 if raw == "~"
530 && let Some(home) = home
531 {
532 return home.to_path_buf();
533 }
534 let path = PathBuf::from(raw);
535 if path.is_absolute() {
536 path
537 } else {
538 base.join(path)
539 }
540}
541
542fn strip_key(mut value: Value, key: &str) -> Value {
544 if let Value::Object(map) = &mut value {
545 map.remove(key);
546 }
547 value
548}
549
550fn merge_values(base: &mut Value, overlay: Value) {
553 match (base, overlay) {
554 (Value::Object(base_map), Value::Object(overlay_map)) => {
555 for (key, overlay_value) in overlay_map {
556 match base_map.get_mut(&key) {
557 Some(base_value) => merge_values(base_value, overlay_value),
558 None => {
559 base_map.insert(key, overlay_value);
560 }
561 }
562 }
563 }
564 (Value::Array(base_items), Value::Array(overlay_items)) => {
565 for item in overlay_items {
566 if !base_items.contains(&item) {
567 base_items.push(item);
568 }
569 }
570 }
571 (base_slot, overlay_value) => *base_slot = overlay_value,
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use serde_json::json;
579 use std::fs;
580
581 struct Fixture {
583 _guards: Vec<tempfile::TempDir>,
584 home: PathBuf, user_dir: PathBuf, repo: PathBuf, }
588
589 fn fixture() -> Fixture {
590 let home_guard = tempfile::tempdir().unwrap();
591 let repo_guard = tempfile::tempdir().unwrap();
592 let home = fs::canonicalize(home_guard.path()).unwrap();
593 let repo = fs::canonicalize(repo_guard.path()).unwrap();
594 let user_dir = home.join(".locode");
595 fs::create_dir_all(&user_dir).unwrap();
596 fs::create_dir(repo.join(".git")).unwrap();
597 Fixture {
598 _guards: vec![home_guard, repo_guard],
599 home,
600 user_dir,
601 repo,
602 }
603 }
604
605 fn read_json(path: &Path) -> Value {
608 serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
609 }
610
611 #[test]
614 fn updating_a_key_preserves_the_rest_of_the_file() {
615 let dir = tempfile::tempdir().unwrap();
616 let path = dir.path().join("settings.json");
617 write(
618 &path,
619 &json!({
620 "harness": "claude",
621 "model": "claude-sonnet-5",
622 "skills": { "extra": ["~/x"] },
623 "some_future_key": 42,
624 }),
625 );
626
627 let written = update_user_setting(dir.path(), "model", json!("claude-opus-5")).unwrap();
628 assert_eq!(written, path);
629 let got = read_json(&path);
630 assert_eq!(got["model"], "claude-opus-5");
631 assert_eq!(got["harness"], "claude", "untouched");
632 assert_eq!(got["skills"]["extra"][0], "~/x", "nested value untouched");
633 assert_eq!(got["some_future_key"], 42, "unknown key survives");
634 }
635
636 #[test]
638 fn a_null_value_removes_the_key() {
639 let dir = tempfile::tempdir().unwrap();
640 write(
641 &dir.path().join("settings.json"),
642 &json!({"model": "x", "harness": "claude"}),
643 );
644 update_user_setting(dir.path(), "model", Value::Null).unwrap();
645 let got = read_json(&dir.path().join("settings.json"));
646 assert!(got.get("model").is_none(), "{got}");
647 assert_eq!(got["harness"], "claude");
648 }
649
650 #[test]
653 fn a_missing_or_corrupt_file_is_replaced_not_merged() {
654 let dir = tempfile::tempdir().unwrap();
655 update_user_setting(dir.path(), "model", json!("m1")).unwrap();
656 assert_eq!(read_json(&dir.path().join("settings.json"))["model"], "m1");
657
658 fs::write(dir.path().join("settings.json"), "{ not json").unwrap();
659 update_user_setting(dir.path(), "model", json!("m2")).unwrap();
660 assert_eq!(read_json(&dir.path().join("settings.json"))["model"], "m2");
661 }
662
663 #[test]
667 fn the_write_is_atomic_and_leaves_no_temp_file_behind() {
668 let dir = tempfile::tempdir().unwrap();
669 let path = dir.path().join("settings.json");
670 write(&path, &json!({"model": "old"}));
671
672 let big: Vec<String> = (0..2000).map(|i| format!("entry-{i}")).collect();
675 update_user_setting(dir.path(), "skills", json!({ "extra": big })).unwrap();
676
677 let got = read_json(&path);
678 assert_eq!(got["model"], "old", "the untouched key survived");
679 assert_eq!(got["skills"]["extra"].as_array().unwrap().len(), 2000);
680
681 let leftovers: Vec<_> = fs::read_dir(dir.path())
682 .unwrap()
683 .filter_map(Result::ok)
684 .map(|e| e.file_name().to_string_lossy().to_string())
685 .filter(|n| n != "settings.json")
686 .collect();
687 assert!(leftovers.is_empty(), "temp files cleaned up: {leftovers:?}");
688 }
689
690 #[test]
692 fn the_written_model_is_what_the_loader_resolves() {
693 let f = fixture();
694 update_user_setting(&f.user_dir, "model", json!("claude-opus-5")).unwrap();
695 let load = load_settings_from(Some(&f.user_dir), &f.repo, Some(&f.home), None);
696 assert_eq!(load.settings.model.as_deref(), Some("claude-opus-5"));
697 }
698 fn write(path: &Path, value: &Value) {
699 if let Some(parent) = path.parent() {
700 fs::create_dir_all(parent).unwrap();
701 }
702 fs::write(path, serde_json::to_string_pretty(value).unwrap()).unwrap();
703 }
704
705 fn load(f: &Fixture, flag: Option<&str>) -> SettingsLoad {
706 load_settings_from(Some(&f.user_dir), &f.repo, Some(&f.home), flag)
707 }
708
709 #[test]
710 fn precedence_user_lt_extends_lt_project_lt_local_lt_flag() {
711 let f = fixture();
712 write(
713 &f.user_dir.join("settings.json"),
714 &json!({"model": "user", "harness": "user", "api_schema": "user",
715 "extends": ["team"]}),
716 );
717 write(
718 &f.user_dir.join("team/settings.json"),
719 &json!({"model": "team", "harness": "team"}),
720 );
721 write(
722 &f.repo.join(".locode/settings.json"),
723 &json!({"model": "project"}),
724 );
725 write(
726 &f.repo.join(".locode/settings.local.json"),
727 &json!({"model": "local"}),
728 );
729
730 let got = load(&f, None);
733 assert_eq!(got.settings.model.as_deref(), Some("local"));
734 assert_eq!(got.settings.harness.as_deref(), Some("team"));
735 assert_eq!(got.settings.api_schema.as_deref(), Some("user"));
736
737 let got = load(&f, Some(r#"{"model": "flag"}"#));
739 assert_eq!(got.settings.model.as_deref(), Some("flag"));
740 }
741
742 #[test]
743 fn extends_is_ordered_and_non_recursive() {
744 let f = fixture();
745 write(
746 &f.user_dir.join("settings.json"),
747 &json!({"extends": ["a", "b"]}),
748 );
749 write(&f.user_dir.join("a/settings.json"), &json!({"model": "a"}));
750 write(
751 &f.user_dir.join("b/settings.json"),
752 &json!({"model": "b", "extends": ["c"]}),
753 );
754 write(&f.user_dir.join("c/settings.json"), &json!({"model": "c"}));
755
756 let got = load(&f, None);
757 assert_eq!(got.settings.model.as_deref(), Some("b"));
759 assert!(
760 got.warnings.iter().any(|w| w.contains("nested `extends`")),
761 "{:?}",
762 got.warnings
763 );
764 assert_eq!(
765 got.extends_dirs,
766 vec![f.user_dir.join("a"), f.user_dir.join("b")],
767 "resolved dotfolders travel out in list order"
768 );
769 }
770
771 #[test]
774 fn extends_dotfolder_without_settings_json_is_silent() {
775 let f = fixture();
776 write(
777 &f.user_dir.join("settings.json"),
778 &json!({"extends": ["team"]}),
779 );
780 std::fs::create_dir_all(f.user_dir.join("team/skills")).unwrap();
781
782 let got = load(&f, None);
783 assert_eq!(got.extends_dirs, vec![f.user_dir.join("team")]);
784 assert!(
785 got.warnings.is_empty(),
786 "a dotfolder with no settings.json is normal: {:?}",
787 got.warnings
788 );
789 }
790
791 #[test]
795 fn extends_entry_pointing_at_a_file_is_refused_explicitly() {
796 let f = fixture();
797 write(
798 &f.user_dir.join("settings.json"),
799 &json!({"extends": ["team.json"]}),
800 );
801 write(&f.user_dir.join("team.json"), &json!({"model": "team"}));
802
803 let got = load(&f, None);
804 assert_eq!(got.settings.model, None, "the file must not be merged");
805 assert!(got.extends_dirs.is_empty());
806 let w = got.warnings.join(" | ");
807 assert!(w.contains("is a file"), "{w}");
808 assert!(w.contains("must be a locode directory"), "{w}");
809 }
810
811 #[test]
812 fn extends_missing_directory_warns_loudly() {
813 let f = fixture();
814 write(
815 &f.user_dir.join("settings.json"),
816 &json!({"extends": ["nope"]}),
817 );
818 let got = load(&f, None);
819 assert!(got.extends_dirs.is_empty());
820 assert!(
821 got.warnings.iter().any(|w| w.contains("not found")),
822 "{:?}",
823 got.warnings
824 );
825 }
826
827 #[test]
828 fn project_layers_cannot_set_denylisted_keys_or_extends() {
829 let f = fixture();
830 write(
831 &f.user_dir.join("settings.json"),
832 &json!({"api_schema": "user"}),
833 );
834 write(
835 &f.repo.join(".locode/settings.json"),
836 &json!({"api_schema": "evil", "extends": ["/tmp/evil.json"], "model": "ok"}),
837 );
838 let got = load(&f, None);
839 assert_eq!(
840 got.settings.api_schema.as_deref(),
841 Some("user"),
842 "denylisted"
843 );
844 assert_eq!(got.settings.model.as_deref(), Some("ok"), "other keys pass");
845 assert_eq!(
846 got.warnings
847 .iter()
848 .filter(|w| w.contains("project layers may not set"))
849 .count(),
850 2,
851 "{:?}",
852 got.warnings
853 );
854 }
855
856 #[test]
857 fn arrays_union_and_objects_deep_merge() {
858 let f = fixture();
859 write(
860 &f.user_dir.join("settings.json"),
861 &json!({"skills": {"extra": ["~/a-skills"]}, "instructions": {"root_stop_pattern": "u"}}),
862 );
863 write(
864 &f.repo.join(".locode/settings.json"),
865 &json!({"skills": {"extra": ["~/b-skills", "~/a-skills"]}}),
866 );
867 let got = load(&f, None);
868 assert_eq!(got.settings.root_stop_pattern.as_deref(), Some("u"));
870 let folders: Vec<_> = got
871 .settings
872 .skills_extra
873 .iter()
874 .map(|e| match e {
875 SkillsExtraEntry::Folder(p) | SkillsExtraEntry::Skill(p) => p.clone(),
876 })
877 .collect();
878 assert_eq!(
879 folders,
880 vec![f.home.join("a-skills"), f.home.join("b-skills")],
881 "union, first occurrence order, no duplicate"
882 );
883 }
884
885 #[test]
886 fn skills_extra_classifies_and_validates() {
887 let f = fixture();
888 let single = f.home.join("one-off");
889 fs::create_dir_all(&single).unwrap();
890 fs::write(single.join("SKILL.md"), "x").unwrap();
891 write(
892 &f.user_dir.join("settings.json"),
893 &json!({"skills": {"extra": ["~/one-off", "~/team-skills/", "~/random-dir"]}}),
894 );
895 let got = load(&f, None);
896 assert_eq!(
897 got.settings.skills_extra,
898 vec![
899 SkillsExtraEntry::Skill(single),
900 SkillsExtraEntry::Folder(f.home.join("team-skills/")),
901 ]
902 );
903 assert!(
904 got.warnings.iter().any(|w| w.contains("random-dir")),
905 "{:?}",
906 got.warnings
907 );
908 }
909
910 #[test]
911 fn malformed_layers_degrade_with_warnings() {
912 let f = fixture();
913 fs::write(f.user_dir.join("settings.json"), "{not json").unwrap();
914 write(
915 &f.repo.join(".locode/settings.json"),
916 &json!({"model": "p"}),
917 );
918 let got = load(&f, None);
919 assert_eq!(
920 got.settings.model.as_deref(),
921 Some("p"),
922 "good layers still load"
923 );
924 assert!(got.warnings.iter().any(|w| w.contains("invalid")));
925
926 write(
929 &f.user_dir.join("settings.json"),
930 &json!({"extends": ["missing.json"], "model": "u"}),
931 );
932 let got = load(&f, None);
933 assert_eq!(got.settings.model.as_deref(), Some("p"));
934 assert!(
935 got.warnings
936 .iter()
937 .any(|w| w.contains("missing.json") && w.contains("not found")),
938 "{:?}",
939 got.warnings
940 );
941 }
942
943 #[test]
944 fn unknown_keys_are_tolerated() {
945 let f = fixture();
946 write(
947 &f.user_dir.join("settings.json"),
948 &json!({"model": "m", "future_feature": {"x": 1}, "another": [1, 2]}),
949 );
950 let got = load(&f, None);
951 assert_eq!(got.settings.model.as_deref(), Some("m"));
952 assert!(got.warnings.is_empty(), "{:?}", got.warnings);
953 }
954
955 #[test]
956 fn no_git_root_uses_cwd_dot_locode() {
957 let dir = tempfile::tempdir().unwrap();
959 let cwd = fs::canonicalize(dir.path()).unwrap();
960 write(
961 &cwd.join(".locode/settings.json"),
962 &json!({"model": "here"}),
963 );
964 let got = load_settings_from(None, &cwd, None, None);
965 assert_eq!(got.settings.model.as_deref(), Some("here"));
966 }
967
968 #[test]
969 fn scaffold_writes_current_defaults_once() {
970 let dir = tempfile::tempdir().unwrap();
971 let user_dir = dir.path().join(".locode");
972 let notice = scaffold_user_settings(&user_dir).expect("scaffolded");
974 assert!(notice.contains("settings.json"));
975 let path = user_dir.join("settings.json");
976 let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
977 assert_eq!(value["harness"], "claude");
978 assert_eq!(value["api_schema"], "anthropic");
979 assert_eq!(value["model"], "claude-sonnet-5");
980 assert_eq!(value["skills"]["extra"], serde_json::json!([]));
981 let cwd = tempfile::tempdir().unwrap();
984 let got = load_settings_from(Some(&user_dir), cwd.path(), None, None);
985 assert_eq!(got.settings.harness.as_deref(), Some("claude"));
986 assert_eq!(got.settings.model.as_deref(), Some("claude-sonnet-5"));
987 assert!(got.warnings.is_empty(), "{:?}", got.warnings);
988 fs::write(&path, r#"{"harness":"claude"}"#).unwrap();
990 assert!(scaffold_user_settings(&user_dir).is_none());
991 let kept: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
992 assert_eq!(kept["harness"], "claude", "existing file untouched");
993 }
994
995 #[test]
996 fn invalid_root_stop_pattern_warns_but_survives() {
997 let f = fixture();
998 write(
999 &f.user_dir.join("settings.json"),
1000 &json!({"instructions": {"root_stop_pattern": "[bad"}, "model": "m"}),
1001 );
1002 let got = load(&f, None);
1003 assert_eq!(got.settings.model.as_deref(), Some("m"));
1004 assert_eq!(got.settings.root_stop_pattern.as_deref(), Some("[bad"));
1005 assert!(
1006 got.warnings.iter().any(|w| w.contains("root_stop_pattern")),
1007 "{:?}",
1008 got.warnings
1009 );
1010 }
1011
1012 #[test]
1013 fn inline_flag_json_and_non_object_rejection() {
1014 let f = fixture();
1015 let got = load(&f, Some(r#"{"harness": "codex"}"#));
1016 assert_eq!(got.settings.harness.as_deref(), Some("codex"));
1017 let got = load(&f, Some("[1,2]"));
1018 assert!(got.warnings.iter().any(|w| w.contains("JSON object")));
1019 }
1020}