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 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
211pub fn update_user_setting(user_dir: &Path, key: &str, value: Value) -> Result<PathBuf, String> {
231 let path = user_dir.join("settings.json");
232 let mut root = match std::fs::read_to_string(&path) {
236 Ok(text) => {
237 serde_json::from_str::<Value>(&text).unwrap_or_else(|_| Value::Object(Map::new()))
238 }
239 Err(_) => Value::Object(Map::new()),
240 };
241 if !root.is_object() {
242 root = Value::Object(Map::new());
243 }
244 let Some(object) = root.as_object_mut() else {
245 unreachable!("just forced to an object");
246 };
247 match value {
248 Value::Null => {
251 object.remove(key);
252 }
253 value => {
254 object.insert(key.to_string(), value);
255 }
256 }
257 let text = serde_json::to_string_pretty(&root).map_err(|e| format!("serialize: {e}"))? + "\n";
258 crate::trace::create_dir_private(user_dir)?;
259 write_atomically(&path, text.as_bytes())?;
260 Ok(path)
261}
262
263fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
268 let dir = path.parent().unwrap_or(Path::new("."));
269 let name = path.file_name().map_or_else(
270 || std::ffi::OsString::from("settings.json"),
271 std::ffi::OsStr::to_os_string,
272 );
273 let mut temp_name = name;
274 temp_name.push(format!(".tmp.{}", std::process::id()));
275 let temp = dir.join(temp_name);
276
277 let write = || -> std::io::Result<()> {
278 let mut file = std::fs::File::create(&temp)?;
279 std::io::Write::write_all(&mut file, bytes)?;
280 file.sync_all()?;
283 Ok(())
284 };
285 if let Err(e) = write() {
286 let _ = std::fs::remove_file(&temp);
287 return Err(format!("write {}: {e}", temp.display()));
288 }
289 std::fs::rename(&temp, path).map_err(|e| {
290 let _ = std::fs::remove_file(&temp);
291 format!("replace {}: {e}", path.display())
292 })
293}
294
295fn scaffold_user_settings(user_dir: &Path) -> Option<String> {
302 let path = user_dir.join("settings.json");
303 if path.exists() {
304 return None;
305 }
306 let body = serde_json::json!({
309 "api_schema": "anthropic",
310 "extends": [],
311 "harness": "claude",
312 "instructions": { "root_stop_pattern": Value::Null },
313 "model": "claude-sonnet-5",
314 "skills": { "extra": [] },
315 });
316 let text = serde_json::to_string_pretty(&body).ok()? + "\n";
317 crate::trace::create_dir_private(user_dir).ok()?;
318 let mut file = std::fs::OpenOptions::new()
319 .write(true)
320 .create_new(true)
321 .open(&path)
322 .ok()?;
323 std::io::Write::write_all(&mut file, text.as_bytes()).ok()?;
324 Some(format!(
325 "settings: created {} with the current defaults",
326 path.display()
327 ))
328}
329
330#[derive(Debug, Default, Deserialize)]
333struct RawSettings {
334 model: Option<String>,
335 api_schema: Option<String>,
336 harness: Option<String>,
337 #[serde(default)]
338 instructions: RawInstructions,
339 #[serde(default)]
340 skills: RawSkills,
341}
342
343#[derive(Debug, Default, Deserialize)]
344struct RawInstructions {
345 root_stop_pattern: Option<String>,
346}
347
348#[derive(Debug, Default, Deserialize)]
349struct RawSkills {
350 #[serde(default)]
351 extra: Vec<String>,
352}
353
354fn read_layer(path: &Path, warnings: &mut Vec<String>) -> Option<Value> {
357 if !path.is_file() {
358 return None;
359 }
360 let text = match std::fs::read_to_string(path) {
361 Ok(text) => text,
362 Err(e) => {
363 warnings.push(format!(
364 "settings: {} unreadable ({e}); skipped",
365 path.display()
366 ));
367 return None;
368 }
369 };
370 match serde_json::from_str::<Value>(&text) {
371 Ok(value) if value.is_object() => Some(value),
372 Ok(_) => {
373 warnings.push(format!(
374 "settings: {} is not a JSON object; skipped",
375 path.display()
376 ));
377 None
378 }
379 Err(e) => {
380 warnings.push(format!(
381 "settings: {} invalid ({e}); skipped",
382 path.display()
383 ));
384 None
385 }
386 }
387}
388
389fn merge_user_and_extends_layers(
397 user_dir: Option<&Path>,
398 home_for_tilde: Option<&Path>,
399 merged: &mut Value,
400 extends_dirs: &mut Vec<PathBuf>,
401 warnings: &mut Vec<String>,
402) {
403 let Some(user_dir) = user_dir else { return };
404 let user_file = user_dir.join("settings.json");
405 let Some(user_value) = read_layer(&user_file, warnings) else {
406 return;
407 };
408 let extends = extract_extends(&user_value, &user_file, warnings);
409 merge_values(merged, strip_key(user_value, EXTENDS_KEY));
410
411 for entry in extends {
412 let dir = expand_tilde(&entry, home_for_tilde, user_dir);
413 if dir.is_file() {
419 warnings.push(format!(
420 "settings: `extends` entry {} is a file; it must be a locode directory \
421 (point it at the folder holding settings.json)",
422 dir.display()
423 ));
424 continue;
425 }
426 if !dir.is_dir() {
429 warnings.push(format!(
430 "settings: extends directory {} not found; skipped",
431 dir.display()
432 ));
433 continue;
434 }
435 extends_dirs.push(dir.clone());
436
437 let path = dir.join("settings.json");
439 if !path.is_file() {
440 continue;
441 }
442 if let Some(mut value) = read_layer(&path, warnings) {
443 if value.get(EXTENDS_KEY).is_some() {
445 warnings.push(format!(
446 "settings: nested `extends` in {} ignored (extends does not recurse)",
447 path.display()
448 ));
449 value = strip_key(value, EXTENDS_KEY);
450 }
451 merge_values(merged, value);
452 }
453 }
454}
455
456fn extract_extends(
458 user_value: &Value,
459 user_file: &Path,
460 warnings: &mut Vec<String>,
461) -> Vec<String> {
462 match user_value.get(EXTENDS_KEY) {
463 None => Vec::new(),
464 Some(Value::Array(items)) => items
465 .iter()
466 .filter_map(|item| match item {
467 Value::String(s) => Some(s.clone()),
468 other => {
469 warnings.push(format!(
470 "settings: non-string `extends` entry {other} in {} ignored",
471 user_file.display()
472 ));
473 None
474 }
475 })
476 .collect(),
477 Some(_) => {
478 warnings.push(format!(
479 "settings: `extends` in {} must be an array of paths; ignored",
480 user_file.display()
481 ));
482 Vec::new()
483 }
484 }
485}
486
487fn validate_skills_extra(
490 entries: &[String],
491 home_for_tilde: Option<&Path>,
492 base: &Path,
493 warnings: &mut Vec<String>,
494) -> Vec<SkillsExtraEntry> {
495 entries
496 .iter()
497 .filter_map(|entry| {
498 let path = expand_tilde(entry, home_for_tilde, base);
499 if path.join("SKILL.md").is_file() {
500 return Some(SkillsExtraEntry::Skill(path));
501 }
502 let trimmed = entry.trim_end_matches('/');
503 if trimmed.ends_with("skills") {
504 return Some(SkillsExtraEntry::Folder(path));
505 }
506 warnings.push(format!(
507 "settings: skills.extra entry `{entry}` is neither a skill (no SKILL.md) \
508 nor a skills folder (path must end in `skills`); ignored"
509 ));
510 None
511 })
512 .collect()
513}
514
515fn expand_tilde(raw: &str, home: Option<&Path>, base: &Path) -> PathBuf {
518 if let Some(rest) = raw.strip_prefix("~/")
519 && let Some(home) = home
520 {
521 return home.join(rest);
522 }
523 if raw == "~"
524 && let Some(home) = home
525 {
526 return home.to_path_buf();
527 }
528 let path = PathBuf::from(raw);
529 if path.is_absolute() {
530 path
531 } else {
532 base.join(path)
533 }
534}
535
536fn strip_key(mut value: Value, key: &str) -> Value {
538 if let Value::Object(map) = &mut value {
539 map.remove(key);
540 }
541 value
542}
543
544fn merge_values(base: &mut Value, overlay: Value) {
547 match (base, overlay) {
548 (Value::Object(base_map), Value::Object(overlay_map)) => {
549 for (key, overlay_value) in overlay_map {
550 match base_map.get_mut(&key) {
551 Some(base_value) => merge_values(base_value, overlay_value),
552 None => {
553 base_map.insert(key, overlay_value);
554 }
555 }
556 }
557 }
558 (Value::Array(base_items), Value::Array(overlay_items)) => {
559 for item in overlay_items {
560 if !base_items.contains(&item) {
561 base_items.push(item);
562 }
563 }
564 }
565 (base_slot, overlay_value) => *base_slot = overlay_value,
566 }
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572 use serde_json::json;
573 use std::fs;
574
575 struct Fixture {
577 _guards: Vec<tempfile::TempDir>,
578 home: PathBuf, user_dir: PathBuf, repo: PathBuf, }
582
583 fn fixture() -> Fixture {
584 let home_guard = tempfile::tempdir().unwrap();
585 let repo_guard = tempfile::tempdir().unwrap();
586 let home = fs::canonicalize(home_guard.path()).unwrap();
587 let repo = fs::canonicalize(repo_guard.path()).unwrap();
588 let user_dir = home.join(".locode");
589 fs::create_dir_all(&user_dir).unwrap();
590 fs::create_dir(repo.join(".git")).unwrap();
591 Fixture {
592 _guards: vec![home_guard, repo_guard],
593 home,
594 user_dir,
595 repo,
596 }
597 }
598
599 fn read_json(path: &Path) -> Value {
602 serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
603 }
604
605 #[test]
608 fn updating_a_key_preserves_the_rest_of_the_file() {
609 let dir = tempfile::tempdir().unwrap();
610 let path = dir.path().join("settings.json");
611 write(
612 &path,
613 &json!({
614 "harness": "claude",
615 "model": "claude-sonnet-5",
616 "skills": { "extra": ["~/x"] },
617 "some_future_key": 42,
618 }),
619 );
620
621 let written = update_user_setting(dir.path(), "model", json!("claude-opus-5")).unwrap();
622 assert_eq!(written, path);
623 let got = read_json(&path);
624 assert_eq!(got["model"], "claude-opus-5");
625 assert_eq!(got["harness"], "claude", "untouched");
626 assert_eq!(got["skills"]["extra"][0], "~/x", "nested value untouched");
627 assert_eq!(got["some_future_key"], 42, "unknown key survives");
628 }
629
630 #[test]
632 fn a_null_value_removes_the_key() {
633 let dir = tempfile::tempdir().unwrap();
634 write(
635 &dir.path().join("settings.json"),
636 &json!({"model": "x", "harness": "claude"}),
637 );
638 update_user_setting(dir.path(), "model", Value::Null).unwrap();
639 let got = read_json(&dir.path().join("settings.json"));
640 assert!(got.get("model").is_none(), "{got}");
641 assert_eq!(got["harness"], "claude");
642 }
643
644 #[test]
647 fn a_missing_or_corrupt_file_is_replaced_not_merged() {
648 let dir = tempfile::tempdir().unwrap();
649 update_user_setting(dir.path(), "model", json!("m1")).unwrap();
650 assert_eq!(read_json(&dir.path().join("settings.json"))["model"], "m1");
651
652 fs::write(dir.path().join("settings.json"), "{ not json").unwrap();
653 update_user_setting(dir.path(), "model", json!("m2")).unwrap();
654 assert_eq!(read_json(&dir.path().join("settings.json"))["model"], "m2");
655 }
656
657 #[test]
661 fn the_write_is_atomic_and_leaves_no_temp_file_behind() {
662 let dir = tempfile::tempdir().unwrap();
663 let path = dir.path().join("settings.json");
664 write(&path, &json!({"model": "old"}));
665
666 let big: Vec<String> = (0..2000).map(|i| format!("entry-{i}")).collect();
669 update_user_setting(dir.path(), "skills", json!({ "extra": big })).unwrap();
670
671 let got = read_json(&path);
672 assert_eq!(got["model"], "old", "the untouched key survived");
673 assert_eq!(got["skills"]["extra"].as_array().unwrap().len(), 2000);
674
675 let leftovers: Vec<_> = fs::read_dir(dir.path())
676 .unwrap()
677 .filter_map(Result::ok)
678 .map(|e| e.file_name().to_string_lossy().to_string())
679 .filter(|n| n != "settings.json")
680 .collect();
681 assert!(leftovers.is_empty(), "temp files cleaned up: {leftovers:?}");
682 }
683
684 #[test]
686 fn the_written_model_is_what_the_loader_resolves() {
687 let f = fixture();
688 update_user_setting(&f.user_dir, "model", json!("claude-opus-5")).unwrap();
689 let load = load_settings_from(Some(&f.user_dir), &f.repo, Some(&f.home), None);
690 assert_eq!(load.settings.model.as_deref(), Some("claude-opus-5"));
691 }
692 fn write(path: &Path, value: &Value) {
693 if let Some(parent) = path.parent() {
694 fs::create_dir_all(parent).unwrap();
695 }
696 fs::write(path, serde_json::to_string_pretty(value).unwrap()).unwrap();
697 }
698
699 fn load(f: &Fixture, flag: Option<&str>) -> SettingsLoad {
700 load_settings_from(Some(&f.user_dir), &f.repo, Some(&f.home), flag)
701 }
702
703 #[test]
704 fn precedence_user_lt_extends_lt_project_lt_local_lt_flag() {
705 let f = fixture();
706 write(
707 &f.user_dir.join("settings.json"),
708 &json!({"model": "user", "harness": "user", "api_schema": "user",
709 "extends": ["team"]}),
710 );
711 write(
712 &f.user_dir.join("team/settings.json"),
713 &json!({"model": "team", "harness": "team"}),
714 );
715 write(
716 &f.repo.join(".locode/settings.json"),
717 &json!({"model": "project"}),
718 );
719 write(
720 &f.repo.join(".locode/settings.local.json"),
721 &json!({"model": "local"}),
722 );
723
724 let got = load(&f, None);
727 assert_eq!(got.settings.model.as_deref(), Some("local"));
728 assert_eq!(got.settings.harness.as_deref(), Some("team"));
729 assert_eq!(got.settings.api_schema.as_deref(), Some("user"));
730
731 let got = load(&f, Some(r#"{"model": "flag"}"#));
733 assert_eq!(got.settings.model.as_deref(), Some("flag"));
734 }
735
736 #[test]
737 fn extends_is_ordered_and_non_recursive() {
738 let f = fixture();
739 write(
740 &f.user_dir.join("settings.json"),
741 &json!({"extends": ["a", "b"]}),
742 );
743 write(&f.user_dir.join("a/settings.json"), &json!({"model": "a"}));
744 write(
745 &f.user_dir.join("b/settings.json"),
746 &json!({"model": "b", "extends": ["c"]}),
747 );
748 write(&f.user_dir.join("c/settings.json"), &json!({"model": "c"}));
749
750 let got = load(&f, None);
751 assert_eq!(got.settings.model.as_deref(), Some("b"));
753 assert!(
754 got.warnings.iter().any(|w| w.contains("nested `extends`")),
755 "{:?}",
756 got.warnings
757 );
758 assert_eq!(
759 got.extends_dirs,
760 vec![f.user_dir.join("a"), f.user_dir.join("b")],
761 "resolved dotfolders travel out in list order"
762 );
763 }
764
765 #[test]
768 fn extends_dotfolder_without_settings_json_is_silent() {
769 let f = fixture();
770 write(
771 &f.user_dir.join("settings.json"),
772 &json!({"extends": ["team"]}),
773 );
774 std::fs::create_dir_all(f.user_dir.join("team/skills")).unwrap();
775
776 let got = load(&f, None);
777 assert_eq!(got.extends_dirs, vec![f.user_dir.join("team")]);
778 assert!(
779 got.warnings.is_empty(),
780 "a dotfolder with no settings.json is normal: {:?}",
781 got.warnings
782 );
783 }
784
785 #[test]
789 fn extends_entry_pointing_at_a_file_is_refused_explicitly() {
790 let f = fixture();
791 write(
792 &f.user_dir.join("settings.json"),
793 &json!({"extends": ["team.json"]}),
794 );
795 write(&f.user_dir.join("team.json"), &json!({"model": "team"}));
796
797 let got = load(&f, None);
798 assert_eq!(got.settings.model, None, "the file must not be merged");
799 assert!(got.extends_dirs.is_empty());
800 let w = got.warnings.join(" | ");
801 assert!(w.contains("is a file"), "{w}");
802 assert!(w.contains("must be a locode directory"), "{w}");
803 }
804
805 #[test]
806 fn extends_missing_directory_warns_loudly() {
807 let f = fixture();
808 write(
809 &f.user_dir.join("settings.json"),
810 &json!({"extends": ["nope"]}),
811 );
812 let got = load(&f, None);
813 assert!(got.extends_dirs.is_empty());
814 assert!(
815 got.warnings.iter().any(|w| w.contains("not found")),
816 "{:?}",
817 got.warnings
818 );
819 }
820
821 #[test]
822 fn project_layers_cannot_set_denylisted_keys_or_extends() {
823 let f = fixture();
824 write(
825 &f.user_dir.join("settings.json"),
826 &json!({"api_schema": "user"}),
827 );
828 write(
829 &f.repo.join(".locode/settings.json"),
830 &json!({"api_schema": "evil", "extends": ["/tmp/evil.json"], "model": "ok"}),
831 );
832 let got = load(&f, None);
833 assert_eq!(
834 got.settings.api_schema.as_deref(),
835 Some("user"),
836 "denylisted"
837 );
838 assert_eq!(got.settings.model.as_deref(), Some("ok"), "other keys pass");
839 assert_eq!(
840 got.warnings
841 .iter()
842 .filter(|w| w.contains("project layers may not set"))
843 .count(),
844 2,
845 "{:?}",
846 got.warnings
847 );
848 }
849
850 #[test]
851 fn arrays_union_and_objects_deep_merge() {
852 let f = fixture();
853 write(
854 &f.user_dir.join("settings.json"),
855 &json!({"skills": {"extra": ["~/a-skills"]}, "instructions": {"root_stop_pattern": "u"}}),
856 );
857 write(
858 &f.repo.join(".locode/settings.json"),
859 &json!({"skills": {"extra": ["~/b-skills", "~/a-skills"]}}),
860 );
861 let got = load(&f, None);
862 assert_eq!(got.settings.root_stop_pattern.as_deref(), Some("u"));
864 let folders: Vec<_> = got
865 .settings
866 .skills_extra
867 .iter()
868 .map(|e| match e {
869 SkillsExtraEntry::Folder(p) | SkillsExtraEntry::Skill(p) => p.clone(),
870 })
871 .collect();
872 assert_eq!(
873 folders,
874 vec![f.home.join("a-skills"), f.home.join("b-skills")],
875 "union, first occurrence order, no duplicate"
876 );
877 }
878
879 #[test]
880 fn skills_extra_classifies_and_validates() {
881 let f = fixture();
882 let single = f.home.join("one-off");
883 fs::create_dir_all(&single).unwrap();
884 fs::write(single.join("SKILL.md"), "x").unwrap();
885 write(
886 &f.user_dir.join("settings.json"),
887 &json!({"skills": {"extra": ["~/one-off", "~/team-skills/", "~/random-dir"]}}),
888 );
889 let got = load(&f, None);
890 assert_eq!(
891 got.settings.skills_extra,
892 vec![
893 SkillsExtraEntry::Skill(single),
894 SkillsExtraEntry::Folder(f.home.join("team-skills/")),
895 ]
896 );
897 assert!(
898 got.warnings.iter().any(|w| w.contains("random-dir")),
899 "{:?}",
900 got.warnings
901 );
902 }
903
904 #[test]
905 fn malformed_layers_degrade_with_warnings() {
906 let f = fixture();
907 fs::write(f.user_dir.join("settings.json"), "{not json").unwrap();
908 write(
909 &f.repo.join(".locode/settings.json"),
910 &json!({"model": "p"}),
911 );
912 let got = load(&f, None);
913 assert_eq!(
914 got.settings.model.as_deref(),
915 Some("p"),
916 "good layers still load"
917 );
918 assert!(got.warnings.iter().any(|w| w.contains("invalid")));
919
920 write(
923 &f.user_dir.join("settings.json"),
924 &json!({"extends": ["missing.json"], "model": "u"}),
925 );
926 let got = load(&f, None);
927 assert_eq!(got.settings.model.as_deref(), Some("p"));
928 assert!(
929 got.warnings
930 .iter()
931 .any(|w| w.contains("missing.json") && w.contains("not found")),
932 "{:?}",
933 got.warnings
934 );
935 }
936
937 #[test]
938 fn unknown_keys_are_tolerated() {
939 let f = fixture();
940 write(
941 &f.user_dir.join("settings.json"),
942 &json!({"model": "m", "future_feature": {"x": 1}, "another": [1, 2]}),
943 );
944 let got = load(&f, None);
945 assert_eq!(got.settings.model.as_deref(), Some("m"));
946 assert!(got.warnings.is_empty(), "{:?}", got.warnings);
947 }
948
949 #[test]
950 fn no_git_root_uses_cwd_dot_locode() {
951 let dir = tempfile::tempdir().unwrap();
953 let cwd = fs::canonicalize(dir.path()).unwrap();
954 write(
955 &cwd.join(".locode/settings.json"),
956 &json!({"model": "here"}),
957 );
958 let got = load_settings_from(None, &cwd, None, None);
959 assert_eq!(got.settings.model.as_deref(), Some("here"));
960 }
961
962 #[test]
963 fn scaffold_writes_current_defaults_once() {
964 let dir = tempfile::tempdir().unwrap();
965 let user_dir = dir.path().join(".locode");
966 let notice = scaffold_user_settings(&user_dir).expect("scaffolded");
968 assert!(notice.contains("settings.json"));
969 let path = user_dir.join("settings.json");
970 let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
971 assert_eq!(value["harness"], "claude");
972 assert_eq!(value["api_schema"], "anthropic");
973 assert_eq!(value["model"], "claude-sonnet-5");
974 assert_eq!(value["skills"]["extra"], serde_json::json!([]));
975 let cwd = tempfile::tempdir().unwrap();
978 let got = load_settings_from(Some(&user_dir), cwd.path(), None, None);
979 assert_eq!(got.settings.harness.as_deref(), Some("claude"));
980 assert_eq!(got.settings.model.as_deref(), Some("claude-sonnet-5"));
981 assert!(got.warnings.is_empty(), "{:?}", got.warnings);
982 fs::write(&path, r#"{"harness":"claude"}"#).unwrap();
984 assert!(scaffold_user_settings(&user_dir).is_none());
985 let kept: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
986 assert_eq!(kept["harness"], "claude", "existing file untouched");
987 }
988
989 #[test]
990 fn invalid_root_stop_pattern_warns_but_survives() {
991 let f = fixture();
992 write(
993 &f.user_dir.join("settings.json"),
994 &json!({"instructions": {"root_stop_pattern": "[bad"}, "model": "m"}),
995 );
996 let got = load(&f, None);
997 assert_eq!(got.settings.model.as_deref(), Some("m"));
998 assert_eq!(got.settings.root_stop_pattern.as_deref(), Some("[bad"));
999 assert!(
1000 got.warnings.iter().any(|w| w.contains("root_stop_pattern")),
1001 "{:?}",
1002 got.warnings
1003 );
1004 }
1005
1006 #[test]
1007 fn inline_flag_json_and_non_object_rejection() {
1008 let f = fixture();
1009 let got = load(&f, Some(r#"{"harness": "codex"}"#));
1010 assert_eq!(got.settings.harness.as_deref(), Some("codex"));
1011 let got = load(&f, Some("[1,2]"));
1012 assert!(got.warnings.iter().any(|w| w.contains("JSON object")));
1013 }
1014}