use super::*;
#[test]
fn settings_lock_timeout_does_not_invoke_operation() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let settings_lock = settings_file_lock(&paths.settings_file).unwrap();
let _guard = settings_lock.lock().unwrap();
let mut invoked = false;
let error = with_settings_file_lock(&paths.settings_file, Instant::now(), || {
invoked = true;
fs::write(&paths.settings_file, b"unexpected")?;
Ok(())
})
.expect_err("held settings lock should time out");
assert!(
error
.to_string()
.contains("timed out waiting for settings lock")
);
assert!(!invoked);
assert!(!paths.settings_file.exists());
}
#[test]
fn write_file_if_changed_skips_identical_content() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("schema.json");
assert!(write_file_if_changed(&path, b"one").unwrap());
assert!(!write_file_if_changed(&path, b"one").unwrap());
assert!(write_file_if_changed(&path, b"two").unwrap());
assert_eq!(fs::read(&path).unwrap(), b"two");
}
#[test]
fn migrated_write_refuses_changed_source_before_backup() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("settings.json");
let original = br#"{"provider":"old"}"#;
let changed = br#"{"provider":"new"}"#;
fs::write(&path, changed).unwrap();
let error = write_migrated_file_if_snapshot_matches(&path, original, b"migrated")
.unwrap_err()
.to_string();
assert!(error.contains("source changed"), "{error}");
assert_eq!(fs::read(&path).unwrap(), changed);
assert!(!pre_v2_backup_path(&path).exists());
}
#[test]
fn migrated_write_refuses_missing_source_before_backup() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("settings.json");
let original = br#"{"provider":"old"}"#;
let error = write_migrated_file_if_snapshot_matches(&path, original, b"migrated")
.unwrap_err()
.to_string();
assert!(error.contains("source disappeared"), "{error}");
assert!(!path.exists());
assert!(!pre_v2_backup_path(&path).exists());
}
#[test]
fn settings_mutation_rejects_non_object_source_without_replacement() {
for original in ["[]", "null", "\"text\""] {
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, original).unwrap();
let error = update_settings_json(&paths, SettingsScope::Global, |raw| {
raw["changed"] = json!(true);
Ok(())
})
.unwrap_err()
.to_string();
assert!(error.contains("settings must be a JSON object"), "{error}");
assert_eq!(fs::read_to_string(&paths.settings_file).unwrap(), original);
assert!(!pre_v2_backup_path(&paths.settings_file).exists());
}
}
#[test]
fn settings_updates_report_write_decisions_for_global_and_project_files() {
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.project_settings_file.parent().unwrap()).unwrap();
assert!(
update_settings_json(&paths, SettingsScope::Global, |raw| {
raw["future"] = json!(true);
Ok(())
})
.unwrap()
);
assert!(
!update_settings_json(&paths, SettingsScope::Global, |raw| {
raw["future"] = json!(true);
Ok(())
})
.unwrap()
);
assert!(
update_settings_json(&paths, SettingsScope::Project, |raw| {
raw["future"] = json!(true);
Ok(())
})
.unwrap()
);
assert!(
!update_settings_json(&paths, SettingsScope::Project, |raw| {
raw["future"] = json!(true);
Ok(())
})
.unwrap()
);
}
#[test]
fn settings_schema_generation_writes_state_schema_with_core_properties() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.state).unwrap();
ensure_settings_schema_files(&paths).unwrap();
let schema_text = fs::read_to_string(paths.state.join("settings.schema.json")).unwrap();
let schema: serde_json::Value = serde_json::from_str(&schema_text).unwrap();
for key in [
"agent",
"providers",
"capabilities",
"knowledge",
"automation",
"sessions",
"interface",
"schema_version",
] {
assert!(
schema["properties"].get(key).is_some(),
"schema missing {key}"
);
}
assert_eq!(
schema
.pointer("/$defs/CompactionSettings/properties/auto/$ref")
.and_then(serde_json::Value::as_str),
Some("#/$defs/AutoCompactionSettings")
);
for field in [
"enabled",
"threshold_percent",
"threshold_tokens",
"max_compactions_per_run",
] {
let pointer = format!("/$defs/AutoCompactionSettings/properties/{field}");
assert!(
schema.pointer(&pointer).is_some(),
"schema missing {pointer}"
);
}
assert_eq!(
read_settings_value(&paths)["$schema"],
SETTINGS_SCHEMA_RELATIVE_REF
);
assert_eq!(read_settings(&paths).unwrap(), Settings::default());
}
#[test]
fn checked_in_settings_schema_matches_generated_settings_schema() {
let schema_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("state")
.join("settings.schema.json");
let checked_in: serde_json::Value =
serde_json::from_str(&fs::read_to_string(schema_path).unwrap()).unwrap();
let generated = serde_json::to_value(schemars::schema_for!(Settings)).unwrap();
assert_eq!(checked_in, generated);
}
#[test]
fn fast_mode_helpers_use_global_scope_and_preserve_unknown_fields() {
let temp = tempfile::TempDir::new().unwrap();
let paths =
McPaths::from_root_and_project_dir(temp.path().join("mc"), temp.path().join("repo"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
fs::write(
&paths.settings_file,
r#"{"future_setting":{"keep":true},"fast":{"future_field":{"keep":true},"enabled":false},"openai_codex":{"future_field":{"keep":true}}}"#,
)
.unwrap();
fs::write(&paths.project_settings_file, r#"{"fast":{"enabled":true}}"#).unwrap();
assert!(!fast_mode_enabled(&paths).unwrap());
assert!(!read_settings(&paths).unwrap().fast.enabled);
assert!(set_fast_mode(&paths, true).unwrap());
assert!(fast_mode_enabled(&paths).unwrap());
let global: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
assert_eq!(global["future_setting"]["keep"], true);
assert_eq!(global["agent"]["fast"]["future_field"]["keep"], true);
assert_eq!(global["agent"]["fast"]["enabled"], true);
assert!(
global["providers"]["openai_codex"]
.get("fast_mode")
.is_none()
);
assert_eq!(
global["providers"]["openai_codex"]["future_field"]["keep"],
true
);
fs::write(
&paths.project_settings_file,
r#"{"fast":{"enabled":false}}"#,
)
.unwrap();
assert!(read_settings(&paths).unwrap().fast.enabled);
}
#[test]
fn project_scope_mutation_removes_global_only_fast_and_preserves_project_settings() {
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.project_settings_file,
r#"{"future_setting":{"keep":true},"fast":{"enabled":true}}"#,
)
.unwrap();
set_tool_disabled(&paths, SettingsScope::Project, "bash", true).unwrap();
let project: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.project_settings_file).unwrap()).unwrap();
assert!(project.get("fast").is_none());
assert_eq!(project["future_setting"]["keep"], true);
assert_eq!(
project["capabilities"]["tools"]["disabled"],
json!(["bash"])
);
}
#[test]
fn fast_mode_false_update_does_not_restore_stale_known_value() {
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#"{"fast":{"enabled":true,"future":{"keep":true}}}"#,
)
.unwrap();
assert!(fast_mode_enabled(&paths).unwrap());
assert!(!set_fast_mode(&paths, false).unwrap());
let persisted = read_settings_value(&paths);
assert!(persisted["agent"]["fast"].get("enabled").is_none());
assert_eq!(persisted["agent"]["fast"]["future"]["keep"], true);
assert!(!read_settings(&paths).unwrap().fast.enabled);
assert!(set_fast_mode(&paths, true).unwrap());
assert!(!toggle_fast_mode(&paths).unwrap());
let reloaded = read_settings(&paths).unwrap();
assert!(!reloaded.fast.enabled);
let persisted = read_settings_value(&paths);
assert_eq!(persisted["agent"]["fast"]["future"]["keep"], true);
}
#[test]
fn unrelated_mutations_preserve_nested_unknowns_in_both_scopes() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, project_settings) = paths_with_local_settings(&temp);
let raw = r#"{
"agent": {"compaction": {"auto": {"future": {"keep": "compaction"}}}},
"providers": {
"openai_codex": {"future": {"keep": "codex"}},
"custom": {"local": {
"label": "Local",
"base_url": "http://127.0.0.1:8080/v1",
"future": {"keep": "custom"}
}}
},
"capabilities": {
"tools": {"bash": {"future": {"keep": "tool"}}},
"mcp": {"docs": {
"type": "stdio",
"command": "docs-mcp",
"future": {"keep": "mcp"}
}}
},
"automation": {"hooks": {"future": {"keep": "hook"}}}
}"#;
fs::write(&paths.settings_file, raw).unwrap();
fs::write(&project_settings, raw).unwrap();
set_fast_mode(&paths, true).unwrap();
set_tool_disabled(&paths, SettingsScope::Project, "bash", true).unwrap();
for (value, scope) in [
(read_settings_value(&paths), "global"),
(
serde_json::from_str::<serde_json::Value>(
&fs::read_to_string(&project_settings).unwrap(),
)
.unwrap(),
"project",
),
] {
assert_eq!(
value["agent"]["compaction"]["auto"]["future"]["keep"], "compaction",
"{scope}"
);
assert_eq!(
value["providers"]["openai_codex"]["future"]["keep"], "codex",
"{scope}"
);
assert_eq!(
value["providers"]["custom"]["local"]["future"]["keep"], "custom",
"{scope}"
);
assert_eq!(
value["capabilities"]["tools"]["bash"]["future"]["keep"], "tool",
"{scope}"
);
assert_eq!(
value["capabilities"]["mcp"]["docs"]["future"]["keep"], "mcp",
"{scope}"
);
assert_eq!(
value["automation"]["hooks"]["future"]["keep"], "hook",
"{scope}"
);
}
}
#[test]
fn settings_schema_setup_inserts_schema_for_valid_settings_and_preserves_values() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(&paths.state).unwrap();
fs::write(
&paths.settings_file,
r#"{"selected_model":{"provider":"openai-codex","model":"gpt-5.5"},"future_setting":{"keep":true}}"#,
)
.unwrap();
ensure_settings_schema_files(&paths).unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["$schema"], SETTINGS_SCHEMA_RELATIVE_REF);
assert_eq!(value["agent"]["model"]["provider"], "openai-codex");
assert_eq!(value["agent"]["model"]["model"], "gpt-5.5");
assert_eq!(value["future_setting"]["keep"], true);
}
#[test]
fn startup_schema_maintenance_creates_and_reuses_missing_settings() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(&paths.state).unwrap();
let (_, settings, appearance, sessions) =
load_startup_config_with_settings(paths.clone(), CliConfigOverrides::default()).unwrap();
assert_eq!(settings, Settings::default());
assert_eq!(appearance, AppearanceSettings::default());
assert_eq!(sessions, SessionSettings::default());
assert_eq!(
read_settings_value(&paths)["$schema"],
SETTINGS_SCHEMA_RELATIVE_REF
);
}
#[test]
fn startup_schema_maintenance_preserves_invalid_settings_error_and_contents() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(&paths.state).unwrap();
let original = r#"{"selected_model":{"thinking_level":"maximum"}}"#;
fs::write(&paths.settings_file, original).unwrap();
assert!(
load_startup_config_with_settings(paths.clone(), CliConfigOverrides::default()).is_err()
);
assert_eq!(fs::read_to_string(&paths.settings_file).unwrap(), original);
}
#[test]
fn startup_rejects_oversized_global_and_project_settings() {
for scope in [SettingsScope::Global, SettingsScope::Project] {
let temp = tempfile::TempDir::new().unwrap();
let paths =
McPaths::from_root_and_project_dir(temp.path().join("mc"), temp.path().join("repo"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(&paths.state).unwrap();
fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
fs::write(&paths.settings_file, "{}").unwrap();
let target = match scope {
SettingsScope::Global => &paths.settings_file,
SettingsScope::Project => &paths.project_settings_file,
};
fs::write(target, vec![b' '; MAX_SETTINGS_FILE_BYTES + 1]).unwrap();
let error = load_startup_config_with_settings(paths.clone(), CliConfigOverrides::default())
.unwrap_err()
.to_string();
assert!(
error.contains("settings file exceeded"),
"{scope:?}: {error}"
);
assert!(error.contains(&target.display().to_string()), "{error}");
}
}
#[test]
fn updates_reject_oversized_global_and_project_settings_without_mutation() {
for scope in [SettingsScope::Global, SettingsScope::Project] {
let temp = tempfile::TempDir::new().unwrap();
let paths =
McPaths::from_root_and_project_dir(temp.path().join("mc"), temp.path().join("repo"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
let target = match scope {
SettingsScope::Global => &paths.settings_file,
SettingsScope::Project => &paths.project_settings_file,
};
let original = vec![b' '; MAX_SETTINGS_FILE_BYTES + 1];
fs::write(target, &original).unwrap();
let mut mutation_called = false;
let error = update_settings_json(&paths, scope, |_| {
mutation_called = true;
Ok(())
})
.unwrap_err()
.to_string();
assert!(
error.contains("settings file exceeded"),
"{scope:?}: {error}"
);
assert!(!mutation_called);
assert_eq!(fs::read(target).unwrap(), original);
}
}
#[test]
fn startup_schema_maintenance_reuses_loaded_global_settings() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(&paths.state).unwrap();
fs::write(
&paths.settings_file,
r#"{"selected_model":{"provider":"openai-codex","model":"gpt-5.5"},"sessions":{"retention_days":17}}"#,
)
.unwrap();
let (config, settings, _, sessions) =
load_startup_config_with_settings(paths.clone(), CliConfigOverrides::default()).unwrap();
assert_eq!(config.provider.as_deref(), Some("openai-codex"));
assert_eq!(settings.selected_model.model.as_deref(), Some("gpt-5.5"));
assert_eq!(sessions.retention_days, 17);
assert_eq!(
read_settings_value(&paths)["$schema"],
SETTINGS_SCHEMA_RELATIVE_REF
);
}
#[test]
fn startup_loaded_global_settings_still_apply_project_merge_rules() {
let temp = tempfile::TempDir::new().unwrap();
let paths =
McPaths::from_root_and_project_dir(temp.path().join("mc"), temp.path().join("repo"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(&paths.state).unwrap();
fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
fs::write(
&paths.settings_file,
r#"{"fast":{"enabled":true},"tools":{"disabled":["bash"]}}"#,
)
.unwrap();
fs::write(
&paths.project_settings_file,
r#"{"selected_model":{"provider":"anthropic","model":"claude-test"},"fast":{"enabled":false}}"#,
)
.unwrap();
let (config, settings, _, _) =
load_startup_config_with_settings(paths, CliConfigOverrides::default()).unwrap();
assert_eq!(config.provider.as_deref(), Some("anthropic"));
assert_eq!(config.model.as_deref(), Some("claude-test"));
assert!(settings.fast.enabled);
assert_eq!(settings.tools.disabled, vec!["bash"]);
}
#[test]
fn settings_schema_setup_preserves_existing_custom_schema() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(&paths.state).unwrap();
fs::write(
&paths.settings_file,
r#"{"$schema":"https://example.test/custom.schema.json","selected_model":{"provider":"openai-codex"}}"#,
)
.unwrap();
ensure_settings_schema_files(&paths).unwrap();
assert_eq!(
read_settings_value(&paths)["$schema"],
"https://example.test/custom.schema.json"
);
}
#[test]
fn settings_schema_setup_leaves_invalid_settings_unchanged() {
for original in [
"not json",
"[]",
r#"{"selected_model":{"thinking_level":"maximum"}}"#,
r#"{"custom_providers":{"bad":{"label":"Bad","base_url":"https://provider.test/v1/models"}}}"#,
] {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir_all(&paths.state).unwrap();
fs::write(&paths.settings_file, original).unwrap();
ensure_settings_schema_files(&paths).unwrap();
assert_eq!(fs::read_to_string(&paths.settings_file).unwrap(), original);
}
}
#[test]
fn settings_write_paths_emit_or_preserve_schema_metadata() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
write_settings(
&paths,
&Settings {
selected_model: SelectedModelSettings {
provider: Some("openai-codex".to_string()),
..SelectedModelSettings::default()
},
..Settings::default()
},
)
.unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["$schema"], SETTINGS_SCHEMA_RELATIVE_REF);
assert_eq!(value["agent"]["model"]["provider"], "openai-codex");
fs::write(
&paths.settings_file,
json!({
"$schema": "https://example.test/custom.schema.json",
"future_setting": true,
"selected_model": {"provider":"old-provider"}
})
.to_string(),
)
.unwrap();
set_selected_model(&paths, "new-provider", "new-model").unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["$schema"], "https://example.test/custom.schema.json");
assert_eq!(value["future_setting"], true);
assert_eq!(value["agent"]["model"]["provider"], "new-provider");
assert_eq!(value["agent"]["model"]["model"], "new-model");
}
#[test]
fn openai_responses_settings_round_trip_and_omit_default() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let configured = Settings {
openai_responses: OpenAiResponsesSettings {
text_verbosity: Some(TextVerbosity::Medium),
},
..Settings::default()
};
write_settings(&paths, &configured).unwrap();
let persisted = read_settings_value(&paths);
assert_eq!(
persisted["providers"]["openai_responses"]["text_verbosity"],
"medium"
);
assert_eq!(
read_settings(&paths)
.unwrap()
.openai_responses
.text_verbosity,
Some(TextVerbosity::Medium)
);
write_settings(&paths, &Settings::default()).unwrap();
let persisted = read_settings_value(&paths);
assert!(persisted["providers"].get("openai_responses").is_none());
assert_eq!(
read_settings(&paths).unwrap().openai_responses,
OpenAiResponsesSettings::default()
);
}
#[test]
fn settings_update_preserves_unknown_openai_responses_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#"{"openai_responses":{"future_field":{"keep":true}}}"#,
)
.unwrap();
set_selected_model(&paths, "provider", "model").unwrap();
let persisted = read_settings_value(&paths);
assert_eq!(
persisted["providers"]["openai_responses"]["future_field"]["keep"],
true
);
assert!(
persisted["providers"]["openai_responses"]
.get("text_verbosity")
.is_none()
);
assert_eq!(
read_settings(&paths).unwrap().openai_responses,
OpenAiResponsesSettings::default()
);
}
#[test]
fn settings_update_round_trips_top_level_scalar_context_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#"{"future_setting":true}"#).unwrap();
update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
settings.no_color = Some(true);
settings.file_autocomplete_respects_gitignore = false;
settings.context = serde_json::from_value(json!({"max_tokens": 128000})).unwrap();
})
.unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["future_setting"], true);
assert_eq!(value["interface"]["no_color"], true);
assert_eq!(
value["interface"]["tui"]["autocomplete"]["respects_gitignore"],
false
);
assert_eq!(value["agent"]["context"]["max_tokens"], 128000);
update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
settings.no_color = None;
settings.file_autocomplete_respects_gitignore = true;
settings.context = None;
})
.unwrap();
let value = read_settings_value(&paths);
assert!(value.get("no_color").is_none());
assert!(value.get("file_autocomplete_respects_gitignore").is_none());
assert_eq!(value["agent"]["context"], serde_json::Value::Null);
}
#[test]
fn settings_update_rejects_invalid_custom_provider_mutation_without_write() {
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,"custom_providers":{"local":{"label":"Local","base_url":"http://localhost:8080/v1"}}}"#,
)
.unwrap();
let before = fs::read_to_string(&paths.settings_file).unwrap();
let error = update_settings_preserving_unknown_top_level_fields(&paths, |settings| {
settings.custom_providers.get_mut("local").unwrap().base_url =
"https://user:password@example.test/v1".to_string();
})
.unwrap_err()
.to_string();
assert!(error.contains("custom provider 'local'"), "{error}");
assert!(error.contains("userinfo"), "{error}");
assert!(!error.contains("password"), "{error}");
assert_eq!(fs::read_to_string(&paths.settings_file).unwrap(), before);
}
#[test]
fn write_settings_rejects_invalid_custom_provider_settings() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let settings = Settings {
custom_providers: BTreeMap::from([(
"local".to_string(),
CustomProviderConfig {
label: "Local".to_string(),
base_url: "https://user:password@example.test/v1".to_string(),
fast_mode: None,
api_key_env_var: None,
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
request_headers: BTreeMap::new(),
},
)]),
..Settings::default()
};
let error = write_settings(&paths, &settings).unwrap_err().to_string();
assert!(error.contains("custom provider 'local'"), "{error}");
assert!(error.contains("userinfo"), "{error}");
assert!(!error.contains("password"), "{error}");
assert!(!paths.settings_file.exists());
}
#[test]
fn upsert_custom_provider_rejects_invalid_config_without_write() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
write_settings(&paths, &Settings::default()).unwrap();
let before = fs::read_to_string(&paths.settings_file).unwrap();
let error = upsert_custom_provider(
&paths,
"local",
CustomProviderConfig {
label: "Local".to_string(),
base_url: "https://user:password@example.test/v1".to_string(),
fast_mode: None,
api_key_env_var: None,
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
request_headers: BTreeMap::new(),
},
)
.unwrap_err()
.to_string();
assert!(error.contains("custom provider 'local'"), "{error}");
assert!(error.contains("userinfo"), "{error}");
assert!(!error.contains("password"), "{error}");
assert_eq!(fs::read_to_string(&paths.settings_file).unwrap(), before);
}
#[test]
fn settings_read_defaults_only_when_file_is_missing() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
assert_eq!(read_settings(&paths).unwrap(), Settings::default());
fs::create_dir_all(&paths.root).unwrap();
fs::create_dir(&paths.settings_file).unwrap();
let error = read_settings(&paths).unwrap_err().to_string();
assert!(error.contains("failed to read"), "{error}");
}
#[test]
fn startup_migrates_existing_exact_cwd_project_settings_and_is_idempotent() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, project_settings) = paths_with_local_settings(&temp);
fs::write(
&project_settings,
r#"{
"$schema":"https://example.test/project-settings.schema.json",
"schema_version":1,
"provider":"legacy-provider",
"model":"legacy-model",
"thinking_level":"high",
"selected_model":{
"provider":"canonical-provider",
"model":"canonical-model",
"thinking_level":"low",
"future":{"shared":"canonical","canonical_only":true}
},
"disabled_skills":["legacy-skill"],
"skills":{"disabled":[],"future":{"keep":true}},
"herdr":{"enabled":true},
"integrations":{"herdr":{"enabled":false,"future":{"shared":"canonical"}}},
"tools":{
"ffgrep":{"absolute_paths":false,"future":{"shared":"alias","alias_only":true}},
"parallel_subagents":{"absolute_paths":false,"max_depth":3}
},
"appearance":{"theme":"project-theme","reduced_motion":true},
"fast":{"enabled":true},
"future_root":{"keep":true}
}"#,
)
.unwrap();
let (_, settings, _, _) =
load_startup_config_with_settings(paths.clone(), CliConfigOverrides::default()).unwrap();
assert_eq!(
settings.selected_model.provider.as_deref(),
Some("canonical-provider")
);
let migrated_text = fs::read_to_string(&project_settings).unwrap();
let migrated: serde_json::Value = serde_json::from_str(&migrated_text).unwrap();
assert_eq!(migrated["schema_version"], 2);
assert_eq!(
migrated["$schema"],
"https://example.test/project-settings.schema.json"
);
assert_eq!(migrated["agent"]["model"]["provider"], "canonical-provider");
assert_eq!(migrated["agent"]["model"]["model"], "canonical-model");
assert_eq!(migrated["agent"]["model"]["thinking_level"], "low");
assert_eq!(migrated["agent"]["model"]["future"]["shared"], "canonical");
assert_eq!(migrated["agent"]["model"]["future"]["canonical_only"], true);
assert_eq!(migrated["knowledge"]["skills"]["disabled"], json!([]));
assert_eq!(migrated["knowledge"]["skills"]["future"]["keep"], true);
assert_eq!(
migrated["automation"]["integrations"]["herdr"]["enabled"],
false
);
assert_eq!(
migrated["capabilities"]["tools"]["grep"]["absolute_paths"],
false
);
assert_eq!(
migrated["capabilities"]["tools"]["grep"]["future"]["alias_only"],
true
);
assert_eq!(migrated["agent"]["subagents"]["execution"]["max_depth"], 3);
assert!(migrated.pointer("/interface/appearance").is_none());
assert!(migrated.pointer("/agent/fast").is_none());
assert_eq!(migrated["future_root"]["keep"], true);
for legacy_key in [
"provider",
"model",
"thinking_level",
"selected_model",
"disabled_skills",
"herdr",
"integrations",
"tools",
"appearance",
"fast",
] {
assert!(
migrated.get(legacy_key).is_none(),
"legacy key survived: {legacy_key}"
);
}
assert!(migrated["capabilities"]["tools"].get("ffgrep").is_none());
assert!(
migrated["capabilities"]["tools"]
.get("parallel_subagents")
.is_none()
);
load_startup_config_with_settings(paths, CliConfigOverrides::default()).unwrap();
assert_eq!(
fs::read_to_string(&project_settings).unwrap(),
migrated_text
);
}
#[test]
fn startup_does_not_create_an_absent_exact_cwd_project_settings_file() {
let temp = tempfile::TempDir::new().unwrap();
let paths =
McPaths::from_root_and_project_dir(temp.path().join("mc"), temp.path().join("project"));
let project_settings = paths.project_settings_file.clone();
assert!(!project_settings.exists());
load_startup_config_with_settings(paths, CliConfigOverrides::default()).unwrap();
assert!(!project_settings.exists());
}
#[test]
fn startup_leaves_invalid_or_unsupported_project_settings_unchanged() {
for original in [
"not json",
"[]",
r#"{"agent":{"model":{"thinking_level":"maximum"}}}"#,
r#"{"schema_version":99,"provider":"legacy-provider"}"#,
] {
let temp = tempfile::TempDir::new().unwrap();
let (paths, project_settings) = paths_with_local_settings(&temp);
fs::write(&project_settings, original).unwrap();
assert!(
load_startup_config_with_settings(paths, CliConfigOverrides::default()).is_err(),
"invalid project settings unexpectedly loaded: {original}"
);
assert_eq!(fs::read_to_string(&project_settings).unwrap(), original);
}
}
#[test]
fn startup_migrates_global_before_validating_partial_project_override() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, project_settings) = paths_with_local_settings(&temp);
fs::write(
&paths.settings_file,
r#"{
"provider":"global-provider",
"model":"global-model",
"compaction":{"auto":{"enabled":true,"threshold_tokens":100}}
}"#,
)
.unwrap();
fs::write(
&project_settings,
r#"{"model":"project-model","compaction":{"auto":{"enabled":true}}}"#,
)
.unwrap();
let (config, settings, _, _) =
load_startup_config_with_settings(paths.clone(), CliConfigOverrides::default()).unwrap();
assert_eq!(config.provider.as_deref(), Some("global-provider"));
assert_eq!(config.model.as_deref(), Some("project-model"));
assert_eq!(settings.compaction.auto.threshold_tokens, Some(100));
let global = read_settings_value(&paths);
assert_eq!(global["schema_version"], 2);
assert!(global.get("provider").is_none());
assert_eq!(global["agent"]["model"]["provider"], "global-provider");
let project: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&project_settings).unwrap()).unwrap();
assert_eq!(project["schema_version"], 2);
assert_eq!(project["agent"]["model"]["model"], "project-model");
assert!(project.get("model").is_none());
}
#[test]
fn startup_migrates_global_and_project_dynamic_settings_together() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, project_settings) = paths_with_local_settings(&temp);
fs::write(
&paths.settings_file,
r#"{
"provider":"global-provider",
"model":"global-model",
"custom_providers": {
"global": {
"label":"Global",
"base_url":"http://127.0.0.1:8080/v1",
"future":{"keep":"global"}
}
},
"mcp_servers": {
"global": {
"type":"stdio",
"command":"global-mcp",
"future":{"keep":"global"}
}
}
}"#,
)
.unwrap();
fs::write(
&project_settings,
r#"{
"custom_providers": {
"project": {
"label":"Project",
"base_url":"http://127.0.0.1:8081/v1",
"future":{"keep":"project"}
}
},
"mcp_servers": {
"project": {
"type":"stdio",
"command":"project-mcp",
"future":{"keep":"project"}
}
}
}"#,
)
.unwrap();
let (_, settings, _, _) =
load_startup_config_with_settings(paths.clone(), CliConfigOverrides::default()).unwrap();
assert_eq!(settings.custom_providers.len(), 2);
assert_eq!(settings.mcp_servers.len(), 2);
let global = read_settings_value(&paths);
assert_eq!(
global["providers"]["custom"]["global"]["future"]["keep"],
"global"
);
assert_eq!(
global["capabilities"]["mcp"]["global"]["future"]["keep"],
"global"
);
let project: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&project_settings).unwrap()).unwrap();
assert_eq!(
project["providers"]["custom"]["project"]["future"]["keep"],
"project"
);
assert_eq!(
project["capabilities"]["mcp"]["project"]["future"]["keep"],
"project"
);
}
#[test]
fn startup_does_not_migrate_project_when_global_schema_is_unsupported() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, project_settings) = paths_with_local_settings(&temp);
let global = r#"{"schema_version":99,"provider":"global-provider"}"#;
let project = r#"{"provider":"project-provider"}"#;
fs::write(&paths.settings_file, global).unwrap();
fs::write(&project_settings, project).unwrap();
assert!(
load_startup_config_with_settings(paths.clone(), CliConfigOverrides::default()).is_err()
);
assert_eq!(fs::read_to_string(&paths.settings_file).unwrap(), global);
assert_eq!(fs::read_to_string(&project_settings).unwrap(), project);
}
#[test]
fn startup_does_not_let_project_override_repair_invalid_global_settings() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, project_settings) = paths_with_local_settings(&temp);
let global = r#"{"agent":null}"#;
let project = r#"{"model":"project-model"}"#;
fs::write(&paths.settings_file, global).unwrap();
fs::write(&project_settings, project).unwrap();
assert!(
load_startup_config_with_settings(paths.clone(), CliConfigOverrides::default()).is_err()
);
assert_eq!(fs::read_to_string(&paths.settings_file).unwrap(), global);
assert_eq!(fs::read_to_string(&project_settings).unwrap(), project);
}
#[test]
fn project_migration_does_not_add_global_schema_metadata() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, project_settings) = paths_with_local_settings(&temp);
fs::write(&project_settings, r#"{"provider":"project-provider"}"#).unwrap();
ensure_settings_schema_files(&paths).unwrap();
let migrated_text = fs::read_to_string(&project_settings).unwrap();
let migrated: serde_json::Value = serde_json::from_str(&migrated_text).unwrap();
assert_eq!(migrated["schema_version"], 2);
assert!(migrated.get("$schema").is_none());
assert_eq!(migrated["agent"]["model"]["provider"], "project-provider");
ensure_settings_schema_files(&paths).unwrap();
assert_eq!(
fs::read_to_string(&project_settings).unwrap(),
migrated_text
);
}
#[test]
fn startup_keeps_fast_and_appearance_global_only_after_project_migration() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, project_settings) = paths_with_local_settings(&temp);
fs::write(
&paths.settings_file,
r#"{"fast":{"enabled":true},"appearance":{"theme":"global-theme","reduced_motion":true}}"#,
)
.unwrap();
fs::write(
&project_settings,
r#"{"fast":{"enabled":false},"appearance":{"theme":"project-theme","reduced_motion":false},"no_color":true}"#,
)
.unwrap();
let (_, settings, appearance, _) =
load_startup_config_with_settings(paths.clone(), CliConfigOverrides::default()).unwrap();
assert!(settings.fast.enabled);
assert_eq!(appearance.theme, "global-theme");
assert!(appearance.reduced_motion);
assert_eq!(settings.no_color, Some(true));
let global = read_settings_value(&paths);
assert_eq!(global["agent"]["fast"]["enabled"], true);
assert_eq!(global["interface"]["appearance"]["theme"], "global-theme");
let project: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&project_settings).unwrap()).unwrap();
assert!(project.pointer("/agent/fast").is_none());
assert!(project.pointer("/interface/appearance").is_none());
assert_eq!(project["interface"]["no_color"], true);
}
#[test]
fn write_settings_writes_global_only_when_local_settings_exists() {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local_settings) = paths_with_local_settings(&temp);
fs::write(&local_settings, r#"{"no_color":false}"#).unwrap();
let local_before = fs::read_to_string(&local_settings).unwrap();
write_settings(
&paths,
&Settings {
no_color: Some(true),
..Settings::default()
},
)
.unwrap();
assert_eq!(fs::read_to_string(&local_settings).unwrap(), local_before);
assert_eq!(read_settings_value(&paths)["interface"]["no_color"], true);
}
#[test]
fn concurrent_settings_updates_preserve_independent_fields() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let left = paths.clone();
let right = paths.clone();
let left = std::thread::spawn(move || {
update_settings_preserving_unknown_top_level_fields(&left, |settings| {
settings.selected_model.provider = Some("provider-a".to_string());
})
.unwrap();
});
let right = std::thread::spawn(move || {
update_settings_preserving_unknown_top_level_fields(&right, |settings| {
settings.session_titles = SessionTitleSettings {
enabled: true,
provider: Some("title-provider".to_string()),
model: Some("title-model".to_string()),
};
})
.unwrap();
});
left.join().unwrap();
right.join().unwrap();
let settings = read_settings(&paths).unwrap();
assert_eq!(
settings.selected_model.provider.as_deref(),
Some("provider-a")
);
assert!(settings.session_titles.enabled);
assert_eq!(
settings.session_titles.provider.as_deref(),
Some("title-provider")
);
assert_eq!(
settings.session_titles.model.as_deref(),
Some("title-model")
);
}
fn pre_v2_backup_path(path: &Path) -> PathBuf {
let file_name = path.file_name().unwrap().to_string_lossy();
path.with_file_name(format!("{file_name}.pre-v2.bak"))
}
#[test]
fn global_legacy_migration_creates_exact_byte_backup() {
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();
let original = b"{\n \"provider\": \"legacy-provider\",\n \"model\": \"legacy-model\"\n}\n";
fs::write(&paths.settings_file, original).unwrap();
ensure_settings_schema_files(&paths).unwrap();
assert_eq!(
fs::read(pre_v2_backup_path(&paths.settings_file)).unwrap(),
original
);
let migrated = read_settings_value(&paths);
assert_eq!(migrated["schema_version"], 2);
assert_eq!(migrated["agent"]["model"]["provider"], "legacy-provider");
assert_eq!(migrated["agent"]["model"]["model"], "legacy-model");
}
#[test]
fn global_and_project_legacy_migrations_backup_each_physical_file() {
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();
let global_original = b"{\n \"provider\": \"global-provider\"\n}\n";
let project_original = b"{\n \"model\": \"project-model\"\n}\n";
fs::write(&paths.settings_file, global_original).unwrap();
fs::write(&paths.project_settings_file, project_original).unwrap();
ensure_settings_schema_files(&paths).unwrap();
assert_eq!(
fs::read(pre_v2_backup_path(&paths.settings_file)).unwrap(),
global_original
);
assert_eq!(
fs::read(pre_v2_backup_path(&paths.project_settings_file)).unwrap(),
project_original
);
assert_eq!(read_settings_value(&paths)["schema_version"], 2);
let project: serde_json::Value =
serde_json::from_slice(&fs::read(&paths.project_settings_file).unwrap()).unwrap();
assert_eq!(project["schema_version"], 2);
}
#[cfg(unix)]
#[test]
fn existing_identical_backup_is_reused_without_rewrite() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
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();
let original = b"{\n \"provider\": \"legacy-provider\"\n}\n";
fs::write(&paths.settings_file, original).unwrap();
fs::set_permissions(&paths.settings_file, fs::Permissions::from_mode(0o640)).unwrap();
ensure_settings_schema_files(&paths).unwrap();
let backup = pre_v2_backup_path(&paths.settings_file);
let inode = fs::metadata(&backup).unwrap().ino();
let mode = fs::metadata(&backup).unwrap().permissions().mode() & 0o777;
fs::write(&paths.settings_file, original).unwrap();
ensure_settings_schema_files(&paths).unwrap();
assert_eq!(fs::metadata(&backup).unwrap().ino(), inode);
assert_eq!(
fs::metadata(&backup).unwrap().permissions().mode() & 0o777,
mode
);
assert_eq!(mode, 0o640);
assert_eq!(fs::read(&backup).unwrap(), original);
}
#[cfg(unix)]
#[test]
fn identical_backup_with_mismatched_mode_blocks_migration_without_changes() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
for (source_mode, backup_mode) in [(0o600, 0o640), (0o640, 0o600)] {
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();
let original = b"{\"provider\":\"legacy-provider\"}";
let backup = pre_v2_backup_path(&paths.settings_file);
fs::write(&paths.settings_file, original).unwrap();
fs::set_permissions(
&paths.settings_file,
fs::Permissions::from_mode(source_mode),
)
.unwrap();
fs::write(&backup, original).unwrap();
fs::set_permissions(&backup, fs::Permissions::from_mode(backup_mode)).unwrap();
let source_inode = fs::metadata(&paths.settings_file).unwrap().ino();
let backup_inode = fs::metadata(&backup).unwrap().ino();
let error = format!("{:#}", ensure_settings_schema_files(&paths).unwrap_err());
assert!(error.contains("different permissions"), "{error}");
assert_eq!(fs::read(&paths.settings_file).unwrap(), original);
assert_eq!(fs::read(&backup).unwrap(), original);
assert_eq!(
fs::metadata(&paths.settings_file).unwrap().ino(),
source_inode
);
assert_eq!(fs::metadata(&backup).unwrap().ino(), backup_inode);
assert_eq!(
fs::metadata(&paths.settings_file)
.unwrap()
.permissions()
.mode()
& 0o777,
source_mode
);
assert_eq!(
fs::metadata(&backup).unwrap().permissions().mode() & 0o777,
backup_mode
);
}
}
#[test]
fn conflicting_backup_blocks_migration_and_preserves_both_files() {
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();
let original = b"{\"provider\":\"legacy-provider\"}";
let conflicting = b"not the original";
fs::write(&paths.settings_file, original).unwrap();
fs::write(pre_v2_backup_path(&paths.settings_file), conflicting).unwrap();
let error = format!("{:#}", ensure_settings_schema_files(&paths).unwrap_err());
assert!(error.contains("pre-v2 settings backup"), "{error}");
assert!(error.contains("different contents"), "{error}");
assert_eq!(fs::read(&paths.settings_file).unwrap(), original);
assert_eq!(
fs::read(pre_v2_backup_path(&paths.settings_file)).unwrap(),
conflicting
);
}
#[cfg(unix)]
#[test]
fn symlink_backup_is_rejected_without_reading_outside_target() {
use std::os::unix::fs::symlink;
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();
let outside = temp.path().join("outside.json");
let original = b"{\"provider\":\"legacy-provider\"}";
let outside_original = b"outside must not be touched";
fs::write(&paths.settings_file, original).unwrap();
fs::write(&outside, outside_original).unwrap();
symlink(&outside, pre_v2_backup_path(&paths.settings_file)).unwrap();
let error = format!("{:#}", ensure_settings_schema_files(&paths).unwrap_err());
assert!(error.contains("pre-v2 settings backup"), "{error}");
assert_eq!(fs::read(&paths.settings_file).unwrap(), original);
assert_eq!(fs::read(&outside).unwrap(), outside_original);
}
#[test]
fn non_regular_backup_is_rejected_without_replacing_source() {
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();
let original = b"{\"provider\":\"legacy-provider\"}";
fs::write(&paths.settings_file, original).unwrap();
fs::create_dir(pre_v2_backup_path(&paths.settings_file)).unwrap();
let error = format!("{:#}", ensure_settings_schema_files(&paths).unwrap_err());
assert!(error.contains("not a regular file"), "{error}");
assert_eq!(fs::read(&paths.settings_file).unwrap(), original);
}
#[test]
fn unchanged_inputs_do_not_create_pre_v2_backups() {
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();
ensure_settings_schema_files(&paths).unwrap();
assert!(!pre_v2_backup_path(&paths.settings_file).exists());
let invalid = b"not json";
fs::write(&paths.settings_file, invalid).unwrap();
ensure_settings_schema_files(&paths).unwrap();
assert_eq!(fs::read(&paths.settings_file).unwrap(), invalid);
assert!(!pre_v2_backup_path(&paths.settings_file).exists());
let non_object = b"[]";
fs::write(&paths.settings_file, non_object).unwrap();
ensure_settings_schema_files(&paths).unwrap();
assert_eq!(fs::read(&paths.settings_file).unwrap(), non_object);
assert!(!pre_v2_backup_path(&paths.settings_file).exists());
let unsupported = b"{\"schema_version\":99}";
fs::write(&paths.settings_file, unsupported).unwrap();
ensure_settings_schema_files(&paths).unwrap();
assert_eq!(fs::read(&paths.settings_file).unwrap(), unsupported);
assert!(!pre_v2_backup_path(&paths.settings_file).exists());
let schema_only = b"{\"schema_version\":2}";
fs::write(&paths.settings_file, schema_only).unwrap();
ensure_settings_schema_files(&paths).unwrap();
assert!(!pre_v2_backup_path(&paths.settings_file).exists());
}
#[test]
fn ordinary_v2_mutation_has_no_backup_but_pre_v2_mutation_does() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
let v2 = br#"{"schema_version":2,"agent":{"model":{"provider":"old-provider","model":"old-model"}}}"#;
fs::write(&paths.settings_file, v2).unwrap();
set_selected_model(&paths, "new-provider", "new-model").unwrap();
assert!(!pre_v2_backup_path(&paths.settings_file).exists());
let pre_v2 = b"{\n \"provider\": \"old-provider\",\n \"model\": \"old-model\"\n}\n";
fs::write(&paths.settings_file, pre_v2).unwrap();
set_selected_model(&paths, "new-provider", "new-model").unwrap();
assert_eq!(
fs::read(pre_v2_backup_path(&paths.settings_file)).unwrap(),
pre_v2
);
}
#[cfg(unix)]
#[test]
fn pre_v2_backup_preserves_source_permission_mode() {
use std::os::unix::fs::PermissionsExt;
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();
let original = b"{\"provider\":\"legacy-provider\"}";
fs::write(&paths.settings_file, original).unwrap();
let mut permissions = fs::metadata(&paths.settings_file).unwrap().permissions();
permissions.set_mode(0o640);
fs::set_permissions(&paths.settings_file, permissions).unwrap();
ensure_settings_schema_files(&paths).unwrap();
let backup_mode = fs::metadata(pre_v2_backup_path(&paths.settings_file))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(backup_mode, 0o640);
}
#[test]
fn concurrent_disabled_list_mutations_preserve_every_change() {
for scope in [SettingsScope::Global, SettingsScope::Project] {
for kind in [
SettingsListKind::Skills,
SettingsListKind::Tools,
SettingsListKind::Subagents,
SettingsListKind::Models,
] {
let temp = tempfile::TempDir::new().unwrap();
let (paths, _) = paths_with_local_settings(&temp);
let barrier = std::sync::Barrier::new(8);
std::thread::scope(|threads| {
let handles: Vec<_> = (0..8)
.map(|index| {
let paths = &paths;
let barrier = &barrier;
threads.spawn(move || {
barrier.wait();
let name = format!("entry-{index}");
match kind {
SettingsListKind::Skills => {
set_skill_disabled_for_scope(paths, scope, &name, true)
}
SettingsListKind::Tools => {
set_tool_disabled(paths, scope, &name, true)
}
SettingsListKind::Subagents => {
set_subagent_profile_disabled(paths, scope, &name, true)
}
SettingsListKind::Models => {
set_model_disabled_for_scope(paths, scope, &name, true)
}
}
.unwrap();
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
});
let names = disabled_names_for_modal_scope(&paths, scope, kind).unwrap();
assert_eq!(
names,
(0..8).map(|index| format!("entry-{index}")).collect(),
"{scope:?} {kind:?}"
);
}
}
}
#[test]
fn empty_project_disabled_lists_override_inheritance_and_preserve_unknown_fields() {
for (kind, pointer) in [
(SettingsListKind::Skills, "/knowledge/skills"),
(SettingsListKind::Tools, "/capabilities/tools"),
(SettingsListKind::Subagents, "/agent/subagents"),
(SettingsListKind::Models, "/providers/catalog"),
] {
let temp = tempfile::TempDir::new().unwrap();
let (paths, local) = paths_with_local_settings(&temp);
let set_disabled = |scope, name: &str, disabled| match kind {
SettingsListKind::Skills => set_skill_disabled_for_scope(&paths, scope, name, disabled),
SettingsListKind::Tools => set_tool_disabled(&paths, scope, name, disabled),
SettingsListKind::Subagents => {
set_subagent_profile_disabled(&paths, scope, name, disabled)
}
SettingsListKind::Models => set_model_disabled_for_scope(&paths, scope, name, disabled),
};
set_disabled(SettingsScope::Global, "inherited", true).unwrap();
let mut raw = json!({});
let segments: Vec<_> = pointer.trim_start_matches('/').split('/').collect();
super::super::json::set_path(&mut raw, &segments, json!({"future": {"keep": true}}))
.unwrap();
fs::write(&local, serde_json::to_vec(&raw).unwrap()).unwrap();
assert!(
set_disabled(SettingsScope::Project, "inherited", false)
.unwrap()
.is_empty()
);
set_disabled(SettingsScope::Project, "absent", false).unwrap();
set_disabled(SettingsScope::Global, "later-global", true).unwrap();
let local_raw: serde_json::Value =
serde_json::from_slice(&fs::read(&local).unwrap()).unwrap();
assert_eq!(
local_raw.pointer(&format!("{pointer}/disabled")),
Some(&json!([]))
);
assert_eq!(
local_raw.pointer(&format!("{pointer}/future")),
Some(&json!({"keep": true}))
);
assert!(
disabled_names_for_modal_scope(&paths, SettingsScope::Project, kind)
.unwrap()
.is_empty()
);
}
}