use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::Value;
use crate::instructions::find_root_from_markers;
const PROJECT_DENYLIST: &[&str] = &["api_schema"];
const EXTENDS_KEY: &str = "extends";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Settings {
pub model: Option<String>,
pub api_schema: Option<String>,
pub harness: Option<String>,
pub root_stop_pattern: Option<String>,
pub skills_extra: Vec<SkillsExtraEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillsExtraEntry {
Skill(PathBuf),
Folder(PathBuf),
}
#[derive(Debug, Clone, Default)]
pub struct SettingsLoad {
pub settings: Settings,
pub warnings: Vec<String>,
}
#[must_use]
pub fn load_settings(cwd: &Path, flag: Option<&str>) -> SettingsLoad {
let mut warnings = Vec::new();
let user_dir = match crate::home::locode_home() {
Ok(dir) => Some(dir),
Err(e) => {
warnings.push(format!("settings: {e}; user layer skipped"));
None
}
};
if let Some(dir) = &user_dir
&& let Some(notice) = scaffold_user_settings(dir)
{
warnings.push(notice);
}
let home_for_tilde = std::env::var_os("HOME")
.filter(|h| !h.is_empty())
.map(PathBuf::from);
let mut load = load_settings_from(user_dir.as_deref(), cwd, home_for_tilde.as_deref(), flag);
warnings.append(&mut load.warnings);
SettingsLoad {
settings: load.settings,
warnings,
}
}
#[must_use]
pub fn load_settings_from(
user_dir: Option<&Path>,
cwd: &Path,
home_for_tilde: Option<&Path>,
flag: Option<&str>,
) -> SettingsLoad {
let mut warnings: Vec<String> = Vec::new();
let mut merged = Value::Object(serde_json::Map::new());
if let Some(user_dir) = user_dir {
let user_file = user_dir.join("settings.json");
if let Some(user_value) = read_layer(&user_file, &mut warnings) {
let extends = extract_extends(&user_value, &user_file, &mut warnings);
merge_values(&mut merged, strip_key(user_value, EXTENDS_KEY));
for entry in extends {
let path = expand_tilde(&entry, home_for_tilde, user_dir);
if !path.is_file() {
warnings.push(format!(
"settings: extends file {} not found; skipped",
path.display()
));
continue;
}
if let Some(mut value) = read_layer(&path, &mut warnings) {
if value.get(EXTENDS_KEY).is_some() {
warnings.push(format!(
"settings: nested `extends` in {} ignored (extends does not recurse)",
path.display()
));
value = strip_key(value, EXTENDS_KEY);
}
merge_values(&mut merged, value);
}
}
}
}
let root = find_root_from_markers(cwd, &[".git".to_string()], None);
for name in ["settings.json", "settings.local.json"] {
let path = root.join(".locode").join(name);
if let Some(mut value) = read_layer(&path, &mut warnings) {
for key in PROJECT_DENYLIST.iter().copied().chain([EXTENDS_KEY]) {
if value.get(key).is_some() {
warnings.push(format!(
"settings: `{key}` in {} ignored (project layers may not set it, ADR-0024 §1.3)",
path.display()
));
value = strip_key(value, key);
}
}
merge_values(&mut merged, value);
}
}
if let Some(flag) = flag {
let parsed = if matches!(flag.trim_start().chars().next(), Some('{' | '[')) {
serde_json::from_str::<Value>(flag)
.map_err(|e| format!("settings: --settings inline JSON: {e}"))
} else {
let path = expand_tilde(flag, home_for_tilde, cwd);
std::fs::read_to_string(&path)
.map_err(|e| format!("settings: --settings {}: {e}", path.display()))
.and_then(|text| {
serde_json::from_str::<Value>(&text)
.map_err(|e| format!("settings: --settings {}: {e}", path.display()))
})
};
match parsed {
Ok(value) if value.is_object() => merge_values(&mut merged, value),
Ok(_) => warnings.push("settings: --settings must be a JSON object".to_string()),
Err(e) => warnings.push(e),
}
}
let raw: RawSettings = serde_json::from_value(merged).unwrap_or_else(|e| {
warnings.push(format!("settings: merged settings did not decode: {e}"));
RawSettings::default()
});
if let Some(pattern) = &raw.instructions.root_stop_pattern
&& let Err(e) = regex::Regex::new(pattern)
{
warnings.push(format!(
"settings: instructions.root_stop_pattern is not a valid regex ({e}); \
root detection will ignore it"
));
}
let skills_extra = validate_skills_extra(
&raw.skills.extra,
home_for_tilde,
user_dir.unwrap_or(cwd),
&mut warnings,
);
SettingsLoad {
settings: Settings {
model: raw.model,
api_schema: raw.api_schema,
harness: raw.harness,
root_stop_pattern: raw.instructions.root_stop_pattern,
skills_extra,
},
warnings,
}
}
fn scaffold_user_settings(user_dir: &Path) -> Option<String> {
let path = user_dir.join("settings.json");
if path.exists() {
return None;
}
let body = serde_json::json!({
"api_schema": "anthropic",
"extends": [],
"harness": "claude",
"instructions": { "root_stop_pattern": Value::Null },
"model": "claude-sonnet-5",
"skills": { "extra": [] },
});
let text = serde_json::to_string_pretty(&body).ok()? + "\n";
crate::trace::create_dir_private(user_dir).ok()?;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.ok()?;
std::io::Write::write_all(&mut file, text.as_bytes()).ok()?;
Some(format!(
"settings: created {} with the current defaults",
path.display()
))
}
#[derive(Debug, Default, Deserialize)]
struct RawSettings {
model: Option<String>,
api_schema: Option<String>,
harness: Option<String>,
#[serde(default)]
instructions: RawInstructions,
#[serde(default)]
skills: RawSkills,
}
#[derive(Debug, Default, Deserialize)]
struct RawInstructions {
root_stop_pattern: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawSkills {
#[serde(default)]
extra: Vec<String>,
}
fn read_layer(path: &Path, warnings: &mut Vec<String>) -> Option<Value> {
if !path.is_file() {
return None;
}
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) => {
warnings.push(format!(
"settings: {} unreadable ({e}); skipped",
path.display()
));
return None;
}
};
match serde_json::from_str::<Value>(&text) {
Ok(value) if value.is_object() => Some(value),
Ok(_) => {
warnings.push(format!(
"settings: {} is not a JSON object; skipped",
path.display()
));
None
}
Err(e) => {
warnings.push(format!(
"settings: {} invalid ({e}); skipped",
path.display()
));
None
}
}
}
fn extract_extends(
user_value: &Value,
user_file: &Path,
warnings: &mut Vec<String>,
) -> Vec<String> {
match user_value.get(EXTENDS_KEY) {
None => Vec::new(),
Some(Value::Array(items)) => items
.iter()
.filter_map(|item| match item {
Value::String(s) => Some(s.clone()),
other => {
warnings.push(format!(
"settings: non-string `extends` entry {other} in {} ignored",
user_file.display()
));
None
}
})
.collect(),
Some(_) => {
warnings.push(format!(
"settings: `extends` in {} must be an array of paths; ignored",
user_file.display()
));
Vec::new()
}
}
}
fn validate_skills_extra(
entries: &[String],
home_for_tilde: Option<&Path>,
base: &Path,
warnings: &mut Vec<String>,
) -> Vec<SkillsExtraEntry> {
entries
.iter()
.filter_map(|entry| {
let path = expand_tilde(entry, home_for_tilde, base);
if path.join("SKILL.md").is_file() {
return Some(SkillsExtraEntry::Skill(path));
}
let trimmed = entry.trim_end_matches('/');
if trimmed.ends_with("skills") {
return Some(SkillsExtraEntry::Folder(path));
}
warnings.push(format!(
"settings: skills.extra entry `{entry}` is neither a skill (no SKILL.md) \
nor a skills folder (path must end in `skills`); ignored"
));
None
})
.collect()
}
fn expand_tilde(raw: &str, home: Option<&Path>, base: &Path) -> PathBuf {
if let Some(rest) = raw.strip_prefix("~/")
&& let Some(home) = home
{
return home.join(rest);
}
if raw == "~"
&& let Some(home) = home
{
return home.to_path_buf();
}
let path = PathBuf::from(raw);
if path.is_absolute() {
path
} else {
base.join(path)
}
}
fn strip_key(mut value: Value, key: &str) -> Value {
if let Value::Object(map) = &mut value {
map.remove(key);
}
value
}
fn merge_values(base: &mut Value, overlay: Value) {
match (base, overlay) {
(Value::Object(base_map), Value::Object(overlay_map)) => {
for (key, overlay_value) in overlay_map {
match base_map.get_mut(&key) {
Some(base_value) => merge_values(base_value, overlay_value),
None => {
base_map.insert(key, overlay_value);
}
}
}
}
(Value::Array(base_items), Value::Array(overlay_items)) => {
for item in overlay_items {
if !base_items.contains(&item) {
base_items.push(item);
}
}
}
(base_slot, overlay_value) => *base_slot = overlay_value,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::fs;
struct Fixture {
_guards: Vec<tempfile::TempDir>,
home: PathBuf, user_dir: PathBuf, repo: PathBuf, }
fn fixture() -> Fixture {
let home_guard = tempfile::tempdir().unwrap();
let repo_guard = tempfile::tempdir().unwrap();
let home = fs::canonicalize(home_guard.path()).unwrap();
let repo = fs::canonicalize(repo_guard.path()).unwrap();
let user_dir = home.join(".locode");
fs::create_dir_all(&user_dir).unwrap();
fs::create_dir(repo.join(".git")).unwrap();
Fixture {
_guards: vec![home_guard, repo_guard],
home,
user_dir,
repo,
}
}
fn write(path: &Path, value: &Value) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, serde_json::to_string_pretty(value).unwrap()).unwrap();
}
fn load(f: &Fixture, flag: Option<&str>) -> SettingsLoad {
load_settings_from(Some(&f.user_dir), &f.repo, Some(&f.home), flag)
}
#[test]
fn precedence_user_lt_extends_lt_project_lt_local_lt_flag() {
let f = fixture();
write(
&f.user_dir.join("settings.json"),
&json!({"model": "user", "harness": "user", "api_schema": "user",
"extends": ["team.json"]}),
);
write(
&f.user_dir.join("team.json"),
&json!({"model": "team", "harness": "team"}),
);
write(
&f.repo.join(".locode/settings.json"),
&json!({"model": "project"}),
);
write(
&f.repo.join(".locode/settings.local.json"),
&json!({"model": "local"}),
);
let got = load(&f, None);
assert_eq!(got.settings.model.as_deref(), Some("local"));
assert_eq!(got.settings.harness.as_deref(), Some("team"));
assert_eq!(got.settings.api_schema.as_deref(), Some("user"));
let got = load(&f, Some(r#"{"model": "flag"}"#));
assert_eq!(got.settings.model.as_deref(), Some("flag"));
}
#[test]
fn extends_is_ordered_and_non_recursive() {
let f = fixture();
write(
&f.user_dir.join("settings.json"),
&json!({"extends": ["a.json", "b.json"]}),
);
write(&f.user_dir.join("a.json"), &json!({"model": "a"}));
write(
&f.user_dir.join("b.json"),
&json!({"model": "b", "extends": ["c.json"]}),
);
write(&f.user_dir.join("c.json"), &json!({"model": "c"}));
let got = load(&f, None);
assert_eq!(got.settings.model.as_deref(), Some("b"));
assert!(
got.warnings.iter().any(|w| w.contains("nested `extends`")),
"{:?}",
got.warnings
);
}
#[test]
fn project_layers_cannot_set_denylisted_keys_or_extends() {
let f = fixture();
write(
&f.user_dir.join("settings.json"),
&json!({"api_schema": "user"}),
);
write(
&f.repo.join(".locode/settings.json"),
&json!({"api_schema": "evil", "extends": ["/tmp/evil.json"], "model": "ok"}),
);
let got = load(&f, None);
assert_eq!(
got.settings.api_schema.as_deref(),
Some("user"),
"denylisted"
);
assert_eq!(got.settings.model.as_deref(), Some("ok"), "other keys pass");
assert_eq!(
got.warnings
.iter()
.filter(|w| w.contains("project layers may not set"))
.count(),
2,
"{:?}",
got.warnings
);
}
#[test]
fn arrays_union_and_objects_deep_merge() {
let f = fixture();
write(
&f.user_dir.join("settings.json"),
&json!({"skills": {"extra": ["~/a-skills"]}, "instructions": {"root_stop_pattern": "u"}}),
);
write(
&f.repo.join(".locode/settings.json"),
&json!({"skills": {"extra": ["~/b-skills", "~/a-skills"]}}),
);
let got = load(&f, None);
assert_eq!(got.settings.root_stop_pattern.as_deref(), Some("u"));
let folders: Vec<_> = got
.settings
.skills_extra
.iter()
.map(|e| match e {
SkillsExtraEntry::Folder(p) | SkillsExtraEntry::Skill(p) => p.clone(),
})
.collect();
assert_eq!(
folders,
vec![f.home.join("a-skills"), f.home.join("b-skills")],
"union, first occurrence order, no duplicate"
);
}
#[test]
fn skills_extra_classifies_and_validates() {
let f = fixture();
let single = f.home.join("one-off");
fs::create_dir_all(&single).unwrap();
fs::write(single.join("SKILL.md"), "x").unwrap();
write(
&f.user_dir.join("settings.json"),
&json!({"skills": {"extra": ["~/one-off", "~/team-skills/", "~/random-dir"]}}),
);
let got = load(&f, None);
assert_eq!(
got.settings.skills_extra,
vec![
SkillsExtraEntry::Skill(single),
SkillsExtraEntry::Folder(f.home.join("team-skills/")),
]
);
assert!(
got.warnings.iter().any(|w| w.contains("random-dir")),
"{:?}",
got.warnings
);
}
#[test]
fn malformed_layers_degrade_with_warnings() {
let f = fixture();
fs::write(f.user_dir.join("settings.json"), "{not json").unwrap();
write(
&f.repo.join(".locode/settings.json"),
&json!({"model": "p"}),
);
let got = load(&f, None);
assert_eq!(
got.settings.model.as_deref(),
Some("p"),
"good layers still load"
);
assert!(got.warnings.iter().any(|w| w.contains("invalid")));
write(
&f.user_dir.join("settings.json"),
&json!({"extends": ["missing.json"], "model": "u"}),
);
let got = load(&f, None);
assert_eq!(got.settings.model.as_deref(), Some("p"));
assert!(
got.warnings
.iter()
.any(|w| w.contains("missing.json") && w.contains("not found")),
"{:?}",
got.warnings
);
}
#[test]
fn unknown_keys_are_tolerated() {
let f = fixture();
write(
&f.user_dir.join("settings.json"),
&json!({"model": "m", "future_feature": {"x": 1}, "another": [1, 2]}),
);
let got = load(&f, None);
assert_eq!(got.settings.model.as_deref(), Some("m"));
assert!(got.warnings.is_empty(), "{:?}", got.warnings);
}
#[test]
fn no_git_root_uses_cwd_dot_locode() {
let dir = tempfile::tempdir().unwrap();
let cwd = fs::canonicalize(dir.path()).unwrap();
write(
&cwd.join(".locode/settings.json"),
&json!({"model": "here"}),
);
let got = load_settings_from(None, &cwd, None, None);
assert_eq!(got.settings.model.as_deref(), Some("here"));
}
#[test]
fn scaffold_writes_current_defaults_once() {
let dir = tempfile::tempdir().unwrap();
let user_dir = dir.path().join(".locode");
let notice = scaffold_user_settings(&user_dir).expect("scaffolded");
assert!(notice.contains("settings.json"));
let path = user_dir.join("settings.json");
let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(value["harness"], "claude");
assert_eq!(value["api_schema"], "anthropic");
assert_eq!(value["model"], "claude-sonnet-5");
assert_eq!(value["skills"]["extra"], serde_json::json!([]));
let cwd = tempfile::tempdir().unwrap();
let got = load_settings_from(Some(&user_dir), cwd.path(), None, None);
assert_eq!(got.settings.harness.as_deref(), Some("claude"));
assert_eq!(got.settings.model.as_deref(), Some("claude-sonnet-5"));
assert!(got.warnings.is_empty(), "{:?}", got.warnings);
fs::write(&path, r#"{"harness":"claude"}"#).unwrap();
assert!(scaffold_user_settings(&user_dir).is_none());
let kept: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(kept["harness"], "claude", "existing file untouched");
}
#[test]
fn invalid_root_stop_pattern_warns_but_survives() {
let f = fixture();
write(
&f.user_dir.join("settings.json"),
&json!({"instructions": {"root_stop_pattern": "[bad"}, "model": "m"}),
);
let got = load(&f, None);
assert_eq!(got.settings.model.as_deref(), Some("m"));
assert_eq!(got.settings.root_stop_pattern.as_deref(), Some("[bad"));
assert!(
got.warnings.iter().any(|w| w.contains("root_stop_pattern")),
"{:?}",
got.warnings
);
}
#[test]
fn inline_flag_json_and_non_object_rejection() {
let f = fixture();
let got = load(&f, Some(r#"{"harness": "codex"}"#));
assert_eq!(got.settings.harness.as_deref(), Some("codex"));
let got = load(&f, Some("[1,2]"));
assert!(got.warnings.iter().any(|w| w.contains("JSON object")));
}
}