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    /// `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/// Write `key = value` into the **user** settings file, preserving every other key.
212///
213/// This is the write half of the settings layer: `/model` persists the model the same
214/// way both reference harnesses do — into the user-global file (Claude Code's
215/// `updateSettingsForSource('userSettings', { model })`; grok's `[models].default` in
216/// `~/.grok/config.toml`). Neither has a project-scoped model, and neither needs one:
217/// a running session holds its model in memory, so the file only decides what the
218/// **next** session starts with.
219///
220/// **The write is atomic.** The new contents go to a temp file in the *same directory*
221/// — same filesystem, so the rename cannot fail with `EXDEV` — are flushed to disk, and
222/// only then renamed over the target. `rename(2)` is atomic, so a crash, a full disk or
223/// two processes writing at once can leave the file as the old contents or the new,
224/// never a truncated mixture. A half-written `settings.json` would be worse than no
225/// write at all: the next run would fall back to defaults with no way to tell why.
226///
227/// # Errors
228/// The path could not be resolved, created, written, or renamed. The existing file is
229/// untouched in every failure case.
230pub fn update_user_setting(user_dir: &Path, key: &str, value: Value) -> Result<PathBuf, String> {
231    let path = user_dir.join("settings.json");
232    // Read what is there, so unrelated keys (and unknown ones a newer version wrote)
233    // survive. An unreadable or malformed file is replaced rather than merged — there
234    // is nothing to preserve in it.
235    let mut root = match std::fs::read_to_string(&path) {
236        Ok(text) => {
237            serde_json::from_str::<Value>(&text).unwrap_or_else(|_| Value::Object(Map::new()))
238        }
239        Err(_) => Value::Object(Map::new()),
240    };
241    if !root.is_object() {
242        root = Value::Object(Map::new());
243    }
244    let Some(object) = root.as_object_mut() else {
245        unreachable!("just forced to an object");
246    };
247    match value {
248        // `null` removes the key rather than storing a null, so "unset it" round-trips
249        // through the same call.
250        Value::Null => {
251            object.remove(key);
252        }
253        value => {
254            object.insert(key.to_string(), value);
255        }
256    }
257    let text = serde_json::to_string_pretty(&root).map_err(|e| format!("serialize: {e}"))? + "\n";
258    crate::trace::create_dir_private(user_dir)?;
259    write_atomically(&path, text.as_bytes())?;
260    Ok(path)
261}
262
263/// Replace `path`'s contents atomically: temp file beside it → flush → rename.
264///
265/// The temp name carries the pid so two processes writing at once each land in their
266/// own file and the rename decides the winner, rather than interleaving into one.
267fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
268    let dir = path.parent().unwrap_or(Path::new("."));
269    let name = path.file_name().map_or_else(
270        || std::ffi::OsString::from("settings.json"),
271        std::ffi::OsStr::to_os_string,
272    );
273    let mut temp_name = name;
274    temp_name.push(format!(".tmp.{}", std::process::id()));
275    let temp = dir.join(temp_name);
276
277    let write = || -> std::io::Result<()> {
278        let mut file = std::fs::File::create(&temp)?;
279        std::io::Write::write_all(&mut file, bytes)?;
280        // Flush to the device before the rename: without it a crash right after the
281        // rename can leave the new name pointing at an empty file.
282        file.sync_all()?;
283        Ok(())
284    };
285    if let Err(e) = write() {
286        let _ = std::fs::remove_file(&temp);
287        return Err(format!("write {}: {e}", temp.display()));
288    }
289    std::fs::rename(&temp, path).map_err(|e| {
290        let _ = std::fs::remove_file(&temp);
291        format!("replace {}: {e}", path.display())
292    })
293}
294
295/// The first-run scaffold: written only when the user `settings.json` is
296/// absent. Carries every v1 key with its **current default** — `null` marks
297/// "no override" (the factory/built-in default applies) — so the file is both
298/// the frozen defaults and a template to edit. `create_new` makes a concurrent
299/// first run race-safe (the loser reads the winner's file); any failure is
300/// silent (the loader works identically without the file).
301fn scaffold_user_settings(user_dir: &Path) -> Option<String> {
302    let path = user_dir.join("settings.json");
303    if path.exists() {
304        return None;
305    }
306    // Keys in lexicographic order — the emitted file is deterministic
307    // regardless of serde_json's map flavor (user decision 2026-07-24).
308    let body = serde_json::json!({
309        "api_schema": "anthropic",
310        "extends": [],
311        "harness": "claude",
312        "instructions": { "root_stop_pattern": Value::Null },
313        "model": "claude-sonnet-5",
314        "skills": { "extra": [] },
315    });
316    let text = serde_json::to_string_pretty(&body).ok()? + "\n";
317    crate::trace::create_dir_private(user_dir).ok()?;
318    let mut file = std::fs::OpenOptions::new()
319        .write(true)
320        .create_new(true)
321        .open(&path)
322        .ok()?;
323    std::io::Write::write_all(&mut file, text.as_bytes()).ok()?;
324    Some(format!(
325        "settings: created {} with the current defaults",
326        path.display()
327    ))
328}
329
330/// The serde shape of one merged settings document. Plain `Deserialize` — unknown
331/// keys are ignored by default, exactly the tolerance ADR-0024 §1.5 requires.
332#[derive(Debug, Default, Deserialize)]
333struct RawSettings {
334    model: Option<String>,
335    api_schema: Option<String>,
336    harness: Option<String>,
337    #[serde(default)]
338    instructions: RawInstructions,
339    #[serde(default)]
340    skills: RawSkills,
341}
342
343#[derive(Debug, Default, Deserialize)]
344struct RawInstructions {
345    root_stop_pattern: Option<String>,
346}
347
348#[derive(Debug, Default, Deserialize)]
349struct RawSkills {
350    #[serde(default)]
351    extra: Vec<String>,
352}
353
354/// Read + parse one layer file. Absent file ⇒ `None` silently; unreadable or
355/// non-object JSON ⇒ `None` with a warning naming the file.
356fn read_layer(path: &Path, warnings: &mut Vec<String>) -> Option<Value> {
357    if !path.is_file() {
358        return None;
359    }
360    let text = match std::fs::read_to_string(path) {
361        Ok(text) => text,
362        Err(e) => {
363            warnings.push(format!(
364                "settings: {} unreadable ({e}); skipped",
365                path.display()
366            ));
367            return None;
368        }
369    };
370    match serde_json::from_str::<Value>(&text) {
371        Ok(value) if value.is_object() => Some(value),
372        Ok(_) => {
373            warnings.push(format!(
374                "settings: {} is not a JSON object; skipped",
375                path.display()
376            ));
377            None
378        }
379        Err(e) => {
380            warnings.push(format!(
381                "settings: {} invalid ({e}); skipped",
382                path.display()
383            ));
384            None
385        }
386    }
387}
388
389/// Merge the user layer and each dotfolder it `extends`, collecting the resolved
390/// dotfolders on the way (ADR-0024 §1.2 amendment 2026-07-24).
391///
392/// Split out of [`load_settings_from`] to keep that function readable; the ordering is
393/// the interesting part — the user file merges first, then each extended dotfolder in
394/// list order, so a later entry wins within the layer and everything here loses to the
395/// project layers.
396fn merge_user_and_extends_layers(
397    user_dir: Option<&Path>,
398    home_for_tilde: Option<&Path>,
399    merged: &mut Value,
400    extends_dirs: &mut Vec<PathBuf>,
401    warnings: &mut Vec<String>,
402) {
403    let Some(user_dir) = user_dir else { return };
404    let user_file = user_dir.join("settings.json");
405    let Some(user_value) = read_layer(&user_file, warnings) else {
406        return;
407    };
408    let extends = extract_extends(&user_value, &user_file, warnings);
409    merge_values(merged, strip_key(user_value, EXTENDS_KEY));
410
411    for entry in extends {
412        let dir = expand_tilde(&entry, home_for_tilde, user_dir);
413        // An entry is a **locode dotfolder**, not a settings file: its `settings.json`
414        // merges here, and its `skills/` + `AGENTS.md` are read by their own loaders
415        // from `extends_dirs`. A file-valued entry is refused explicitly rather than
416        // reinterpreted — §1.5 forbids silently changing what an existing key means,
417        // and the file form was valid until this amendment.
418        if dir.is_file() {
419            warnings.push(format!(
420                "settings: `extends` entry {} is a file; it must be a locode directory \
421                 (point it at the folder holding settings.json)",
422                dir.display()
423            ));
424            continue;
425        }
426        // The user explicitly pointed at this directory — absence is loud (unlike the
427        // standard layers, whose absence is normal).
428        if !dir.is_dir() {
429            warnings.push(format!(
430                "settings: extends directory {} not found; skipped",
431                dir.display()
432            ));
433            continue;
434        }
435        extends_dirs.push(dir.clone());
436
437        // A dotfolder that ships only skills or only `AGENTS.md` is normal.
438        let path = dir.join("settings.json");
439        if !path.is_file() {
440            continue;
441        }
442        if let Some(mut value) = read_layer(&path, warnings) {
443            // Non-recursive (§1.2 amendment): a nested `extends` is ignored.
444            if value.get(EXTENDS_KEY).is_some() {
445                warnings.push(format!(
446                    "settings: nested `extends` in {} ignored (extends does not recurse)",
447                    path.display()
448                ));
449                value = strip_key(value, EXTENDS_KEY);
450            }
451            merge_values(merged, value);
452        }
453    }
454}
455
456/// Pull the user layer's `extends` list (strings only; anything else warns).
457fn extract_extends(
458    user_value: &Value,
459    user_file: &Path,
460    warnings: &mut Vec<String>,
461) -> Vec<String> {
462    match user_value.get(EXTENDS_KEY) {
463        None => Vec::new(),
464        Some(Value::Array(items)) => items
465            .iter()
466            .filter_map(|item| match item {
467                Value::String(s) => Some(s.clone()),
468                other => {
469                    warnings.push(format!(
470                        "settings: non-string `extends` entry {other} in {} ignored",
471                        user_file.display()
472                    ));
473                    None
474                }
475            })
476            .collect(),
477        Some(_) => {
478            warnings.push(format!(
479                "settings: `extends` in {} must be an array of paths; ignored",
480                user_file.display()
481            ));
482            Vec::new()
483        }
484    }
485}
486
487/// Validate `skills.extra` entries (ADR-0024 §1.4): contains `SKILL.md` ⇒ a single
488/// skill; else the path must end in `skills` ⇒ a folder; anything else warns + drops.
489fn validate_skills_extra(
490    entries: &[String],
491    home_for_tilde: Option<&Path>,
492    base: &Path,
493    warnings: &mut Vec<String>,
494) -> Vec<SkillsExtraEntry> {
495    entries
496        .iter()
497        .filter_map(|entry| {
498            let path = expand_tilde(entry, home_for_tilde, base);
499            if path.join("SKILL.md").is_file() {
500                return Some(SkillsExtraEntry::Skill(path));
501            }
502            let trimmed = entry.trim_end_matches('/');
503            if trimmed.ends_with("skills") {
504                return Some(SkillsExtraEntry::Folder(path));
505            }
506            warnings.push(format!(
507                "settings: skills.extra entry `{entry}` is neither a skill (no SKILL.md) \
508                 nor a skills folder (path must end in `skills`); ignored"
509            ));
510            None
511        })
512        .collect()
513}
514
515/// `~`/`~/…` expansion against `home`, else resolution of relative paths against
516/// `base` (the referencing file's directory — ADR-0024 §1.2 amendment).
517fn expand_tilde(raw: &str, home: Option<&Path>, base: &Path) -> PathBuf {
518    if let Some(rest) = raw.strip_prefix("~/")
519        && let Some(home) = home
520    {
521        return home.join(rest);
522    }
523    if raw == "~"
524        && let Some(home) = home
525    {
526        return home.to_path_buf();
527    }
528    let path = PathBuf::from(raw);
529    if path.is_absolute() {
530        path
531    } else {
532        base.join(path)
533    }
534}
535
536/// Remove `key` from an object value (no-op otherwise).
537fn strip_key(mut value: Value, key: &str) -> Value {
538    if let Value::Object(map) = &mut value {
539        map.remove(key);
540    }
541    value
542}
543
544/// ADR-0024 §1.2 merge: objects deep-merge, arrays concat+dedupe, scalars (and
545/// type mismatches) overwrite.
546fn merge_values(base: &mut Value, overlay: Value) {
547    match (base, overlay) {
548        (Value::Object(base_map), Value::Object(overlay_map)) => {
549            for (key, overlay_value) in overlay_map {
550                match base_map.get_mut(&key) {
551                    Some(base_value) => merge_values(base_value, overlay_value),
552                    None => {
553                        base_map.insert(key, overlay_value);
554                    }
555                }
556            }
557        }
558        (Value::Array(base_items), Value::Array(overlay_items)) => {
559            for item in overlay_items {
560                if !base_items.contains(&item) {
561                    base_items.push(item);
562                }
563            }
564        }
565        (base_slot, overlay_value) => *base_slot = overlay_value,
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use serde_json::json;
573    use std::fs;
574
575    /// A canonicalized tempdir tree with a `.git` root and a `~/.locode` home.
576    struct Fixture {
577        _guards: Vec<tempfile::TempDir>,
578        home: PathBuf,     // fake $HOME
579        user_dir: PathBuf, // fake ~/.locode
580        repo: PathBuf,     // project root (.git)
581    }
582
583    fn fixture() -> Fixture {
584        let home_guard = tempfile::tempdir().unwrap();
585        let repo_guard = tempfile::tempdir().unwrap();
586        let home = fs::canonicalize(home_guard.path()).unwrap();
587        let repo = fs::canonicalize(repo_guard.path()).unwrap();
588        let user_dir = home.join(".locode");
589        fs::create_dir_all(&user_dir).unwrap();
590        fs::create_dir(repo.join(".git")).unwrap();
591        Fixture {
592            _guards: vec![home_guard, repo_guard],
593            home,
594            user_dir,
595            repo,
596        }
597    }
598
599    // ── the write half ──────────────────────────────────────────────────────
600
601    fn read_json(path: &Path) -> Value {
602        serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
603    }
604
605    /// Setting one key leaves every other one alone — including keys this version does
606    /// not know about, which a newer version may have written.
607    #[test]
608    fn updating_a_key_preserves_the_rest_of_the_file() {
609        let dir = tempfile::tempdir().unwrap();
610        let path = dir.path().join("settings.json");
611        write(
612            &path,
613            &json!({
614                "harness": "claude",
615                "model": "claude-sonnet-5",
616                "skills": { "extra": ["~/x"] },
617                "some_future_key": 42,
618            }),
619        );
620
621        let written = update_user_setting(dir.path(), "model", json!("claude-opus-5")).unwrap();
622        assert_eq!(written, path);
623        let got = read_json(&path);
624        assert_eq!(got["model"], "claude-opus-5");
625        assert_eq!(got["harness"], "claude", "untouched");
626        assert_eq!(got["skills"]["extra"][0], "~/x", "nested value untouched");
627        assert_eq!(got["some_future_key"], 42, "unknown key survives");
628    }
629
630    /// A `null` unsets rather than storing a null, so the same call both sets and clears.
631    #[test]
632    fn a_null_value_removes_the_key() {
633        let dir = tempfile::tempdir().unwrap();
634        write(
635            &dir.path().join("settings.json"),
636            &json!({"model": "x", "harness": "claude"}),
637        );
638        update_user_setting(dir.path(), "model", Value::Null).unwrap();
639        let got = read_json(&dir.path().join("settings.json"));
640        assert!(got.get("model").is_none(), "{got}");
641        assert_eq!(got["harness"], "claude");
642    }
643
644    /// No file, or an unreadable one, still yields a valid file with the key set — and
645    /// the loader reads it back.
646    #[test]
647    fn a_missing_or_corrupt_file_is_replaced_not_merged() {
648        let dir = tempfile::tempdir().unwrap();
649        update_user_setting(dir.path(), "model", json!("m1")).unwrap();
650        assert_eq!(read_json(&dir.path().join("settings.json"))["model"], "m1");
651
652        fs::write(dir.path().join("settings.json"), "{ not json").unwrap();
653        update_user_setting(dir.path(), "model", json!("m2")).unwrap();
654        assert_eq!(read_json(&dir.path().join("settings.json"))["model"], "m2");
655    }
656
657    /// What the writer exists to guarantee: the target is **replaced by rename**, never
658    /// written through. A reader therefore sees the old contents or the new, never a
659    /// truncated mixture — so a crash mid-write cannot lose the user's settings.
660    #[test]
661    fn the_write_is_atomic_and_leaves_no_temp_file_behind() {
662        let dir = tempfile::tempdir().unwrap();
663        let path = dir.path().join("settings.json");
664        write(&path, &json!({"model": "old"}));
665
666        // A big payload: a write-through implementation would be observably torn here,
667        // and would leave the temp file if it aborted.
668        let big: Vec<String> = (0..2000).map(|i| format!("entry-{i}")).collect();
669        update_user_setting(dir.path(), "skills", json!({ "extra": big })).unwrap();
670
671        let got = read_json(&path);
672        assert_eq!(got["model"], "old", "the untouched key survived");
673        assert_eq!(got["skills"]["extra"].as_array().unwrap().len(), 2000);
674
675        let leftovers: Vec<_> = fs::read_dir(dir.path())
676            .unwrap()
677            .filter_map(Result::ok)
678            .map(|e| e.file_name().to_string_lossy().to_string())
679            .filter(|n| n != "settings.json")
680            .collect();
681        assert!(leftovers.is_empty(), "temp files cleaned up: {leftovers:?}");
682    }
683
684    /// The written file is what the loader reads — the round trip has to close.
685    #[test]
686    fn the_written_model_is_what_the_loader_resolves() {
687        let f = fixture();
688        update_user_setting(&f.user_dir, "model", json!("claude-opus-5")).unwrap();
689        let load = load_settings_from(Some(&f.user_dir), &f.repo, Some(&f.home), None);
690        assert_eq!(load.settings.model.as_deref(), Some("claude-opus-5"));
691    }
692    fn write(path: &Path, value: &Value) {
693        if let Some(parent) = path.parent() {
694            fs::create_dir_all(parent).unwrap();
695        }
696        fs::write(path, serde_json::to_string_pretty(value).unwrap()).unwrap();
697    }
698
699    fn load(f: &Fixture, flag: Option<&str>) -> SettingsLoad {
700        load_settings_from(Some(&f.user_dir), &f.repo, Some(&f.home), flag)
701    }
702
703    #[test]
704    fn precedence_user_lt_extends_lt_project_lt_local_lt_flag() {
705        let f = fixture();
706        write(
707            &f.user_dir.join("settings.json"),
708            &json!({"model": "user", "harness": "user", "api_schema": "user",
709                    "extends": ["team"]}),
710        );
711        write(
712            &f.user_dir.join("team/settings.json"),
713            &json!({"model": "team", "harness": "team"}),
714        );
715        write(
716            &f.repo.join(".locode/settings.json"),
717            &json!({"model": "project"}),
718        );
719        write(
720            &f.repo.join(".locode/settings.local.json"),
721            &json!({"model": "local"}),
722        );
723
724        // No flag: local wins model; team beat user for harness; api_schema
725        // survives from user (projects can't set it).
726        let got = load(&f, None);
727        assert_eq!(got.settings.model.as_deref(), Some("local"));
728        assert_eq!(got.settings.harness.as_deref(), Some("team"));
729        assert_eq!(got.settings.api_schema.as_deref(), Some("user"));
730
731        // Flag beats everything.
732        let got = load(&f, Some(r#"{"model": "flag"}"#));
733        assert_eq!(got.settings.model.as_deref(), Some("flag"));
734    }
735
736    #[test]
737    fn extends_is_ordered_and_non_recursive() {
738        let f = fixture();
739        write(
740            &f.user_dir.join("settings.json"),
741            &json!({"extends": ["a", "b"]}),
742        );
743        write(&f.user_dir.join("a/settings.json"), &json!({"model": "a"}));
744        write(
745            &f.user_dir.join("b/settings.json"),
746            &json!({"model": "b", "extends": ["c"]}),
747        );
748        write(&f.user_dir.join("c/settings.json"), &json!({"model": "c"}));
749
750        let got = load(&f, None);
751        // Later extends entry wins; `c` never loads (no recursion).
752        assert_eq!(got.settings.model.as_deref(), Some("b"));
753        assert!(
754            got.warnings.iter().any(|w| w.contains("nested `extends`")),
755            "{:?}",
756            got.warnings
757        );
758        assert_eq!(
759            got.extends_dirs,
760            vec![f.user_dir.join("a"), f.user_dir.join("b")],
761            "resolved dotfolders travel out in list order"
762        );
763    }
764
765    /// A dotfolder may ship only skills or only `AGENTS.md`; a missing
766    /// `settings.json` is normal and must stay silent.
767    #[test]
768    fn extends_dotfolder_without_settings_json_is_silent() {
769        let f = fixture();
770        write(
771            &f.user_dir.join("settings.json"),
772            &json!({"extends": ["team"]}),
773        );
774        std::fs::create_dir_all(f.user_dir.join("team/skills")).unwrap();
775
776        let got = load(&f, None);
777        assert_eq!(got.extends_dirs, vec![f.user_dir.join("team")]);
778        assert!(
779            got.warnings.is_empty(),
780            "a dotfolder with no settings.json is normal: {:?}",
781            got.warnings
782        );
783    }
784
785    /// The old form (an entry pointing at a settings *file*) is refused with a message
786    /// naming the fix — never reinterpreted as "a directory with no settings.json",
787    /// which would silently drop a layer the user still expects (ADR-0024 §1.5).
788    #[test]
789    fn extends_entry_pointing_at_a_file_is_refused_explicitly() {
790        let f = fixture();
791        write(
792            &f.user_dir.join("settings.json"),
793            &json!({"extends": ["team.json"]}),
794        );
795        write(&f.user_dir.join("team.json"), &json!({"model": "team"}));
796
797        let got = load(&f, None);
798        assert_eq!(got.settings.model, None, "the file must not be merged");
799        assert!(got.extends_dirs.is_empty());
800        let w = got.warnings.join(" | ");
801        assert!(w.contains("is a file"), "{w}");
802        assert!(w.contains("must be a locode directory"), "{w}");
803    }
804
805    #[test]
806    fn extends_missing_directory_warns_loudly() {
807        let f = fixture();
808        write(
809            &f.user_dir.join("settings.json"),
810            &json!({"extends": ["nope"]}),
811        );
812        let got = load(&f, None);
813        assert!(got.extends_dirs.is_empty());
814        assert!(
815            got.warnings.iter().any(|w| w.contains("not found")),
816            "{:?}",
817            got.warnings
818        );
819    }
820
821    #[test]
822    fn project_layers_cannot_set_denylisted_keys_or_extends() {
823        let f = fixture();
824        write(
825            &f.user_dir.join("settings.json"),
826            &json!({"api_schema": "user"}),
827        );
828        write(
829            &f.repo.join(".locode/settings.json"),
830            &json!({"api_schema": "evil", "extends": ["/tmp/evil.json"], "model": "ok"}),
831        );
832        let got = load(&f, None);
833        assert_eq!(
834            got.settings.api_schema.as_deref(),
835            Some("user"),
836            "denylisted"
837        );
838        assert_eq!(got.settings.model.as_deref(), Some("ok"), "other keys pass");
839        assert_eq!(
840            got.warnings
841                .iter()
842                .filter(|w| w.contains("project layers may not set"))
843                .count(),
844            2,
845            "{:?}",
846            got.warnings
847        );
848    }
849
850    #[test]
851    fn arrays_union_and_objects_deep_merge() {
852        let f = fixture();
853        write(
854            &f.user_dir.join("settings.json"),
855            &json!({"skills": {"extra": ["~/a-skills"]}, "instructions": {"root_stop_pattern": "u"}}),
856        );
857        write(
858            &f.repo.join(".locode/settings.json"),
859            &json!({"skills": {"extra": ["~/b-skills", "~/a-skills"]}}),
860        );
861        let got = load(&f, None);
862        // Deep merge kept instructions from user; arrays unioned without dupes.
863        assert_eq!(got.settings.root_stop_pattern.as_deref(), Some("u"));
864        let folders: Vec<_> = got
865            .settings
866            .skills_extra
867            .iter()
868            .map(|e| match e {
869                SkillsExtraEntry::Folder(p) | SkillsExtraEntry::Skill(p) => p.clone(),
870            })
871            .collect();
872        assert_eq!(
873            folders,
874            vec![f.home.join("a-skills"), f.home.join("b-skills")],
875            "union, first occurrence order, no duplicate"
876        );
877    }
878
879    #[test]
880    fn skills_extra_classifies_and_validates() {
881        let f = fixture();
882        let single = f.home.join("one-off");
883        fs::create_dir_all(&single).unwrap();
884        fs::write(single.join("SKILL.md"), "x").unwrap();
885        write(
886            &f.user_dir.join("settings.json"),
887            &json!({"skills": {"extra": ["~/one-off", "~/team-skills/", "~/random-dir"]}}),
888        );
889        let got = load(&f, None);
890        assert_eq!(
891            got.settings.skills_extra,
892            vec![
893                SkillsExtraEntry::Skill(single),
894                SkillsExtraEntry::Folder(f.home.join("team-skills/")),
895            ]
896        );
897        assert!(
898            got.warnings.iter().any(|w| w.contains("random-dir")),
899            "{:?}",
900            got.warnings
901        );
902    }
903
904    #[test]
905    fn malformed_layers_degrade_with_warnings() {
906        let f = fixture();
907        fs::write(f.user_dir.join("settings.json"), "{not json").unwrap();
908        write(
909            &f.repo.join(".locode/settings.json"),
910            &json!({"model": "p"}),
911        );
912        let got = load(&f, None);
913        assert_eq!(
914            got.settings.model.as_deref(),
915            Some("p"),
916            "good layers still load"
917        );
918        assert!(got.warnings.iter().any(|w| w.contains("invalid")));
919
920        // Missing extends file warns loudly (the user pointed at it) but the load
921        // survives — the project layer (model "p") still wins as usual.
922        write(
923            &f.user_dir.join("settings.json"),
924            &json!({"extends": ["missing.json"], "model": "u"}),
925        );
926        let got = load(&f, None);
927        assert_eq!(got.settings.model.as_deref(), Some("p"));
928        assert!(
929            got.warnings
930                .iter()
931                .any(|w| w.contains("missing.json") && w.contains("not found")),
932            "{:?}",
933            got.warnings
934        );
935    }
936
937    #[test]
938    fn unknown_keys_are_tolerated() {
939        let f = fixture();
940        write(
941            &f.user_dir.join("settings.json"),
942            &json!({"model": "m", "future_feature": {"x": 1}, "another": [1, 2]}),
943        );
944        let got = load(&f, None);
945        assert_eq!(got.settings.model.as_deref(), Some("m"));
946        assert!(got.warnings.is_empty(), "{:?}", got.warnings);
947    }
948
949    #[test]
950    fn no_git_root_uses_cwd_dot_locode() {
951        // Without .git the "project root" is the cwd itself.
952        let dir = tempfile::tempdir().unwrap();
953        let cwd = fs::canonicalize(dir.path()).unwrap();
954        write(
955            &cwd.join(".locode/settings.json"),
956            &json!({"model": "here"}),
957        );
958        let got = load_settings_from(None, &cwd, None, None);
959        assert_eq!(got.settings.model.as_deref(), Some("here"));
960    }
961
962    #[test]
963    fn scaffold_writes_current_defaults_once() {
964        let dir = tempfile::tempdir().unwrap();
965        let user_dir = dir.path().join(".locode");
966        // Absent file (and absent dir): scaffolded.
967        let notice = scaffold_user_settings(&user_dir).expect("scaffolded");
968        assert!(notice.contains("settings.json"));
969        let path = user_dir.join("settings.json");
970        let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
971        assert_eq!(value["harness"], "claude");
972        assert_eq!(value["api_schema"], "anthropic");
973        assert_eq!(value["model"], "claude-sonnet-5");
974        assert_eq!(value["skills"]["extra"], serde_json::json!([]));
975        // The scaffold round-trips through the loader with the same effective
976        // result as no file at all (nulls decode to None).
977        let cwd = tempfile::tempdir().unwrap();
978        let got = load_settings_from(Some(&user_dir), cwd.path(), None, None);
979        assert_eq!(got.settings.harness.as_deref(), Some("claude"));
980        assert_eq!(got.settings.model.as_deref(), Some("claude-sonnet-5"));
981        assert!(got.warnings.is_empty(), "{:?}", got.warnings);
982        // Second call: never overwrites.
983        fs::write(&path, r#"{"harness":"claude"}"#).unwrap();
984        assert!(scaffold_user_settings(&user_dir).is_none());
985        let kept: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
986        assert_eq!(kept["harness"], "claude", "existing file untouched");
987    }
988
989    #[test]
990    fn invalid_root_stop_pattern_warns_but_survives() {
991        let f = fixture();
992        write(
993            &f.user_dir.join("settings.json"),
994            &json!({"instructions": {"root_stop_pattern": "[bad"}, "model": "m"}),
995        );
996        let got = load(&f, None);
997        assert_eq!(got.settings.model.as_deref(), Some("m"));
998        assert_eq!(got.settings.root_stop_pattern.as_deref(), Some("[bad"));
999        assert!(
1000            got.warnings.iter().any(|w| w.contains("root_stop_pattern")),
1001            "{:?}",
1002            got.warnings
1003        );
1004    }
1005
1006    #[test]
1007    fn inline_flag_json_and_non_object_rejection() {
1008        let f = fixture();
1009        let got = load(&f, Some(r#"{"harness": "codex"}"#));
1010        assert_eq!(got.settings.harness.as_deref(), Some("codex"));
1011        let got = load(&f, Some("[1,2]"));
1012        assert!(got.warnings.iter().any(|w| w.contains("JSON object")));
1013    }
1014}