use super::*;
#[test]
fn settings_deserialize_ignores_schema_metadata() {
let settings: Settings = serde_json::from_str(
r#"{"$schema":"./state/settings.schema.json","selected_model":{"provider":"openai-codex"}}"#,
)
.unwrap();
assert_eq!(
settings.selected_model.provider.as_deref(),
Some("openai-codex")
);
}
#[test]
fn normalized_settings_read_canonical_legacy_and_conflicts() {
let canonical: Settings = serde_json::from_str(
r#"{
"selected_model":{"provider":"canonical-provider","model":"canonical-model","thinking_level":"high"},
"skills":{"disabled":["review"]},
"integrations":{"herdr":{"enabled":true}}
}"#,
)
.unwrap();
assert_eq!(
canonical.selected_model.provider.as_deref(),
Some("canonical-provider")
);
assert_eq!(
canonical.selected_model.model.as_deref(),
Some("canonical-model")
);
assert_eq!(
canonical.selected_model.thinking_level,
Some(ThinkingLevel::High)
);
assert_eq!(canonical.skills.disabled, vec!["review"]);
assert!(canonical.integrations.herdr.enabled);
let legacy: Settings = serde_json::from_str(
r#"{
"provider":"legacy-provider",
"model":"legacy-model",
"thinking_level":"medium",
"disabled_skills":["plan"],
"herdr":{"enabled":true}
}"#,
)
.unwrap();
assert_eq!(
legacy.selected_model.provider.as_deref(),
Some("legacy-provider")
);
assert_eq!(legacy.selected_model.model.as_deref(), Some("legacy-model"));
assert_eq!(
legacy.selected_model.thinking_level,
Some(ThinkingLevel::Medium)
);
assert_eq!(legacy.skills.disabled, vec!["plan"]);
assert!(legacy.integrations.herdr.enabled);
let conflict: Settings = serde_json::from_str(
r#"{
"selected_model":{"provider":"canonical-provider","model":"canonical-model","thinking_level":"low"},
"provider":"legacy-provider",
"model":"legacy-model",
"thinking_level":"high",
"skills":{"disabled":[]},
"disabled_skills":["legacy-skill"],
"integrations":{"herdr":{"enabled":false}},
"herdr":{"enabled":true}
}"#,
)
.unwrap();
assert_eq!(
conflict.selected_model.provider.as_deref(),
Some("canonical-provider")
);
assert_eq!(
conflict.selected_model.model.as_deref(),
Some("canonical-model")
);
assert_eq!(
conflict.selected_model.thinking_level,
Some(ThinkingLevel::Low)
);
assert!(conflict.skills.disabled.is_empty());
assert!(!conflict.integrations.herdr.enabled);
}
#[test]
fn tool_settings_accept_canonical_and_legacy_tool_keys() {
let canonical: Settings = serde_json::from_str(
r#"{"tools":{"grep":{"absolute_paths":false},"find":{"absolute_paths":false},"list_files":{"absolute_paths":false},"ast_grep":{"absolute_paths":false},"subagents":{"absolute_paths":false,"max_depth":2}}}"#,
)
.unwrap();
assert!(!canonical.tools.grep.absolute_paths);
assert!(!canonical.tools.find.absolute_paths);
assert!(!canonical.tools.list_files.absolute_paths);
assert!(!canonical.tools.ast_grep.absolute_paths);
assert!(!canonical.tools.subagents.absolute_paths);
assert_eq!(canonical.tools.subagents.max_depth, 2);
let legacy: Settings = serde_json::from_str(
r#"{"tools":{"ffgrep":{"absolute_paths":false},"fffind":{"absolute_paths":false},"list_files":{"absolute_paths":false},"parallel_subagents":{"absolute_paths":false,"max_depth":3}}}"#,
)
.unwrap();
assert!(!legacy.tools.grep.absolute_paths);
assert!(!legacy.tools.find.absolute_paths);
assert!(!legacy.tools.list_files.absolute_paths);
assert!(!legacy.tools.subagents.absolute_paths);
assert_eq!(legacy.tools.subagents.max_depth, 3);
}
#[test]
fn normalized_settings_default_serialization_omits_empty_groups() {
let value = serde_json::to_value(Settings::default()).unwrap();
assert!(value.get("selected_model").is_none());
assert!(value.get("integrations").is_none());
assert!(value.get("instructions").is_none());
assert!(value.get("skills").is_none());
}
#[test]
fn normalized_settings_update_preserves_nested_unknowns_and_removes_legacy_keys() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{
"future_setting":true,
"provider":"legacy-provider",
"model":"legacy-model",
"thinking_level":"high",
"disabled_skills":["legacy-skill"],
"herdr":{"enabled":true,"legacy_future":"keep"},
"selected_model":{"provider":"canonical-provider","future":"keep"},
"instructions":{"additional_markdown_paths":["/opt/team.md"],"future":"keep"},
"skills":{"additional_paths":["/opt/skills"],"future":"keep"},
"integrations":{"future":"keep","herdr":{"enabled":false,"future":"keep"}},
"tui":{"future":"keep"}
}"#,
)
.unwrap();
set_selected_model(&paths, "new-provider", "new-model").unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
assert_eq!(value["future_setting"], true);
assert_eq!(value["agent"]["model"]["provider"], "new-provider");
assert_eq!(value["agent"]["model"]["model"], "new-model");
assert_eq!(value["agent"]["model"]["thinking_level"], "high");
assert_eq!(value["agent"]["model"]["future"], "keep");
assert_eq!(
value["knowledge"]["instructions"]["additional_markdown_paths"][0],
"/opt/team.md"
);
assert_eq!(value["knowledge"]["instructions"]["future"], "keep");
assert_eq!(value["knowledge"]["skills"]["disabled"][0], "legacy-skill");
assert_eq!(value["knowledge"]["skills"]["future"], "keep");
assert_eq!(value["automation"]["integrations"]["future"], "keep");
assert_eq!(
value["automation"]["integrations"]["herdr"]["enabled"],
false
);
assert_eq!(
value["automation"]["integrations"]["herdr"]["future"],
"keep"
);
assert_eq!(value["interface"]["tui"]["future"], "keep");
for legacy_key in [
"provider",
"model",
"thinking_level",
"disabled_skills",
"herdr",
] {
assert!(
value.get(legacy_key).is_none(),
"legacy key survived: {legacy_key}"
);
}
}
#[test]
fn hook_settings_preserve_unknown_fields_on_settings_update() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"hooks":{"future":{"keep":true}}}"#,
)
.unwrap();
set_selected_model(&paths, "provider", "model").unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["automation"]["hooks"]["future"]["keep"], true);
assert_eq!(value["agent"]["model"]["provider"], "provider");
assert_eq!(value["agent"]["model"]["model"], "model");
}
#[test]
fn hook_settings_default_without_unknown_fields_is_omitted_on_settings_update() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(&paths.settings_file, r#"{}"#).unwrap();
set_selected_model(&paths, "provider", "model").unwrap();
let value = read_settings_value(&paths);
assert!(value.get("hooks").is_none());
}
#[test]
fn hook_settings_known_fields_override_old_raw_values_on_settings_update() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"hooks":{"enabled":false,"future":{"keep":true}}}"#,
)
.unwrap();
update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
settings.hooks.enabled = true;
})
.unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["automation"]["hooks"]["enabled"], true);
assert_eq!(value["automation"]["hooks"]["future"]["keep"], true);
}
#[test]
fn settings_migration_canonical_values_win_recursively_over_tool_aliases() {
let migrated = migrate_settings_value(json!({
"schema_version": 2,
"agent": {
"model": {
"provider": "canonical-provider",
"future": {
"shared": "canonical",
"canonical_only": true
}
},
"subagents": {
"execution": {
"absolute_paths": false,
"max_depth": 4,
"future": {"shared": "canonical"}
}
}
},
"capabilities": {
"tools": {
"grep": {
"absolute_paths": false,
"future": {"shared": "canonical"}
},
"subagents": {
"absolute_paths": false,
"max_depth": 4,
"future": {"shared": "canonical"}
}
}
},
"selected_model": {
"provider": "legacy-provider",
"future": {
"shared": "legacy",
"legacy_only": true
}
},
"tools": {
"ffgrep": {
"absolute_paths": true,
"future": {"shared": "alias", "alias_only": true}
},
"parallel_subagents": {
"absolute_paths": true,
"max_depth": 3,
"future": {"shared": "alias", "alias_only": true}
}
}
}))
.unwrap();
assert_eq!(migrated["schema_version"], 2);
assert_eq!(migrated["agent"]["model"]["provider"], "canonical-provider");
assert_eq!(migrated["agent"]["model"]["future"]["shared"], "canonical");
assert_eq!(migrated["agent"]["model"]["future"]["canonical_only"], true);
assert_eq!(migrated["agent"]["model"]["future"]["legacy_only"], true);
assert_eq!(
migrated["capabilities"]["tools"]["grep"]["absolute_paths"],
false
);
assert_eq!(
migrated["capabilities"]["tools"]["grep"]["future"]["shared"],
"canonical"
);
assert_eq!(
migrated["capabilities"]["tools"]["grep"]["future"]["alias_only"],
true
);
assert_eq!(
migrated["agent"]["subagents"]["execution"]["absolute_paths"],
false
);
assert_eq!(migrated["agent"]["subagents"]["execution"]["max_depth"], 4);
assert_eq!(
migrated["agent"]["subagents"]["execution"]["future"]["shared"],
"canonical"
);
assert_eq!(
migrated["agent"]["subagents"]["execution"]["future"]["alias_only"],
true
);
assert!(migrated["capabilities"]["tools"].get("ffgrep").is_none());
assert!(
migrated["capabilities"]["tools"]
.get("parallel_subagents")
.is_none()
);
assert!(migrated.get("selected_model").is_none());
assert!(migrated.get("tools").is_none());
}
#[test]
fn settings_migration_does_not_fill_explicit_empty_agent_from_tool_alias() {
for agent in [json!({}), json!({"subagents": {}})] {
let migrated = migrate_settings_value(json!({
"schema_version": 2,
"agent": agent.clone(),
"capabilities": {
"tools": {
"parallel_subagents": {"absolute_paths": false, "max_depth": 3}
}
}
}))
.unwrap();
assert_eq!(migrated["agent"], agent);
assert_eq!(migrated["capabilities"]["tools"], json!({}));
}
}
#[test]
fn settings_migration_keeps_malformed_canonical_containers_instead_of_using_aliases() {
let migrated = migrate_settings_value(json!({
"schema_version": 2,
"agent": {"model": []},
"capabilities": {"tools": {"grep": null}},
"selected_model": {"provider": "legacy-provider"},
"tools": {"ffgrep": {"absolute_paths": false}}
}))
.unwrap();
assert_eq!(migrated["agent"]["model"], json!([]));
assert_eq!(
migrated["capabilities"]["tools"]["grep"],
serde_json::Value::Null
);
assert!(migrated.get("selected_model").is_none());
assert!(migrated.get("tools").is_none());
}
#[test]
fn settings_update_preserves_explicit_default_groups_and_null_values() {
let mut raw = json!({
"schema_version": 2,
"agent": {
"model": {"provider": "provider"},
"fast": {"enabled": false},
"context": null,
"subagents": {}
},
"providers": {"custom": {}},
"capabilities": {"tools": {"read": {}}, "mcp": {}},
"interface": {
"appearance": {"theme": "matrix-green", "future": null},
"no_color": null
},
"future": null
});
let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap();
let before = settings.clone();
settings.selected_model.model = Some("model".to_string());
update_raw_from_settings(&mut raw, &before, &settings, SettingsScope::Global).unwrap();
assert_eq!(raw["agent"]["model"]["model"], "model");
assert_eq!(raw["agent"]["fast"], json!({"enabled": false}));
assert_eq!(raw["agent"]["context"], serde_json::Value::Null);
assert_eq!(raw["agent"]["subagents"], json!({}));
assert_eq!(raw["providers"]["custom"], json!({}));
assert_eq!(raw["capabilities"]["tools"]["read"], json!({}));
assert_eq!(raw["capabilities"]["mcp"], json!({}));
assert_eq!(
raw["interface"]["appearance"]["future"],
serde_json::Value::Null
);
assert_eq!(raw["interface"]["no_color"], serde_json::Value::Null);
assert_eq!(raw["future"], serde_json::Value::Null);
}
#[test]
fn settings_update_removes_last_entries_from_dynamic_maps() {
let mut raw = json!({
"schema_version": 2,
"agent": {"context": {
"enabled": true,
"max_tokens": 128000,
"reserve_tokens": 16384,
"keep_recent_tokens": 20000,
"model_overrides": {
"provider/model": {"max_tokens": 1000, "future": null}
}
}},
"providers": {"custom": {
"old": {
"label": "Old",
"base_url": "http://127.0.0.1:8080/v1",
"future": {"keep": true}
}
}},
"capabilities": {
"mcp": {
"old": {"type": "stdio", "command": "old-mcp", "future": {"keep": true}}
},
"lsp": {
"enabled": true,
"servers": {"rust": {"command": "rust-analyzer", "future": {"keep": true}}}
}
},
"future_root": {"keep": true}
});
let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap();
let before = settings.clone();
settings.custom_providers.clear();
settings.mcp_servers.clear();
settings.lsp.servers.clear();
settings.context.as_mut().unwrap().model_overrides.clear();
update_raw_from_settings(&mut raw, &before, &settings, SettingsScope::Global).unwrap();
assert!(raw.pointer("/providers/custom").is_none());
assert!(raw.pointer("/capabilities/mcp").is_none());
assert!(raw.pointer("/capabilities/lsp/servers").is_none());
assert!(raw.pointer("/agent/context/model_overrides").is_none());
assert_eq!(raw["future_root"]["keep"], true);
}
#[test]
fn shared_wire_preserves_null_empty_and_explicit_false_projections() {
let cases = [
(json!({}), json!({"schema_version": 2})),
(
json!({
"agent": {"primary_agent": null, "context": null, "subagents": {"disabled": []}},
"providers": {"anthropic": {"cache_ttl": null}, "custom": {}, "catalog": {"disabled": []}},
"capabilities": {"tools": {"disabled": []}, "mcp": {}},
"knowledge": {"skills": {"disabled": []}},
"automation": {}, "sessions": {"retention_days": 7},
"interface": {"no_color": null}
}),
json!({"schema_version": 2}),
),
(
json!({"interface": {"no_color": false, "tui": {"autocomplete": {"respects_gitignore": false}}}}),
json!({"schema_version": 2, "interface": {"no_color": false, "tui": {"autocomplete": {"respects_gitignore": false}}}}),
),
(
json!({"interface": {"tui": {"autocomplete": {"respects_gitignore": true}}}}),
json!({"schema_version": 2, "interface": {"tui": {}}}),
),
(
json!({"agent": {"subagents": {"execution": {"max_depth": 3}, "future": 42}},
"interface": {"tui": {"future": true, "autocomplete": {"respects_gitignore": true, "future": 42}}}}),
json!({"schema_version": 2, "agent": {"subagents": {"execution": {"absolute_paths": true, "max_depth": 3}, "future": 42}},
"interface": {"tui": {"future": true, "autocomplete": {"future": 42}}}}),
),
];
for (input, expected) in cases {
let settings: Settings = serde_json::from_value(input.clone()).unwrap();
assert_eq!(
serde_json::to_value(settings).unwrap(),
expected,
"input: {input}"
);
}
}
#[test]
fn shared_wire_rejects_non_object_groups_and_non_optional_nulls() {
for path in [
vec!["agent"],
vec!["providers"],
vec!["capabilities"],
vec!["knowledge"],
vec!["automation"],
vec!["sessions"],
vec!["interface"],
vec!["providers", "anthropic"],
vec!["interface", "tui"],
vec!["interface", "tui", "autocomplete"],
] {
for value in [json!([]), json!(null), json!(false)] {
let mut raw = json!({"schema_version": 2});
super::super::json::set_path(&mut raw, &path, value).unwrap();
assert!(
serde_json::from_value::<Settings>(raw.clone()).is_err(),
"accepted {raw}"
);
}
}
for raw in [
json!({"agent": {"fast": null}}),
json!({"agent": {"subagents": {"execution": null}}}),
json!({"knowledge": {"skills": null}}),
json!({"interface": {"tui": {"autocomplete": {"respects_gitignore": null}}}}),
] {
assert!(
serde_json::from_value::<Settings>(raw.clone()).is_err(),
"accepted {raw}"
);
}
}