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::instructions::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    /// Skipped layers, stripped keys, invalid entries — in discovery order.
68    pub warnings: Vec<String>,
69}
70
71/// Load and merge the five layers for `cwd`. `flag` is the raw `--settings`
72/// value (a path, or inline JSON when it starts with `{`).
73///
74/// Env reads happen only here (`~` expansion + the home resolver); the core is
75/// [`load_settings_from`] so tests inject everything.
76#[must_use]
77pub fn load_settings(cwd: &Path, flag: Option<&str>) -> SettingsLoad {
78    let mut warnings = Vec::new();
79    let user_dir = match crate::home::locode_home() {
80        Ok(dir) => Some(dir),
81        Err(e) => {
82            warnings.push(format!("settings: {e}; user layer skipped"));
83            None
84        }
85    };
86    // First-run scaffold (user decision 2026-07-24, ADR-0024 §1 amendment): an
87    // absent user settings.json is written with the CURRENT defaults, freezing
88    // them as explicit config and doubling as a discoverable template.
89    if let Some(dir) = &user_dir
90        && let Some(notice) = scaffold_user_settings(dir)
91    {
92        warnings.push(notice);
93    }
94    let home_for_tilde = std::env::var_os("HOME")
95        .filter(|h| !h.is_empty())
96        .map(PathBuf::from);
97    let mut load = load_settings_from(user_dir.as_deref(), cwd, home_for_tilde.as_deref(), flag);
98    warnings.append(&mut load.warnings);
99    SettingsLoad {
100        settings: load.settings,
101        warnings,
102    }
103}
104
105/// The env-free core of [`load_settings`]: `user_dir` is the resolved `~/.locode`
106/// (or `None`), `home_for_tilde` backs `~` expansion.
107#[must_use]
108pub fn load_settings_from(
109    user_dir: Option<&Path>,
110    cwd: &Path,
111    home_for_tilde: Option<&Path>,
112    flag: Option<&str>,
113) -> SettingsLoad {
114    let mut warnings: Vec<String> = Vec::new();
115    let mut merged = Value::Object(serde_json::Map::new());
116
117    // ---- 1. user layer + 2. its extends files ----
118    if let Some(user_dir) = user_dir {
119        let user_file = user_dir.join("settings.json");
120        if let Some(user_value) = read_layer(&user_file, &mut warnings) {
121            let extends = extract_extends(&user_value, &user_file, &mut warnings);
122            merge_values(&mut merged, strip_key(user_value, EXTENDS_KEY));
123            for entry in extends {
124                let path = expand_tilde(&entry, home_for_tilde, user_dir);
125                // The user explicitly pointed at this file — absence is loud
126                // (unlike the standard layers, whose absence is normal).
127                if !path.is_file() {
128                    warnings.push(format!(
129                        "settings: extends file {} not found; skipped",
130                        path.display()
131                    ));
132                    continue;
133                }
134                if let Some(mut value) = read_layer(&path, &mut warnings) {
135                    // Non-recursive (§1.2 amendment): a nested `extends` is ignored.
136                    if value.get(EXTENDS_KEY).is_some() {
137                        warnings.push(format!(
138                            "settings: nested `extends` in {} ignored (extends does not recurse)",
139                            path.display()
140                        ));
141                        value = strip_key(value, EXTENDS_KEY);
142                    }
143                    merge_values(&mut merged, value);
144                }
145            }
146        }
147    }
148
149    // ---- 3. project + 4. project-local layers (denylisted) ----
150    let root = find_root_from_markers(cwd, &[".git".to_string()], None);
151    for name in ["settings.json", "settings.local.json"] {
152        let path = root.join(".locode").join(name);
153        if let Some(mut value) = read_layer(&path, &mut warnings) {
154            for key in PROJECT_DENYLIST.iter().copied().chain([EXTENDS_KEY]) {
155                if value.get(key).is_some() {
156                    warnings.push(format!(
157                        "settings: `{key}` in {} ignored (project layers may not set it, ADR-0024 §1.3)",
158                        path.display()
159                    ));
160                    value = strip_key(value, key);
161                }
162            }
163            merge_values(&mut merged, value);
164        }
165    }
166
167    // ---- 5. flag layer ----
168    if let Some(flag) = flag {
169        // Inline JSON when it *looks* like JSON (object or array — the array case
170        // still fails the object check below, with a clearer message than ENOENT).
171        let parsed = if matches!(flag.trim_start().chars().next(), Some('{' | '[')) {
172            serde_json::from_str::<Value>(flag)
173                .map_err(|e| format!("settings: --settings inline JSON: {e}"))
174        } else {
175            let path = expand_tilde(flag, home_for_tilde, cwd);
176            std::fs::read_to_string(&path)
177                .map_err(|e| format!("settings: --settings {}: {e}", path.display()))
178                .and_then(|text| {
179                    serde_json::from_str::<Value>(&text)
180                        .map_err(|e| format!("settings: --settings {}: {e}", path.display()))
181                })
182        };
183        match parsed {
184            Ok(value) if value.is_object() => merge_values(&mut merged, value),
185            Ok(_) => warnings.push("settings: --settings must be a JSON object".to_string()),
186            Err(e) => warnings.push(e),
187        }
188    }
189
190    // ---- decode the typed view + validate skills.extra ----
191    let raw: RawSettings = serde_json::from_value(merged).unwrap_or_else(|e| {
192        warnings.push(format!("settings: merged settings did not decode: {e}"));
193        RawSettings::default()
194    });
195    if let Some(pattern) = &raw.instructions.root_stop_pattern
196        && let Err(e) = regex::Regex::new(pattern)
197    {
198        warnings.push(format!(
199            "settings: instructions.root_stop_pattern is not a valid regex ({e}); \
200             root detection will ignore it"
201        ));
202    }
203    let skills_extra = validate_skills_extra(
204        &raw.skills.extra,
205        home_for_tilde,
206        user_dir.unwrap_or(cwd),
207        &mut warnings,
208    );
209    SettingsLoad {
210        settings: Settings {
211            model: raw.model,
212            api_schema: raw.api_schema,
213            harness: raw.harness,
214            root_stop_pattern: raw.instructions.root_stop_pattern,
215            skills_extra,
216        },
217        warnings,
218    }
219}
220
221/// The first-run scaffold: written only when the user `settings.json` is
222/// absent. Carries every v1 key with its **current default** — `null` marks
223/// "no override" (the factory/built-in default applies) — so the file is both
224/// the frozen defaults and a template to edit. `create_new` makes a concurrent
225/// first run race-safe (the loser reads the winner's file); any failure is
226/// silent (the loader works identically without the file).
227fn scaffold_user_settings(user_dir: &Path) -> Option<String> {
228    let path = user_dir.join("settings.json");
229    if path.exists() {
230        return None;
231    }
232    // Keys in lexicographic order — the emitted file is deterministic
233    // regardless of serde_json's map flavor (user decision 2026-07-24).
234    let body = serde_json::json!({
235        "api_schema": "anthropic",
236        "extends": [],
237        "harness": "claude",
238        "instructions": { "root_stop_pattern": Value::Null },
239        "model": "claude-sonnet-5",
240        "skills": { "extra": [] },
241    });
242    let text = serde_json::to_string_pretty(&body).ok()? + "\n";
243    crate::trace::create_dir_private(user_dir).ok()?;
244    let mut file = std::fs::OpenOptions::new()
245        .write(true)
246        .create_new(true)
247        .open(&path)
248        .ok()?;
249    std::io::Write::write_all(&mut file, text.as_bytes()).ok()?;
250    Some(format!(
251        "settings: created {} with the current defaults",
252        path.display()
253    ))
254}
255
256/// The serde shape of one merged settings document. Plain `Deserialize` — unknown
257/// keys are ignored by default, exactly the tolerance ADR-0024 §1.5 requires.
258#[derive(Debug, Default, Deserialize)]
259struct RawSettings {
260    model: Option<String>,
261    api_schema: Option<String>,
262    harness: Option<String>,
263    #[serde(default)]
264    instructions: RawInstructions,
265    #[serde(default)]
266    skills: RawSkills,
267}
268
269#[derive(Debug, Default, Deserialize)]
270struct RawInstructions {
271    root_stop_pattern: Option<String>,
272}
273
274#[derive(Debug, Default, Deserialize)]
275struct RawSkills {
276    #[serde(default)]
277    extra: Vec<String>,
278}
279
280/// Read + parse one layer file. Absent file ⇒ `None` silently; unreadable or
281/// non-object JSON ⇒ `None` with a warning naming the file.
282fn read_layer(path: &Path, warnings: &mut Vec<String>) -> Option<Value> {
283    if !path.is_file() {
284        return None;
285    }
286    let text = match std::fs::read_to_string(path) {
287        Ok(text) => text,
288        Err(e) => {
289            warnings.push(format!(
290                "settings: {} unreadable ({e}); skipped",
291                path.display()
292            ));
293            return None;
294        }
295    };
296    match serde_json::from_str::<Value>(&text) {
297        Ok(value) if value.is_object() => Some(value),
298        Ok(_) => {
299            warnings.push(format!(
300                "settings: {} is not a JSON object; skipped",
301                path.display()
302            ));
303            None
304        }
305        Err(e) => {
306            warnings.push(format!(
307                "settings: {} invalid ({e}); skipped",
308                path.display()
309            ));
310            None
311        }
312    }
313}
314
315/// Pull the user layer's `extends` list (strings only; anything else warns).
316fn extract_extends(
317    user_value: &Value,
318    user_file: &Path,
319    warnings: &mut Vec<String>,
320) -> Vec<String> {
321    match user_value.get(EXTENDS_KEY) {
322        None => Vec::new(),
323        Some(Value::Array(items)) => items
324            .iter()
325            .filter_map(|item| match item {
326                Value::String(s) => Some(s.clone()),
327                other => {
328                    warnings.push(format!(
329                        "settings: non-string `extends` entry {other} in {} ignored",
330                        user_file.display()
331                    ));
332                    None
333                }
334            })
335            .collect(),
336        Some(_) => {
337            warnings.push(format!(
338                "settings: `extends` in {} must be an array of paths; ignored",
339                user_file.display()
340            ));
341            Vec::new()
342        }
343    }
344}
345
346/// Validate `skills.extra` entries (ADR-0024 §1.4): contains `SKILL.md` ⇒ a single
347/// skill; else the path must end in `skills` ⇒ a folder; anything else warns + drops.
348fn validate_skills_extra(
349    entries: &[String],
350    home_for_tilde: Option<&Path>,
351    base: &Path,
352    warnings: &mut Vec<String>,
353) -> Vec<SkillsExtraEntry> {
354    entries
355        .iter()
356        .filter_map(|entry| {
357            let path = expand_tilde(entry, home_for_tilde, base);
358            if path.join("SKILL.md").is_file() {
359                return Some(SkillsExtraEntry::Skill(path));
360            }
361            let trimmed = entry.trim_end_matches('/');
362            if trimmed.ends_with("skills") {
363                return Some(SkillsExtraEntry::Folder(path));
364            }
365            warnings.push(format!(
366                "settings: skills.extra entry `{entry}` is neither a skill (no SKILL.md) \
367                 nor a skills folder (path must end in `skills`); ignored"
368            ));
369            None
370        })
371        .collect()
372}
373
374/// `~`/`~/…` expansion against `home`, else resolution of relative paths against
375/// `base` (the referencing file's directory — ADR-0024 §1.2 amendment).
376fn expand_tilde(raw: &str, home: Option<&Path>, base: &Path) -> PathBuf {
377    if let Some(rest) = raw.strip_prefix("~/")
378        && let Some(home) = home
379    {
380        return home.join(rest);
381    }
382    if raw == "~"
383        && let Some(home) = home
384    {
385        return home.to_path_buf();
386    }
387    let path = PathBuf::from(raw);
388    if path.is_absolute() {
389        path
390    } else {
391        base.join(path)
392    }
393}
394
395/// Remove `key` from an object value (no-op otherwise).
396fn strip_key(mut value: Value, key: &str) -> Value {
397    if let Value::Object(map) = &mut value {
398        map.remove(key);
399    }
400    value
401}
402
403/// ADR-0024 §1.2 merge: objects deep-merge, arrays concat+dedupe, scalars (and
404/// type mismatches) overwrite.
405fn merge_values(base: &mut Value, overlay: Value) {
406    match (base, overlay) {
407        (Value::Object(base_map), Value::Object(overlay_map)) => {
408            for (key, overlay_value) in overlay_map {
409                match base_map.get_mut(&key) {
410                    Some(base_value) => merge_values(base_value, overlay_value),
411                    None => {
412                        base_map.insert(key, overlay_value);
413                    }
414                }
415            }
416        }
417        (Value::Array(base_items), Value::Array(overlay_items)) => {
418            for item in overlay_items {
419                if !base_items.contains(&item) {
420                    base_items.push(item);
421                }
422            }
423        }
424        (base_slot, overlay_value) => *base_slot = overlay_value,
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use serde_json::json;
432    use std::fs;
433
434    /// A canonicalized tempdir tree with a `.git` root and a `~/.locode` home.
435    struct Fixture {
436        _guards: Vec<tempfile::TempDir>,
437        home: PathBuf,     // fake $HOME
438        user_dir: PathBuf, // fake ~/.locode
439        repo: PathBuf,     // project root (.git)
440    }
441
442    fn fixture() -> Fixture {
443        let home_guard = tempfile::tempdir().unwrap();
444        let repo_guard = tempfile::tempdir().unwrap();
445        let home = fs::canonicalize(home_guard.path()).unwrap();
446        let repo = fs::canonicalize(repo_guard.path()).unwrap();
447        let user_dir = home.join(".locode");
448        fs::create_dir_all(&user_dir).unwrap();
449        fs::create_dir(repo.join(".git")).unwrap();
450        Fixture {
451            _guards: vec![home_guard, repo_guard],
452            home,
453            user_dir,
454            repo,
455        }
456    }
457
458    fn write(path: &Path, value: &Value) {
459        if let Some(parent) = path.parent() {
460            fs::create_dir_all(parent).unwrap();
461        }
462        fs::write(path, serde_json::to_string_pretty(value).unwrap()).unwrap();
463    }
464
465    fn load(f: &Fixture, flag: Option<&str>) -> SettingsLoad {
466        load_settings_from(Some(&f.user_dir), &f.repo, Some(&f.home), flag)
467    }
468
469    #[test]
470    fn precedence_user_lt_extends_lt_project_lt_local_lt_flag() {
471        let f = fixture();
472        write(
473            &f.user_dir.join("settings.json"),
474            &json!({"model": "user", "harness": "user", "api_schema": "user",
475                    "extends": ["team.json"]}),
476        );
477        write(
478            &f.user_dir.join("team.json"),
479            &json!({"model": "team", "harness": "team"}),
480        );
481        write(
482            &f.repo.join(".locode/settings.json"),
483            &json!({"model": "project"}),
484        );
485        write(
486            &f.repo.join(".locode/settings.local.json"),
487            &json!({"model": "local"}),
488        );
489
490        // No flag: local wins model; team beat user for harness; api_schema
491        // survives from user (projects can't set it).
492        let got = load(&f, None);
493        assert_eq!(got.settings.model.as_deref(), Some("local"));
494        assert_eq!(got.settings.harness.as_deref(), Some("team"));
495        assert_eq!(got.settings.api_schema.as_deref(), Some("user"));
496
497        // Flag beats everything.
498        let got = load(&f, Some(r#"{"model": "flag"}"#));
499        assert_eq!(got.settings.model.as_deref(), Some("flag"));
500    }
501
502    #[test]
503    fn extends_is_ordered_and_non_recursive() {
504        let f = fixture();
505        write(
506            &f.user_dir.join("settings.json"),
507            &json!({"extends": ["a.json", "b.json"]}),
508        );
509        write(&f.user_dir.join("a.json"), &json!({"model": "a"}));
510        write(
511            &f.user_dir.join("b.json"),
512            &json!({"model": "b", "extends": ["c.json"]}),
513        );
514        write(&f.user_dir.join("c.json"), &json!({"model": "c"}));
515
516        let got = load(&f, None);
517        // Later extends entry wins; c.json never loads (no recursion).
518        assert_eq!(got.settings.model.as_deref(), Some("b"));
519        assert!(
520            got.warnings.iter().any(|w| w.contains("nested `extends`")),
521            "{:?}",
522            got.warnings
523        );
524    }
525
526    #[test]
527    fn project_layers_cannot_set_denylisted_keys_or_extends() {
528        let f = fixture();
529        write(
530            &f.user_dir.join("settings.json"),
531            &json!({"api_schema": "user"}),
532        );
533        write(
534            &f.repo.join(".locode/settings.json"),
535            &json!({"api_schema": "evil", "extends": ["/tmp/evil.json"], "model": "ok"}),
536        );
537        let got = load(&f, None);
538        assert_eq!(
539            got.settings.api_schema.as_deref(),
540            Some("user"),
541            "denylisted"
542        );
543        assert_eq!(got.settings.model.as_deref(), Some("ok"), "other keys pass");
544        assert_eq!(
545            got.warnings
546                .iter()
547                .filter(|w| w.contains("project layers may not set"))
548                .count(),
549            2,
550            "{:?}",
551            got.warnings
552        );
553    }
554
555    #[test]
556    fn arrays_union_and_objects_deep_merge() {
557        let f = fixture();
558        write(
559            &f.user_dir.join("settings.json"),
560            &json!({"skills": {"extra": ["~/a-skills"]}, "instructions": {"root_stop_pattern": "u"}}),
561        );
562        write(
563            &f.repo.join(".locode/settings.json"),
564            &json!({"skills": {"extra": ["~/b-skills", "~/a-skills"]}}),
565        );
566        let got = load(&f, None);
567        // Deep merge kept instructions from user; arrays unioned without dupes.
568        assert_eq!(got.settings.root_stop_pattern.as_deref(), Some("u"));
569        let folders: Vec<_> = got
570            .settings
571            .skills_extra
572            .iter()
573            .map(|e| match e {
574                SkillsExtraEntry::Folder(p) | SkillsExtraEntry::Skill(p) => p.clone(),
575            })
576            .collect();
577        assert_eq!(
578            folders,
579            vec![f.home.join("a-skills"), f.home.join("b-skills")],
580            "union, first occurrence order, no duplicate"
581        );
582    }
583
584    #[test]
585    fn skills_extra_classifies_and_validates() {
586        let f = fixture();
587        let single = f.home.join("one-off");
588        fs::create_dir_all(&single).unwrap();
589        fs::write(single.join("SKILL.md"), "x").unwrap();
590        write(
591            &f.user_dir.join("settings.json"),
592            &json!({"skills": {"extra": ["~/one-off", "~/team-skills/", "~/random-dir"]}}),
593        );
594        let got = load(&f, None);
595        assert_eq!(
596            got.settings.skills_extra,
597            vec![
598                SkillsExtraEntry::Skill(single),
599                SkillsExtraEntry::Folder(f.home.join("team-skills/")),
600            ]
601        );
602        assert!(
603            got.warnings.iter().any(|w| w.contains("random-dir")),
604            "{:?}",
605            got.warnings
606        );
607    }
608
609    #[test]
610    fn malformed_layers_degrade_with_warnings() {
611        let f = fixture();
612        fs::write(f.user_dir.join("settings.json"), "{not json").unwrap();
613        write(
614            &f.repo.join(".locode/settings.json"),
615            &json!({"model": "p"}),
616        );
617        let got = load(&f, None);
618        assert_eq!(
619            got.settings.model.as_deref(),
620            Some("p"),
621            "good layers still load"
622        );
623        assert!(got.warnings.iter().any(|w| w.contains("invalid")));
624
625        // Missing extends file warns loudly (the user pointed at it) but the load
626        // survives — the project layer (model "p") still wins as usual.
627        write(
628            &f.user_dir.join("settings.json"),
629            &json!({"extends": ["missing.json"], "model": "u"}),
630        );
631        let got = load(&f, None);
632        assert_eq!(got.settings.model.as_deref(), Some("p"));
633        assert!(
634            got.warnings
635                .iter()
636                .any(|w| w.contains("missing.json") && w.contains("not found")),
637            "{:?}",
638            got.warnings
639        );
640    }
641
642    #[test]
643    fn unknown_keys_are_tolerated() {
644        let f = fixture();
645        write(
646            &f.user_dir.join("settings.json"),
647            &json!({"model": "m", "future_feature": {"x": 1}, "another": [1, 2]}),
648        );
649        let got = load(&f, None);
650        assert_eq!(got.settings.model.as_deref(), Some("m"));
651        assert!(got.warnings.is_empty(), "{:?}", got.warnings);
652    }
653
654    #[test]
655    fn no_git_root_uses_cwd_dot_locode() {
656        // Without .git the "project root" is the cwd itself.
657        let dir = tempfile::tempdir().unwrap();
658        let cwd = fs::canonicalize(dir.path()).unwrap();
659        write(
660            &cwd.join(".locode/settings.json"),
661            &json!({"model": "here"}),
662        );
663        let got = load_settings_from(None, &cwd, None, None);
664        assert_eq!(got.settings.model.as_deref(), Some("here"));
665    }
666
667    #[test]
668    fn scaffold_writes_current_defaults_once() {
669        let dir = tempfile::tempdir().unwrap();
670        let user_dir = dir.path().join(".locode");
671        // Absent file (and absent dir): scaffolded.
672        let notice = scaffold_user_settings(&user_dir).expect("scaffolded");
673        assert!(notice.contains("settings.json"));
674        let path = user_dir.join("settings.json");
675        let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
676        assert_eq!(value["harness"], "claude");
677        assert_eq!(value["api_schema"], "anthropic");
678        assert_eq!(value["model"], "claude-sonnet-5");
679        assert_eq!(value["skills"]["extra"], serde_json::json!([]));
680        // The scaffold round-trips through the loader with the same effective
681        // result as no file at all (nulls decode to None).
682        let cwd = tempfile::tempdir().unwrap();
683        let got = load_settings_from(Some(&user_dir), cwd.path(), None, None);
684        assert_eq!(got.settings.harness.as_deref(), Some("claude"));
685        assert_eq!(got.settings.model.as_deref(), Some("claude-sonnet-5"));
686        assert!(got.warnings.is_empty(), "{:?}", got.warnings);
687        // Second call: never overwrites.
688        fs::write(&path, r#"{"harness":"claude"}"#).unwrap();
689        assert!(scaffold_user_settings(&user_dir).is_none());
690        let kept: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
691        assert_eq!(kept["harness"], "claude", "existing file untouched");
692    }
693
694    #[test]
695    fn invalid_root_stop_pattern_warns_but_survives() {
696        let f = fixture();
697        write(
698            &f.user_dir.join("settings.json"),
699            &json!({"instructions": {"root_stop_pattern": "[bad"}, "model": "m"}),
700        );
701        let got = load(&f, None);
702        assert_eq!(got.settings.model.as_deref(), Some("m"));
703        assert_eq!(got.settings.root_stop_pattern.as_deref(), Some("[bad"));
704        assert!(
705            got.warnings.iter().any(|w| w.contains("root_stop_pattern")),
706            "{:?}",
707            got.warnings
708        );
709    }
710
711    #[test]
712    fn inline_flag_json_and_non_object_rejection() {
713        let f = fixture();
714        let got = load(&f, Some(r#"{"harness": "codex"}"#));
715        assert_eq!(got.settings.harness.as_deref(), Some("codex"));
716        let got = load(&f, Some("[1,2]"));
717        assert!(got.warnings.iter().any(|w| w.contains("JSON object")));
718    }
719}