Skip to main content

locode_host/
settings.rs

1//! Layered `settings.json` loading (ADR-0024 §1).
2//!
3//! Five layers, lowest → highest precedence:
4//! 1. `~/.locode/settings.json` (user)
5//! 2. the user layer's `extends` files (list order; ADR-0024 §1.2 amendment)
6//! 3. `<project-root>/.locode/settings.json` (committed)
7//! 4. `<project-root>/.locode/settings.local.json` (gitignored)
8//! 5. `--settings <file-or-inline-json>` (flag)
9//!
10//! Merge semantics (Claude `settings.ts:529-547`): objects deep-merge, scalars
11//! overwrite, arrays **concatenate + dedupe** (permission-style lists accumulate).
12//! Merging happens on raw `serde_json::Value`s, so unknown keys survive and are
13//! simply not interpreted (never rejected). A malformed/missing layer degrades to
14//! skipped-with-warning — never a hard error (Claude's filter-not-reject).
15//!
16//! Security (§1.3): the two **project** layers are attacker-controlled (a cloned
17//! repo ships them), so the denylisted keys (`api_schema`) and the `extends`
18//! pointer are stripped from them with a warning. `extends` files merge with
19//! *user* trust — the user explicitly pointed at them (§1.2 amendment).
20
21use std::path::{Path, PathBuf};
22
23use serde::Deserialize;
24use serde_json::Value;
25
26use crate::root::find_root_from_markers;
27
28/// Keys stripped from the project layers before merging (ADR-0024 §1.3 — a
29/// reviewed list: extending it is a normal change, shrinking needs an amendment).
30const PROJECT_DENYLIST: &[&str] = &["api_schema"];
31/// The user-layer-only pointer key (§1.2 amendment): stripped from project layers.
32const EXTENDS_KEY: &str = "extends";
33
34/// The typed view of the merged settings (v1 fields, ADR-0024 §1.4). Unknown keys
35/// are tolerated at every layer; absent keys are `None`/empty.
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct Settings {
38    /// Default model (threaded to the provider factory; no flag yet).
39    pub model: Option<String>,
40    /// Default wire (`--api-schema`/`LOCODE_API_SCHEMA` win). Project-denylisted.
41    pub api_schema: Option<String>,
42    /// Default harness pack (`--harness` wins).
43    pub harness: Option<String>,
44    /// `instructions.root_stop_pattern` — activates ADR-0023's root-detection
45    /// regex (matching itself lands in Task 31 S2).
46    pub root_stop_pattern: Option<String>,
47    /// `skills.extra` — validated manual skill entries (consumed by the skills P0).
48    pub skills_extra: Vec<SkillsExtraEntry>,
49}
50
51/// One validated `skills.extra` entry (ADR-0024 §1.4).
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum SkillsExtraEntry {
54    /// The path itself contains `SKILL.md` — a single skill.
55    Skill(PathBuf),
56    /// A folder of skills (its path ends in `skills`); children holding
57    /// `SKILL.md` are skills.
58    Folder(PathBuf),
59}
60
61/// The loader result: the merged settings plus human-readable warnings the
62/// caller surfaces on stderr (this crate never prints).
63#[derive(Debug, Clone, Default)]
64pub struct SettingsLoad {
65    /// The merged, typed settings.
66    pub settings: Settings,
67    /// The resolved `extends` dotfolders, in list order (ADR-0024 §1.2 amendment).
68    ///
69    /// Each also contributes a `skills/` root and an `AGENTS.md` entry, read by their
70    /// own loaders. Resolving them once here is what makes the load order an invariant
71    /// rather than a convention (ADR-0025 §6.1): a caller cannot discover skills or
72    /// instructions without first holding this value.
73    pub extends_dirs: Vec<PathBuf>,
74    /// Skipped layers, stripped keys, invalid entries — in discovery order.
75    pub warnings: Vec<String>,
76}
77
78/// Load and merge the five layers for `cwd`. `flag` is the raw `--settings`
79/// value (a path, or inline JSON when it starts with `{`).
80///
81/// Env reads happen only here (`~` expansion + the home resolver); the core is
82/// [`load_settings_from`] so tests inject everything.
83#[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    // First-run scaffold (user decision 2026-07-24, ADR-0024 §1 amendment): an
94    // absent user settings.json is written with the CURRENT defaults, freezing
95    // them as explicit config and doubling as a discoverable template.
96    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/// The env-free core of [`load_settings`]: `user_dir` is the resolved `~/.locode`
114/// (or `None`), `home_for_tilde` backs `~` expansion.
115#[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    // Resolved `extends` dotfolders, in list order — the *other* two things they
125    // contribute (skills roots, `AGENTS.md`) are read by their own loaders, which is
126    // why the resolved list has to leave this function.
127    let mut extends_dirs: Vec<PathBuf> = Vec::new();
128
129    // ---- 1. user layer + 2. its extends dotfolders ----
130    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    // ---- 3. project + 4. project-local layers (denylisted) ----
139    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    // ---- 5. flag layer ----
157    if let Some(flag) = flag {
158        // Inline JSON when it *looks* like JSON (object or array — the array case
159        // still fails the object check below, with a clearer message than ENOENT).
160        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    // ---- decode the typed view + validate skills.extra ----
180    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
211/// The first-run scaffold: written only when the user `settings.json` is
212/// absent. Carries every v1 key with its **current default** — `null` marks
213/// "no override" (the factory/built-in default applies) — so the file is both
214/// the frozen defaults and a template to edit. `create_new` makes a concurrent
215/// first run race-safe (the loser reads the winner's file); any failure is
216/// silent (the loader works identically without the file).
217fn 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    // Keys in lexicographic order — the emitted file is deterministic
223    // regardless of serde_json's map flavor (user decision 2026-07-24).
224    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/// The serde shape of one merged settings document. Plain `Deserialize` — unknown
247/// keys are ignored by default, exactly the tolerance ADR-0024 §1.5 requires.
248#[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
270/// Read + parse one layer file. Absent file ⇒ `None` silently; unreadable or
271/// non-object JSON ⇒ `None` with a warning naming the file.
272fn 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
305/// Merge the user layer and each dotfolder it `extends`, collecting the resolved
306/// dotfolders on the way (ADR-0024 §1.2 amendment 2026-07-24).
307///
308/// Split out of [`load_settings_from`] to keep that function readable; the ordering is
309/// the interesting part — the user file merges first, then each extended dotfolder in
310/// list order, so a later entry wins within the layer and everything here loses to the
311/// project layers.
312fn 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        // An entry is a **locode dotfolder**, not a settings file: its `settings.json`
330        // merges here, and its `skills/` + `AGENTS.md` are read by their own loaders
331        // from `extends_dirs`. A file-valued entry is refused explicitly rather than
332        // reinterpreted — §1.5 forbids silently changing what an existing key means,
333        // and the file form was valid until this amendment.
334        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        // The user explicitly pointed at this directory — absence is loud (unlike the
343        // standard layers, whose absence is normal).
344        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        // A dotfolder that ships only skills or only `AGENTS.md` is normal.
354        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            // Non-recursive (§1.2 amendment): a nested `extends` is ignored.
360            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
372/// Pull the user layer's `extends` list (strings only; anything else warns).
373fn 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
403/// Validate `skills.extra` entries (ADR-0024 §1.4): contains `SKILL.md` ⇒ a single
404/// skill; else the path must end in `skills` ⇒ a folder; anything else warns + drops.
405fn 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
431/// `~`/`~/…` expansion against `home`, else resolution of relative paths against
432/// `base` (the referencing file's directory — ADR-0024 §1.2 amendment).
433fn 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
452/// Remove `key` from an object value (no-op otherwise).
453fn 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
460/// ADR-0024 §1.2 merge: objects deep-merge, arrays concat+dedupe, scalars (and
461/// type mismatches) overwrite.
462fn 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    /// A canonicalized tempdir tree with a `.git` root and a `~/.locode` home.
492    struct Fixture {
493        _guards: Vec<tempfile::TempDir>,
494        home: PathBuf,     // fake $HOME
495        user_dir: PathBuf, // fake ~/.locode
496        repo: PathBuf,     // project root (.git)
497    }
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        // No flag: local wins model; team beat user for harness; api_schema
548        // survives from user (projects can't set it).
549        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        // Flag beats everything.
555        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        // Later extends entry wins; `c` never loads (no recursion).
575        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    /// A dotfolder may ship only skills or only `AGENTS.md`; a missing
589    /// `settings.json` is normal and must stay silent.
590    #[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    /// The old form (an entry pointing at a settings *file*) is refused with a message
609    /// naming the fix — never reinterpreted as "a directory with no settings.json",
610    /// which would silently drop a layer the user still expects (ADR-0024 §1.5).
611    #[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        // Deep merge kept instructions from user; arrays unioned without dupes.
686        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        // Missing extends file warns loudly (the user pointed at it) but the load
744        // survives — the project layer (model "p") still wins as usual.
745        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        // Without .git the "project root" is the cwd itself.
775        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        // Absent file (and absent dir): scaffolded.
790        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        // The scaffold round-trips through the loader with the same effective
799        // result as no file at all (nulls decode to None).
800        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        // Second call: never overwrites.
806        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}