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::{Map, 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    /// Default reasoning effort, as a locode ladder name (`--effort` wins).
45    /// Kept as a string here so this crate stays free of provider types; the
46    /// caller parses it and warns on an unknown rung rather than failing.
47    pub effort: Option<String>,
48    /// `instructions.root_stop_pattern` — activates ADR-0023's root-detection
49    /// regex (matching itself lands in Task 31 S2).
50    pub root_stop_pattern: Option<String>,
51    /// `skills.extra` — validated manual skill entries (consumed by the skills P0).
52    pub skills_extra: Vec<SkillsExtraEntry>,
53}
54
55/// One validated `skills.extra` entry (ADR-0024 §1.4).
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum SkillsExtraEntry {
58    /// The path itself contains `SKILL.md` — a single skill.
59    Skill(PathBuf),
60    /// A folder of skills (its path ends in `skills`); children holding
61    /// `SKILL.md` are skills.
62    Folder(PathBuf),
63}
64
65/// The loader result: the merged settings plus human-readable warnings the
66/// caller surfaces on stderr (this crate never prints).
67#[derive(Debug, Clone, Default)]
68pub struct SettingsLoad {
69    /// The merged, typed settings.
70    pub settings: Settings,
71    /// The resolved `extends` dotfolders, in list order (ADR-0024 §1.2 amendment).
72    ///
73    /// Each also contributes a `skills/` root and an `AGENTS.md` entry, read by their
74    /// own loaders. Resolving them once here is what makes the load order an invariant
75    /// rather than a convention (ADR-0025 §6.1): a caller cannot discover skills or
76    /// instructions without first holding this value.
77    pub extends_dirs: Vec<PathBuf>,
78    /// Skipped layers, stripped keys, invalid entries — in discovery order.
79    pub warnings: Vec<String>,
80}
81
82/// Load and merge the five layers for `cwd`. `flag` is the raw `--settings`
83/// value (a path, or inline JSON when it starts with `{`).
84///
85/// Env reads happen only here (`~` expansion + the home resolver); the core is
86/// [`load_settings_from`] so tests inject everything.
87#[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    // First-run scaffold (user decision 2026-07-24, ADR-0024 §1 amendment): an
98    // absent user settings.json is written with the CURRENT defaults, freezing
99    // them as explicit config and doubling as a discoverable template.
100    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/// The env-free core of [`load_settings`]: `user_dir` is the resolved `~/.locode`
118/// (or `None`), `home_for_tilde` backs `~` expansion.
119#[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    // Resolved `extends` dotfolders, in list order — the *other* two things they
129    // contribute (skills roots, `AGENTS.md`) are read by their own loaders, which is
130    // why the resolved list has to leave this function.
131    let mut extends_dirs: Vec<PathBuf> = Vec::new();
132
133    // ---- 1. user layer + 2. its extends dotfolders ----
134    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    // ---- 3. project + 4. project-local layers (denylisted) ----
143    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    // ---- 5. flag layer ----
161    if let Some(flag) = flag {
162        // Inline JSON when it *looks* like JSON (object or array — the array case
163        // still fails the object check below, with a clearer message than ENOENT).
164        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    // ---- decode the typed view + validate skills.extra ----
184    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
216/// Write `key = value` into the **user** settings file, preserving every other key.
217///
218/// This is the write half of the settings layer: `/model` persists the model the same
219/// way both reference harnesses do — into the user-global file (Claude Code's
220/// `updateSettingsForSource('userSettings', { model })`; grok's `[models].default` in
221/// `~/.grok/config.toml`). Neither has a project-scoped model, and neither needs one:
222/// a running session holds its model in memory, so the file only decides what the
223/// **next** session starts with.
224///
225/// **The write is atomic.** The new contents go to a temp file in the *same directory*
226/// — same filesystem, so the rename cannot fail with `EXDEV` — are flushed to disk, and
227/// only then renamed over the target. `rename(2)` is atomic, so a crash, a full disk or
228/// two processes writing at once can leave the file as the old contents or the new,
229/// never a truncated mixture. A half-written `settings.json` would be worse than no
230/// write at all: the next run would fall back to defaults with no way to tell why.
231///
232/// # Errors
233/// The path could not be resolved, created, written, or renamed. The existing file is
234/// untouched in every failure case.
235pub fn update_user_setting(user_dir: &Path, key: &str, value: Value) -> Result<PathBuf, String> {
236    let path = user_dir.join("settings.json");
237    // Read what is there, so unrelated keys (and unknown ones a newer version wrote)
238    // survive. An unreadable or malformed file is replaced rather than merged — there
239    // is nothing to preserve in it.
240    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        // `null` removes the key rather than storing a null, so "unset it" round-trips
254        // through the same call.
255        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
268/// Replace `path`'s contents atomically: temp file beside it → flush → rename.
269///
270/// The temp name carries the pid so two processes writing at once each land in their
271/// own file and the rename decides the winner, rather than interleaving into one.
272fn 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        // Flush to the device before the rename: without it a crash right after the
286        // rename can leave the new name pointing at an empty file.
287        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
300/// The first-run scaffold: written only when the user `settings.json` is
301/// absent. Carries every v1 key with its **current default** — `null` marks
302/// "no override" (the factory/built-in default applies) — so the file is both
303/// the frozen defaults and a template to edit. `create_new` makes a concurrent
304/// first run race-safe (the loser reads the winner's file); any failure is
305/// silent (the loader works identically without the file).
306fn 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    // Keys in lexicographic order — the emitted file is deterministic
312    // regardless of serde_json's map flavor (user decision 2026-07-24).
313    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/// The serde shape of one merged settings document. Plain `Deserialize` — unknown
336/// keys are ignored by default, exactly the tolerance ADR-0024 §1.5 requires.
337#[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
360/// Read + parse one layer file. Absent file ⇒ `None` silently; unreadable or
361/// non-object JSON ⇒ `None` with a warning naming the file.
362fn 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
395/// Merge the user layer and each dotfolder it `extends`, collecting the resolved
396/// dotfolders on the way (ADR-0024 §1.2 amendment 2026-07-24).
397///
398/// Split out of [`load_settings_from`] to keep that function readable; the ordering is
399/// the interesting part — the user file merges first, then each extended dotfolder in
400/// list order, so a later entry wins within the layer and everything here loses to the
401/// project layers.
402fn 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        // An entry is a **locode dotfolder**, not a settings file: its `settings.json`
420        // merges here, and its `skills/` + `AGENTS.md` are read by their own loaders
421        // from `extends_dirs`. A file-valued entry is refused explicitly rather than
422        // reinterpreted — §1.5 forbids silently changing what an existing key means,
423        // and the file form was valid until this amendment.
424        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        // The user explicitly pointed at this directory — absence is loud (unlike the
433        // standard layers, whose absence is normal).
434        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        // A dotfolder that ships only skills or only `AGENTS.md` is normal.
444        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            // Non-recursive (§1.2 amendment): a nested `extends` is ignored.
450            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
462/// Pull the user layer's `extends` list (strings only; anything else warns).
463fn 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
493/// Validate `skills.extra` entries (ADR-0024 §1.4): contains `SKILL.md` ⇒ a single
494/// skill; else the path must end in `skills` ⇒ a folder; anything else warns + drops.
495fn 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
521/// `~`/`~/…` expansion against `home`, else resolution of relative paths against
522/// `base` (the referencing file's directory — ADR-0024 §1.2 amendment).
523fn 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
542/// Remove `key` from an object value (no-op otherwise).
543fn 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
550/// ADR-0024 §1.2 merge: objects deep-merge, arrays concat+dedupe, scalars (and
551/// type mismatches) overwrite.
552fn 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    /// A canonicalized tempdir tree with a `.git` root and a `~/.locode` home.
582    struct Fixture {
583        _guards: Vec<tempfile::TempDir>,
584        home: PathBuf,     // fake $HOME
585        user_dir: PathBuf, // fake ~/.locode
586        repo: PathBuf,     // project root (.git)
587    }
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    // ── the write half ──────────────────────────────────────────────────────
606
607    fn read_json(path: &Path) -> Value {
608        serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
609    }
610
611    /// Setting one key leaves every other one alone — including keys this version does
612    /// not know about, which a newer version may have written.
613    #[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    /// A `null` unsets rather than storing a null, so the same call both sets and clears.
637    #[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    /// No file, or an unreadable one, still yields a valid file with the key set — and
651    /// the loader reads it back.
652    #[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    /// What the writer exists to guarantee: the target is **replaced by rename**, never
664    /// written through. A reader therefore sees the old contents or the new, never a
665    /// truncated mixture — so a crash mid-write cannot lose the user's settings.
666    #[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        // A big payload: a write-through implementation would be observably torn here,
673        // and would leave the temp file if it aborted.
674        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    /// The written file is what the loader reads — the round trip has to close.
691    #[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        // No flag: local wins model; team beat user for harness; api_schema
731        // survives from user (projects can't set it).
732        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        // Flag beats everything.
738        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        // Later extends entry wins; `c` never loads (no recursion).
758        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    /// A dotfolder may ship only skills or only `AGENTS.md`; a missing
772    /// `settings.json` is normal and must stay silent.
773    #[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    /// The old form (an entry pointing at a settings *file*) is refused with a message
792    /// naming the fix — never reinterpreted as "a directory with no settings.json",
793    /// which would silently drop a layer the user still expects (ADR-0024 §1.5).
794    #[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        // Deep merge kept instructions from user; arrays unioned without dupes.
869        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        // Missing extends file warns loudly (the user pointed at it) but the load
927        // survives — the project layer (model "p") still wins as usual.
928        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        // Without .git the "project root" is the cwd itself.
958        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        // Absent file (and absent dir): scaffolded.
973        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        // The scaffold round-trips through the loader with the same effective
982        // result as no file at all (nulls decode to None).
983        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        // Second call: never overwrites.
989        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}