use super::*;
#[test]
fn deep_merge_json_recurses_objects_and_replaces_non_objects() {
let mut base = json!({
"object": {"keep": true, "replace": {"old": true}},
"array": ["a"],
"scalar": true
});
let override_val = json!({
"object": {"replace": {"new": true}},
"array": ["b"],
"scalar": null
});
deep_merge_json(&mut base, &override_val);
assert_eq!(base["object"]["keep"], true);
assert_eq!(base["object"]["replace"], json!({"old": true, "new": true}));
assert_eq!(base["array"], json!(["b"]));
assert_eq!(base["scalar"], serde_json::Value::Null);
}
#[test]
fn appearance_settings_are_a_document_sidecar_and_remain_in_schema() {
let defaults: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(
serde_json::to_value(&defaults).unwrap().get("appearance"),
None
);
let default_document = SettingsDocument::from_value(json!({})).unwrap();
assert_eq!(default_document.appearance, AppearanceSettings::default());
let custom = SettingsDocument::from_value(json!({
"appearance": {"theme": "custom:neon.toml", "reduced_motion": true}
}))
.unwrap();
assert_eq!(custom.appearance.theme, "custom:neon.toml");
assert!(custom.appearance.reduced_motion);
assert!(
serde_json::to_value(&custom.settings)
.unwrap()
.get("appearance")
.is_none()
);
let schema = serde_json::to_value(schemars::schema_for!(Settings)).unwrap();
assert!(
schema
.pointer("/$defs/InterfaceSettingsSchema/properties/appearance")
.is_some()
);
}
#[test]
fn appearance_settings_are_global_and_project_values_are_ignored() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"appearance":{"theme":"dark","reduced_motion":true},"sessions":{"retention_days":7},"future_global":true}"#,
)
.unwrap();
fs::write(
&local_settings,
r#"{"appearance":{"theme":"project","reduced_motion":false},"sessions":{"retention_days":14},"no_color":true}"#,
)
.unwrap();
let (loaded, sessions) = read_settings_document_with_session_settings(&paths).unwrap();
assert_eq!(loaded.appearance.theme, "dark");
assert!(loaded.appearance.reduced_motion);
assert_eq!(loaded.settings.no_color, Some(true));
assert_eq!(sessions.retention_days, 14);
}
#[test]
fn unrelated_settings_writes_preserve_appearance_and_unknown_fields() {
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#"{"appearance":{"theme":"dark","reduced_motion":true,"future":7},"future_global":true}"#,
)
.unwrap();
set_selected_model(&paths, "provider", "model").unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["interface"]["appearance"]["theme"], "dark");
assert_eq!(value["interface"]["appearance"]["reduced_motion"], true);
assert_eq!(value["interface"]["appearance"]["future"], 7);
assert_eq!(value["future_global"], true);
assert_eq!(value["agent"]["model"]["provider"], "provider");
}
#[test]
fn set_appearance_theme_updates_only_the_raw_theme_field() {
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#"{"appearance":{"theme":"old","reduced_motion":true,"future":7},"future_global":true}"#,
)
.unwrap();
set_appearance_theme(&paths, "new").unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["interface"]["appearance"]["theme"], "new");
assert_eq!(value["interface"]["appearance"]["reduced_motion"], true);
assert_eq!(value["interface"]["appearance"]["future"], 7);
assert_eq!(value["future_global"], true);
}
#[test]
fn set_appearance_theme_rejects_invalid_legacy_appearance_without_repair() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
let original = br#"{"appearance":{"theme":123}}"#;
fs::write(&paths.settings_file, original).unwrap();
let error = set_appearance_theme(&paths, "new").unwrap_err().to_string();
assert!(
error.contains("invalid settings interface.appearance"),
"{error}"
);
assert_eq!(fs::read(&paths.settings_file).unwrap(), original);
assert!(
!paths
.settings_file
.with_file_name("settings.json.pre-v2.bak")
.exists()
);
}
#[test]
fn read_settings_local_scalar_overrides_global() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(&paths.settings_file, r#"{"no_color":true}"#).unwrap();
fs::write(&local_settings, r#"{"no_color":false}"#).unwrap();
let settings = read_settings(&paths).unwrap();
assert_eq!(settings.no_color, Some(false));
}
#[test]
fn read_settings_local_fast_mode_uses_global_value_but_other_codex_fields_override() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(
&paths.settings_file,
r#"{"fast":{"enabled":false},"openai_codex":{"text_verbosity":"low","future_global":true}}"#,
)
.unwrap();
fs::write(
&local_settings,
r#"{"fast":{"enabled":true},"openai_codex":{"text_verbosity":"high","future_project":true}}"#,
)
.unwrap();
let settings = read_settings(&paths).unwrap();
assert!(!settings.fast.enabled);
assert_eq!(settings.openai_codex.text_verbosity, TextVerbosity::High);
fs::write(
&paths.settings_file,
r#"{"fast":{"enabled":true},"openai_codex":{"text_verbosity":"low"}}"#,
)
.unwrap();
fs::write(&local_settings, r#"{"fast":{"enabled":false}}"#).unwrap();
let settings = read_settings(&paths).unwrap();
assert!(settings.fast.enabled);
assert_eq!(settings.openai_codex.text_verbosity, TextVerbosity::Low);
}
#[test]
fn read_settings_local_omission_keeps_global_nested_values() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(
&paths.settings_file,
r#"{"tools":{"read":{"absolute_paths":false}}}"#,
)
.unwrap();
fs::write(&local_settings, r#"{"no_color":true}"#).unwrap();
let settings = read_settings(&paths).unwrap();
assert!(!settings.tools.read.absolute_paths);
assert_eq!(settings.no_color, Some(true));
}
#[test]
fn read_settings_project_compaction_limit_inherits_and_zero_overrides_global() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(
&paths.settings_file,
r#"{"compaction":{"auto":{"threshold_tokens":1,"max_compactions_per_run":7}}}"#,
)
.unwrap();
fs::write(
&local_settings,
r#"{"compaction":{"auto":{"threshold_tokens":2}}}"#,
)
.unwrap();
let inherited = read_settings(&paths).unwrap();
assert_eq!(inherited.compaction.auto.max_compactions_per_run, Some(7));
fs::write(
&local_settings,
r#"{"compaction":{"auto":{"max_compactions_per_run":0}}}"#,
)
.unwrap();
let overridden = read_settings(&paths).unwrap();
assert_eq!(overridden.compaction.auto.max_compactions_per_run, Some(0));
assert_eq!(
overridden.compaction.auto.compaction_limit(),
AutoCompactionLimit::NoCountCap
);
}
#[test]
fn read_settings_local_map_union_and_key_override() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(
&paths.settings_file,
r#"{"mcp_servers":{"server1":{"type":"stdio","command":"global"}}}"#,
)
.unwrap();
fs::write(
&local_settings,
r#"{"mcp_servers":{"server1":{"type":"stdio","command":"local"},"server2":{"type":"stdio","command":"second"}}}"#,
)
.unwrap();
let settings = read_settings(&paths).unwrap();
assert_eq!(settings.mcp_servers.len(), 2);
let McpServerConfig::Stdio(server1) = settings.mcp_servers.get("server1").unwrap() else {
panic!("expected stdio server1");
};
let McpServerConfig::Stdio(server2) = settings.mcp_servers.get("server2").unwrap() else {
panic!("expected stdio server2");
};
assert_eq!(server1.command, "local");
assert_eq!(server2.command, "second");
}
#[test]
fn read_settings_local_vec_replaces_global_vec() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(
&paths.settings_file,
r#"{"skills":{"additional_paths":["a"]}}"#,
)
.unwrap();
fs::write(&local_settings, r#"{"skills":{"additional_paths":["b"]}}"#).unwrap();
let settings = read_settings(&paths).unwrap();
assert_eq!(settings.skills.additional_paths, vec![PathBuf::from("b")]);
}
#[test]
fn scoped_project_update_creates_exact_cwd_settings_and_preserves_unknowns() {
let temp = tempfile::TempDir::new().unwrap();
let paths =
McPaths::from_root_and_project_dir(temp.path().join("mc"), temp.path().join("project"));
fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
fs::write(
&paths.project_settings_file,
r#"{"future":true,"tools":{"future_tool":true},"subagents":{"future_subagent":true}}"#,
)
.unwrap();
set_tool_disabled(&paths, SettingsScope::Project, "bash", true).unwrap();
set_subagent_profile_disabled(&paths, SettingsScope::Project, "reviewer", true).unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.project_settings_file).unwrap()).unwrap();
assert_eq!(value["future"], true);
assert_eq!(value["capabilities"]["tools"]["future_tool"], true);
assert_eq!(value["capabilities"]["tools"]["disabled"], json!(["bash"]));
assert_eq!(value["agent"]["subagents"]["future_subagent"], true);
assert_eq!(value["agent"]["subagents"]["disabled"], json!(["reviewer"]));
assert!(value.get("$schema").is_none());
}
#[test]
fn project_disabled_empty_list_overrides_global_disabled() {
let temp = tempfile::TempDir::new().unwrap();
let paths =
McPaths::from_root_and_project_dir(temp.path().join("mc"), temp.path().join("project"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
fs::write(&paths.settings_file, r#"{"tools":{"disabled":["bash"]}}"#).unwrap();
set_tool_disabled(&paths, SettingsScope::Project, "bash", false).unwrap();
let local: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.project_settings_file).unwrap()).unwrap();
assert_eq!(local["capabilities"]["tools"]["disabled"], json!([]));
assert!(disabled_tool_names_from_settings(&read_settings(&paths).unwrap()).is_empty());
}
#[test]
fn scoped_project_update_rejects_magi_code_symlink_escape() {
let temp = tempfile::TempDir::new().unwrap();
let project = temp.path().join("project");
let outside = temp.path().join("outside");
fs::create_dir_all(&project).unwrap();
fs::create_dir_all(&outside).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&outside, project.join(".magi-code")).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&outside, project.join(".magi-code")).unwrap();
let paths = McPaths::from_root_and_project_dir(temp.path().join("mc"), project);
let error = set_skill_disabled_for_scope(&paths, SettingsScope::Project, "review", true)
.unwrap_err()
.to_string();
assert!(error.contains("escapes cwd"), "{error}");
}
#[test]
fn project_modal_scope_inherits_effective_until_local_list_exists() {
let temp = tempfile::TempDir::new().unwrap();
let paths =
McPaths::from_root_and_project_dir(temp.path().join("mc"), temp.path().join("project"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(&paths.settings_file, r#"{"skills":{"disabled":["audit"]}}"#).unwrap();
let inherited =
disabled_names_for_modal_scope(&paths, SettingsScope::Project, SettingsListKind::Skills)
.unwrap();
assert!(inherited.contains("audit"));
assert!(!paths.project_settings_file.exists());
}
#[test]
fn project_update_does_not_serialize_full_default_tools_block() {
let temp = tempfile::TempDir::new().unwrap();
let paths =
McPaths::from_root_and_project_dir(temp.path().join("mc"), temp.path().join("project"));
fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
set_tool_disabled(&paths, SettingsScope::Project, "bash", true).unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.project_settings_file).unwrap()).unwrap();
assert_eq!(value["capabilities"]["tools"]["disabled"], json!(["bash"]));
assert!(value["capabilities"]["tools"].get("read").is_none());
assert!(value["capabilities"]["tools"].get("subagents").is_none());
}
#[test]
fn project_update_does_not_emit_default_execution_or_override_global_execution() {
let temp = tempfile::TempDir::new().unwrap();
let mut paths = McPaths::from_root(temp.path().join("mc"));
paths.project_settings_file = temp.path().join("project/.magi-code/settings.json");
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
fs::write(
&paths.settings_file,
r#"{"agent":{"subagents":{"execution":{"absolute_paths":false,"max_depth":4}}}}"#,
)
.unwrap();
set_tool_disabled(&paths, SettingsScope::Project, "bash", true).unwrap();
let local: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.project_settings_file).unwrap()).unwrap();
assert!(local.pointer("/agent/subagents/execution").is_none());
assert_eq!(local["capabilities"]["tools"]["disabled"], json!(["bash"]));
let effective = read_settings(&paths).unwrap();
assert!(!effective.tools.subagents.absolute_paths);
assert_eq!(effective.tools.subagents.max_depth, 4);
}
#[test]
fn project_mutation_preserves_partial_custom_provider_override() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(
&paths.settings_file,
r#"{
"providers": {
"custom": {
"shared": {
"label": "Shared",
"base_url": "https://provider.example.test/v1",
"future": {"global": true}
}
}
}
}"#,
)
.unwrap();
fs::write(
&local_settings,
r#"{
"providers": {
"custom": {
"shared": {
"use_responses_endpoint": true,
"future": {"local": true}
}
}
}
}"#,
)
.unwrap();
set_tool_disabled(&paths, SettingsScope::Project, "bash", true).unwrap();
let local: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&local_settings).unwrap()).unwrap();
let provider = &local["providers"]["custom"]["shared"];
assert!(provider.get("label").is_none());
assert!(provider.get("base_url").is_none());
assert_eq!(provider["use_responses_endpoint"], true);
assert_eq!(provider["future"]["local"], true);
assert_eq!(local["capabilities"]["tools"]["disabled"], json!(["bash"]));
let effective = read_settings(&paths).unwrap();
let provider = effective.custom_providers.get("shared").unwrap();
assert_eq!(provider.label, "Shared");
assert!(provider.use_responses_endpoint);
}
#[test]
fn project_mutation_preserves_partial_mcp_server_override() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(
&paths.settings_file,
r#"{
"capabilities": {
"mcp": {
"shared": {
"type": "stdio",
"command": "global-mcp",
"future": {"global": true}
}
}
}
}"#,
)
.unwrap();
fs::write(
&local_settings,
r#"{
"capabilities": {
"mcp": {
"shared": {
"enabled": false,
"future": {"local": true}
}
}
}
}"#,
)
.unwrap();
set_tool_disabled(&paths, SettingsScope::Project, "grep", true).unwrap();
let local: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&local_settings).unwrap()).unwrap();
let server = &local["capabilities"]["mcp"]["shared"];
assert!(server.get("type").is_none());
assert!(server.get("command").is_none());
assert_eq!(server["enabled"], false);
assert_eq!(server["future"]["local"], true);
assert_eq!(local["capabilities"]["tools"]["disabled"], json!(["grep"]));
let effective = read_settings(&paths).unwrap();
let McpServerConfig::Stdio(server) = effective.mcp_servers.get("shared").unwrap() else {
panic!("expected stdio MCP server");
};
assert_eq!(server.command, "global-mcp");
assert!(!server.enabled);
}
#[test]
fn read_settings_for_scope_reads_only_target_file() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(&paths.settings_file, r#"{"no_color":true}"#).unwrap();
fs::write(&local_settings, r#"{"no_color":false}"#).unwrap();
assert_eq!(
read_settings_for_scope(&paths, SettingsScope::Global)
.unwrap()
.no_color,
Some(true)
);
assert_eq!(
read_settings_for_scope(&paths, SettingsScope::Project)
.unwrap()
.no_color,
Some(false)
);
}
#[test]
fn read_settings_accepts_local_only_settings() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(&local_settings, r#"{"no_color":true}"#).unwrap();
let settings = read_settings(&paths).unwrap();
assert_eq!(settings.no_color, Some(true));
}
#[test]
fn read_settings_keeps_global_when_local_is_empty_or_missing() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(&paths.settings_file, r#"{"no_color":true}"#).unwrap();
fs::write(&local_settings, r#"{}"#).unwrap();
assert_eq!(read_settings(&paths).unwrap().no_color, Some(true));
fs::remove_file(&local_settings).unwrap();
assert_eq!(read_settings(&paths).unwrap().no_color, Some(true));
}
#[test]
fn read_settings_invalid_local_json_error_names_local_path() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(&paths.settings_file, r#"{"no_color":true}"#).unwrap();
fs::write(&local_settings, "not json").unwrap();
let error = read_settings(&paths).unwrap_err().to_string();
assert!(
error.contains(&local_settings.display().to_string()),
"{error}"
);
}