use crate::config::Config;
use crate::config::profile::{home_for_profile, with_profile_home};
fn write_profile_home(home: &std::path::Path, config_toml: &str, keys_toml: &str) {
std::fs::create_dir_all(home).expect("create profile home");
std::fs::write(home.join("config.toml"), config_toml).expect("write config");
std::fs::write(home.join("keys.toml"), keys_toml).expect("write keys");
}
fn read_keys_toml(home: &std::path::Path) -> String {
std::fs::read_to_string(home.join("keys.toml")).expect("read keys.toml")
}
#[cfg(unix)]
#[test]
fn remove_secret_section_drops_named_provider_only() {
let profile = format!("test_rename_drop_{}", uuid::Uuid::new_v4());
let home = home_for_profile(Some(&profile));
write_profile_home(
&home,
"",
"[providers.custom.modelscope-qwen]\napi_key = \"old\"\n\n\
[providers.custom.modelscope]\napi_key = \"new\"\n",
);
with_profile_home(Some(&profile), || {
Config::remove_secret_section("providers.custom.modelscope-qwen")
.expect("remove_secret_section succeeds");
let after = read_keys_toml(&home);
assert!(
!after.contains("modelscope-qwen"),
"old-name section must be gone from keys.toml after remove_secret_section; got:\n{}",
after
);
assert!(
after.contains("[providers.custom.modelscope]"),
"new-name section must survive untouched; got:\n{}",
after
);
assert!(
after.contains("api_key = \"new\""),
"new section's api_key must survive untouched; got:\n{}",
after
);
});
}
#[cfg(unix)]
#[test]
fn remove_secret_section_missing_file_is_ok() {
let profile = format!("test_rename_nofile_{}", uuid::Uuid::new_v4());
let home = home_for_profile(Some(&profile));
std::fs::create_dir_all(&home).expect("create profile home");
with_profile_home(Some(&profile), || {
Config::remove_secret_section("providers.custom.whatever")
.expect("missing keys.toml must not error");
});
}
#[cfg(unix)]
#[test]
fn remove_secret_section_missing_section_is_ok() {
let profile = format!("test_rename_nosec_{}", uuid::Uuid::new_v4());
let home = home_for_profile(Some(&profile));
write_profile_home(&home, "", "[providers.custom.other]\napi_key = \"key\"\n");
with_profile_home(Some(&profile), || {
Config::remove_secret_section("providers.custom.does-not-exist")
.expect("missing section must not error");
let after = read_keys_toml(&home);
assert!(
after.contains("[providers.custom.other]"),
"unrelated sections must survive a noop remove; got:\n{}",
after
);
});
}
#[cfg(unix)]
#[test]
fn cleanup_drops_orphan_keys_when_config_has_no_matching_entry() {
let profile = format!("test_cleanup_orphan_{}", uuid::Uuid::new_v4());
let home = home_for_profile(Some(&profile));
write_profile_home(
&home,
"[providers.custom.modelscope]\nenabled = true\nbase_url = \"https://api/v1\"\ndefault_model = \"m\"\n",
"[providers.custom.modelscope-qwen]\napi_key = \"orphan\"\n\n\
[providers.custom.modelscope]\napi_key = \"current\"\n",
);
with_profile_home(Some(&profile), || {
Config::cleanup_keys_custom_providers();
let after = read_keys_toml(&home);
assert!(
!after.contains("modelscope-qwen"),
"orphan keys.toml entry (no config.toml counterpart) must be removed by cleanup. \
If this assertion fires, the cleanup is back to consulting the merged config \
loader instead of `raw_config_custom_provider_names`, and the circular bug is back. \
Got keys.toml:\n{}",
after
);
assert!(
after.contains("[providers.custom.modelscope]"),
"non-orphan entry must survive cleanup; got:\n{}",
after
);
});
}
#[cfg(unix)]
#[test]
fn cleanup_preserves_keys_when_every_entry_has_config_counterpart() {
let profile = format!("test_cleanup_preserve_{}", uuid::Uuid::new_v4());
let home = home_for_profile(Some(&profile));
write_profile_home(
&home,
"[providers.custom.a]\nenabled = true\nbase_url = \"u\"\ndefault_model = \"m\"\n\n\
[providers.custom.b]\nenabled = true\nbase_url = \"u\"\ndefault_model = \"m\"\n",
"[providers.custom.a]\napi_key = \"key-a\"\n\n\
[providers.custom.b]\napi_key = \"key-b\"\n",
);
with_profile_home(Some(&profile), || {
Config::cleanup_keys_custom_providers();
let after = read_keys_toml(&home);
assert!(after.contains("[providers.custom.a]"));
assert!(after.contains("[providers.custom.b]"));
assert!(after.contains("api_key = \"key-a\""));
assert!(after.contains("api_key = \"key-b\""));
});
}
#[cfg(unix)]
#[test]
fn cleanup_no_op_when_keys_toml_does_not_exist() {
let profile = format!("test_cleanup_nokeys_{}", uuid::Uuid::new_v4());
let home = home_for_profile(Some(&profile));
std::fs::create_dir_all(&home).expect("create profile home");
with_profile_home(Some(&profile), || {
Config::cleanup_keys_custom_providers();
assert!(
!home.join("keys.toml").exists(),
"cleanup must not create keys.toml as a side effect when it didn't exist"
);
});
}
#[cfg(unix)]
#[test]
fn rename_path_in_onboarding_save_calls_remove_secret_section() {
const SAVE_SRC: &str = include_str!("../tui/onboarding/config.rs");
let no_comments: String = SAVE_SRC
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
assert!(
no_comments.contains("Config::remove_secret_section(&old_section)"),
"apply_config must call Config::remove_secret_section(&old_section) in the \
custom-provider rename branch (gated on editing_custom_key != custom_name). \
Without it, keys.toml retains the old `[providers.custom.<old>]` section after a \
rename and merge_provider_keys resurrects the old name as a phantom entry on the \
next Config::load — exactly the 2026-06-05 modelscope-qwen → modelscope bug."
);
}
#[cfg(unix)]
#[test]
fn rename_path_in_onboarding_save_removes_old_config_section() {
const SAVE_SRC: &str = include_str!("../tui/onboarding/config.rs");
let no_comments: String = SAVE_SRC
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
assert!(
no_comments.contains("Config::remove_section(&old_section)"),
"apply_config must call Config::remove_section(&old_section) in the custom-provider \
rename branch so the old config.toml table doesn't linger as a phantom entry."
);
}