1use std::path::{Path, PathBuf};
22
23use serde::Deserialize;
24use serde_json::Value;
25
26use crate::instructions::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 warnings: Vec<String>,
69}
70
71#[must_use]
77pub fn load_settings(cwd: &Path, flag: Option<&str>) -> SettingsLoad {
78 let mut warnings = Vec::new();
79 let user_dir = match crate::home::locode_home() {
80 Ok(dir) => Some(dir),
81 Err(e) => {
82 warnings.push(format!("settings: {e}; user layer skipped"));
83 None
84 }
85 };
86 if let Some(dir) = &user_dir
90 && let Some(notice) = scaffold_user_settings(dir)
91 {
92 warnings.push(notice);
93 }
94 let home_for_tilde = std::env::var_os("HOME")
95 .filter(|h| !h.is_empty())
96 .map(PathBuf::from);
97 let mut load = load_settings_from(user_dir.as_deref(), cwd, home_for_tilde.as_deref(), flag);
98 warnings.append(&mut load.warnings);
99 SettingsLoad {
100 settings: load.settings,
101 warnings,
102 }
103}
104
105#[must_use]
108pub fn load_settings_from(
109 user_dir: Option<&Path>,
110 cwd: &Path,
111 home_for_tilde: Option<&Path>,
112 flag: Option<&str>,
113) -> SettingsLoad {
114 let mut warnings: Vec<String> = Vec::new();
115 let mut merged = Value::Object(serde_json::Map::new());
116
117 if let Some(user_dir) = user_dir {
119 let user_file = user_dir.join("settings.json");
120 if let Some(user_value) = read_layer(&user_file, &mut warnings) {
121 let extends = extract_extends(&user_value, &user_file, &mut warnings);
122 merge_values(&mut merged, strip_key(user_value, EXTENDS_KEY));
123 for entry in extends {
124 let path = expand_tilde(&entry, home_for_tilde, user_dir);
125 if !path.is_file() {
128 warnings.push(format!(
129 "settings: extends file {} not found; skipped",
130 path.display()
131 ));
132 continue;
133 }
134 if let Some(mut value) = read_layer(&path, &mut warnings) {
135 if value.get(EXTENDS_KEY).is_some() {
137 warnings.push(format!(
138 "settings: nested `extends` in {} ignored (extends does not recurse)",
139 path.display()
140 ));
141 value = strip_key(value, EXTENDS_KEY);
142 }
143 merge_values(&mut merged, value);
144 }
145 }
146 }
147 }
148
149 let root = find_root_from_markers(cwd, &[".git".to_string()], None);
151 for name in ["settings.json", "settings.local.json"] {
152 let path = root.join(".locode").join(name);
153 if let Some(mut value) = read_layer(&path, &mut warnings) {
154 for key in PROJECT_DENYLIST.iter().copied().chain([EXTENDS_KEY]) {
155 if value.get(key).is_some() {
156 warnings.push(format!(
157 "settings: `{key}` in {} ignored (project layers may not set it, ADR-0024 §1.3)",
158 path.display()
159 ));
160 value = strip_key(value, key);
161 }
162 }
163 merge_values(&mut merged, value);
164 }
165 }
166
167 if let Some(flag) = flag {
169 let parsed = if matches!(flag.trim_start().chars().next(), Some('{' | '[')) {
172 serde_json::from_str::<Value>(flag)
173 .map_err(|e| format!("settings: --settings inline JSON: {e}"))
174 } else {
175 let path = expand_tilde(flag, home_for_tilde, cwd);
176 std::fs::read_to_string(&path)
177 .map_err(|e| format!("settings: --settings {}: {e}", path.display()))
178 .and_then(|text| {
179 serde_json::from_str::<Value>(&text)
180 .map_err(|e| format!("settings: --settings {}: {e}", path.display()))
181 })
182 };
183 match parsed {
184 Ok(value) if value.is_object() => merge_values(&mut merged, value),
185 Ok(_) => warnings.push("settings: --settings must be a JSON object".to_string()),
186 Err(e) => warnings.push(e),
187 }
188 }
189
190 let raw: RawSettings = serde_json::from_value(merged).unwrap_or_else(|e| {
192 warnings.push(format!("settings: merged settings did not decode: {e}"));
193 RawSettings::default()
194 });
195 if let Some(pattern) = &raw.instructions.root_stop_pattern
196 && let Err(e) = regex::Regex::new(pattern)
197 {
198 warnings.push(format!(
199 "settings: instructions.root_stop_pattern is not a valid regex ({e}); \
200 root detection will ignore it"
201 ));
202 }
203 let skills_extra = validate_skills_extra(
204 &raw.skills.extra,
205 home_for_tilde,
206 user_dir.unwrap_or(cwd),
207 &mut warnings,
208 );
209 SettingsLoad {
210 settings: Settings {
211 model: raw.model,
212 api_schema: raw.api_schema,
213 harness: raw.harness,
214 root_stop_pattern: raw.instructions.root_stop_pattern,
215 skills_extra,
216 },
217 warnings,
218 }
219}
220
221fn scaffold_user_settings(user_dir: &Path) -> Option<String> {
228 let path = user_dir.join("settings.json");
229 if path.exists() {
230 return None;
231 }
232 let body = serde_json::json!({
235 "api_schema": "anthropic",
236 "extends": [],
237 "harness": "claude",
238 "instructions": { "root_stop_pattern": Value::Null },
239 "model": "claude-sonnet-5",
240 "skills": { "extra": [] },
241 });
242 let text = serde_json::to_string_pretty(&body).ok()? + "\n";
243 crate::trace::create_dir_private(user_dir).ok()?;
244 let mut file = std::fs::OpenOptions::new()
245 .write(true)
246 .create_new(true)
247 .open(&path)
248 .ok()?;
249 std::io::Write::write_all(&mut file, text.as_bytes()).ok()?;
250 Some(format!(
251 "settings: created {} with the current defaults",
252 path.display()
253 ))
254}
255
256#[derive(Debug, Default, Deserialize)]
259struct RawSettings {
260 model: Option<String>,
261 api_schema: Option<String>,
262 harness: Option<String>,
263 #[serde(default)]
264 instructions: RawInstructions,
265 #[serde(default)]
266 skills: RawSkills,
267}
268
269#[derive(Debug, Default, Deserialize)]
270struct RawInstructions {
271 root_stop_pattern: Option<String>,
272}
273
274#[derive(Debug, Default, Deserialize)]
275struct RawSkills {
276 #[serde(default)]
277 extra: Vec<String>,
278}
279
280fn read_layer(path: &Path, warnings: &mut Vec<String>) -> Option<Value> {
283 if !path.is_file() {
284 return None;
285 }
286 let text = match std::fs::read_to_string(path) {
287 Ok(text) => text,
288 Err(e) => {
289 warnings.push(format!(
290 "settings: {} unreadable ({e}); skipped",
291 path.display()
292 ));
293 return None;
294 }
295 };
296 match serde_json::from_str::<Value>(&text) {
297 Ok(value) if value.is_object() => Some(value),
298 Ok(_) => {
299 warnings.push(format!(
300 "settings: {} is not a JSON object; skipped",
301 path.display()
302 ));
303 None
304 }
305 Err(e) => {
306 warnings.push(format!(
307 "settings: {} invalid ({e}); skipped",
308 path.display()
309 ));
310 None
311 }
312 }
313}
314
315fn extract_extends(
317 user_value: &Value,
318 user_file: &Path,
319 warnings: &mut Vec<String>,
320) -> Vec<String> {
321 match user_value.get(EXTENDS_KEY) {
322 None => Vec::new(),
323 Some(Value::Array(items)) => items
324 .iter()
325 .filter_map(|item| match item {
326 Value::String(s) => Some(s.clone()),
327 other => {
328 warnings.push(format!(
329 "settings: non-string `extends` entry {other} in {} ignored",
330 user_file.display()
331 ));
332 None
333 }
334 })
335 .collect(),
336 Some(_) => {
337 warnings.push(format!(
338 "settings: `extends` in {} must be an array of paths; ignored",
339 user_file.display()
340 ));
341 Vec::new()
342 }
343 }
344}
345
346fn validate_skills_extra(
349 entries: &[String],
350 home_for_tilde: Option<&Path>,
351 base: &Path,
352 warnings: &mut Vec<String>,
353) -> Vec<SkillsExtraEntry> {
354 entries
355 .iter()
356 .filter_map(|entry| {
357 let path = expand_tilde(entry, home_for_tilde, base);
358 if path.join("SKILL.md").is_file() {
359 return Some(SkillsExtraEntry::Skill(path));
360 }
361 let trimmed = entry.trim_end_matches('/');
362 if trimmed.ends_with("skills") {
363 return Some(SkillsExtraEntry::Folder(path));
364 }
365 warnings.push(format!(
366 "settings: skills.extra entry `{entry}` is neither a skill (no SKILL.md) \
367 nor a skills folder (path must end in `skills`); ignored"
368 ));
369 None
370 })
371 .collect()
372}
373
374fn expand_tilde(raw: &str, home: Option<&Path>, base: &Path) -> PathBuf {
377 if let Some(rest) = raw.strip_prefix("~/")
378 && let Some(home) = home
379 {
380 return home.join(rest);
381 }
382 if raw == "~"
383 && let Some(home) = home
384 {
385 return home.to_path_buf();
386 }
387 let path = PathBuf::from(raw);
388 if path.is_absolute() {
389 path
390 } else {
391 base.join(path)
392 }
393}
394
395fn strip_key(mut value: Value, key: &str) -> Value {
397 if let Value::Object(map) = &mut value {
398 map.remove(key);
399 }
400 value
401}
402
403fn merge_values(base: &mut Value, overlay: Value) {
406 match (base, overlay) {
407 (Value::Object(base_map), Value::Object(overlay_map)) => {
408 for (key, overlay_value) in overlay_map {
409 match base_map.get_mut(&key) {
410 Some(base_value) => merge_values(base_value, overlay_value),
411 None => {
412 base_map.insert(key, overlay_value);
413 }
414 }
415 }
416 }
417 (Value::Array(base_items), Value::Array(overlay_items)) => {
418 for item in overlay_items {
419 if !base_items.contains(&item) {
420 base_items.push(item);
421 }
422 }
423 }
424 (base_slot, overlay_value) => *base_slot = overlay_value,
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use serde_json::json;
432 use std::fs;
433
434 struct Fixture {
436 _guards: Vec<tempfile::TempDir>,
437 home: PathBuf, user_dir: PathBuf, repo: PathBuf, }
441
442 fn fixture() -> Fixture {
443 let home_guard = tempfile::tempdir().unwrap();
444 let repo_guard = tempfile::tempdir().unwrap();
445 let home = fs::canonicalize(home_guard.path()).unwrap();
446 let repo = fs::canonicalize(repo_guard.path()).unwrap();
447 let user_dir = home.join(".locode");
448 fs::create_dir_all(&user_dir).unwrap();
449 fs::create_dir(repo.join(".git")).unwrap();
450 Fixture {
451 _guards: vec![home_guard, repo_guard],
452 home,
453 user_dir,
454 repo,
455 }
456 }
457
458 fn write(path: &Path, value: &Value) {
459 if let Some(parent) = path.parent() {
460 fs::create_dir_all(parent).unwrap();
461 }
462 fs::write(path, serde_json::to_string_pretty(value).unwrap()).unwrap();
463 }
464
465 fn load(f: &Fixture, flag: Option<&str>) -> SettingsLoad {
466 load_settings_from(Some(&f.user_dir), &f.repo, Some(&f.home), flag)
467 }
468
469 #[test]
470 fn precedence_user_lt_extends_lt_project_lt_local_lt_flag() {
471 let f = fixture();
472 write(
473 &f.user_dir.join("settings.json"),
474 &json!({"model": "user", "harness": "user", "api_schema": "user",
475 "extends": ["team.json"]}),
476 );
477 write(
478 &f.user_dir.join("team.json"),
479 &json!({"model": "team", "harness": "team"}),
480 );
481 write(
482 &f.repo.join(".locode/settings.json"),
483 &json!({"model": "project"}),
484 );
485 write(
486 &f.repo.join(".locode/settings.local.json"),
487 &json!({"model": "local"}),
488 );
489
490 let got = load(&f, None);
493 assert_eq!(got.settings.model.as_deref(), Some("local"));
494 assert_eq!(got.settings.harness.as_deref(), Some("team"));
495 assert_eq!(got.settings.api_schema.as_deref(), Some("user"));
496
497 let got = load(&f, Some(r#"{"model": "flag"}"#));
499 assert_eq!(got.settings.model.as_deref(), Some("flag"));
500 }
501
502 #[test]
503 fn extends_is_ordered_and_non_recursive() {
504 let f = fixture();
505 write(
506 &f.user_dir.join("settings.json"),
507 &json!({"extends": ["a.json", "b.json"]}),
508 );
509 write(&f.user_dir.join("a.json"), &json!({"model": "a"}));
510 write(
511 &f.user_dir.join("b.json"),
512 &json!({"model": "b", "extends": ["c.json"]}),
513 );
514 write(&f.user_dir.join("c.json"), &json!({"model": "c"}));
515
516 let got = load(&f, None);
517 assert_eq!(got.settings.model.as_deref(), Some("b"));
519 assert!(
520 got.warnings.iter().any(|w| w.contains("nested `extends`")),
521 "{:?}",
522 got.warnings
523 );
524 }
525
526 #[test]
527 fn project_layers_cannot_set_denylisted_keys_or_extends() {
528 let f = fixture();
529 write(
530 &f.user_dir.join("settings.json"),
531 &json!({"api_schema": "user"}),
532 );
533 write(
534 &f.repo.join(".locode/settings.json"),
535 &json!({"api_schema": "evil", "extends": ["/tmp/evil.json"], "model": "ok"}),
536 );
537 let got = load(&f, None);
538 assert_eq!(
539 got.settings.api_schema.as_deref(),
540 Some("user"),
541 "denylisted"
542 );
543 assert_eq!(got.settings.model.as_deref(), Some("ok"), "other keys pass");
544 assert_eq!(
545 got.warnings
546 .iter()
547 .filter(|w| w.contains("project layers may not set"))
548 .count(),
549 2,
550 "{:?}",
551 got.warnings
552 );
553 }
554
555 #[test]
556 fn arrays_union_and_objects_deep_merge() {
557 let f = fixture();
558 write(
559 &f.user_dir.join("settings.json"),
560 &json!({"skills": {"extra": ["~/a-skills"]}, "instructions": {"root_stop_pattern": "u"}}),
561 );
562 write(
563 &f.repo.join(".locode/settings.json"),
564 &json!({"skills": {"extra": ["~/b-skills", "~/a-skills"]}}),
565 );
566 let got = load(&f, None);
567 assert_eq!(got.settings.root_stop_pattern.as_deref(), Some("u"));
569 let folders: Vec<_> = got
570 .settings
571 .skills_extra
572 .iter()
573 .map(|e| match e {
574 SkillsExtraEntry::Folder(p) | SkillsExtraEntry::Skill(p) => p.clone(),
575 })
576 .collect();
577 assert_eq!(
578 folders,
579 vec![f.home.join("a-skills"), f.home.join("b-skills")],
580 "union, first occurrence order, no duplicate"
581 );
582 }
583
584 #[test]
585 fn skills_extra_classifies_and_validates() {
586 let f = fixture();
587 let single = f.home.join("one-off");
588 fs::create_dir_all(&single).unwrap();
589 fs::write(single.join("SKILL.md"), "x").unwrap();
590 write(
591 &f.user_dir.join("settings.json"),
592 &json!({"skills": {"extra": ["~/one-off", "~/team-skills/", "~/random-dir"]}}),
593 );
594 let got = load(&f, None);
595 assert_eq!(
596 got.settings.skills_extra,
597 vec![
598 SkillsExtraEntry::Skill(single),
599 SkillsExtraEntry::Folder(f.home.join("team-skills/")),
600 ]
601 );
602 assert!(
603 got.warnings.iter().any(|w| w.contains("random-dir")),
604 "{:?}",
605 got.warnings
606 );
607 }
608
609 #[test]
610 fn malformed_layers_degrade_with_warnings() {
611 let f = fixture();
612 fs::write(f.user_dir.join("settings.json"), "{not json").unwrap();
613 write(
614 &f.repo.join(".locode/settings.json"),
615 &json!({"model": "p"}),
616 );
617 let got = load(&f, None);
618 assert_eq!(
619 got.settings.model.as_deref(),
620 Some("p"),
621 "good layers still load"
622 );
623 assert!(got.warnings.iter().any(|w| w.contains("invalid")));
624
625 write(
628 &f.user_dir.join("settings.json"),
629 &json!({"extends": ["missing.json"], "model": "u"}),
630 );
631 let got = load(&f, None);
632 assert_eq!(got.settings.model.as_deref(), Some("p"));
633 assert!(
634 got.warnings
635 .iter()
636 .any(|w| w.contains("missing.json") && w.contains("not found")),
637 "{:?}",
638 got.warnings
639 );
640 }
641
642 #[test]
643 fn unknown_keys_are_tolerated() {
644 let f = fixture();
645 write(
646 &f.user_dir.join("settings.json"),
647 &json!({"model": "m", "future_feature": {"x": 1}, "another": [1, 2]}),
648 );
649 let got = load(&f, None);
650 assert_eq!(got.settings.model.as_deref(), Some("m"));
651 assert!(got.warnings.is_empty(), "{:?}", got.warnings);
652 }
653
654 #[test]
655 fn no_git_root_uses_cwd_dot_locode() {
656 let dir = tempfile::tempdir().unwrap();
658 let cwd = fs::canonicalize(dir.path()).unwrap();
659 write(
660 &cwd.join(".locode/settings.json"),
661 &json!({"model": "here"}),
662 );
663 let got = load_settings_from(None, &cwd, None, None);
664 assert_eq!(got.settings.model.as_deref(), Some("here"));
665 }
666
667 #[test]
668 fn scaffold_writes_current_defaults_once() {
669 let dir = tempfile::tempdir().unwrap();
670 let user_dir = dir.path().join(".locode");
671 let notice = scaffold_user_settings(&user_dir).expect("scaffolded");
673 assert!(notice.contains("settings.json"));
674 let path = user_dir.join("settings.json");
675 let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
676 assert_eq!(value["harness"], "claude");
677 assert_eq!(value["api_schema"], "anthropic");
678 assert_eq!(value["model"], "claude-sonnet-5");
679 assert_eq!(value["skills"]["extra"], serde_json::json!([]));
680 let cwd = tempfile::tempdir().unwrap();
683 let got = load_settings_from(Some(&user_dir), cwd.path(), None, None);
684 assert_eq!(got.settings.harness.as_deref(), Some("claude"));
685 assert_eq!(got.settings.model.as_deref(), Some("claude-sonnet-5"));
686 assert!(got.warnings.is_empty(), "{:?}", got.warnings);
687 fs::write(&path, r#"{"harness":"claude"}"#).unwrap();
689 assert!(scaffold_user_settings(&user_dir).is_none());
690 let kept: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
691 assert_eq!(kept["harness"], "claude", "existing file untouched");
692 }
693
694 #[test]
695 fn invalid_root_stop_pattern_warns_but_survives() {
696 let f = fixture();
697 write(
698 &f.user_dir.join("settings.json"),
699 &json!({"instructions": {"root_stop_pattern": "[bad"}, "model": "m"}),
700 );
701 let got = load(&f, None);
702 assert_eq!(got.settings.model.as_deref(), Some("m"));
703 assert_eq!(got.settings.root_stop_pattern.as_deref(), Some("[bad"));
704 assert!(
705 got.warnings.iter().any(|w| w.contains("root_stop_pattern")),
706 "{:?}",
707 got.warnings
708 );
709 }
710
711 #[test]
712 fn inline_flag_json_and_non_object_rejection() {
713 let f = fixture();
714 let got = load(&f, Some(r#"{"harness": "codex"}"#));
715 assert_eq!(got.settings.harness.as_deref(), Some("codex"));
716 let got = load(&f, Some("[1,2]"));
717 assert!(got.warnings.iter().any(|w| w.contains("JSON object")));
718 }
719}