diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index de9c69094..da6b245f0 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -3881,4200 +3881,4 @@ impl EnvRuntimeOverrides { } #[cfg(test)] -mod tests { - use super::*; - use std::env; - use std::ffi::OsString; - use std::sync::Arc; - use std::sync::{Mutex, OnceLock}; - - fn env_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } - - #[test] - fn network_policy_toml_deserializes_proxy_hosts() { - let policy: NetworkPolicyToml = toml::from_str( - r#" - default = "allow" - proxy = ["github.com", ".githubusercontent.com"] - "#, - ) - .expect("network policy toml"); - - assert_eq!(policy.default, "allow"); - assert_eq!(policy.proxy, ["github.com", ".githubusercontent.com"]); - assert!(policy.audit); - } - - #[test] - fn permissions_toml_deserializes_typed_ask_rules() { - let permissions: PermissionsToml = toml::from_str( - r#" - [[rules]] - tool = "exec_shell" - command = "cargo test" - - [[rules]] - tool = "read_file" - path = "secrets/api_key.txt" - "#, - ) - .expect("permissions toml"); - - assert_eq!( - permissions.rules, - vec![ - ToolAskRule::exec_shell("cargo test"), - ToolAskRule::file_path("read_file", "secrets/api_key.txt"), - ] - ); - } - - #[test] - fn permissions_toml_rejects_typed_allow_deny_shape() { - let err = toml::from_str::( - r#" - [[rules]] - tool = "exec_shell" - decision = "allow" - command = "cargo test" - "#, - ) - .expect_err("permissions.toml should be ask-only in this slice"); - - assert!(err.message().contains("unknown field")); - } - - #[test] - fn hotbar_defaults_when_config_is_absent() { - let config = ConfigToml::default(); - - let resolved = config.resolve_hotbar_bindings(&DEFAULT_HOTBAR_ACTIONS); - - assert_eq!(resolved.warnings, Vec::new()); - assert_eq!(resolved.bindings, default_hotbar_bindings()); - assert_eq!( - resolved - .bindings - .iter() - .map(|binding| (binding.slot, binding.action.as_str())) - .collect::>(), - vec![ - (1, "voice.toggle"), - (2, "session.compact"), - (3, "mode.plan"), - (4, "mode.agent"), - (5, "mode.yolo"), - (6, "palette.open"), - (7, "sidebar.toggle"), - (8, "trust.toggle"), - ] - ); - } - - #[test] - fn hotbar_tables_parse_and_round_trip() { - let config: ConfigToml = toml::from_str( - r#" -[[hotbar]] -slot = 1 -label = "Plan" -action = "mode.plan" - -[[hotbar]] -slot = 2 -action = "session.compact" -"#, - ) - .expect("parse hotbar tables"); - - let resolved = config.resolve_hotbar_bindings(&["mode.plan", "session.compact"]); - - assert_eq!( - resolved.bindings, - vec![ - HotbarBinding { - slot: 1, - action: "mode.plan".to_string(), - label: Some("Plan".to_string()), - }, - HotbarBinding { - slot: 2, - action: "session.compact".to_string(), - label: None, - }, - ] - ); - assert_eq!(resolved.warnings, Vec::new()); - - let serialized = toml::to_string_pretty(&config).expect("serialize config"); - let round_tripped: ConfigToml = - toml::from_str(&serialized).expect("deserialize serialized config"); - assert_eq!(round_tripped.hotbar, config.hotbar); - } - - #[test] - fn hotbar_validation_warns_without_dropping_unknown_actions() { - let config: ConfigToml = toml::from_str( - r#" -[[hotbar]] -slot = 0 -action = "mode.plan" - -[[hotbar]] -slot = 2 -action = "mode.plan" - -[[hotbar]] -slot = 2 -action = "custom.action" - -[[hotbar]] -slot = 9 -action = "mode.agent" -"#, - ) - .expect("parse hotbar tables"); - - let resolved = config.resolve_hotbar_bindings(&["mode.plan", "mode.agent"]); - - assert_eq!( - resolved.bindings, - vec![HotbarBinding { - slot: 2, - action: "custom.action".to_string(), - label: None, - }] - ); - assert_eq!( - resolved.warnings, - vec![ - HotbarConfigWarning::SlotOutOfRange { - slot: 0, - action: "mode.plan".to_string(), - }, - HotbarConfigWarning::UnknownAction { - slot: 2, - action: "custom.action".to_string(), - }, - HotbarConfigWarning::DuplicateSlot { - slot: 2, - previous_action: "mode.plan".to_string(), - replacement_action: "custom.action".to_string(), - }, - HotbarConfigWarning::SlotOutOfRange { - slot: 9, - action: "mode.agent".to_string(), - }, - ] - ); - assert!(resolved.warnings[1].to_string().contains("keeping binding")); - } - - #[test] - fn config_store_loads_sibling_permissions_toml() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "codewhale-permissions-schema-{}-{unique}", - std::process::id() - )); - fs::create_dir_all(&dir).expect("mkdir"); - let config_path = dir.join(CONFIG_FILE_NAME); - fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config"); - fs::write( - dir.join(PERMISSIONS_FILE_NAME), - r#" - [[rules]] - tool = "exec_shell" - command = "cargo test" - - [[rules]] - tool = "read_file" - path = "secrets/api_key.txt" - "#, - ) - .expect("write permissions"); - - let store = ConfigStore::load(Some(config_path.clone())).expect("load config store"); - - assert_eq!(store.config.model.as_deref(), Some("deepseek-v4-flash")); - assert_eq!( - store.permissions().rules.as_slice(), - &[ - ToolAskRule::exec_shell("cargo test"), - ToolAskRule::file_path("read_file", "secrets/api_key.txt"), - ] - ); - assert_eq!( - store.permissions_path(), - config_path.with_file_name(PERMISSIONS_FILE_NAME) - ); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn config_store_loads_permissions_even_when_config_is_absent() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "codewhale-permissions-only-{}-{unique}", - std::process::id() - )); - fs::create_dir_all(&dir).expect("mkdir"); - let config_path = dir.join(CONFIG_FILE_NAME); - fs::write( - dir.join(PERMISSIONS_FILE_NAME), - r#" - [[rules]] - tool = "exec_shell" - command = "cargo check" - "#, - ) - .expect("write permissions"); - - let store = ConfigStore::load(Some(config_path)).expect("load config store"); - - assert!(store.config.model.is_none()); - assert_eq!( - store.permissions().rules.as_slice(), - &[ToolAskRule::exec_shell("cargo check")] - ); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn config_store_exec_policy_engine_uses_sibling_permissions() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "codewhale-permissions-engine-{}-{unique}", - std::process::id() - )); - fs::create_dir_all(&dir).expect("mkdir"); - let config_path = dir.join(CONFIG_FILE_NAME); - fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config"); - fs::write( - dir.join(PERMISSIONS_FILE_NAME), - r#" - [[rules]] - tool = "exec_shell" - command = "cargo test" - "#, - ) - .expect("write permissions"); - - let store = ConfigStore::load(Some(config_path)).expect("load config store"); - let decision = store - .exec_policy_engine() - .check(codewhale_execpolicy::ExecPolicyContext { - command: "cargo test --workspace", - cwd: "/workspace", - tool: Some("exec_shell"), - path: None, - ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted, - sandbox_mode: Some("workspace-write"), - }) - .expect("policy check"); - - assert!(decision.allow); - assert!(decision.requires_approval); - assert_eq!( - decision.matched_rule.as_deref(), - Some("tool=exec_shell command=cargo test") - ); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn config_store_appends_ask_rules_without_losing_comments_or_duplicates() { - let dir = tempfile::tempdir().expect("tempdir"); - let config_path = dir.path().join(CONFIG_FILE_NAME); - let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME); - fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config"); - fs::write( - &permissions_path, - r#"# keep this permission note -[[rules]] -tool = "exec_shell" -command = "cargo check" -"#, - ) - .expect("write permissions"); - - let mut store = ConfigStore::load(Some(config_path)).expect("load config store"); - let existing = ToolAskRule::exec_shell("cargo check"); - let added_rule = ToolAskRule::file_path("read_file", "docs/README.md"); - let added = store - .append_ask_rules(&[existing, added_rule.clone(), added_rule.clone()]) - .expect("append ask rules"); - - assert_eq!(added, 1); - assert_eq!( - store.permissions().rules, - vec![ToolAskRule::exec_shell("cargo check"), added_rule.clone(),] - ); - let body = fs::read_to_string(&permissions_path).expect("read permissions"); - assert!(body.contains("# keep this permission note")); - assert_eq!(body.matches("docs/README.md").count(), 1); - assert!(!body.contains("decision")); - - let before_duplicate_append = body; - assert_eq!( - store - .append_ask_rules(&[added_rule]) - .expect("dedupe ask rule"), - 0 - ); - assert_eq!( - fs::read_to_string(&permissions_path).expect("read unchanged permissions"), - before_duplicate_append - ); - - let reloaded = ConfigStore::load(Some(dir.path().join(CONFIG_FILE_NAME))) - .expect("reload config store"); - assert_eq!(reloaded.permissions(), store.permissions()); - } - - #[test] - fn config_store_appends_ask_rule_to_inline_rules_array() { - let dir = tempfile::tempdir().expect("tempdir"); - let config_path = dir.path().join(CONFIG_FILE_NAME); - let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME); - fs::write( - &permissions_path, - "# inline rules stay valid\nrules = [{ tool = \"exec_shell\", command = \"cargo check\" }]\n", - ) - .expect("write permissions"); - - let mut store = ConfigStore::load(Some(config_path)).expect("load config store"); - assert_eq!( - store - .append_ask_rules(&[ToolAskRule::file_path("read_file", "README.md")]) - .expect("append inline ask rule"), - 1 - ); - - let body = fs::read_to_string(&permissions_path).expect("read permissions"); - assert!(body.contains("# inline rules stay valid")); - let parsed: PermissionsToml = toml::from_str(&body).expect("parse persisted permissions"); - assert_eq!( - parsed.rules, - vec![ - ToolAskRule::exec_shell("cargo check"), - ToolAskRule::file_path("read_file", "README.md"), - ] - ); - } - - #[test] - fn config_store_does_not_overwrite_invalid_permissions_file() { - let dir = tempfile::tempdir().expect("tempdir"); - let config_path = dir.path().join(CONFIG_FILE_NAME); - let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME); - let mut store = ConfigStore::load(Some(config_path)).expect("load config store"); - let invalid = "rules = \"not-an-array\"\n"; - fs::write(&permissions_path, invalid).expect("write invalid permissions"); - - let error = store - .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")]) - .expect_err("invalid permissions should fail"); - - assert!(error.to_string().contains("failed to parse permissions")); - assert_eq!( - fs::read_to_string(&permissions_path).expect("read invalid permissions"), - invalid - ); - assert!(store.permissions().is_empty()); - } - - #[test] - fn duplicate_append_refreshes_permissions_changed_on_disk() { - let dir = tempfile::tempdir().expect("tempdir"); - let config_path = dir.path().join(CONFIG_FILE_NAME); - let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME); - let mut store = ConfigStore::load(Some(config_path)).expect("load config store"); - fs::write( - permissions_path, - "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo check\"\n", - ) - .expect("write external permissions update"); - - assert_eq!( - store - .append_ask_rules(&[ToolAskRule::exec_shell("cargo check")]) - .expect("dedupe external ask rule"), - 0 - ); - assert_eq!( - store.permissions().rules, - vec![ToolAskRule::exec_shell("cargo check")] - ); - } - - #[cfg(unix)] - #[test] - fn config_store_secures_persisted_permissions_file() { - let dir = tempfile::tempdir().expect("tempdir"); - let config_path = dir.path().join(CONFIG_FILE_NAME); - let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME); - let mut store = ConfigStore::load(Some(config_path)).expect("load config store"); - - store - .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")]) - .expect("append ask rule"); - - let mode = fs::metadata(permissions_path) - .expect("permissions metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o600); - } - - struct EnvGuard { - deepseek_api_key: Option, - deepseek_base_url: Option, - deepseek_http_headers: Option, - deepseek_model: Option, - deepseek_default_text_model: Option, - deepseek_provider: Option, - deepseek_auth_mode: Option, - nvidia_api_key: Option, - nvidia_nim_api_key: Option, - nim_base_url: Option, - nvidia_base_url: Option, - nvidia_nim_base_url: Option, - openrouter_api_key: Option, - openrouter_base_url: Option, - openrouter_model: Option, - xiaomi_mimo_token_plan_api_key: Option, - mimo_token_plan_api_key: Option, - xiaomi_mimo_api_key: Option, - xiaomi_api_key: Option, - mimo_api_key: Option, - xiaomi_mimo_base_url: Option, - mimo_base_url: Option, - xiaomi_mimo_model: Option, - mimo_model: Option, - xiaomi_mimo_mode: Option, - mimo_mode: Option, - wanjie_ark_api_key: Option, - volcengine_api_key: Option, - volcengine_ark_api_key: Option, - ark_api_key: Option, - volcengine_base_url: Option, - volcengine_ark_base_url: Option, - ark_base_url: Option, - wanjie_ark_base_url: Option, - wanjie_base_url: Option, - wanjie_maas_base_url: Option, - volcengine_model: Option, - volcengine_ark_model: Option, - wanjie_ark_model: Option, - wanjie_model: Option, - wanjie_maas_model: Option, - novita_api_key: Option, - novita_base_url: Option, - novita_model: Option, - fireworks_api_key: Option, - fireworks_base_url: Option, - fireworks_model: Option, - siliconflow_api_key: Option, - siliconflow_base_url: Option, - siliconflow_model: Option, - arcee_api_key: Option, - arcee_base_url: Option, - arcee_model: Option, - moonshot_api_key: Option, - moonshot_base_url: Option, - moonshot_model: Option, - kimi_api_key: Option, - kimi_base_url: Option, - kimi_model: Option, - kimi_model_name: Option, - zai_api_key: Option, - z_ai_api_key: Option, - zai_base_url: Option, - zai_model: Option, - stepfun_api_key: Option, - step_api_key: Option, - stepfun_base_url: Option, - stepfun_model: Option, - minimax_api_key: Option, - minimax_base_url: Option, - minimax_model: Option, - sglang_api_key: Option, - sglang_base_url: Option, - vllm_api_key: Option, - vllm_base_url: Option, - ollama_api_key: Option, - ollama_base_url: Option, - huggingface_api_key: Option, - huggingface_token: Option, - huggingface_base_url: Option, - hf_base_url: Option, - huggingface_model: Option, - hf_model: Option, - codewhale_provider: Option, - codewhale_model: Option, - codewhale_base_url: Option, - } - - impl EnvGuard { - fn without_deepseek_runtime_overrides() -> Self { - let guard = Self { - deepseek_api_key: env::var_os("DEEPSEEK_API_KEY"), - deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"), - deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"), - deepseek_model: env::var_os("DEEPSEEK_MODEL"), - deepseek_default_text_model: env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"), - deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"), - deepseek_auth_mode: env::var_os("DEEPSEEK_AUTH_MODE"), - codewhale_provider: env::var_os("CODEWHALE_PROVIDER"), - codewhale_model: env::var_os("CODEWHALE_MODEL"), - codewhale_base_url: env::var_os("CODEWHALE_BASE_URL"), - nvidia_api_key: env::var_os("NVIDIA_API_KEY"), - nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"), - nim_base_url: env::var_os("NIM_BASE_URL"), - nvidia_base_url: env::var_os("NVIDIA_BASE_URL"), - nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"), - openrouter_api_key: env::var_os("OPENROUTER_API_KEY"), - openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"), - openrouter_model: env::var_os("OPENROUTER_MODEL"), - xiaomi_mimo_token_plan_api_key: env::var_os("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"), - mimo_token_plan_api_key: env::var_os("MIMO_TOKEN_PLAN_API_KEY"), - xiaomi_mimo_api_key: env::var_os("XIAOMI_MIMO_API_KEY"), - xiaomi_api_key: env::var_os("XIAOMI_API_KEY"), - mimo_api_key: env::var_os("MIMO_API_KEY"), - xiaomi_mimo_base_url: env::var_os("XIAOMI_MIMO_BASE_URL"), - mimo_base_url: env::var_os("MIMO_BASE_URL"), - xiaomi_mimo_model: env::var_os("XIAOMI_MIMO_MODEL"), - mimo_model: env::var_os("MIMO_MODEL"), - xiaomi_mimo_mode: env::var_os("XIAOMI_MIMO_MODE"), - mimo_mode: env::var_os("MIMO_MODE"), - wanjie_ark_api_key: env::var_os("WANJIE_ARK_API_KEY"), - volcengine_api_key: env::var_os("VOLCENGINE_API_KEY"), - volcengine_ark_api_key: env::var_os("VOLCENGINE_ARK_API_KEY"), - ark_api_key: env::var_os("ARK_API_KEY"), - volcengine_base_url: env::var_os("VOLCENGINE_BASE_URL"), - volcengine_ark_base_url: env::var_os("VOLCENGINE_ARK_BASE_URL"), - ark_base_url: env::var_os("ARK_BASE_URL"), - wanjie_ark_base_url: env::var_os("WANJIE_ARK_BASE_URL"), - wanjie_base_url: env::var_os("WANJIE_BASE_URL"), - wanjie_maas_base_url: env::var_os("WANJIE_MAAS_BASE_URL"), - volcengine_model: env::var_os("VOLCENGINE_MODEL"), - volcengine_ark_model: env::var_os("VOLCENGINE_ARK_MODEL"), - wanjie_ark_model: env::var_os("WANJIE_ARK_MODEL"), - wanjie_model: env::var_os("WANJIE_MODEL"), - wanjie_maas_model: env::var_os("WANJIE_MAAS_MODEL"), - novita_api_key: env::var_os("NOVITA_API_KEY"), - novita_base_url: env::var_os("NOVITA_BASE_URL"), - novita_model: env::var_os("NOVITA_MODEL"), - fireworks_api_key: env::var_os("FIREWORKS_API_KEY"), - fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"), - fireworks_model: env::var_os("FIREWORKS_MODEL"), - siliconflow_api_key: env::var_os("SILICONFLOW_API_KEY"), - siliconflow_base_url: env::var_os("SILICONFLOW_BASE_URL"), - siliconflow_model: env::var_os("SILICONFLOW_MODEL"), - arcee_api_key: env::var_os("ARCEE_API_KEY"), - arcee_base_url: env::var_os("ARCEE_BASE_URL"), - arcee_model: env::var_os("ARCEE_MODEL"), - moonshot_api_key: env::var_os("MOONSHOT_API_KEY"), - moonshot_base_url: env::var_os("MOONSHOT_BASE_URL"), - moonshot_model: env::var_os("MOONSHOT_MODEL"), - kimi_api_key: env::var_os("KIMI_API_KEY"), - kimi_base_url: env::var_os("KIMI_BASE_URL"), - kimi_model: env::var_os("KIMI_MODEL"), - kimi_model_name: env::var_os("KIMI_MODEL_NAME"), - zai_api_key: env::var_os("ZAI_API_KEY"), - z_ai_api_key: env::var_os("Z_AI_API_KEY"), - zai_base_url: env::var_os("ZAI_BASE_URL"), - zai_model: env::var_os("ZAI_MODEL"), - stepfun_api_key: env::var_os("STEPFUN_API_KEY"), - step_api_key: env::var_os("STEP_API_KEY"), - stepfun_base_url: env::var_os("STEPFUN_BASE_URL"), - stepfun_model: env::var_os("STEPFUN_MODEL"), - minimax_api_key: env::var_os("MINIMAX_API_KEY"), - minimax_base_url: env::var_os("MINIMAX_BASE_URL"), - minimax_model: env::var_os("MINIMAX_MODEL"), - sglang_api_key: env::var_os("SGLANG_API_KEY"), - sglang_base_url: env::var_os("SGLANG_BASE_URL"), - vllm_api_key: env::var_os("VLLM_API_KEY"), - vllm_base_url: env::var_os("VLLM_BASE_URL"), - ollama_api_key: env::var_os("OLLAMA_API_KEY"), - ollama_base_url: env::var_os("OLLAMA_BASE_URL"), - huggingface_api_key: env::var_os("HUGGINGFACE_API_KEY"), - huggingface_token: env::var_os("HF_TOKEN"), - huggingface_base_url: env::var_os("HUGGINGFACE_BASE_URL"), - hf_base_url: env::var_os("HF_BASE_URL"), - huggingface_model: env::var_os("HUGGINGFACE_MODEL"), - hf_model: env::var_os("HF_MODEL"), - }; - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::remove_var("DEEPSEEK_API_KEY"); - env::remove_var("DEEPSEEK_BASE_URL"); - env::remove_var("DEEPSEEK_HTTP_HEADERS"); - env::remove_var("DEEPSEEK_MODEL"); - env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL"); - env::remove_var("DEEPSEEK_PROVIDER"); - env::remove_var("DEEPSEEK_AUTH_MODE"); - env::remove_var("CODEWHALE_PROVIDER"); - env::remove_var("CODEWHALE_MODEL"); - env::remove_var("CODEWHALE_BASE_URL"); - env::remove_var("NVIDIA_API_KEY"); - env::remove_var("NVIDIA_NIM_API_KEY"); - env::remove_var("NIM_BASE_URL"); - env::remove_var("NVIDIA_BASE_URL"); - env::remove_var("NVIDIA_NIM_BASE_URL"); - env::remove_var("OPENROUTER_API_KEY"); - env::remove_var("OPENROUTER_BASE_URL"); - env::remove_var("OPENROUTER_MODEL"); - env::remove_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"); - env::remove_var("MIMO_TOKEN_PLAN_API_KEY"); - env::remove_var("XIAOMI_MIMO_API_KEY"); - env::remove_var("XIAOMI_API_KEY"); - env::remove_var("MIMO_API_KEY"); - env::remove_var("XIAOMI_MIMO_BASE_URL"); - env::remove_var("MIMO_BASE_URL"); - env::remove_var("XIAOMI_MIMO_MODEL"); - env::remove_var("MIMO_MODEL"); - env::remove_var("XIAOMI_MIMO_MODE"); - env::remove_var("MIMO_MODE"); - env::remove_var("WANJIE_ARK_API_KEY"); - env::remove_var("VOLCENGINE_API_KEY"); - env::remove_var("VOLCENGINE_ARK_API_KEY"); - env::remove_var("ARK_API_KEY"); - env::remove_var("VOLCENGINE_BASE_URL"); - env::remove_var("VOLCENGINE_ARK_BASE_URL"); - env::remove_var("ARK_BASE_URL"); - env::remove_var("WANJIE_ARK_BASE_URL"); - env::remove_var("WANJIE_BASE_URL"); - env::remove_var("WANJIE_MAAS_BASE_URL"); - env::remove_var("VOLCENGINE_MODEL"); - env::remove_var("VOLCENGINE_ARK_MODEL"); - env::remove_var("WANJIE_ARK_MODEL"); - env::remove_var("WANJIE_MODEL"); - env::remove_var("WANJIE_MAAS_MODEL"); - env::remove_var("NOVITA_API_KEY"); - env::remove_var("NOVITA_BASE_URL"); - env::remove_var("NOVITA_MODEL"); - env::remove_var("FIREWORKS_API_KEY"); - env::remove_var("FIREWORKS_BASE_URL"); - env::remove_var("FIREWORKS_MODEL"); - env::remove_var("SILICONFLOW_API_KEY"); - env::remove_var("SILICONFLOW_BASE_URL"); - env::remove_var("SILICONFLOW_MODEL"); - env::remove_var("ARCEE_API_KEY"); - env::remove_var("ARCEE_BASE_URL"); - env::remove_var("ARCEE_MODEL"); - env::remove_var("MOONSHOT_API_KEY"); - env::remove_var("MOONSHOT_BASE_URL"); - env::remove_var("MOONSHOT_MODEL"); - env::remove_var("KIMI_API_KEY"); - env::remove_var("KIMI_BASE_URL"); - env::remove_var("KIMI_MODEL"); - env::remove_var("KIMI_MODEL_NAME"); - env::remove_var("ZAI_API_KEY"); - env::remove_var("Z_AI_API_KEY"); - env::remove_var("ZAI_BASE_URL"); - env::remove_var("ZAI_MODEL"); - env::remove_var("STEPFUN_API_KEY"); - env::remove_var("STEP_API_KEY"); - env::remove_var("STEPFUN_BASE_URL"); - env::remove_var("STEPFUN_MODEL"); - env::remove_var("MINIMAX_API_KEY"); - env::remove_var("MINIMAX_BASE_URL"); - env::remove_var("MINIMAX_MODEL"); - env::remove_var("SGLANG_API_KEY"); - env::remove_var("SGLANG_BASE_URL"); - env::remove_var("VLLM_API_KEY"); - env::remove_var("VLLM_BASE_URL"); - env::remove_var("OLLAMA_API_KEY"); - env::remove_var("OLLAMA_BASE_URL"); - env::remove_var("HUGGINGFACE_API_KEY"); - env::remove_var("HF_TOKEN"); - env::remove_var("HUGGINGFACE_BASE_URL"); - env::remove_var("HF_BASE_URL"); - env::remove_var("HUGGINGFACE_MODEL"); - env::remove_var("HF_MODEL"); - } - guard - } - - unsafe fn restore_var(key: &str, value: Option) { - if let Some(value) = value { - unsafe { env::set_var(key, value) }; - } else { - unsafe { env::remove_var(key) }; - } - } - } - - impl Drop for EnvGuard { - fn drop(&mut self) { - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take()); - Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take()); - Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take()); - Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take()); - Self::restore_var( - "DEEPSEEK_DEFAULT_TEXT_MODEL", - self.deepseek_default_text_model.take(), - ); - Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take()); - Self::restore_var("DEEPSEEK_AUTH_MODE", self.deepseek_auth_mode.take()); - Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take()); - Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take()); - Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take()); - Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take()); - Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take()); - Self::restore_var("NIM_BASE_URL", self.nim_base_url.take()); - Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take()); - Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take()); - Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take()); - Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take()); - Self::restore_var("OPENROUTER_MODEL", self.openrouter_model.take()); - Self::restore_var( - "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", - self.xiaomi_mimo_token_plan_api_key.take(), - ); - Self::restore_var( - "MIMO_TOKEN_PLAN_API_KEY", - self.mimo_token_plan_api_key.take(), - ); - Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take()); - Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take()); - Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take()); - Self::restore_var("XIAOMI_MIMO_BASE_URL", self.xiaomi_mimo_base_url.take()); - Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take()); - Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take()); - Self::restore_var("MIMO_MODEL", self.mimo_model.take()); - Self::restore_var("XIAOMI_MIMO_MODE", self.xiaomi_mimo_mode.take()); - Self::restore_var("MIMO_MODE", self.mimo_mode.take()); - Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take()); - Self::restore_var("VOLCENGINE_API_KEY", self.volcengine_api_key.take()); - Self::restore_var("VOLCENGINE_ARK_API_KEY", self.volcengine_ark_api_key.take()); - Self::restore_var("ARK_API_KEY", self.ark_api_key.take()); - Self::restore_var("VOLCENGINE_BASE_URL", self.volcengine_base_url.take()); - Self::restore_var( - "VOLCENGINE_ARK_BASE_URL", - self.volcengine_ark_base_url.take(), - ); - Self::restore_var("ARK_BASE_URL", self.ark_base_url.take()); - Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take()); - Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take()); - Self::restore_var("WANJIE_MAAS_BASE_URL", self.wanjie_maas_base_url.take()); - Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take()); - Self::restore_var("VOLCENGINE_ARK_MODEL", self.volcengine_ark_model.take()); - Self::restore_var("WANJIE_ARK_MODEL", self.wanjie_ark_model.take()); - Self::restore_var("WANJIE_MODEL", self.wanjie_model.take()); - Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take()); - Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take()); - Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take()); - Self::restore_var("NOVITA_MODEL", self.novita_model.take()); - Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take()); - Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take()); - Self::restore_var("FIREWORKS_MODEL", self.fireworks_model.take()); - Self::restore_var("SILICONFLOW_API_KEY", self.siliconflow_api_key.take()); - Self::restore_var("SILICONFLOW_BASE_URL", self.siliconflow_base_url.take()); - Self::restore_var("SILICONFLOW_MODEL", self.siliconflow_model.take()); - Self::restore_var("ARCEE_API_KEY", self.arcee_api_key.take()); - Self::restore_var("ARCEE_BASE_URL", self.arcee_base_url.take()); - Self::restore_var("ARCEE_MODEL", self.arcee_model.take()); - Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take()); - Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take()); - Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take()); - Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take()); - Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take()); - Self::restore_var("KIMI_MODEL", self.kimi_model.take()); - Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take()); - Self::restore_var("ZAI_API_KEY", self.zai_api_key.take()); - Self::restore_var("Z_AI_API_KEY", self.z_ai_api_key.take()); - Self::restore_var("ZAI_BASE_URL", self.zai_base_url.take()); - Self::restore_var("ZAI_MODEL", self.zai_model.take()); - Self::restore_var("STEPFUN_API_KEY", self.stepfun_api_key.take()); - Self::restore_var("STEP_API_KEY", self.step_api_key.take()); - Self::restore_var("STEPFUN_BASE_URL", self.stepfun_base_url.take()); - Self::restore_var("STEPFUN_MODEL", self.stepfun_model.take()); - Self::restore_var("MINIMAX_API_KEY", self.minimax_api_key.take()); - Self::restore_var("MINIMAX_BASE_URL", self.minimax_base_url.take()); - Self::restore_var("MINIMAX_MODEL", self.minimax_model.take()); - Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take()); - Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take()); - Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take()); - Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take()); - Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take()); - Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take()); - Self::restore_var("HUGGINGFACE_API_KEY", self.huggingface_api_key.take()); - Self::restore_var("HF_TOKEN", self.huggingface_token.take()); - Self::restore_var("HUGGINGFACE_BASE_URL", self.huggingface_base_url.take()); - Self::restore_var("HF_BASE_URL", self.hf_base_url.take()); - Self::restore_var("HUGGINGFACE_MODEL", self.huggingface_model.take()); - Self::restore_var("HF_MODEL", self.hf_model.take()); - } - } - } - - struct RecordingSecretsStore { - gets: Mutex>, - value: Option, - } - - impl RecordingSecretsStore { - fn with_value(value: &str) -> Self { - Self { - gets: Mutex::new(Vec::new()), - value: Some(value.to_string()), - } - } - } - - impl codewhale_secrets::KeyringStore for RecordingSecretsStore { - fn get(&self, key: &str) -> Result, codewhale_secrets::SecretsError> { - self.gets.lock().unwrap().push(key.to_string()); - Ok(self.value.clone()) - } - - fn set(&self, _key: &str, _value: &str) -> Result<(), codewhale_secrets::SecretsError> { - Ok(()) - } - - fn delete(&self, _key: &str) -> Result<(), codewhale_secrets::SecretsError> { - Ok(()) - } - - fn backend_name(&self) -> &'static str { - "recording" - } - } - - #[test] - fn root_deepseek_fields_are_runtime_fallbacks() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - api_key: Some("root-key".to_string()), - base_url: Some("https://api.deepseek.com".to_string()), - default_text_model: Some("deepseek-v4-pro".to_string()), - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Deepseek); - assert_eq!(resolved.api_key.as_deref(), Some("root-key")); - assert_eq!(resolved.base_url, "https://api.deepseek.com"); - assert_eq!(resolved.model, "deepseek-v4-pro"); - } - - #[test] - fn deepseek_runtime_defaults_to_beta_endpoint() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml::default(); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Deepseek); - assert_eq!(resolved.base_url, DEFAULT_DEEPSEEK_BASE_URL); - assert_eq!(resolved.model, DEFAULT_DEEPSEEK_MODEL); - } - - #[test] - fn provider_specific_deepseek_fields_override_tui_compat_fields() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let mut config = ConfigToml { - api_key: Some("root-key".to_string()), - base_url: Some("https://api.deepseek.com".to_string()), - default_text_model: Some("deepseek-v4-pro".to_string()), - ..ConfigToml::default() - }; - config.providers.deepseek.api_key = Some("provider-key".to_string()); - config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string()); - config.providers.deepseek.model = Some("deepseek-v4-flash".to_string()); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.api_key.as_deref(), Some("provider-key")); - assert_eq!(resolved.base_url, "https://gateway.example/v1"); - assert_eq!(resolved.model, "deepseek-v4-flash"); - } - - #[test] - fn provider_http_headers_override_root_headers() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let mut config = ConfigToml { - api_key: Some("root-key".to_string()), - base_url: Some("https://api.deepseek.com".to_string()), - default_text_model: Some("deepseek-v4-pro".to_string()), - ..ConfigToml::default() - }; - config.providers.deepseek.api_key = Some("provider-key".to_string()); - config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string()); - config.providers.deepseek.model = Some("deepseek-v4-flash".to_string()); - config - .http_headers - .insert("X-Shared".to_string(), "root".to_string()); - config - .providers - .deepseek - .http_headers - .insert("X-Model-Provider-Id".to_string(), "tongyi".to_string()); - config - .providers - .deepseek - .http_headers - .insert("X-Shared".to_string(), "provider".to_string()); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.api_key.as_deref(), Some("provider-key")); - assert_eq!(resolved.base_url, "https://gateway.example/v1"); - assert_eq!(resolved.model, "deepseek-v4-flash"); - assert_eq!( - resolved - .http_headers - .get("X-Model-Provider-Id") - .map(String::as_str), - Some("tongyi") - ); - assert_eq!( - resolved.http_headers.get("X-Shared").map(String::as_str), - Some("provider") - ); - } - - #[test] - fn insecure_skip_tls_verify_resolves_only_for_active_provider() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let mut config = ConfigToml { - provider: ProviderKind::Openai, - ..ConfigToml::default() - }; - config.providers.deepseek.insecure_skip_tls_verify = Some(true); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Openai); - assert!(!resolved.insecure_skip_tls_verify); - - config.providers.openai.insecure_skip_tls_verify = Some(true); - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Openai); - assert!(resolved.insecure_skip_tls_verify); - } - - #[test] - fn http_headers_env_overrides_config() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let mut config = ConfigToml::default(); - config - .http_headers - .insert("X-Model-Provider-Id".to_string(), "from-file".to_string()); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Model-Provider-Id=from-env"); - } - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!( - resolved - .http_headers - .get("X-Model-Provider-Id") - .map(String::as_str), - Some("from-env") - ); - } - - #[test] - fn nvidia_nim_provider_defaults_to_catalog_endpoint_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::NvidiaNim, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::NvidiaNim); - assert_eq!(resolved.base_url, DEFAULT_NVIDIA_NIM_BASE_URL); - assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL); - } - - #[test] - fn nvidia_nim_provider_uses_provider_specific_credentials() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let mut config = ConfigToml { - provider: ProviderKind::NvidiaNim, - ..ConfigToml::default() - }; - config.providers.nvidia_nim.api_key = Some("nim-key".to_string()); - config.providers.nvidia_nim.base_url = Some("https://nim.example/v1".to_string()); - config.providers.nvidia_nim.model = Some("deepseek-ai/deepseek-v4-pro".to_string()); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::NvidiaNim); - assert_eq!(resolved.api_key.as_deref(), Some("nim-key")); - assert_eq!(resolved.base_url, "https://nim.example/v1"); - assert_eq!(resolved.model, "deepseek-ai/deepseek-v4-pro"); - } - - #[test] - fn nvidia_nim_provider_normalizes_flash_aliases() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let cli = CliRuntimeOverrides { - provider: Some(ProviderKind::NvidiaNim), - model: Some("deepseek-v4-flash".to_string()), - ..CliRuntimeOverrides::default() - }; - - let resolved = ConfigToml::default().resolve_runtime_options(&cli); - - assert_eq!(resolved.provider, ProviderKind::NvidiaNim); - assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_FLASH_MODEL); - } - - #[test] - fn nvidia_nim_provider_uses_nvidia_env_credentials() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim"); - env::set_var("NVIDIA_API_KEY", "nim-env-key"); - env::set_var("NVIDIA_NIM_BASE_URL", "https://nim-env.example/v1"); - } - - let config = ConfigToml::default(); - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::NvidiaNim); - assert_eq!(resolved.api_key.as_deref(), Some("nim-env-key")); - assert_eq!(resolved.base_url, "https://nim-env.example/v1"); - assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL); - } - - #[test] - fn nvidia_nim_provider_accepts_short_nim_base_url_alias() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim"); - env::set_var("NVIDIA_API_KEY", "nim-env-key"); - env::set_var("NIM_BASE_URL", "https://short-nim.example/v1"); - } - - let config = ConfigToml::default(); - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::NvidiaNim); - assert_eq!(resolved.base_url, "https://short-nim.example/v1"); - } - - #[test] - fn nvidia_nim_provider_can_fallback_to_deepseek_api_key_env() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim"); - env::set_var("DEEPSEEK_API_KEY", "deepseek-compat-key"); - } - - let config = ConfigToml::default(); - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::NvidiaNim); - assert_eq!(resolved.api_key.as_deref(), Some("deepseek-compat-key")); - } - - #[test] - fn list_values_redacts_root_api_key() { - let config = ConfigToml { - api_key: Some("sk-deepseek-secret".to_string()), - ..ConfigToml::default() - }; - - let values = config.list_values(); - - assert_eq!( - values.get("api_key").map(String::as_str), - Some("sk-d***cret") - ); - } - - #[test] - fn list_values_fully_redacts_short_api_key() { - let config = ConfigToml { - api_key: Some("short-key".to_string()), - ..ConfigToml::default() - }; - - let values = config.list_values(); - - assert_eq!(values.get("api_key").map(String::as_str), Some("********")); - } - - #[test] - fn get_display_value_redacts_sensitive_keys() { - let mut config = ConfigToml { - api_key: Some("sk-deepseek-secret".to_string()), - ..ConfigToml::default() - }; - config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string()); - config.model = Some("deepseek-v4-pro".to_string()); - - assert_eq!( - config.get_display_value("api_key").as_deref(), - Some("sk-d***cret") - ); - assert_eq!( - config - .get_display_value("providers.openrouter.api_key") - .as_deref(), - Some("open***alue") - ); - assert_eq!( - config.get_display_value("model").as_deref(), - Some("deepseek-v4-pro") - ); - } - - #[test] - fn hook_sinks_config_uses_separate_table_from_lifecycle_hooks() -> Result<()> { - let raw = r#" -[hooks] -enabled = true -default_timeout_secs = 20 - -[[hooks.hooks]] -event = "message_submit" -command = "echo ok" - -[hook_sinks] -unix_socket_path = "/tmp/cw-hooks.sock" -"#; - - let config: ConfigToml = toml::from_str(raw)?; - - assert_eq!( - config.get_value("hook_sinks.unix_socket_path").as_deref(), - Some("/tmp/cw-hooks.sock") - ); - assert!( - config.extras.contains_key("hooks"), - "legacy lifecycle hooks table must remain an opaque extra" - ); - - let serialized = toml::to_string_pretty(&config)?; - let round_tripped: ConfigToml = toml::from_str(&serialized)?; - let hooks = round_tripped - .extras - .get("hooks") - .and_then(toml::Value::as_table) - .expect("hooks table preserved"); - - assert_eq!( - hooks.get("enabled").and_then(toml::Value::as_bool), - Some(true) - ); - assert_eq!( - hooks - .get("default_timeout_secs") - .and_then(toml::Value::as_integer), - Some(20) - ); - assert!( - hooks.get("hooks").and_then(toml::Value::as_array).is_some(), - "nested lifecycle hooks array must survive config rewrites" - ); - assert_eq!( - round_tripped - .get_value("hook_sinks.unix_socket_path") - .as_deref(), - Some("/tmp/cw-hooks.sock") - ); - - Ok(()) - } - - #[test] - fn hook_sinks_unix_socket_path_round_trips_through_key_value_api() -> Result<()> { - let mut config = ConfigToml::default(); - - config.set_value("hook_sinks.unix_socket_path", "/tmp/cw-events.sock")?; - - assert_eq!( - config.get_value("hook_sinks.unix_socket_path").as_deref(), - Some("/tmp/cw-events.sock") - ); - assert_eq!( - config - .list_values() - .get("hook_sinks.unix_socket_path") - .map(String::as_str), - Some("/tmp/cw-events.sock") - ); - - config.unset_value("hook_sinks.unix_socket_path")?; - assert_eq!(config.get_value("hook_sinks.unix_socket_path"), None); - - Ok(()) - } - - /// End-to-end smoke for the preferred Kimi Code setup path: - /// 1. Start from a fresh root config that uses DeepSeek defaults. - /// 2. Mutate it through the same key-value setters the - /// `codewhale config set providers.moonshot.*` CLI invokes. - /// 3. Switch the active provider through `CODEWHALE_PROVIDER` — - /// the public env alias — without ever touching the legacy - /// `DEEPSEEK_PROVIDER` name. - /// 4. Resolve the runtime and confirm the doctor/runtime values. - /// - /// No real API key is required; the `api_key` here is just a - /// non-empty placeholder. - #[test] - fn moonshot_kimi_code_smoke_config_set_then_resolve() -> Result<()> { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - - let mut config = ConfigToml { - provider: ProviderKind::Deepseek, - default_text_model: Some("deepseek-v4-pro".to_string()), - ..ConfigToml::default() - }; - - // Same key paths a user would run via `codewhale config set`. - config.set_value("providers.moonshot.api_key", "kimi-code-key-placeholder")?; - config.set_value("providers.moonshot.auth_mode", "api_key")?; - config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?; - config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?; - - // Public env alias for the active-provider switch. - // Safety: test-only env mutation guarded by env_lock(). - unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Moonshot); - assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); - assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); - assert_eq!(resolved.auth_mode.as_deref(), Some("api_key")); - assert_eq!( - resolved.api_key.as_deref(), - Some("kimi-code-key-placeholder") - ); - assert_eq!( - resolved.api_key_source, - Some(RuntimeApiKeySource::ConfigFile) - ); - Ok(()) - } - - #[test] - fn moonshot_provider_config_values_round_trip() -> Result<()> { - let mut config = ConfigToml::default(); - - config.set_value("providers.moonshot.api_key", "moonshot-secret-value")?; - config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?; - config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?; - config.set_value("providers.moonshot.auth_mode", "api_key")?; - config.set_value("providers.moonshot.http_headers", "X-Test=ok")?; - - assert_eq!( - config - .get_display_value("providers.moonshot.api_key") - .as_deref(), - Some("moon***alue") - ); - assert_eq!( - config.get_value("providers.moonshot.base_url").as_deref(), - Some(DEFAULT_KIMI_CODE_BASE_URL) - ); - assert_eq!( - config.get_value("providers.moonshot.model").as_deref(), - Some(DEFAULT_KIMI_CODE_MODEL) - ); - assert_eq!( - config.get_value("providers.moonshot.auth_mode").as_deref(), - Some("api_key") - ); - assert_eq!( - config - .list_values() - .get("providers.moonshot.api_key") - .map(String::as_str), - Some("moon***alue") - ); - - config.unset_value("providers.moonshot.auth_mode")?; - config.unset_value("providers.moonshot.base_url")?; - config.unset_value("providers.moonshot.model")?; - - assert_eq!(config.get_value("providers.moonshot.auth_mode"), None); - assert_eq!(config.get_value("providers.moonshot.base_url"), None); - assert_eq!(config.get_value("providers.moonshot.model"), None); - Ok(()) - } - - #[test] - fn siliconflow_cn_provider_config_values_round_trip() -> Result<()> { - let mut config = ConfigToml::default(); - - config.set_value("providers.siliconflow_cn.api_key", "sf-cn-secret-value")?; - config.set_value( - "providers.siliconflow_cn.base_url", - DEFAULT_SILICONFLOW_CN_BASE_URL, - )?; - config.set_value("providers.siliconflow_cn.model", DEFAULT_SILICONFLOW_MODEL)?; - config.set_value("providers.siliconflow_cn.http_headers", "X-Test=ok")?; - - assert_eq!( - config - .get_display_value("providers.siliconflow_cn.api_key") - .as_deref(), - Some("sf-c***alue") - ); - assert_eq!( - config - .get_value("providers.siliconflow_cn.base_url") - .as_deref(), - Some(DEFAULT_SILICONFLOW_CN_BASE_URL) - ); - assert_eq!( - config - .get_value("providers.siliconflow_cn.model") - .as_deref(), - Some(DEFAULT_SILICONFLOW_MODEL) - ); - assert_eq!( - config - .list_values() - .get("providers.siliconflow_cn.api_key") - .map(String::as_str), - Some("sf-c***alue") - ); - - config.unset_value("providers.siliconflow_cn.api_key")?; - config.unset_value("providers.siliconflow_cn.base_url")?; - config.unset_value("providers.siliconflow_cn.model")?; - config.unset_value("providers.siliconflow_cn.http_headers")?; - - assert_eq!(config.get_value("providers.siliconflow_cn.api_key"), None); - assert_eq!(config.get_value("providers.siliconflow_cn.base_url"), None); - assert_eq!(config.get_value("providers.siliconflow_cn.model"), None); - assert_eq!( - config.get_value("providers.siliconflow_cn.http_headers"), - None - ); - Ok(()) - } - - #[test] - fn volcengine_provider_config_values_round_trip() -> Result<()> { - let mut config = ConfigToml::default(); - - config.set_value("providers.volcengine.api_key", "volcengine-secret-value")?; - config.set_value("providers.volcengine.base_url", DEFAULT_VOLCENGINE_BASE_URL)?; - config.set_value("providers.volcengine.model", DEFAULT_VOLCENGINE_MODEL)?; - config.set_value("providers.volcengine.http_headers", "X-Test=ok")?; - - assert_eq!( - config - .get_display_value("providers.volcengine.api_key") - .as_deref(), - Some("volc***alue") - ); - assert_eq!( - config.get_value("providers.volcengine.base_url").as_deref(), - Some(DEFAULT_VOLCENGINE_BASE_URL) - ); - assert_eq!( - config.get_value("providers.volcengine.model").as_deref(), - Some(DEFAULT_VOLCENGINE_MODEL) - ); - assert_eq!( - config - .get_value("providers.volcengine.http_headers") - .as_deref(), - Some("X-Test=ok") - ); - assert_eq!( - config - .list_values() - .get("providers.volcengine.http_headers") - .map(String::as_str), - Some("X-Test=ok") - ); - - config.unset_value("providers.volcengine.http_headers")?; - assert_eq!(config.get_value("providers.volcengine.http_headers"), None); - Ok(()) - } - - #[test] - fn provider_key_value_api_covers_all_provider_metadata_entries() -> Result<()> { - for provider in ProviderKind::ALL { - let table = provider.provider().provider_config_key(); - let mut config = ConfigToml::default(); - let api_key = format!("secret-value-for-{table}-123456"); - let api_key_path = format!("providers.{table}.api_key"); - let base_url_path = format!("providers.{table}.base_url"); - let model_path = format!("providers.{table}.model"); - let headers_path = format!("providers.{table}.http_headers"); - let mode_path = format!("providers.{table}.mode"); - let auth_mode_path = format!("providers.{table}.auth_mode"); - let insecure_path = format!("providers.{table}.insecure_skip_tls_verify"); - let path_suffix_path = format!("providers.{table}.path_suffix"); - - config.set_value(&api_key_path, &api_key)?; - config.set_value(&base_url_path, "https://gateway.example/v1")?; - config.set_value(&model_path, "provider-test-model")?; - config.set_value(&headers_path, "X-Test=ok")?; - config.set_value(&mode_path, "concise")?; - config.set_value(&auth_mode_path, "api_key")?; - config.set_value(&insecure_path, "true")?; - config.set_value(&path_suffix_path, "/chat/completions")?; - - assert_eq!( - config.get_value(&api_key_path).as_deref(), - Some(api_key.as_str()) - ); - assert_eq!( - config.get_value(&base_url_path).as_deref(), - Some("https://gateway.example/v1") - ); - assert_eq!( - config.get_value(&model_path).as_deref(), - Some("provider-test-model") - ); - assert_eq!( - config.get_value(&headers_path).as_deref(), - Some("X-Test=ok") - ); - assert_eq!(config.get_value(&mode_path).as_deref(), Some("concise")); - assert_eq!( - config.get_value(&auth_mode_path).as_deref(), - Some("api_key") - ); - assert_eq!(config.get_value(&insecure_path).as_deref(), Some("true")); - assert_eq!( - config.get_value(&path_suffix_path).as_deref(), - Some("/chat/completions") - ); - - let listed = config.list_values(); - let listed_api_key = listed - .get(&api_key_path) - .expect("provider API key is listed"); - assert!(listed_api_key.contains("***")); - assert_ne!(listed_api_key, &api_key); - assert_eq!( - listed.get(&headers_path).map(String::as_str), - Some("X-Test=ok") - ); - assert_eq!(listed.get(&insecure_path).map(String::as_str), Some("true")); - - config.unset_value(&api_key_path)?; - config.unset_value(&base_url_path)?; - config.unset_value(&model_path)?; - config.unset_value(&headers_path)?; - config.unset_value(&mode_path)?; - config.unset_value(&auth_mode_path)?; - config.unset_value(&insecure_path)?; - config.unset_value(&path_suffix_path)?; - - assert_eq!(config.get_value(&api_key_path), None); - assert_eq!(config.get_value(&base_url_path), None); - assert_eq!(config.get_value(&model_path), None); - assert_eq!(config.get_value(&headers_path), None); - assert_eq!(config.get_value(&mode_path), None); - assert_eq!(config.get_value(&auth_mode_path), None); - assert_eq!(config.get_value(&insecure_path), None); - assert_eq!(config.get_value(&path_suffix_path), None); - - if provider == ProviderKind::Deepseek { - assert_eq!(config.api_key, None); - assert_eq!(config.base_url, None); - assert_eq!(config.default_text_model, None); - assert!(config.http_headers.is_empty()); - } - } - - Ok(()) - } - - #[test] - fn project_merge_denies_credentials_endpoints_and_provider_selection() { - let mut base = ConfigToml { - provider: ProviderKind::Deepseek, - api_key: Some("user-key".to_string()), - base_url: Some("https://api.deepseek.com".to_string()), - default_text_model: Some("deepseek-v4-flash".to_string()), - ..ConfigToml::default() - }; - base.providers.openrouter.api_key = Some("user-openrouter-key".to_string()); - base.providers.openrouter.path_suffix = Some("/chat/completions".to_string()); - - let mut project = ConfigToml { - provider: ProviderKind::Openrouter, - api_key: Some("attacker-key".to_string()), - base_url: Some("https://evil.example/v1".to_string()), - default_text_model: Some("deepseek-v4-pro".to_string()), - auth_mode: Some("oauth".to_string()), - telemetry: Some(true), - ..ConfigToml::default() - }; - project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string()); - project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string()); - project.providers.openrouter.insecure_skip_tls_verify = Some(true); - project.providers.openrouter.path_suffix = Some("/attacker/chat".to_string()); - project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string()); - project.providers.volcengine.model = Some("DeepSeek-V4-Pro".to_string()); - project.providers.moonshot.model = Some("kimi-k2.6".to_string()); - - base.merge_project_overrides(project); - - assert_eq!(base.provider, ProviderKind::Deepseek); - assert_eq!(base.api_key.as_deref(), Some("user-key")); - assert_eq!(base.base_url.as_deref(), Some("https://api.deepseek.com")); - assert_eq!(base.auth_mode, None); - assert_eq!(base.telemetry, None); - assert_eq!( - base.providers.openrouter.api_key.as_deref(), - Some("user-openrouter-key") - ); - assert_eq!(base.providers.openrouter.base_url, None); - assert_eq!(base.providers.openrouter.insecure_skip_tls_verify, None); - assert_eq!( - base.providers.openrouter.path_suffix.as_deref(), - Some("/chat/completions") - ); - assert_eq!(base.default_text_model.as_deref(), Some("deepseek-v4-pro")); - assert_eq!( - base.providers.openrouter.model.as_deref(), - Some("deepseek/deepseek-v4-pro") - ); - assert_eq!( - base.providers.volcengine.model.as_deref(), - Some("DeepSeek-V4-Pro") - ); - assert_eq!(base.providers.moonshot.model.as_deref(), Some("kimi-k2.6")); - } - - #[test] - fn project_merge_forwards_all_provider_model_overrides() { - let mut project_toml = String::new(); - for provider in ProviderKind::ALL { - let key = provider.provider().provider_config_key(); - project_toml.push_str(&format!( - "[providers.{key}]\nmodel = \"project-{key}-model\"\n\n" - )); - } - - let project: ConfigToml = - toml::from_str(&project_toml).expect("project provider overrides parse"); - let mut base = ConfigToml::default(); - - base.merge_project_overrides(project); - - for provider in ProviderKind::ALL { - let key = provider.provider().provider_config_key(); - let expected = format!("project-{key}-model"); - assert_eq!( - base.providers.for_provider(provider).model.as_deref(), - Some(expected.as_str()), - "provider {key} should merge repo-local model override" - ); - } - } - - #[test] - fn project_merge_only_tightens_approval_and_sandbox_policy() { - let mut strict = ConfigToml { - approval_policy: Some("never".to_string()), - sandbox_mode: Some("read-only".to_string()), - ..ConfigToml::default() - }; - strict.merge_project_overrides(ConfigToml { - approval_policy: Some("on-request".to_string()), - sandbox_mode: Some("workspace-write".to_string()), - ..ConfigToml::default() - }); - assert_eq!(strict.approval_policy.as_deref(), Some("never")); - assert_eq!(strict.sandbox_mode.as_deref(), Some("read-only")); - - let mut permissive = ConfigToml { - approval_policy: Some("auto".to_string()), - sandbox_mode: Some("workspace-write".to_string()), - ..ConfigToml::default() - }; - permissive.merge_project_overrides(ConfigToml { - approval_policy: Some("never".to_string()), - sandbox_mode: Some("read-only".to_string()), - ..ConfigToml::default() - }); - assert_eq!(permissive.approval_policy.as_deref(), Some("never")); - assert_eq!(permissive.sandbox_mode.as_deref(), Some("read-only")); - - let mut unset = ConfigToml::default(); - unset.merge_project_overrides(ConfigToml { - approval_policy: Some("on-request".to_string()), - sandbox_mode: Some("workspace-write".to_string()), - ..ConfigToml::default() - }); - assert_eq!(unset.approval_policy, None); - assert_eq!(unset.sandbox_mode, None); - } - - #[test] - fn list_values_redacts_unicode_api_key_without_byte_slicing() { - let config = ConfigToml { - api_key: Some("密钥密钥密钥密钥123456789".to_string()), - ..ConfigToml::default() - }; - - let values = config.list_values(); - - assert_eq!( - values.get("api_key").map(String::as_str), - Some("密钥密钥***6789") - ); - } - - #[test] - fn app_homes_prefer_home_env_before_platform_home_fallback() { - let _lock = env_lock(); - struct HomeEnvGuard { - home: Option, - userprofile: Option, - codewhale_home: Option, - } - - impl Drop for HomeEnvGuard { - fn drop(&mut self) { - // Safety: test-only environment mutation is serialized by env_lock(). - unsafe { - match self.home.take() { - Some(value) => env::set_var("HOME", value), - None => env::remove_var("HOME"), - } - match self.userprofile.take() { - Some(value) => env::set_var("USERPROFILE", value), - None => env::remove_var("USERPROFILE"), - } - match self.codewhale_home.take() { - Some(value) => env::set_var("CODEWHALE_HOME", value), - None => env::remove_var("CODEWHALE_HOME"), - } - } - } - } - - let home = - std::env::temp_dir().join(format!("codewhale-config-home-env-{}", std::process::id())); - let userprofile = std::env::temp_dir().join(format!( - "codewhale-config-userprofile-{}", - std::process::id() - )); - let _env = HomeEnvGuard { - home: env::var_os("HOME"), - userprofile: env::var_os("USERPROFILE"), - codewhale_home: env::var_os("CODEWHALE_HOME"), - }; - // Safety: test-only environment mutation is serialized by env_lock(). - unsafe { - env::set_var("HOME", &home); - env::set_var("USERPROFILE", &userprofile); - env::remove_var("CODEWHALE_HOME"); - } - - assert_eq!( - codewhale_home().expect("codewhale home"), - home.join(CODEWHALE_APP_DIR) - ); - assert_eq!( - legacy_deepseek_home().expect("legacy home"), - home.join(LEGACY_APP_DIR) - ); - - let explicit = std::env::temp_dir().join(format!( - "codewhale-config-explicit-home-{}", - std::process::id() - )); - // Safety: test-only environment mutation is serialized by env_lock(). - unsafe { - env::set_var("CODEWHALE_HOME", &explicit); - } - assert_eq!(codewhale_home().expect("explicit home"), explicit); - } - - #[test] - fn migrate_config_reports_copied_legacy_path() { - let _lock = env_lock(); - struct HomeEnvGuard { - home: Option, - userprofile: Option, - codewhale_home: Option, - } - - impl Drop for HomeEnvGuard { - fn drop(&mut self) { - // Safety: test-only environment mutation is serialized by env_lock(). - unsafe { - match self.home.take() { - Some(value) => env::set_var("HOME", value), - None => env::remove_var("HOME"), - } - match self.userprofile.take() { - Some(value) => env::set_var("USERPROFILE", value), - None => env::remove_var("USERPROFILE"), - } - match self.codewhale_home.take() { - Some(value) => env::set_var("CODEWHALE_HOME", value), - None => env::remove_var("CODEWHALE_HOME"), - } - } - } - } - - struct LegacyConfigGuard { - path: PathBuf, - original: Option>, - } - - impl LegacyConfigGuard { - fn install(path: PathBuf, contents: &[u8]) -> Self { - let original = fs::read(&path).ok(); - fs::create_dir_all(path.parent().expect("legacy config parent")) - .expect("legacy dir"); - fs::write(&path, contents).expect("legacy config"); - Self { path, original } - } - } - - impl Drop for LegacyConfigGuard { - fn drop(&mut self) { - if let Some(original) = self.original.take() { - let _ = fs::write(&self.path, original); - } else { - let _ = fs::remove_file(&self.path); - if let Some(parent) = self.path.parent() { - let _ = fs::remove_dir(parent); - } - } - } - } - - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock") - .as_nanos(); - let home = std::env::temp_dir().join(format!( - "codewhale-config-migration-{}-{unique}", - std::process::id() - )); - let legacy_dir = home.join(LEGACY_APP_DIR); - let primary_dir = home.join(CODEWHALE_APP_DIR); - let legacy_config = legacy_dir.join(CONFIG_FILE_NAME); - let _legacy = - LegacyConfigGuard::install(legacy_config.clone(), b"provider = \"deepseek\"\n"); - - let _env = HomeEnvGuard { - home: env::var_os("HOME"), - userprofile: env::var_os("USERPROFILE"), - codewhale_home: env::var_os("CODEWHALE_HOME"), - }; - // Safety: test-only environment mutation is serialized by env_lock(). - unsafe { - env::set_var("HOME", &home); - env::set_var("USERPROFILE", &home); - env::set_var("CODEWHALE_HOME", &primary_dir); - } - - let migration = migrate_config_if_needed() - .expect("migration") - .expect("legacy config should be copied"); - - assert_eq!(migration.legacy_path, legacy_config); - assert_eq!(migration.primary_path, primary_dir.join(CONFIG_FILE_NAME)); - let notice = migration.user_notice(); - assert!(notice.contains(&legacy_dir.join(CONFIG_FILE_NAME).display().to_string())); - assert!(notice.contains(&primary_dir.join(CONFIG_FILE_NAME).display().to_string())); - assert!(notice.contains(".codewhale path for future edits")); - assert!(notice.contains(".deepseek file remains only as a compatibility fallback")); - assert_eq!( - fs::read_to_string(primary_dir.join(CONFIG_FILE_NAME)).expect("primary config"), - "provider = \"deepseek\"\n" - ); - - let _ = fs::remove_dir_all(home); - } - - #[test] - fn normalize_config_file_path_rejects_traversal() { - let err = normalize_config_file_path(PathBuf::from("../config.toml")) - .expect_err("traversal path should fail"); - assert!(format!("{err:#}").contains("cannot contain '..'")); - } - - #[cfg(unix)] - #[test] - fn save_clamps_existing_config_permissions() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "deepseek-config-perms-{}-{unique}", - std::process::id() - )); - fs::create_dir_all(&dir).expect("mkdir"); - let path = dir.join(CONFIG_FILE_NAME); - fs::write(&path, "api_key = \"old\"\n").expect("seed config"); - fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod seed"); - - let store = ConfigStore { - path: path.clone(), - config: ConfigToml { - api_key: Some("new-secret".to_string()), - ..ConfigToml::default() - }, - permissions: PermissionsToml::default(), - original_raw: None, - }; - store.save().expect("save"); - - let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777; - assert_eq!(mode, 0o600); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn config_store_save_skips_identical_serialized_body() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "codewhale-config-noop-save-{}-{unique}", - std::process::id() - )); - fs::create_dir_all(&dir).expect("mkdir"); - let path = dir.join(CONFIG_FILE_NAME); - let config = ConfigToml { - model: Some("deepseek-v4-flash".to_string()), - ..ConfigToml::default() - }; - let body = toml::to_string_pretty(&config).expect("serialize"); - fs::write(&path, &body).expect("seed config"); - #[cfg(unix)] - fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).expect("chmod seed"); - - let store = ConfigStore { - path: path.clone(), - config, - permissions: PermissionsToml::default(), - original_raw: None, - }; - store.save().expect("identical save should not rewrite"); - - #[cfg(unix)] - fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("chmod restore"); - assert_eq!(fs::read_to_string(&path).expect("read config"), body); - assert!( - !config_backup_path(&path).exists(), - "no-op save must not create a migration backup" - ); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn config_store_save_creates_one_time_backup_before_changed_write() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "codewhale-config-backup-save-{}-{unique}", - std::process::id() - )); - fs::create_dir_all(&dir).expect("mkdir"); - let path = dir.join(CONFIG_FILE_NAME); - let original = "model = \"deepseek-v4-flash\"\n"; - fs::write(&path, original).expect("seed config"); - - let store = ConfigStore { - path: path.clone(), - config: ConfigToml { - model: Some("deepseek-v4-pro".to_string()), - ..ConfigToml::default() - }, - permissions: PermissionsToml::default(), - original_raw: None, - }; - store.save().expect("changed save"); - - let backup_path = config_backup_path(&path); - assert_eq!( - fs::read_to_string(&backup_path).expect("read backup"), - original - ); - let updated = fs::read_to_string(&path).expect("read updated config"); - assert!(updated.contains("model = \"deepseek-v4-pro\"")); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn config_store_save_preserves_comments() { - let dir = tempfile::tempdir().expect("tempdir"); - let config_path = dir.path().join(CONFIG_FILE_NAME); - let original = "# my model\nmodel = \"deepseek-v4-flash\"\n# end comment\n"; - fs::write(&config_path, original).expect("write config"); - - let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store"); - store.config.model = Some("deepseek-v4-pro".to_string()); - store.save().expect("save"); - - let body = fs::read_to_string(&config_path).expect("read config"); - assert!(body.contains("# my model"), "prefix comment preserved"); - assert!(body.contains("# end comment"), "suffix comment preserved"); - assert!(body.contains("model = \"deepseek-v4-pro\"")); - } - - #[test] - fn config_store_save_preserves_disabled_keys() { - let dir = tempfile::tempdir().expect("tempdir"); - let config_path = dir.path().join(CONFIG_FILE_NAME); - fs::write( - &config_path, - "# my note\nmodel = \"deepseek-v4-flash\"\n# base_url = \"http://localhost:11434/v1\"\n", - ) - .expect("write config"); - - let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store"); - store.config.model = Some("deepseek-v4-pro".to_string()); - store.save().expect("save"); - - let body = fs::read_to_string(&config_path).expect("read config"); - assert!( - body.contains("# base_url = \"http://localhost:11434/v1\""), - "disabled key preserved as comment" - ); - assert!(body.contains("model = \"deepseek-v4-pro\"")); - } - - #[test] - fn config_store_save_preserves_comments_with_other_keys() { - // Realistic scenario: user already has api_key + model, adds a comment, - // then changes model via `codewhale config set model`. - let dir = tempfile::tempdir().expect("tempdir"); - let config_path = dir.path().join(CONFIG_FILE_NAME); - fs::write( - &config_path, - "# my deepseek key\napi_key = \"sk-1234\"\n\n# my current model\nmodel = \"deepseek-v4-flash\"\n", - ) - .expect("write config"); - - let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store"); - store.config.model = Some("deepseek-v4-pro".to_string()); - store.save().expect("save"); - - let body = fs::read_to_string(&config_path).expect("read config"); - assert!(body.contains("# my deepseek key"), "api_key comment lost"); - assert!(body.contains("# my current model"), "model comment lost"); - assert!( - body.contains("model = \"deepseek-v4-pro\""), - "new model not written" - ); - assert!(body.contains("api_key = \"sk-1234\""), "api_key lost"); - } - - #[test] - fn merge_and_preserve_comments_returns_err_on_invalid_serialized() { - let err = merge_and_preserve_comments("{{{ not toml", "model = 1\n") - .expect_err("invalid serialized should fail"); - assert!( - format!("{err:#}").contains("failed to parse serialized"), - "unexpected error: {err:#}" - ); - } - - #[test] - fn merge_and_preserve_comments_returns_err_on_invalid_original() { - let err = merge_and_preserve_comments("model = 1\n", "{{{ not toml") - .expect_err("invalid original should fail"); - assert!( - format!("{err:#}").contains("failed to parse original"), - "unexpected error: {err:#}" - ); - } - - #[test] - fn config_store_save_falls_back_when_comment_merge_fails() { - let dir = tempfile::tempdir().expect("tempdir"); - let config_path = dir.path().join(CONFIG_FILE_NAME); - // Valid TOML so load succeeds, but the raw is corrupt so the merge - // will fail inside save() — save must still succeed and write the - // plain serialized config. - fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config"); - - // Bypass ConfigStore::load to inject a deliberately broken original_raw. - let store = ConfigStore { - path: config_path.clone(), - config: ConfigToml { - model: Some("deepseek-v4-pro".to_string()), - ..ConfigToml::default() - }, - permissions: PermissionsToml::default(), - original_raw: Some("{ broken".to_string()), - }; - store - .save() - .expect("save should succeed even when merge fails"); - - let body = fs::read_to_string(&config_path).expect("read config"); - assert!( - body.contains("deepseek-v4-pro"), - "config should be written: {body}" - ); - } - - #[test] - fn provider_kind_parses_openrouter_and_novita_aliases() { - assert_eq!( - ProviderKind::parse("openrouter"), - Some(ProviderKind::Openrouter) - ); - assert_eq!( - ProviderKind::parse("OPEN_ROUTER"), - Some(ProviderKind::Openrouter) - ); - assert_eq!( - ProviderKind::parse("xiaomi-mimo"), - Some(ProviderKind::XiaomiMimo) - ); - assert_eq!( - ProviderKind::parse("xiaomi"), - Some(ProviderKind::XiaomiMimo) - ); - assert_eq!(ProviderKind::parse("novita"), Some(ProviderKind::Novita)); - assert_eq!(ProviderKind::parse("Novita"), Some(ProviderKind::Novita)); - assert_eq!( - ProviderKind::parse("fireworks-ai"), - Some(ProviderKind::Fireworks) - ); - assert_eq!( - ProviderKind::parse("silicon-flow"), - Some(ProviderKind::Siliconflow) - ); - assert_eq!( - ProviderKind::parse("silicon_flow"), - Some(ProviderKind::Siliconflow) - ); - assert_eq!(ProviderKind::parse("kimi"), Some(ProviderKind::Moonshot)); - assert_eq!( - ProviderKind::parse("moonshot-ai"), - Some(ProviderKind::Moonshot) - ); - assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang)); - assert_eq!(ProviderKind::parse("v-llm"), Some(ProviderKind::Vllm)); - assert_eq!(ProviderKind::parse("vllm"), Some(ProviderKind::Vllm)); - assert_eq!(ProviderKind::parse("ollama"), Some(ProviderKind::Ollama)); - assert_eq!( - ProviderKind::parse("ollama-local"), - Some(ProviderKind::Ollama) - ); - assert_eq!( - ProviderKind::parse("wanjie-ark"), - Some(ProviderKind::WanjieArk) - ); - assert_eq!( - ProviderKind::parse("ark_wanjie"), - Some(ProviderKind::WanjieArk) - ); - for alias in ["huggingface", "hugging-face", "hugging_face", "hf"] { - assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Huggingface)); - - let parsed: ConfigToml = - toml::from_str(&format!("provider = \"{alias}\"")).expect("huggingface alias"); - assert_eq!(parsed.provider, ProviderKind::Huggingface); - } - - for alias in ["deepinfra", "deep-infra", "deep_infra"] { - assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepinfra)); - - let parsed: ConfigToml = - toml::from_str(&format!("provider = \"{alias}\"")).expect("deepinfra alias"); - assert_eq!(parsed.provider, ProviderKind::Deepinfra); - } - - let parsed: ConfigToml = - toml::from_str("provider = \"ark-wanjie\"").expect("wanjie provider alias"); - assert_eq!(parsed.provider, ProviderKind::WanjieArk); - - let parsed: ConfigToml = - toml::from_str("provider = \"silicon-flow\"").expect("siliconflow provider alias"); - assert_eq!(parsed.provider, ProviderKind::Siliconflow); - } - - #[test] - fn unknown_provider_error_lists_huggingface() { - let mut config = ConfigToml::default(); - let err = config - .set_value("provider", "not-a-provider") - .expect_err("unknown provider should fail"); - let message = err.to_string(); - assert!(message.contains("unknown provider 'not-a-provider'")); - assert!(message.contains("huggingface")); - } - - #[test] - fn provider_kind_accepts_legacy_deepseek_cn_aliases() { - for alias in [ - "deepseek-cn", - "deepseek_china", - "deepseekcn", - "deepseek-china", - ] { - assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepseek)); - - let parsed: ConfigToml = - toml::from_str(&format!("provider = \"{alias}\"")).expect("legacy provider alias"); - assert_eq!(parsed.provider, ProviderKind::Deepseek); - } - } - - #[test] - fn provider_metadata_registry_covers_every_provider_kind_once() { - let providers = provider::all_providers(); - assert_eq!(providers.len(), ProviderKind::ALL.len()); - - for (kind, provider) in ProviderKind::ALL.iter().zip(providers.iter()) { - assert_eq!(provider.kind(), *kind); - assert_eq!(provider.id(), kind.as_str()); - assert_eq!(kind.provider().id(), kind.as_str()); - } - - let mut ids = std::collections::BTreeSet::new(); - for provider in providers { - assert!(ids.insert(provider.id()), "duplicate provider id"); - } - } - - #[test] - fn provider_metadata_lookup_does_not_fall_back_to_deepseek() { - assert!(provider::lookup_provider("not-a-provider").is_none()); - assert!(provider::resolve_provider("not-a-provider").is_none()); - assert!(provider::lookup_provider("deepseek-cn").is_none()); - assert_eq!( - provider::resolve_provider("deepseek-cn") - .expect("legacy alias resolves") - .kind(), - ProviderKind::Deepseek - ); - } - - #[test] - fn provider_metadata_preserves_alias_and_config_key_semantics() { - assert_eq!( - provider::resolve_provider("open_router") - .expect("openrouter alias") - .kind(), - ProviderKind::Openrouter - ); - assert_eq!( - provider::resolve_provider("xiaomi") - .expect("xiaomi alias") - .kind(), - ProviderKind::XiaomiMimo - ); - assert_eq!( - provider::resolve_provider("kimi") - .expect("kimi alias") - .kind(), - ProviderKind::Moonshot - ); - assert_eq!( - provider::resolve_provider("hf") - .expect("huggingface alias") - .kind(), - ProviderKind::Huggingface - ); - - let siliconflow_cn = - provider::resolve_provider("siliconflow-cn").expect("siliconflow-cn alias resolves"); - assert_eq!(siliconflow_cn.kind(), ProviderKind::SiliconflowCN); - assert_eq!(siliconflow_cn.id(), "siliconflow-CN"); - assert_eq!(siliconflow_cn.provider_config_key(), "siliconflow_cn"); - - let config = ProvidersToml::default(); - let shared_table = config.for_provider(ProviderKind::SiliconflowCN); - assert!(!std::ptr::eq( - shared_table, - config.for_provider(ProviderKind::Siliconflow) - )); - } - - #[test] - fn provider_metadata_defaults_match_runtime_helpers() { - for kind in ProviderKind::ALL { - let provider = kind.provider(); - assert_eq!(provider.default_model(), default_model_for_provider(kind)); - assert_eq!( - provider.default_base_url(), - default_base_url_for_provider(kind) - ); - assert!(!provider.display_name().trim().is_empty()); - assert!(!provider.env_vars().is_empty()); - // OpenAI Codex (ChatGPT) speaks the Responses API and Anthropic - // speaks the native Messages API; every other built-in provider - // is OpenAI-compatible Chat Completions. - let expected_wire = match kind { - ProviderKind::OpenaiCodex => provider::WireFormat::Responses, - ProviderKind::Anthropic => provider::WireFormat::AnthropicMessages, - _ => provider::WireFormat::ChatCompletions, - }; - assert_eq!(provider.wire(), expected_wire); - } - } - - #[test] - fn openrouter_provider_defaults_to_canonical_endpoint_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::Openrouter, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Openrouter); - assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL); - assert_eq!(resolved.model, DEFAULT_OPENROUTER_MODEL); - } - - #[test] - fn xiaomi_mimo_provider_defaults_to_canonical_endpoint_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::XiaomiMimo, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); - assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL); - assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL); - } - - #[test] - fn xiaomi_provider_alias_table_maps_to_mimo_runtime_config() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config: ConfigToml = toml::from_str( - r#" -provider = "xiaomi-mimo" -default_text_model = "deepseek/deepseek-v4-pro" - -[providers.xiaomi] -api_key = "mimo-table-key" -base_url = "https://token-plan-sgp.xiaomimimo.com/v1" -model = "mimo-v2.5-pro" -"#, - ) - .expect("xiaomi provider alias config"); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); - assert_eq!(resolved.api_key.as_deref(), Some("mimo-table-key")); - assert_eq!( - resolved.base_url, - "https://token-plan-sgp.xiaomimimo.com/v1" - ); - assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL); - } - - #[test] - fn xiaomi_token_plan_key_rewrites_saved_pay_as_you_go_base_url() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config: ConfigToml = toml::from_str( - r#" -provider = "xiaomi-mimo" - -[providers.xiaomi_mimo] -api_key = "tp-test-token-plan-key" -base_url = "https://api.xiaomimimo.com/v1" -model = "mimo-v2.5-pro" -"#, - ) - .expect("xiaomi token-plan config"); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); - assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL); - assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL); - } - - #[test] - fn xiaomi_mimo_token_plan_mode_accepts_region_aliases() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config: ConfigToml = toml::from_str( - r#" -provider = "mimo" - -[providers.mimo] -mode = "token-plan-ams" -"#, - ) - .expect("xiaomi token-plan region config"); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); - assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL); - } - - #[test] - fn xiaomi_mimo_unknown_mode_stays_on_token_plan_endpoint() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config: ConfigToml = toml::from_str( - r#" -provider = "mimo" - -[providers.mimo] -mode = "token-plan-usa" -"#, - ) - .expect("xiaomi token-plan unknown mode config"); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); - assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL); - } - - #[test] - fn xiaomi_mimo_aliases_resolve_to_canonical_models() { - assert_eq!( - normalize_model_for_provider(ProviderKind::XiaomiMimo, "omni"), - "mimo-v2.5" - ); - assert_eq!( - normalize_model_for_provider(ProviderKind::XiaomiMimo, "tts"), - "mimo-v2.5-tts" - ); - assert_eq!( - normalize_model_for_provider(ProviderKind::XiaomiMimo, "voice-design"), - "mimo-v2.5-tts-voicedesign" - ); - assert_eq!( - normalize_model_for_provider(ProviderKind::XiaomiMimo, "voiceclone"), - "mimo-v2.5-tts-voiceclone" - ); - assert_eq!( - normalize_model_for_provider(ProviderKind::XiaomiMimo, "custom-mimo-model"), - "custom-mimo-model" - ); - } - - #[test] - fn zai_aliases_resolve_to_canonical_models() { - // GLM-5.2 is the default; the glm-5.1 alias must still resolve to 5.1 - // (not to the default), and GLM-5-Turbo resolves to its own id. - assert_eq!( - normalize_model_for_provider(ProviderKind::Zai, "glm-5.1"), - ZAI_GLM_5_1_MODEL - ); - assert_eq!( - normalize_model_for_provider(ProviderKind::Zai, "glm-5-2"), - DEFAULT_ZAI_MODEL - ); - assert_eq!(DEFAULT_ZAI_MODEL, ZAI_GLM_5_2_MODEL); - assert_eq!( - normalize_model_for_provider(ProviderKind::Zai, "glm-5-turbo"), - ZAI_GLM_5_TURBO_MODEL - ); - assert_eq!( - normalize_model_for_provider(ProviderKind::Zai, "custom-glm-preview"), - "custom-glm-preview" - ); - } - - #[test] - fn novita_provider_defaults_to_canonical_endpoint_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::Novita, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Novita); - assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL); - assert_eq!(resolved.model, DEFAULT_NOVITA_MODEL); - } - - #[test] - fn fireworks_provider_defaults_to_canonical_endpoint_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::Fireworks, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Fireworks); - assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL); - assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL); - } - - #[test] - fn siliconflow_provider_defaults_to_canonical_endpoint_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::Siliconflow, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Siliconflow); - assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_BASE_URL); - assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL); - } - - #[test] - fn siliconflow_cn_config_falls_back_to_shared_table_when_unset() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let mut config = ConfigToml { - provider: ProviderKind::SiliconflowCN, - ..ConfigToml::default() - }; - config.providers.siliconflow.api_key = Some("sf-shared-key".to_string()); - config.providers.siliconflow.base_url = Some(DEFAULT_SILICONFLOW_BASE_URL.to_string()); - config.providers.siliconflow.model = Some("deepseek-chat".to_string()); - config.providers.siliconflow_cn.base_url = - Some(DEFAULT_SILICONFLOW_CN_BASE_URL.to_string()); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::SiliconflowCN); - assert_eq!(resolved.api_key.as_deref(), Some("sf-shared-key")); - assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_CN_BASE_URL); - assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL); - } - - #[test] - fn moonshot_provider_defaults_to_kimi_k27_code() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::Moonshot, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Moonshot); - assert_eq!(resolved.base_url, DEFAULT_MOONSHOT_BASE_URL); - assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL); - } - - #[test] - fn zai_stepfun_and_minimax_default_to_first_party_routes() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - - for (provider, expected_base_url, expected_model) in [ - (ProviderKind::Zai, DEFAULT_ZAI_BASE_URL, DEFAULT_ZAI_MODEL), - ( - ProviderKind::Stepfun, - DEFAULT_STEPFUN_BASE_URL, - DEFAULT_STEPFUN_MODEL, - ), - ( - ProviderKind::Minimax, - DEFAULT_MINIMAX_BASE_URL, - DEFAULT_MINIMAX_MODEL, - ), - ] { - let config = ConfigToml { - provider, - ..ConfigToml::default() - }; - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, provider); - assert_eq!(resolved.base_url, expected_base_url); - assert_eq!(resolved.model, expected_model); - } - } - - #[test] - fn first_party_provider_env_model_overrides_pass_through() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - unsafe { - env::set_var("CODEWHALE_PROVIDER", "minimax"); - env::set_var("MINIMAX_MODEL", "MiniMax-M2.7-highspeed"); - env::set_var("MINIMAX_BASE_URL", "https://minimax.example/v1"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Minimax); - assert_eq!(resolved.base_url, "https://minimax.example/v1"); - assert_eq!(resolved.model, "MiniMax-M2.7-highspeed"); - } - - #[test] - fn minimax_env_model_override_canonicalizes_known_aliases() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - unsafe { - env::set_var("CODEWHALE_PROVIDER", "minimax"); - env::set_var("MINIMAX_MODEL", "minimax-m2-5-highspeed"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Minimax); - assert_eq!(resolved.model, "MiniMax-M2.5-highspeed"); - } - - #[test] - fn moonshot_provider_preserves_explicit_kimi_k26() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let mut config = ConfigToml { - provider: ProviderKind::Moonshot, - ..ConfigToml::default() - }; - config.providers.moonshot.model = Some("kimi-k2.6".to_string()); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Moonshot); - assert_eq!(resolved.model, MOONSHOT_KIMI_K2_6_MODEL); - } - - #[test] - fn moonshot_kimi_oauth_uses_kimi_code_endpoint_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let mut config = ConfigToml { - provider: ProviderKind::Moonshot, - ..ConfigToml::default() - }; - config.providers.moonshot.auth_mode = Some("kimi_oauth".to_string()); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Moonshot); - assert_eq!(resolved.auth_mode.as_deref(), Some("kimi_oauth")); - assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); - assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); - assert_eq!(resolved.api_key, None); - assert_eq!(resolved.api_key_source, None); - } - - #[test] - fn moonshot_kimi_code_api_key_endpoint_defaults_to_kimi_for_coding() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let mut config = ConfigToml { - provider: ProviderKind::Moonshot, - ..ConfigToml::default() - }; - config.providers.moonshot.api_key = Some("kimi-code-key".to_string()); - config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string()); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Moonshot); - assert_eq!(resolved.auth_mode, None); - assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); - assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); - assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key")); - assert_eq!( - resolved.api_key_source, - Some(RuntimeApiKeySource::ConfigFile) - ); - } - - /// `CODEWHALE_PROVIDER` is the user-facing env alias for switching the - /// active provider. It must be honored by the runtime resolver and win - /// over a root `provider = "deepseek"` config entry. - #[test] - fn codewhale_provider_env_switches_active_provider() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only env mutation guarded by env_lock(). - unsafe { - env::set_var("CODEWHALE_PROVIDER", "moonshot"); - } - let mut config = ConfigToml { - provider: ProviderKind::Deepseek, - ..ConfigToml::default() - }; - config.providers.moonshot.api_key = Some("kimi-code-key".to_string()); - config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string()); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Moonshot); - assert_eq!( - resolved.provider_source, - ProviderSource::Env("CODEWHALE_PROVIDER") - ); - assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); - assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); - assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key")); - } - - /// When both `CODEWHALE_PROVIDER` and the legacy `DEEPSEEK_PROVIDER` - /// are set, the public alias wins — a user adopting `CODEWHALE_*` in a - /// fresh shell config is not tripped up by a stale legacy export still - /// living in their dotfiles. - #[test] - fn codewhale_provider_env_wins_over_deepseek_provider_env() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only env mutation guarded by env_lock(). - unsafe { - env::set_var("CODEWHALE_PROVIDER", "moonshot"); - env::set_var("DEEPSEEK_PROVIDER", "openrouter"); - } - let config = ConfigToml { - provider: ProviderKind::Deepseek, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Moonshot); - assert_eq!( - resolved.provider_source, - ProviderSource::Env("CODEWHALE_PROVIDER") - ); - } - - #[test] - fn legacy_deepseek_provider_env_records_provider_source() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only env mutation guarded by env_lock(). - unsafe { - env::set_var("DEEPSEEK_PROVIDER", "openrouter"); - } - let config = ConfigToml { - provider: ProviderKind::Deepseek, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Openrouter); - assert_eq!( - resolved.provider_source, - ProviderSource::Env("DEEPSEEK_PROVIDER") - ); - } - - #[test] - fn cli_provider_records_provider_source() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only env mutation guarded by env_lock(). - unsafe { - env::set_var("CODEWHALE_PROVIDER", "moonshot"); - } - let cli = CliRuntimeOverrides { - provider: Some(ProviderKind::Openai), - ..CliRuntimeOverrides::default() - }; - let config = ConfigToml { - provider: ProviderKind::Deepseek, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&cli); - - assert_eq!(resolved.provider, ProviderKind::Openai); - assert_eq!(resolved.provider_source, ProviderSource::Cli); - } - - #[test] - fn config_provider_records_provider_source() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::Moonshot, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Moonshot); - assert_eq!(resolved.provider_source, ProviderSource::Config); - } - - /// `CODEWHALE_MODEL` is the user-facing env alias for picking a model - /// against the active provider. It must be honored by the runtime - /// resolver in place of `DEEPSEEK_MODEL`. - #[test] - fn codewhale_model_env_alias_overrides_default_for_active_provider() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only env mutation guarded by env_lock(). - unsafe { - env::set_var("CODEWHALE_PROVIDER", "moonshot"); - env::set_var("CODEWHALE_MODEL", "custom-kimi-test-model"); - } - let config = ConfigToml::default(); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Moonshot); - assert_eq!(resolved.model, "custom-kimi-test-model"); - } - - #[test] - fn blank_codewhale_model_env_alias_does_not_override_default_for_active_provider() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only env mutation guarded by env_lock(). - unsafe { - env::set_var("CODEWHALE_PROVIDER", "moonshot"); - env::set_var("CODEWHALE_MODEL", " "); - } - let config = ConfigToml::default(); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Moonshot); - assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL); - } - - #[test] - fn deepseek_default_text_model_legacy_alias_still_overrides_active_provider_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only env mutation guarded by env_lock(). - unsafe { - env::set_var("CODEWHALE_PROVIDER", "moonshot"); - env::set_var("DEEPSEEK_DEFAULT_TEXT_MODEL", "legacy-env-model"); - } - let config = ConfigToml::default(); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Moonshot); - assert_eq!(resolved.model, "legacy-env-model"); - } - - #[test] - fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::WanjieArk, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::WanjieArk); - assert_eq!(resolved.base_url, DEFAULT_WANJIE_ARK_BASE_URL); - assert_eq!(resolved.model, DEFAULT_WANJIE_ARK_MODEL); - } - - #[test] - fn sglang_provider_defaults_to_local_endpoint_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::Sglang, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Sglang); - assert_eq!(resolved.base_url, DEFAULT_SGLANG_BASE_URL); - assert_eq!(resolved.model, DEFAULT_SGLANG_MODEL); - } - - #[test] - fn vllm_provider_defaults_to_local_endpoint_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::Vllm, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Vllm); - assert_eq!(resolved.base_url, DEFAULT_VLLM_BASE_URL); - assert_eq!(resolved.model, DEFAULT_VLLM_MODEL); - } - - #[test] - fn ollama_provider_defaults_to_local_endpoint_and_small_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::Ollama, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Ollama); - assert_eq!(resolved.base_url, DEFAULT_OLLAMA_BASE_URL); - assert_eq!(resolved.model, DEFAULT_OLLAMA_MODEL); - assert_eq!(resolved.api_key, None); - } - - #[test] - fn self_hosted_providers_do_not_probe_secret_store_by_default() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key")); - let secrets = Secrets::new(store.clone()); - - for provider in [ - ProviderKind::Sglang, - ProviderKind::Vllm, - ProviderKind::Ollama, - ] { - let config = ConfigToml { - provider, - ..ConfigToml::default() - }; - - let resolved = config - .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); - - assert_eq!(resolved.provider, provider); - assert_eq!(resolved.api_key, None); - } - - assert!( - store.gets.lock().unwrap().is_empty(), - "self-hosted providers should not read the secret store by default" - ); - } - - #[test] - fn self_hosted_api_key_auth_can_use_secret_store_when_requested() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key")); - let secrets = Secrets::new(store.clone()); - let config = ConfigToml { - provider: ProviderKind::Ollama, - auth_mode: Some("api_key".to_string()), - ..ConfigToml::default() - }; - - let resolved = - config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); - - assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key")); - assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama"]); - } - - #[test] - fn moonshot_api_key_mode_can_use_secret_store_by_default() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key")); - let secrets = Secrets::new(store.clone()); - let config = ConfigToml { - provider: ProviderKind::Moonshot, - ..ConfigToml::default() - }; - - let resolved = - config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); - - assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key")); - assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring)); - assert_eq!(store.gets.lock().unwrap().as_slice(), ["moonshot"]); - } - - #[test] - fn loopback_custom_deepseek_base_url_does_not_probe_secret_store_by_default() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let store = Arc::new(RecordingSecretsStore::with_value("stale-deepseek-key")); - let secrets = Secrets::new(store.clone()); - let config = ConfigToml { - base_url: Some("http://127.0.0.1:8000/v1".to_string()), - ..ConfigToml::default() - }; - - let resolved = - config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); - - assert_eq!(resolved.provider, ProviderKind::Deepseek); - assert_eq!(resolved.base_url, "http://127.0.0.1:8000/v1"); - assert_eq!(resolved.api_key, None); - assert!( - store.gets.lock().unwrap().is_empty(), - "loopback custom endpoints should not read macOS Keychain or any secret store" - ); - } - - #[test] - fn ollama_provider_preserves_model_tags() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let cli = CliRuntimeOverrides { - provider: Some(ProviderKind::Ollama), - model: Some("deepseek-coder-v2:16b".to_string()), - ..CliRuntimeOverrides::default() - }; - - let resolved = ConfigToml::default().resolve_runtime_options(&cli); - - assert_eq!(resolved.provider, ProviderKind::Ollama); - assert_eq!(resolved.model, "deepseek-coder-v2:16b"); - } - - #[test] - fn ollama_env_overrides_provider_base_url_and_optional_key() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("DEEPSEEK_PROVIDER", "ollama-local"); - env::set_var("OLLAMA_BASE_URL", "http://ollama.example/v1"); - env::set_var("OLLAMA_API_KEY", "ollama-env-key"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Ollama); - assert_eq!(resolved.base_url, "http://ollama.example/v1"); - assert_eq!(resolved.api_key.as_deref(), Some("ollama-env-key")); - } - - #[test] - fn openrouter_env_overrides_key_and_model_when_config_missing() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("DEEPSEEK_PROVIDER", "openrouter"); - env::set_var("OPENROUTER_API_KEY", "or-env-key"); - env::set_var("OPENROUTER_MODEL", "deepseek-v4-flash"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Openrouter); - assert_eq!(resolved.api_key.as_deref(), Some("or-env-key")); - assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL); - assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL); - } - - #[test] - fn xiaomi_mimo_env_overrides_provider_key_base_url_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo"); - env::set_var("MIMO_API_KEY", "mimo-env-key"); - env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1"); - env::set_var("MIMO_MODEL", "mimo-v2.5"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); - assert_eq!(resolved.api_key.as_deref(), Some("mimo-env-key")); - assert_eq!(resolved.base_url, "https://mimo-gateway.example/v1"); - assert_eq!(resolved.model, "mimo-v2.5"); - } - - #[test] - fn xiaomi_mimo_env_token_plan_mode_uses_token_plan_key_and_endpoint() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo"); - env::set_var("XIAOMI_MIMO_MODE", "token-plan-cn"); - env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key"); - env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); - assert_eq!(resolved.api_key.as_deref(), Some("tp-env-key")); - assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); - assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL); - } - - #[test] - fn xiaomi_mimo_env_pay_as_you_go_mode_prefers_standard_key() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo"); - env::set_var("XIAOMI_MIMO_MODE", "pay-as-you-go"); - env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key"); - env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); - assert_eq!(resolved.api_key.as_deref(), Some("sk-env-key")); - assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); - assert_eq!(resolved.base_url, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL); - } - - #[test] - fn novita_env_overrides_key_and_model_when_config_missing() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("DEEPSEEK_PROVIDER", "novita"); - env::set_var("NOVITA_API_KEY", "novita-env-key"); - env::set_var("NOVITA_MODEL", "deepseek-v4-flash"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Novita); - assert_eq!(resolved.api_key.as_deref(), Some("novita-env-key")); - assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL); - assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL); - } - - #[test] - fn fireworks_env_overrides_key_and_model_when_config_missing() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("DEEPSEEK_PROVIDER", "fireworks"); - env::set_var("FIREWORKS_API_KEY", "fw-env-key"); - env::set_var( - "FIREWORKS_MODEL", - "accounts/fireworks/models/account-specific-model", - ); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Fireworks); - assert_eq!(resolved.api_key.as_deref(), Some("fw-env-key")); - assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL); - assert_eq!( - resolved.model, - "accounts/fireworks/models/account-specific-model" - ); - } - - #[test] - fn siliconflow_env_overrides_key_base_url_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("CODEWHALE_PROVIDER", "siliconflow"); - env::set_var("SILICONFLOW_API_KEY", "sf-env-key"); - env::set_var("SILICONFLOW_BASE_URL", "https://sf-mirror.example/v1"); - env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Siliconflow); - assert_eq!(resolved.api_key.as_deref(), Some("sf-env-key")); - assert_eq!(resolved.base_url, "https://sf-mirror.example/v1"); - assert_eq!(resolved.model, "deepseek-v4-flash"); - } - - #[test] - fn arcee_provider_defaults_to_direct_api_endpoint_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::Arcee, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Arcee); - assert_eq!(resolved.base_url, DEFAULT_ARCEE_BASE_URL); - assert_eq!(resolved.model, DEFAULT_ARCEE_MODEL); - } - - #[test] - fn arcee_env_overrides_key_base_url_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("CODEWHALE_PROVIDER", "arcee"); - env::set_var("ARCEE_API_KEY", "arcee-env-key"); - env::set_var("ARCEE_BASE_URL", "https://arcee-mirror.example/api/v1"); - env::set_var("ARCEE_MODEL", "trinity-large-preview"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Arcee); - assert_eq!(resolved.api_key.as_deref(), Some("arcee-env-key")); - assert_eq!(resolved.base_url, "https://arcee-mirror.example/api/v1"); - assert_eq!(resolved.model, "trinity-large-preview"); - } - - #[test] - fn arcee_provider_config_overrides_runtime_defaults() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let mut config = ConfigToml { - provider: ProviderKind::Arcee, - ..ConfigToml::default() - }; - config.providers.arcee.api_key = Some("arcee-file-key".to_string()); - config.providers.arcee.base_url = Some(DEFAULT_ARCEE_BASE_URL.to_string()); - config.providers.arcee.model = Some("arcee-trinity-large-preview".to_string()); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Arcee); - assert_eq!(resolved.api_key.as_deref(), Some("arcee-file-key")); - assert_eq!(resolved.base_url, DEFAULT_ARCEE_BASE_URL); - assert_eq!(resolved.model, ARCEE_TRINITY_LARGE_PREVIEW_MODEL); - } - - #[test] - fn huggingface_env_precedence_prefers_documented_names() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("CODEWHALE_PROVIDER", "hf"); - env::set_var("HUGGINGFACE_API_KEY", "hf-full-key"); - env::set_var("HF_TOKEN", "hf-token-fallback"); - env::set_var("HUGGINGFACE_BASE_URL", "https://hf-full.example/v1"); - env::set_var("HF_BASE_URL", "https://hf-short.example/v1"); - env::set_var("HUGGINGFACE_MODEL", "org/full-model"); - env::set_var("HF_MODEL", "org/short-model"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Huggingface); - assert_eq!(resolved.api_key.as_deref(), Some("hf-full-key")); - assert_eq!(resolved.base_url, "https://hf-full.example/v1"); - assert_eq!(resolved.model, "org/full-model"); - } - - #[test] - fn huggingface_short_env_fallbacks_resolve_when_primary_names_are_absent() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("CODEWHALE_PROVIDER", "huggingface"); - env::set_var("HF_TOKEN", "hf-token-fallback"); - env::set_var("HF_BASE_URL", "https://hf-short.example/v1"); - env::set_var("HF_MODEL", "org/short-model"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Huggingface); - assert_eq!(resolved.api_key.as_deref(), Some("hf-token-fallback")); - assert_eq!(resolved.base_url, "https://hf-short.example/v1"); - assert_eq!(resolved.model, "org/short-model"); - } - - #[test] - fn huggingface_token_fallback_resolves_when_primary_api_key_is_blank() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("CODEWHALE_PROVIDER", "huggingface"); - env::set_var("HUGGINGFACE_API_KEY", " "); - env::set_var("HF_TOKEN", "hf-token-fallback"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Huggingface); - assert_eq!(resolved.api_key.as_deref(), Some("hf-token-fallback")); - } - - #[test] - fn siliconflow_cn_base_url_env_normalizes_model_aliases() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("CODEWHALE_PROVIDER", "siliconflow"); - env::set_var("SILICONFLOW_API_KEY", "sf-env-key"); - env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1"); - } - - for (alias, expected) in [ - ("deepseek-v4-flash", DEFAULT_SILICONFLOW_FLASH_MODEL), - ("deepseek-reasoner", DEFAULT_SILICONFLOW_MODEL), - ] { - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("SILICONFLOW_MODEL", alias); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Siliconflow); - assert_eq!(resolved.base_url, "https://api.siliconflow.cn/v1"); - assert_eq!(resolved.model, expected); - } - } - - #[test] - fn wanjie_ark_env_api_key_and_base_url_fall_back_when_config_missing() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("DEEPSEEK_PROVIDER", "wanjie-ark"); - env::set_var("WANJIE_ARK_API_KEY", "wanjie-env-key"); - env::set_var("WANJIE_ARK_BASE_URL", "https://wanjie.example/api/v1"); - env::set_var("WANJIE_ARK_MODEL", "account-model-id"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::WanjieArk); - assert_eq!(resolved.api_key.as_deref(), Some("wanjie-env-key")); - assert_eq!(resolved.base_url, "https://wanjie.example/api/v1"); - assert_eq!(resolved.model, "account-model-id"); - } - - #[test] - fn volcengine_env_aliases_override_key_base_url_and_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: test-only environment mutation guarded by a module mutex. - unsafe { - env::set_var("DEEPSEEK_PROVIDER", "volcengine"); - env::set_var("ARK_API_KEY", "volcengine-env-key"); - env::set_var("ARK_BASE_URL", "https://volcengine.example/api/coding/v3"); - env::set_var("VOLCENGINE_ARK_MODEL", "DeepSeek-V4-Flash"); - } - - let resolved = - ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Volcengine); - assert_eq!(resolved.api_key.as_deref(), Some("volcengine-env-key")); - assert_eq!( - resolved.base_url, - "https://volcengine.example/api/coding/v3" - ); - assert_eq!(resolved.model, "DeepSeek-V4-Flash"); - } - - #[test] - fn openrouter_provider_normalizes_flash_aliases() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let cli = CliRuntimeOverrides { - provider: Some(ProviderKind::Openrouter), - model: Some("deepseek-v4-flash".to_string()), - ..CliRuntimeOverrides::default() - }; - - let resolved = ConfigToml::default().resolve_runtime_options(&cli); - - assert_eq!(resolved.provider, ProviderKind::Openrouter); - assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL); - } - - #[test] - fn qwen3_6_plus_resolves_to_canonical_on_openrouter() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::Openrouter, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides { - model: Some("qwen3.6-plus".to_string()), - ..CliRuntimeOverrides::default() - }); - - assert_eq!(resolved.provider, ProviderKind::Openrouter); - assert_eq!(resolved.model, OPENROUTER_QWEN_3_6_PLUS_MODEL); - } - - #[test] - fn qwen3_6_plus_alias_qwen_dash_resolves() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::Openrouter, - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides { - model: Some("qwen-3.6-plus".to_string()), - ..CliRuntimeOverrides::default() - }); - - assert_eq!(resolved.model, OPENROUTER_QWEN_3_6_PLUS_MODEL); - } - - #[test] - fn openrouter_provider_normalizes_recent_large_model_aliases() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - - for (alias, expected) in [ - ( - "trinity-large-thinking", - OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL, - ), - ("qwen3.6-flash", OPENROUTER_QWEN_3_6_FLASH_MODEL), - ("qwen3.6-35b-a3b", OPENROUTER_QWEN_3_6_35B_A3B_MODEL), - ("qwen3.6-max-preview", OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL), - ("qwen3.6-plus", OPENROUTER_QWEN_3_6_PLUS_MODEL), - ("mimo-v2.5-pro", OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL), - ("kimi-k2.7-code", OPENROUTER_KIMI_K2_7_CODE_MODEL), - ("kimi", OPENROUTER_KIMI_K2_7_CODE_MODEL), - ("kimi-k2.6", OPENROUTER_KIMI_K2_6_MODEL), - ("minimax-m3", OPENROUTER_MINIMAX_M3_MODEL), - ("minimax-2.7", OPENROUTER_MINIMAX_2_7_MODEL), - ("gemma-4-31b-it", OPENROUTER_GEMMA_4_31B_MODEL), - ("glm-5.1", OPENROUTER_GLM_5_1_MODEL), - ("glm-5.2", OPENROUTER_GLM_5_2_MODEL), - ] { - let cli = CliRuntimeOverrides { - provider: Some(ProviderKind::Openrouter), - model: Some(alias.to_string()), - ..CliRuntimeOverrides::default() - }; - - let resolved = ConfigToml::default().resolve_runtime_options(&cli); - - assert_eq!(resolved.provider, ProviderKind::Openrouter); - assert_eq!(resolved.model, expected); - } - } - - #[test] - fn novita_provider_normalizes_flash_aliases() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let cli = CliRuntimeOverrides { - provider: Some(ProviderKind::Novita), - model: Some("deepseek-v4-flash".to_string()), - ..CliRuntimeOverrides::default() - }; - - let resolved = ConfigToml::default().resolve_runtime_options(&cli); - - assert_eq!(resolved.provider, ProviderKind::Novita); - assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL); - } - - #[test] - fn siliconflow_provider_normalizes_flash_aliases() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let cli = CliRuntimeOverrides { - provider: Some(ProviderKind::Siliconflow), - model: Some("deepseek-v4-flash".to_string()), - ..CliRuntimeOverrides::default() - }; - - let resolved = ConfigToml::default().resolve_runtime_options(&cli); - - assert_eq!(resolved.provider, ProviderKind::Siliconflow); - assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL); - } - - #[test] - fn siliconflow_provider_normalizes_reasoning_aliases_to_pro() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - - for alias in ["deepseek-reasoner", "deepseek-r1"] { - let cli = CliRuntimeOverrides { - provider: Some(ProviderKind::Siliconflow), - model: Some(alias.to_string()), - ..CliRuntimeOverrides::default() - }; - - let resolved = ConfigToml::default().resolve_runtime_options(&cli); - - assert_eq!(resolved.provider, ProviderKind::Siliconflow); - assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL); - } - } - - #[test] - fn siliconflow_provider_preserves_deepseek_v3_2_alias() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let cli = CliRuntimeOverrides { - provider: Some(ProviderKind::Siliconflow), - model: Some("deepseek-v3.2".to_string()), - ..CliRuntimeOverrides::default() - }; - - let resolved = ConfigToml::default().resolve_runtime_options(&cli); - - assert_eq!(resolved.provider, ProviderKind::Siliconflow); - assert_eq!(resolved.model, "deepseek-v3.2"); - } - - #[test] - fn sglang_provider_normalizes_flash_aliases() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let cli = CliRuntimeOverrides { - provider: Some(ProviderKind::Sglang), - model: Some("deepseek-v4-flash".to_string()), - ..CliRuntimeOverrides::default() - }; - - let resolved = ConfigToml::default().resolve_runtime_options(&cli); - - assert_eq!(resolved.provider, ProviderKind::Sglang); - assert_eq!(resolved.model, DEFAULT_SGLANG_FLASH_MODEL); - } - - #[test] - fn vllm_provider_normalizes_flash_aliases() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let cli = CliRuntimeOverrides { - provider: Some(ProviderKind::Vllm), - model: Some("deepseek-v4-flash".to_string()), - ..CliRuntimeOverrides::default() - }; - - let resolved = ConfigToml::default().resolve_runtime_options(&cli); - - assert_eq!(resolved.provider, ProviderKind::Vllm); - assert_eq!(resolved.model, DEFAULT_VLLM_FLASH_MODEL); - } - - #[test] - fn openrouter_provider_specific_config_overrides_env() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let mut config = ConfigToml { - provider: ProviderKind::Openrouter, - ..ConfigToml::default() - }; - config.providers.openrouter.api_key = Some("file-key".to_string()); - config.providers.openrouter.base_url = Some("https://or-mirror.example/v1".to_string()); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.api_key.as_deref(), Some("file-key")); - assert_eq!(resolved.base_url, "https://or-mirror.example/v1"); - } - - #[test] - fn openrouter_custom_base_url_preserves_provider_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let mut config = ConfigToml { - provider: ProviderKind::Openrouter, - ..ConfigToml::default() - }; - config.providers.openrouter.base_url = Some("https://gateway.example.com/v1".to_string()); - config.providers.openrouter.model = Some("DeepSeek-V4-Pro".to_string()); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Openrouter); - assert_eq!(resolved.base_url, "https://gateway.example.com/v1"); - assert_eq!(resolved.model, "DeepSeek-V4-Pro"); - } - - #[test] - fn fireworks_custom_base_url_preserves_provider_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let mut config = ConfigToml { - provider: ProviderKind::Fireworks, - ..ConfigToml::default() - }; - config.providers.fireworks.base_url = Some("https://my-gateway.example/v1".to_string()); - config.providers.fireworks.model = Some("DeepSeek-V4-Pro".to_string()); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Fireworks); - assert_eq!(resolved.base_url, "https://my-gateway.example/v1"); - // Custom base URL skips provider-specific model prefixing. - assert_eq!(resolved.model, "DeepSeek-V4-Pro"); - } - - #[test] - fn siliconflow_custom_base_url_preserves_provider_model() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let mut config = ConfigToml { - provider: ProviderKind::Siliconflow, - ..ConfigToml::default() - }; - config.providers.siliconflow.base_url = Some("https://my-gateway.example/v1".to_string()); - config.providers.siliconflow.model = Some("DeepSeek-V4-Pro".to_string()); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::Siliconflow); - assert_eq!(resolved.base_url, "https://my-gateway.example/v1"); - assert_eq!(resolved.model, "DeepSeek-V4-Pro"); - } - - #[test] - fn config_file_resolves_above_env_and_keyring() { - use codewhale_secrets::KeyringStore; - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: env mutation guarded by env_lock(). - unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") }; - - let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new()); - store.set("deepseek", "ring-key").unwrap(); - let secrets = Secrets::new(store); - - let mut config = ConfigToml::default(); - config.providers.deepseek.api_key = Some("file-key".to_string()); - - let resolved = - config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); - assert_eq!(resolved.api_key.as_deref(), Some("file-key")); - assert_eq!( - resolved.api_key_source, - Some(RuntimeApiKeySource::ConfigFile) - ); - - // Safety: env mutation guarded by env_lock(). - unsafe { std::env::remove_var("DEEPSEEK_API_KEY") }; - } - - #[test] - fn env_resolves_when_config_file_and_keyring_empty() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: env mutation guarded by env_lock(). - unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") }; - - let secrets = Secrets::new(std::sync::Arc::new( - codewhale_secrets::InMemoryKeyringStore::new(), - )); - let config = ConfigToml::default(); - - let resolved = - config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); - assert_eq!(resolved.api_key.as_deref(), Some("env-key")); - assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); - - // Safety: env mutation guarded by env_lock(). - unsafe { std::env::remove_var("DEEPSEEK_API_KEY") }; - } - - #[test] - fn config_file_resolves_when_keyring_and_env_empty() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - - let secrets = Secrets::new(std::sync::Arc::new( - codewhale_secrets::InMemoryKeyringStore::new(), - )); - let mut config = ConfigToml::default(); - config.providers.deepseek.api_key = Some("file-key".to_string()); - - let resolved = - config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); - assert_eq!(resolved.api_key.as_deref(), Some("file-key")); - assert_eq!( - resolved.api_key_source, - Some(RuntimeApiKeySource::ConfigFile) - ); - } - - #[test] - fn keyring_resolves_when_config_file_empty_even_if_env_is_set() { - use codewhale_secrets::KeyringStore; - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - // Safety: env mutation guarded by env_lock(). - unsafe { std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key") }; - - let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new()); - store.set("deepseek", "ring-key").unwrap(); - let secrets = Secrets::new(store); - - let resolved = ConfigToml::default() - .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); - assert_eq!(resolved.api_key.as_deref(), Some("ring-key")); - assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring)); - - // Safety: env mutation guarded by env_lock(). - unsafe { std::env::remove_var("DEEPSEEK_API_KEY") }; - } - - #[test] - fn cli_flag_still_overrides_keyring() { - use codewhale_secrets::KeyringStore; - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - - let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new()); - store.set("deepseek", "ring-key").unwrap(); - let secrets = Secrets::new(store); - - let cli = CliRuntimeOverrides { - api_key: Some("cli-key".to_string()), - ..CliRuntimeOverrides::default() - }; - let resolved = ConfigToml::default().resolve_runtime_options_with_secrets(&cli, &secrets); - assert_eq!(resolved.api_key.as_deref(), Some("cli-key")); - assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli)); - } - - #[test] - fn provider_chain_initial_current_is_active() { - let chain = ProviderChain::new( - ProviderKind::NvidiaNim, - &[ProviderKind::Deepseek, ProviderKind::Openrouter], - ); - - assert_eq!(chain.current(), ProviderKind::NvidiaNim); - assert_eq!(chain.position(), 0); - assert_eq!( - chain.providers(), - &[ - ProviderKind::NvidiaNim, - ProviderKind::Deepseek, - ProviderKind::Openrouter, - ] - ); - assert!(!chain.is_fallback_active()); - } - - #[test] - fn provider_chain_advance_switches_to_fallback() { - let mut chain = ProviderChain::new( - ProviderKind::NvidiaNim, - &[ProviderKind::Deepseek, ProviderKind::Openrouter], - ); - - assert!(chain.has_next()); - assert_eq!(chain.advance(), Some(ProviderKind::Deepseek)); - assert_eq!(chain.current(), ProviderKind::Deepseek); - assert!(chain.is_fallback_active()); - } - - #[test] - fn provider_chain_exhausts_returns_none() { - let mut chain = ProviderChain::new(ProviderKind::Deepseek, &[ProviderKind::Openrouter]); - - assert_eq!(chain.advance(), Some(ProviderKind::Openrouter)); - assert!(!chain.has_next()); - assert_eq!(chain.advance(), None); - } - - #[test] - fn provider_chain_skips_duplicates() { - let chain = ProviderChain::new( - ProviderKind::Deepseek, - &[ - ProviderKind::Deepseek, - ProviderKind::NvidiaNim, - ProviderKind::Deepseek, - ], - ); - - assert_eq!( - chain.providers(), - &[ProviderKind::Deepseek, ProviderKind::NvidiaNim] - ); - } - - #[test] - fn provider_chain_remaining_counts_current_and_untried_entries() { - let mut chain = ProviderChain::new( - ProviderKind::Deepseek, - &[ProviderKind::NvidiaNim, ProviderKind::Openrouter], - ); - - assert_eq!(chain.remaining(), 3); - assert_eq!(chain.advance(), Some(ProviderKind::NvidiaNim)); - assert_eq!(chain.remaining(), 2); - } - - #[test] - fn config_toml_parses_fallback_providers() { - let config: ConfigToml = toml::from_str( - r#" -provider = "nvidia-nim" -fallback_providers = ["deepseek", "openrouter"] -"#, - ) - .expect("fallback providers config"); - - assert_eq!(config.provider, ProviderKind::NvidiaNim); - assert_eq!( - config.fallback_providers, - [ProviderKind::Deepseek, ProviderKind::Openrouter] - ); - } - - #[test] - fn empty_fallback_providers_do_not_serialize() { - let serialized = toml::to_string_pretty(&ConfigToml::default()).expect("config serializes"); - - assert!(!serialized.contains("fallback_providers")); - } - - #[test] - fn fleet_exec_config_default_matches_subagent_depth() { - // Fleet workers and standalone sub-agents share one recursion axis: - // the fleet default equals DEFAULT_SPAWN_DEPTH (3) and affords >=3 - // nested delegation levels out of the box. - assert_eq!( - FleetExecConfig::default().max_spawn_depth, - DEFAULT_SPAWN_DEPTH - ); - assert_eq!(FleetExecConfig::default().max_spawn_depth, 3); - const { assert!(DEFAULT_SPAWN_DEPTH <= MAX_SPAWN_DEPTH_CEILING) }; - } - - #[test] - fn fleet_exec_config_parses_max_spawn_depth() { - let config: ConfigToml = toml::from_str( - r#" -[fleet.exec] -max_spawn_depth = 2 -"#, - ) - .expect("fleet exec config should parse"); - - assert_eq!(config.fleet.expect("fleet config").exec.max_spawn_depth, 2); - } - - #[test] - fn fallback_providers_do_not_change_runtime_resolution() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::NvidiaNim, - fallback_providers: vec![ProviderKind::Deepseek], - ..ConfigToml::default() - }; - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - - assert_eq!(resolved.provider, ProviderKind::NvidiaNim); - } - - #[test] - fn harness_posture_default_is_standard() { - let posture = HarnessPosture::default(); - - assert_eq!( - posture, - HarnessPosture { - kind: HarnessPostureKind::Standard, - max_subagents: 0, - prefer_codebase_search: false, - compaction_strategy: HarnessCompactionStrategy::Default, - tool_surface: HarnessToolSurface::Full, - safety_posture: HarnessSafetyPosture::Standard, - } - ); - } - - #[test] - fn harness_posture_factories_are_typed() { - assert_eq!( - HarnessPosture::cache_heavy(), - HarnessPosture { - kind: HarnessPostureKind::CacheHeavy, - max_subagents: 10, - prefer_codebase_search: false, - compaction_strategy: HarnessCompactionStrategy::PrefixCache, - tool_surface: HarnessToolSurface::Full, - safety_posture: HarnessSafetyPosture::Standard, - } - ); - assert_eq!( - HarnessPosture::lean(), - HarnessPosture { - kind: HarnessPostureKind::Lean, - max_subagents: 20, - prefer_codebase_search: true, - compaction_strategy: HarnessCompactionStrategy::Aggressive, - tool_surface: HarnessToolSurface::Full, - safety_posture: HarnessSafetyPosture::Standard, - } - ); - } - - #[test] - fn harness_profile_serde_round_trips_as_a_whole_struct() { - let profile = HarnessProfile { - provider_route: "deepseek".to_string(), - model_pattern: "deepseek-v4.*".to_string(), - posture: HarnessPosture::cache_heavy(), - }; - - let json = serde_json::to_string(&profile).expect("serialize profile"); - let round_tripped: HarnessProfile = - serde_json::from_str(&json).expect("deserialize profile"); - - assert_eq!(round_tripped, profile); - } - - #[test] - fn config_toml_accepts_harness_profiles() { - let config: ConfigToml = toml::from_str( - r#" -provider = "deepseek" -model = "deepseek-v4-pro" - -[[harness_profiles]] -provider_route = "deepseek" -model_pattern = "deepseek-v4.*" - -[harness_profiles.posture] -kind = "cache-heavy" -max_subagents = 10 -compaction_strategy = "prefix-cache" -tool_surface = "read-only" -safety_posture = "strict" -"#, - ) - .expect("parse harness profiles"); - - assert_eq!( - config.harness_profiles, - vec![HarnessProfile { - provider_route: "deepseek".to_string(), - model_pattern: "deepseek-v4.*".to_string(), - posture: HarnessPosture { - kind: HarnessPostureKind::CacheHeavy, - max_subagents: 10, - prefer_codebase_search: false, - compaction_strategy: HarnessCompactionStrategy::PrefixCache, - tool_surface: HarnessToolSurface::ReadOnly, - safety_posture: HarnessSafetyPosture::Strict, - }, - }] - ); - } - - #[test] - fn harness_profile_matches_provider_alias_and_model_wildcard() { - let profile = HarnessProfile { - provider_route: "xiaomi-mimo".to_string(), - model_pattern: "mimo-v2.?-pro".to_string(), - posture: HarnessPosture::cache_heavy(), - }; - - assert!(profile.matches_route("mimo", "mimo-v2.5-pro")); - assert!(!profile.matches_route("mimo", "mimo-v2.50-pro")); - assert!(!profile.matches_route("deepseek", "mimo-v2.5-pro")); - } - - #[test] - fn resolve_harness_profile_returns_first_matching_profile() { - let config = ConfigToml { - harness_profiles: vec![ - HarnessProfile { - provider_route: "deepseek".to_string(), - model_pattern: "deepseek-v4-flash".to_string(), - posture: HarnessPosture::lean(), - }, - HarnessProfile { - provider_route: "deepseek".to_string(), - model_pattern: "deepseek-v4-*".to_string(), - posture: HarnessPosture::cache_heavy(), - }, - ], - ..ConfigToml::default() - }; - - let flash = config - .resolve_harness_profile("deepseek-cn", "deepseek-v4-flash") - .expect("exact profile should match first"); - assert_eq!(flash.posture.kind, HarnessPostureKind::Lean); - - let pro = config - .resolve_harness_profile("deepseek", "deepseek-v4-pro") - .expect("wildcard profile should match pro model"); - assert_eq!(pro.posture.kind, HarnessPostureKind::CacheHeavy); - } - - #[test] - fn resolve_harness_profile_uses_built_in_seed_when_config_has_no_match() { - let config = ConfigToml::default(); - - let xiaomi = config - .resolve_harness_profile("xiaomi", "mimo-v2.5-pro") - .expect("direct Xiaomi MiMo seed should resolve"); - assert_eq!(xiaomi.provider_route, "xiaomi-mimo"); - assert_eq!(xiaomi.posture.kind, HarnessPostureKind::CacheHeavy); - - let arcee = config - .resolve_harness_profile("arcee", "trinity-large-thinking") - .expect("direct Arcee seed should resolve"); - assert_eq!(arcee.posture.kind, HarnessPostureKind::CacheHeavy); - - let local = config - .resolve_harness_profile("vllm", "Qwen/Qwen3.6-Coder") - .expect("local seed should resolve"); - assert_eq!(local.posture.kind, HarnessPostureKind::Lean); - assert!(local.posture.prefer_codebase_search); - } - - #[test] - fn configured_harness_profile_overrides_built_in_seed() { - let config = ConfigToml { - harness_profiles: vec![HarnessProfile { - provider_route: "xiaomi-mimo".to_string(), - model_pattern: "mimo-v2.5-pro".to_string(), - posture: HarnessPosture { - kind: HarnessPostureKind::Custom, - max_subagents: 3, - prefer_codebase_search: true, - compaction_strategy: HarnessCompactionStrategy::Default, - tool_surface: HarnessToolSurface::Auto, - safety_posture: HarnessSafetyPosture::Strict, - }, - }], - ..ConfigToml::default() - }; - - let profile = config - .resolve_harness_profile("xiaomi-mimo", "mimo-v2.5-pro") - .expect("configured profile should match first"); - - assert_eq!(profile.posture.kind, HarnessPostureKind::Custom); - assert_eq!(profile.posture.max_subagents, 3); - assert_eq!(profile.posture.tool_surface, HarnessToolSurface::Auto); - assert_eq!(profile.posture.safety_posture, HarnessSafetyPosture::Strict); - } - - #[test] - fn resolve_harness_profile_returns_none_when_route_or_model_misses() { - let config = ConfigToml { - harness_profiles: vec![HarnessProfile { - provider_route: "huggingface".to_string(), - model_pattern: "deepseek-ai/*".to_string(), - posture: HarnessPosture::lean(), - }], - ..ConfigToml::default() - }; - - assert!( - config - .resolve_harness_profile("openrouter", "deepseek-ai/DeepSeek-V4-Pro") - .is_none() - ); - assert!( - config - .resolve_harness_profile("deepseek", "Qwen/Qwen3.6-Coder") - .is_none() - ); - assert!( - config - .resolve_harness_profile("openai", "mimo-v2.5-pro") - .is_none() - ); - } - - #[test] - fn resolving_harness_profile_does_not_change_runtime_options() { - let _lock = env_lock(); - let _env = EnvGuard::without_deepseek_runtime_overrides(); - let config = ConfigToml { - provider: ProviderKind::Deepseek, - model: Some("deepseek-v4-pro".to_string()), - harness_profiles: vec![HarnessProfile { - provider_route: "deepseek".to_string(), - model_pattern: "deepseek-v4-*".to_string(), - posture: HarnessPosture::lean(), - }], - ..ConfigToml::default() - }; - - let profile = config - .resolve_harness_profile("deepseek", "deepseek-v4-pro") - .expect("profile should resolve for display/future runtime"); - assert_eq!(profile.posture.kind, HarnessPostureKind::Lean); - - let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); - assert_eq!(resolved.provider, ProviderKind::Deepseek); - assert_eq!(resolved.model, "deepseek-v4-pro"); - } - - #[test] - fn harness_posture_kind_rejects_unknown_values() { - let err = toml::from_str::( - r#" -[[harness_profiles]] -provider_route = "deepseek" -model_pattern = "deepseek-v4.*" - -[harness_profiles.posture] -kind = "cahce-heavy" -"#, - ) - .expect_err("misspelled kind should not deserialize as custom"); - - assert!(err.to_string().contains("cahce-heavy")); - } - - #[test] - fn harness_posture_rejects_unknown_policy_keys() { - let err = toml::from_str::( - r#" -[[harness_profiles]] -provider_route = "deepseek" -model_pattern = "deepseek-v4.*" - -[harness_profiles.posture] -kind = "custom" -unknown_policy = "surprise" -"#, - ) - .expect_err("unknown posture keys should not be ignored"); - - assert!(err.to_string().contains("unknown_policy")); - } - - #[test] - fn test_verbosity_resolution() { - let _lock = env_lock(); - // Test TOML parsing - let toml_str = r#" - verbosity = "concise" - "#; - let config: ConfigToml = toml::from_str(toml_str).unwrap(); - assert_eq!(config.verbosity, Some("concise".to_string())); - - // Test Env overrides - let _env = EnvGuard::without_deepseek_runtime_overrides(); - unsafe { - std::env::set_var("CODEWHALE_VERBOSITY", "normal"); - } - let env_overrides = EnvRuntimeOverrides::load(); - assert_eq!(env_overrides.verbosity, Some("normal".to_string())); - unsafe { - std::env::remove_var("CODEWHALE_VERBOSITY"); - } - - // Test fallback to DEEPSEEK_VERBOSITY - unsafe { - std::env::set_var("DEEPSEEK_VERBOSITY", "concise"); - } - let env_overrides = EnvRuntimeOverrides::load(); - assert_eq!(env_overrides.verbosity, Some("concise".to_string())); - unsafe { - std::env::remove_var("DEEPSEEK_VERBOSITY"); - } - } -} +mod tests; diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs new file mode 100644 index 000000000..4ef0220f1 --- /dev/null +++ b/crates/config/src/tests.rs @@ -0,0 +1,4175 @@ +use super::*; +use std::env; +use std::ffi::OsString; +use std::sync::Arc; +use std::sync::{Mutex, OnceLock}; + +fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[test] +fn network_policy_toml_deserializes_proxy_hosts() { + let policy: NetworkPolicyToml = toml::from_str( + r#" + default = "allow" + proxy = ["github.com", ".githubusercontent.com"] + "#, + ) + .expect("network policy toml"); + + assert_eq!(policy.default, "allow"); + assert_eq!(policy.proxy, ["github.com", ".githubusercontent.com"]); + assert!(policy.audit); +} + +#[test] +fn permissions_toml_deserializes_typed_ask_rules() { + let permissions: PermissionsToml = toml::from_str( + r#" + [[rules]] + tool = "exec_shell" + command = "cargo test" + + [[rules]] + tool = "read_file" + path = "secrets/api_key.txt" + "#, + ) + .expect("permissions toml"); + + assert_eq!( + permissions.rules, + vec![ + ToolAskRule::exec_shell("cargo test"), + ToolAskRule::file_path("read_file", "secrets/api_key.txt"), + ] + ); +} + +#[test] +fn permissions_toml_rejects_typed_allow_deny_shape() { + let err = toml::from_str::( + r#" + [[rules]] + tool = "exec_shell" + decision = "allow" + command = "cargo test" + "#, + ) + .expect_err("permissions.toml should be ask-only in this slice"); + + assert!(err.message().contains("unknown field")); +} + +#[test] +fn hotbar_defaults_when_config_is_absent() { + let config = ConfigToml::default(); + + let resolved = config.resolve_hotbar_bindings(&DEFAULT_HOTBAR_ACTIONS); + + assert_eq!(resolved.warnings, Vec::new()); + assert_eq!(resolved.bindings, default_hotbar_bindings()); + assert_eq!( + resolved + .bindings + .iter() + .map(|binding| (binding.slot, binding.action.as_str())) + .collect::>(), + vec![ + (1, "voice.toggle"), + (2, "session.compact"), + (3, "mode.plan"), + (4, "mode.agent"), + (5, "mode.yolo"), + (6, "palette.open"), + (7, "sidebar.toggle"), + (8, "trust.toggle"), + ] + ); +} + +#[test] +fn hotbar_tables_parse_and_round_trip() { + let config: ConfigToml = toml::from_str( + r#" +[[hotbar]] +slot = 1 +label = "Plan" +action = "mode.plan" + +[[hotbar]] +slot = 2 +action = "session.compact" +"#, + ) + .expect("parse hotbar tables"); + + let resolved = config.resolve_hotbar_bindings(&["mode.plan", "session.compact"]); + + assert_eq!( + resolved.bindings, + vec![ + HotbarBinding { + slot: 1, + action: "mode.plan".to_string(), + label: Some("Plan".to_string()), + }, + HotbarBinding { + slot: 2, + action: "session.compact".to_string(), + label: None, + }, + ] + ); + assert_eq!(resolved.warnings, Vec::new()); + + let serialized = toml::to_string_pretty(&config).expect("serialize config"); + let round_tripped: ConfigToml = + toml::from_str(&serialized).expect("deserialize serialized config"); + assert_eq!(round_tripped.hotbar, config.hotbar); +} + +#[test] +fn hotbar_validation_warns_without_dropping_unknown_actions() { + let config: ConfigToml = toml::from_str( + r#" +[[hotbar]] +slot = 0 +action = "mode.plan" + +[[hotbar]] +slot = 2 +action = "mode.plan" + +[[hotbar]] +slot = 2 +action = "custom.action" + +[[hotbar]] +slot = 9 +action = "mode.agent" +"#, + ) + .expect("parse hotbar tables"); + + let resolved = config.resolve_hotbar_bindings(&["mode.plan", "mode.agent"]); + + assert_eq!( + resolved.bindings, + vec![HotbarBinding { + slot: 2, + action: "custom.action".to_string(), + label: None, + }] + ); + assert_eq!( + resolved.warnings, + vec![ + HotbarConfigWarning::SlotOutOfRange { + slot: 0, + action: "mode.plan".to_string(), + }, + HotbarConfigWarning::UnknownAction { + slot: 2, + action: "custom.action".to_string(), + }, + HotbarConfigWarning::DuplicateSlot { + slot: 2, + previous_action: "mode.plan".to_string(), + replacement_action: "custom.action".to_string(), + }, + HotbarConfigWarning::SlotOutOfRange { + slot: 9, + action: "mode.agent".to_string(), + }, + ] + ); + assert!(resolved.warnings[1].to_string().contains("keeping binding")); +} + +#[test] +fn config_store_loads_sibling_permissions_toml() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "codewhale-permissions-schema-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("mkdir"); + let config_path = dir.join(CONFIG_FILE_NAME); + fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config"); + fs::write( + dir.join(PERMISSIONS_FILE_NAME), + r#" + [[rules]] + tool = "exec_shell" + command = "cargo test" + + [[rules]] + tool = "read_file" + path = "secrets/api_key.txt" + "#, + ) + .expect("write permissions"); + + let store = ConfigStore::load(Some(config_path.clone())).expect("load config store"); + + assert_eq!(store.config.model.as_deref(), Some("deepseek-v4-flash")); + assert_eq!( + store.permissions().rules.as_slice(), + &[ + ToolAskRule::exec_shell("cargo test"), + ToolAskRule::file_path("read_file", "secrets/api_key.txt"), + ] + ); + assert_eq!( + store.permissions_path(), + config_path.with_file_name(PERMISSIONS_FILE_NAME) + ); + + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn config_store_loads_permissions_even_when_config_is_absent() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "codewhale-permissions-only-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("mkdir"); + let config_path = dir.join(CONFIG_FILE_NAME); + fs::write( + dir.join(PERMISSIONS_FILE_NAME), + r#" + [[rules]] + tool = "exec_shell" + command = "cargo check" + "#, + ) + .expect("write permissions"); + + let store = ConfigStore::load(Some(config_path)).expect("load config store"); + + assert!(store.config.model.is_none()); + assert_eq!( + store.permissions().rules.as_slice(), + &[ToolAskRule::exec_shell("cargo check")] + ); + + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn config_store_exec_policy_engine_uses_sibling_permissions() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "codewhale-permissions-engine-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("mkdir"); + let config_path = dir.join(CONFIG_FILE_NAME); + fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config"); + fs::write( + dir.join(PERMISSIONS_FILE_NAME), + r#" + [[rules]] + tool = "exec_shell" + command = "cargo test" + "#, + ) + .expect("write permissions"); + + let store = ConfigStore::load(Some(config_path)).expect("load config store"); + let decision = store + .exec_policy_engine() + .check(codewhale_execpolicy::ExecPolicyContext { + command: "cargo test --workspace", + cwd: "/workspace", + tool: Some("exec_shell"), + path: None, + ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted, + sandbox_mode: Some("workspace-write"), + }) + .expect("policy check"); + + assert!(decision.allow); + assert!(decision.requires_approval); + assert_eq!( + decision.matched_rule.as_deref(), + Some("tool=exec_shell command=cargo test") + ); + + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn config_store_appends_ask_rules_without_losing_comments_or_duplicates() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join(CONFIG_FILE_NAME); + let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME); + fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config"); + fs::write( + &permissions_path, + r#"# keep this permission note +[[rules]] +tool = "exec_shell" +command = "cargo check" +"#, + ) + .expect("write permissions"); + + let mut store = ConfigStore::load(Some(config_path)).expect("load config store"); + let existing = ToolAskRule::exec_shell("cargo check"); + let added_rule = ToolAskRule::file_path("read_file", "docs/README.md"); + let added = store + .append_ask_rules(&[existing, added_rule.clone(), added_rule.clone()]) + .expect("append ask rules"); + + assert_eq!(added, 1); + assert_eq!( + store.permissions().rules, + vec![ToolAskRule::exec_shell("cargo check"), added_rule.clone(),] + ); + let body = fs::read_to_string(&permissions_path).expect("read permissions"); + assert!(body.contains("# keep this permission note")); + assert_eq!(body.matches("docs/README.md").count(), 1); + assert!(!body.contains("decision")); + + let before_duplicate_append = body; + assert_eq!( + store + .append_ask_rules(&[added_rule]) + .expect("dedupe ask rule"), + 0 + ); + assert_eq!( + fs::read_to_string(&permissions_path).expect("read unchanged permissions"), + before_duplicate_append + ); + + let reloaded = + ConfigStore::load(Some(dir.path().join(CONFIG_FILE_NAME))).expect("reload config store"); + assert_eq!(reloaded.permissions(), store.permissions()); +} + +#[test] +fn config_store_appends_ask_rule_to_inline_rules_array() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join(CONFIG_FILE_NAME); + let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME); + fs::write( + &permissions_path, + "# inline rules stay valid\nrules = [{ tool = \"exec_shell\", command = \"cargo check\" }]\n", + ) + .expect("write permissions"); + + let mut store = ConfigStore::load(Some(config_path)).expect("load config store"); + assert_eq!( + store + .append_ask_rules(&[ToolAskRule::file_path("read_file", "README.md")]) + .expect("append inline ask rule"), + 1 + ); + + let body = fs::read_to_string(&permissions_path).expect("read permissions"); + assert!(body.contains("# inline rules stay valid")); + let parsed: PermissionsToml = toml::from_str(&body).expect("parse persisted permissions"); + assert_eq!( + parsed.rules, + vec![ + ToolAskRule::exec_shell("cargo check"), + ToolAskRule::file_path("read_file", "README.md"), + ] + ); +} + +#[test] +fn config_store_does_not_overwrite_invalid_permissions_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join(CONFIG_FILE_NAME); + let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME); + let mut store = ConfigStore::load(Some(config_path)).expect("load config store"); + let invalid = "rules = \"not-an-array\"\n"; + fs::write(&permissions_path, invalid).expect("write invalid permissions"); + + let error = store + .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")]) + .expect_err("invalid permissions should fail"); + + assert!(error.to_string().contains("failed to parse permissions")); + assert_eq!( + fs::read_to_string(&permissions_path).expect("read invalid permissions"), + invalid + ); + assert!(store.permissions().is_empty()); +} + +#[test] +fn duplicate_append_refreshes_permissions_changed_on_disk() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join(CONFIG_FILE_NAME); + let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME); + let mut store = ConfigStore::load(Some(config_path)).expect("load config store"); + fs::write( + permissions_path, + "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo check\"\n", + ) + .expect("write external permissions update"); + + assert_eq!( + store + .append_ask_rules(&[ToolAskRule::exec_shell("cargo check")]) + .expect("dedupe external ask rule"), + 0 + ); + assert_eq!( + store.permissions().rules, + vec![ToolAskRule::exec_shell("cargo check")] + ); +} + +#[cfg(unix)] +#[test] +fn config_store_secures_persisted_permissions_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join(CONFIG_FILE_NAME); + let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME); + let mut store = ConfigStore::load(Some(config_path)).expect("load config store"); + + store + .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")]) + .expect("append ask rule"); + + let mode = fs::metadata(permissions_path) + .expect("permissions metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); +} + +struct EnvGuard { + deepseek_api_key: Option, + deepseek_base_url: Option, + deepseek_http_headers: Option, + deepseek_model: Option, + deepseek_default_text_model: Option, + deepseek_provider: Option, + deepseek_auth_mode: Option, + nvidia_api_key: Option, + nvidia_nim_api_key: Option, + nim_base_url: Option, + nvidia_base_url: Option, + nvidia_nim_base_url: Option, + openrouter_api_key: Option, + openrouter_base_url: Option, + openrouter_model: Option, + xiaomi_mimo_token_plan_api_key: Option, + mimo_token_plan_api_key: Option, + xiaomi_mimo_api_key: Option, + xiaomi_api_key: Option, + mimo_api_key: Option, + xiaomi_mimo_base_url: Option, + mimo_base_url: Option, + xiaomi_mimo_model: Option, + mimo_model: Option, + xiaomi_mimo_mode: Option, + mimo_mode: Option, + wanjie_ark_api_key: Option, + volcengine_api_key: Option, + volcengine_ark_api_key: Option, + ark_api_key: Option, + volcengine_base_url: Option, + volcengine_ark_base_url: Option, + ark_base_url: Option, + wanjie_ark_base_url: Option, + wanjie_base_url: Option, + wanjie_maas_base_url: Option, + volcengine_model: Option, + volcengine_ark_model: Option, + wanjie_ark_model: Option, + wanjie_model: Option, + wanjie_maas_model: Option, + novita_api_key: Option, + novita_base_url: Option, + novita_model: Option, + fireworks_api_key: Option, + fireworks_base_url: Option, + fireworks_model: Option, + siliconflow_api_key: Option, + siliconflow_base_url: Option, + siliconflow_model: Option, + arcee_api_key: Option, + arcee_base_url: Option, + arcee_model: Option, + moonshot_api_key: Option, + moonshot_base_url: Option, + moonshot_model: Option, + kimi_api_key: Option, + kimi_base_url: Option, + kimi_model: Option, + kimi_model_name: Option, + zai_api_key: Option, + z_ai_api_key: Option, + zai_base_url: Option, + zai_model: Option, + stepfun_api_key: Option, + step_api_key: Option, + stepfun_base_url: Option, + stepfun_model: Option, + minimax_api_key: Option, + minimax_base_url: Option, + minimax_model: Option, + sglang_api_key: Option, + sglang_base_url: Option, + vllm_api_key: Option, + vllm_base_url: Option, + ollama_api_key: Option, + ollama_base_url: Option, + huggingface_api_key: Option, + huggingface_token: Option, + huggingface_base_url: Option, + hf_base_url: Option, + huggingface_model: Option, + hf_model: Option, + codewhale_provider: Option, + codewhale_model: Option, + codewhale_base_url: Option, +} + +impl EnvGuard { + fn without_deepseek_runtime_overrides() -> Self { + let guard = Self { + deepseek_api_key: env::var_os("DEEPSEEK_API_KEY"), + deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"), + deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"), + deepseek_model: env::var_os("DEEPSEEK_MODEL"), + deepseek_default_text_model: env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"), + deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"), + deepseek_auth_mode: env::var_os("DEEPSEEK_AUTH_MODE"), + codewhale_provider: env::var_os("CODEWHALE_PROVIDER"), + codewhale_model: env::var_os("CODEWHALE_MODEL"), + codewhale_base_url: env::var_os("CODEWHALE_BASE_URL"), + nvidia_api_key: env::var_os("NVIDIA_API_KEY"), + nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"), + nim_base_url: env::var_os("NIM_BASE_URL"), + nvidia_base_url: env::var_os("NVIDIA_BASE_URL"), + nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"), + openrouter_api_key: env::var_os("OPENROUTER_API_KEY"), + openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"), + openrouter_model: env::var_os("OPENROUTER_MODEL"), + xiaomi_mimo_token_plan_api_key: env::var_os("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"), + mimo_token_plan_api_key: env::var_os("MIMO_TOKEN_PLAN_API_KEY"), + xiaomi_mimo_api_key: env::var_os("XIAOMI_MIMO_API_KEY"), + xiaomi_api_key: env::var_os("XIAOMI_API_KEY"), + mimo_api_key: env::var_os("MIMO_API_KEY"), + xiaomi_mimo_base_url: env::var_os("XIAOMI_MIMO_BASE_URL"), + mimo_base_url: env::var_os("MIMO_BASE_URL"), + xiaomi_mimo_model: env::var_os("XIAOMI_MIMO_MODEL"), + mimo_model: env::var_os("MIMO_MODEL"), + xiaomi_mimo_mode: env::var_os("XIAOMI_MIMO_MODE"), + mimo_mode: env::var_os("MIMO_MODE"), + wanjie_ark_api_key: env::var_os("WANJIE_ARK_API_KEY"), + volcengine_api_key: env::var_os("VOLCENGINE_API_KEY"), + volcengine_ark_api_key: env::var_os("VOLCENGINE_ARK_API_KEY"), + ark_api_key: env::var_os("ARK_API_KEY"), + volcengine_base_url: env::var_os("VOLCENGINE_BASE_URL"), + volcengine_ark_base_url: env::var_os("VOLCENGINE_ARK_BASE_URL"), + ark_base_url: env::var_os("ARK_BASE_URL"), + wanjie_ark_base_url: env::var_os("WANJIE_ARK_BASE_URL"), + wanjie_base_url: env::var_os("WANJIE_BASE_URL"), + wanjie_maas_base_url: env::var_os("WANJIE_MAAS_BASE_URL"), + volcengine_model: env::var_os("VOLCENGINE_MODEL"), + volcengine_ark_model: env::var_os("VOLCENGINE_ARK_MODEL"), + wanjie_ark_model: env::var_os("WANJIE_ARK_MODEL"), + wanjie_model: env::var_os("WANJIE_MODEL"), + wanjie_maas_model: env::var_os("WANJIE_MAAS_MODEL"), + novita_api_key: env::var_os("NOVITA_API_KEY"), + novita_base_url: env::var_os("NOVITA_BASE_URL"), + novita_model: env::var_os("NOVITA_MODEL"), + fireworks_api_key: env::var_os("FIREWORKS_API_KEY"), + fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"), + fireworks_model: env::var_os("FIREWORKS_MODEL"), + siliconflow_api_key: env::var_os("SILICONFLOW_API_KEY"), + siliconflow_base_url: env::var_os("SILICONFLOW_BASE_URL"), + siliconflow_model: env::var_os("SILICONFLOW_MODEL"), + arcee_api_key: env::var_os("ARCEE_API_KEY"), + arcee_base_url: env::var_os("ARCEE_BASE_URL"), + arcee_model: env::var_os("ARCEE_MODEL"), + moonshot_api_key: env::var_os("MOONSHOT_API_KEY"), + moonshot_base_url: env::var_os("MOONSHOT_BASE_URL"), + moonshot_model: env::var_os("MOONSHOT_MODEL"), + kimi_api_key: env::var_os("KIMI_API_KEY"), + kimi_base_url: env::var_os("KIMI_BASE_URL"), + kimi_model: env::var_os("KIMI_MODEL"), + kimi_model_name: env::var_os("KIMI_MODEL_NAME"), + zai_api_key: env::var_os("ZAI_API_KEY"), + z_ai_api_key: env::var_os("Z_AI_API_KEY"), + zai_base_url: env::var_os("ZAI_BASE_URL"), + zai_model: env::var_os("ZAI_MODEL"), + stepfun_api_key: env::var_os("STEPFUN_API_KEY"), + step_api_key: env::var_os("STEP_API_KEY"), + stepfun_base_url: env::var_os("STEPFUN_BASE_URL"), + stepfun_model: env::var_os("STEPFUN_MODEL"), + minimax_api_key: env::var_os("MINIMAX_API_KEY"), + minimax_base_url: env::var_os("MINIMAX_BASE_URL"), + minimax_model: env::var_os("MINIMAX_MODEL"), + sglang_api_key: env::var_os("SGLANG_API_KEY"), + sglang_base_url: env::var_os("SGLANG_BASE_URL"), + vllm_api_key: env::var_os("VLLM_API_KEY"), + vllm_base_url: env::var_os("VLLM_BASE_URL"), + ollama_api_key: env::var_os("OLLAMA_API_KEY"), + ollama_base_url: env::var_os("OLLAMA_BASE_URL"), + huggingface_api_key: env::var_os("HUGGINGFACE_API_KEY"), + huggingface_token: env::var_os("HF_TOKEN"), + huggingface_base_url: env::var_os("HUGGINGFACE_BASE_URL"), + hf_base_url: env::var_os("HF_BASE_URL"), + huggingface_model: env::var_os("HUGGINGFACE_MODEL"), + hf_model: env::var_os("HF_MODEL"), + }; + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::remove_var("DEEPSEEK_API_KEY"); + env::remove_var("DEEPSEEK_BASE_URL"); + env::remove_var("DEEPSEEK_HTTP_HEADERS"); + env::remove_var("DEEPSEEK_MODEL"); + env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL"); + env::remove_var("DEEPSEEK_PROVIDER"); + env::remove_var("DEEPSEEK_AUTH_MODE"); + env::remove_var("CODEWHALE_PROVIDER"); + env::remove_var("CODEWHALE_MODEL"); + env::remove_var("CODEWHALE_BASE_URL"); + env::remove_var("NVIDIA_API_KEY"); + env::remove_var("NVIDIA_NIM_API_KEY"); + env::remove_var("NIM_BASE_URL"); + env::remove_var("NVIDIA_BASE_URL"); + env::remove_var("NVIDIA_NIM_BASE_URL"); + env::remove_var("OPENROUTER_API_KEY"); + env::remove_var("OPENROUTER_BASE_URL"); + env::remove_var("OPENROUTER_MODEL"); + env::remove_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"); + env::remove_var("MIMO_TOKEN_PLAN_API_KEY"); + env::remove_var("XIAOMI_MIMO_API_KEY"); + env::remove_var("XIAOMI_API_KEY"); + env::remove_var("MIMO_API_KEY"); + env::remove_var("XIAOMI_MIMO_BASE_URL"); + env::remove_var("MIMO_BASE_URL"); + env::remove_var("XIAOMI_MIMO_MODEL"); + env::remove_var("MIMO_MODEL"); + env::remove_var("XIAOMI_MIMO_MODE"); + env::remove_var("MIMO_MODE"); + env::remove_var("WANJIE_ARK_API_KEY"); + env::remove_var("VOLCENGINE_API_KEY"); + env::remove_var("VOLCENGINE_ARK_API_KEY"); + env::remove_var("ARK_API_KEY"); + env::remove_var("VOLCENGINE_BASE_URL"); + env::remove_var("VOLCENGINE_ARK_BASE_URL"); + env::remove_var("ARK_BASE_URL"); + env::remove_var("WANJIE_ARK_BASE_URL"); + env::remove_var("WANJIE_BASE_URL"); + env::remove_var("WANJIE_MAAS_BASE_URL"); + env::remove_var("VOLCENGINE_MODEL"); + env::remove_var("VOLCENGINE_ARK_MODEL"); + env::remove_var("WANJIE_ARK_MODEL"); + env::remove_var("WANJIE_MODEL"); + env::remove_var("WANJIE_MAAS_MODEL"); + env::remove_var("NOVITA_API_KEY"); + env::remove_var("NOVITA_BASE_URL"); + env::remove_var("NOVITA_MODEL"); + env::remove_var("FIREWORKS_API_KEY"); + env::remove_var("FIREWORKS_BASE_URL"); + env::remove_var("FIREWORKS_MODEL"); + env::remove_var("SILICONFLOW_API_KEY"); + env::remove_var("SILICONFLOW_BASE_URL"); + env::remove_var("SILICONFLOW_MODEL"); + env::remove_var("ARCEE_API_KEY"); + env::remove_var("ARCEE_BASE_URL"); + env::remove_var("ARCEE_MODEL"); + env::remove_var("MOONSHOT_API_KEY"); + env::remove_var("MOONSHOT_BASE_URL"); + env::remove_var("MOONSHOT_MODEL"); + env::remove_var("KIMI_API_KEY"); + env::remove_var("KIMI_BASE_URL"); + env::remove_var("KIMI_MODEL"); + env::remove_var("KIMI_MODEL_NAME"); + env::remove_var("ZAI_API_KEY"); + env::remove_var("Z_AI_API_KEY"); + env::remove_var("ZAI_BASE_URL"); + env::remove_var("ZAI_MODEL"); + env::remove_var("STEPFUN_API_KEY"); + env::remove_var("STEP_API_KEY"); + env::remove_var("STEPFUN_BASE_URL"); + env::remove_var("STEPFUN_MODEL"); + env::remove_var("MINIMAX_API_KEY"); + env::remove_var("MINIMAX_BASE_URL"); + env::remove_var("MINIMAX_MODEL"); + env::remove_var("SGLANG_API_KEY"); + env::remove_var("SGLANG_BASE_URL"); + env::remove_var("VLLM_API_KEY"); + env::remove_var("VLLM_BASE_URL"); + env::remove_var("OLLAMA_API_KEY"); + env::remove_var("OLLAMA_BASE_URL"); + env::remove_var("HUGGINGFACE_API_KEY"); + env::remove_var("HF_TOKEN"); + env::remove_var("HUGGINGFACE_BASE_URL"); + env::remove_var("HF_BASE_URL"); + env::remove_var("HUGGINGFACE_MODEL"); + env::remove_var("HF_MODEL"); + } + guard + } + + unsafe fn restore_var(key: &str, value: Option) { + if let Some(value) = value { + unsafe { env::set_var(key, value) }; + } else { + unsafe { env::remove_var(key) }; + } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take()); + Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take()); + Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take()); + Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take()); + Self::restore_var( + "DEEPSEEK_DEFAULT_TEXT_MODEL", + self.deepseek_default_text_model.take(), + ); + Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take()); + Self::restore_var("DEEPSEEK_AUTH_MODE", self.deepseek_auth_mode.take()); + Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take()); + Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take()); + Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take()); + Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take()); + Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take()); + Self::restore_var("NIM_BASE_URL", self.nim_base_url.take()); + Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take()); + Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take()); + Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take()); + Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take()); + Self::restore_var("OPENROUTER_MODEL", self.openrouter_model.take()); + Self::restore_var( + "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", + self.xiaomi_mimo_token_plan_api_key.take(), + ); + Self::restore_var( + "MIMO_TOKEN_PLAN_API_KEY", + self.mimo_token_plan_api_key.take(), + ); + Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take()); + Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take()); + Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take()); + Self::restore_var("XIAOMI_MIMO_BASE_URL", self.xiaomi_mimo_base_url.take()); + Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take()); + Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take()); + Self::restore_var("MIMO_MODEL", self.mimo_model.take()); + Self::restore_var("XIAOMI_MIMO_MODE", self.xiaomi_mimo_mode.take()); + Self::restore_var("MIMO_MODE", self.mimo_mode.take()); + Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take()); + Self::restore_var("VOLCENGINE_API_KEY", self.volcengine_api_key.take()); + Self::restore_var("VOLCENGINE_ARK_API_KEY", self.volcengine_ark_api_key.take()); + Self::restore_var("ARK_API_KEY", self.ark_api_key.take()); + Self::restore_var("VOLCENGINE_BASE_URL", self.volcengine_base_url.take()); + Self::restore_var( + "VOLCENGINE_ARK_BASE_URL", + self.volcengine_ark_base_url.take(), + ); + Self::restore_var("ARK_BASE_URL", self.ark_base_url.take()); + Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take()); + Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take()); + Self::restore_var("WANJIE_MAAS_BASE_URL", self.wanjie_maas_base_url.take()); + Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take()); + Self::restore_var("VOLCENGINE_ARK_MODEL", self.volcengine_ark_model.take()); + Self::restore_var("WANJIE_ARK_MODEL", self.wanjie_ark_model.take()); + Self::restore_var("WANJIE_MODEL", self.wanjie_model.take()); + Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take()); + Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take()); + Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take()); + Self::restore_var("NOVITA_MODEL", self.novita_model.take()); + Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take()); + Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take()); + Self::restore_var("FIREWORKS_MODEL", self.fireworks_model.take()); + Self::restore_var("SILICONFLOW_API_KEY", self.siliconflow_api_key.take()); + Self::restore_var("SILICONFLOW_BASE_URL", self.siliconflow_base_url.take()); + Self::restore_var("SILICONFLOW_MODEL", self.siliconflow_model.take()); + Self::restore_var("ARCEE_API_KEY", self.arcee_api_key.take()); + Self::restore_var("ARCEE_BASE_URL", self.arcee_base_url.take()); + Self::restore_var("ARCEE_MODEL", self.arcee_model.take()); + Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take()); + Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take()); + Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take()); + Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take()); + Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take()); + Self::restore_var("KIMI_MODEL", self.kimi_model.take()); + Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take()); + Self::restore_var("ZAI_API_KEY", self.zai_api_key.take()); + Self::restore_var("Z_AI_API_KEY", self.z_ai_api_key.take()); + Self::restore_var("ZAI_BASE_URL", self.zai_base_url.take()); + Self::restore_var("ZAI_MODEL", self.zai_model.take()); + Self::restore_var("STEPFUN_API_KEY", self.stepfun_api_key.take()); + Self::restore_var("STEP_API_KEY", self.step_api_key.take()); + Self::restore_var("STEPFUN_BASE_URL", self.stepfun_base_url.take()); + Self::restore_var("STEPFUN_MODEL", self.stepfun_model.take()); + Self::restore_var("MINIMAX_API_KEY", self.minimax_api_key.take()); + Self::restore_var("MINIMAX_BASE_URL", self.minimax_base_url.take()); + Self::restore_var("MINIMAX_MODEL", self.minimax_model.take()); + Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take()); + Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take()); + Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take()); + Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take()); + Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take()); + Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take()); + Self::restore_var("HUGGINGFACE_API_KEY", self.huggingface_api_key.take()); + Self::restore_var("HF_TOKEN", self.huggingface_token.take()); + Self::restore_var("HUGGINGFACE_BASE_URL", self.huggingface_base_url.take()); + Self::restore_var("HF_BASE_URL", self.hf_base_url.take()); + Self::restore_var("HUGGINGFACE_MODEL", self.huggingface_model.take()); + Self::restore_var("HF_MODEL", self.hf_model.take()); + } + } +} + +struct RecordingSecretsStore { + gets: Mutex>, + value: Option, +} + +impl RecordingSecretsStore { + fn with_value(value: &str) -> Self { + Self { + gets: Mutex::new(Vec::new()), + value: Some(value.to_string()), + } + } +} + +impl codewhale_secrets::KeyringStore for RecordingSecretsStore { + fn get(&self, key: &str) -> Result, codewhale_secrets::SecretsError> { + self.gets.lock().unwrap().push(key.to_string()); + Ok(self.value.clone()) + } + + fn set(&self, _key: &str, _value: &str) -> Result<(), codewhale_secrets::SecretsError> { + Ok(()) + } + + fn delete(&self, _key: &str) -> Result<(), codewhale_secrets::SecretsError> { + Ok(()) + } + + fn backend_name(&self) -> &'static str { + "recording" + } +} + +#[test] +fn root_deepseek_fields_are_runtime_fallbacks() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + api_key: Some("root-key".to_string()), + base_url: Some("https://api.deepseek.com".to_string()), + default_text_model: Some("deepseek-v4-pro".to_string()), + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Deepseek); + assert_eq!(resolved.api_key.as_deref(), Some("root-key")); + assert_eq!(resolved.base_url, "https://api.deepseek.com"); + assert_eq!(resolved.model, "deepseek-v4-pro"); +} + +#[test] +fn deepseek_runtime_defaults_to_beta_endpoint() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml::default(); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Deepseek); + assert_eq!(resolved.base_url, DEFAULT_DEEPSEEK_BASE_URL); + assert_eq!(resolved.model, DEFAULT_DEEPSEEK_MODEL); +} + +#[test] +fn provider_specific_deepseek_fields_override_tui_compat_fields() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + api_key: Some("root-key".to_string()), + base_url: Some("https://api.deepseek.com".to_string()), + default_text_model: Some("deepseek-v4-pro".to_string()), + ..ConfigToml::default() + }; + config.providers.deepseek.api_key = Some("provider-key".to_string()); + config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string()); + config.providers.deepseek.model = Some("deepseek-v4-flash".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.api_key.as_deref(), Some("provider-key")); + assert_eq!(resolved.base_url, "https://gateway.example/v1"); + assert_eq!(resolved.model, "deepseek-v4-flash"); +} + +#[test] +fn provider_http_headers_override_root_headers() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + api_key: Some("root-key".to_string()), + base_url: Some("https://api.deepseek.com".to_string()), + default_text_model: Some("deepseek-v4-pro".to_string()), + ..ConfigToml::default() + }; + config.providers.deepseek.api_key = Some("provider-key".to_string()); + config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string()); + config.providers.deepseek.model = Some("deepseek-v4-flash".to_string()); + config + .http_headers + .insert("X-Shared".to_string(), "root".to_string()); + config + .providers + .deepseek + .http_headers + .insert("X-Model-Provider-Id".to_string(), "tongyi".to_string()); + config + .providers + .deepseek + .http_headers + .insert("X-Shared".to_string(), "provider".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.api_key.as_deref(), Some("provider-key")); + assert_eq!(resolved.base_url, "https://gateway.example/v1"); + assert_eq!(resolved.model, "deepseek-v4-flash"); + assert_eq!( + resolved + .http_headers + .get("X-Model-Provider-Id") + .map(String::as_str), + Some("tongyi") + ); + assert_eq!( + resolved.http_headers.get("X-Shared").map(String::as_str), + Some("provider") + ); +} + +#[test] +fn insecure_skip_tls_verify_resolves_only_for_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Openai, + ..ConfigToml::default() + }; + config.providers.deepseek.insecure_skip_tls_verify = Some(true); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Openai); + assert!(!resolved.insecure_skip_tls_verify); + + config.providers.openai.insecure_skip_tls_verify = Some(true); + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Openai); + assert!(resolved.insecure_skip_tls_verify); +} + +#[test] +fn http_headers_env_overrides_config() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml::default(); + config + .http_headers + .insert("X-Model-Provider-Id".to_string(), "from-file".to_string()); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Model-Provider-Id=from-env"); + } + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!( + resolved + .http_headers + .get("X-Model-Provider-Id") + .map(String::as_str), + Some("from-env") + ); +} + +#[test] +fn nvidia_nim_provider_defaults_to_catalog_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::NvidiaNim, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::NvidiaNim); + assert_eq!(resolved.base_url, DEFAULT_NVIDIA_NIM_BASE_URL); + assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL); +} + +#[test] +fn nvidia_nim_provider_uses_provider_specific_credentials() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::NvidiaNim, + ..ConfigToml::default() + }; + config.providers.nvidia_nim.api_key = Some("nim-key".to_string()); + config.providers.nvidia_nim.base_url = Some("https://nim.example/v1".to_string()); + config.providers.nvidia_nim.model = Some("deepseek-ai/deepseek-v4-pro".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::NvidiaNim); + assert_eq!(resolved.api_key.as_deref(), Some("nim-key")); + assert_eq!(resolved.base_url, "https://nim.example/v1"); + assert_eq!(resolved.model, "deepseek-ai/deepseek-v4-pro"); +} + +#[test] +fn nvidia_nim_provider_normalizes_flash_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::NvidiaNim), + model: Some("deepseek-v4-flash".to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::NvidiaNim); + assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_FLASH_MODEL); +} + +#[test] +fn nvidia_nim_provider_uses_nvidia_env_credentials() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim"); + env::set_var("NVIDIA_API_KEY", "nim-env-key"); + env::set_var("NVIDIA_NIM_BASE_URL", "https://nim-env.example/v1"); + } + + let config = ConfigToml::default(); + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::NvidiaNim); + assert_eq!(resolved.api_key.as_deref(), Some("nim-env-key")); + assert_eq!(resolved.base_url, "https://nim-env.example/v1"); + assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL); +} + +#[test] +fn nvidia_nim_provider_accepts_short_nim_base_url_alias() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim"); + env::set_var("NVIDIA_API_KEY", "nim-env-key"); + env::set_var("NIM_BASE_URL", "https://short-nim.example/v1"); + } + + let config = ConfigToml::default(); + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::NvidiaNim); + assert_eq!(resolved.base_url, "https://short-nim.example/v1"); +} + +#[test] +fn nvidia_nim_provider_can_fallback_to_deepseek_api_key_env() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim"); + env::set_var("DEEPSEEK_API_KEY", "deepseek-compat-key"); + } + + let config = ConfigToml::default(); + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::NvidiaNim); + assert_eq!(resolved.api_key.as_deref(), Some("deepseek-compat-key")); +} + +#[test] +fn list_values_redacts_root_api_key() { + let config = ConfigToml { + api_key: Some("sk-deepseek-secret".to_string()), + ..ConfigToml::default() + }; + + let values = config.list_values(); + + assert_eq!( + values.get("api_key").map(String::as_str), + Some("sk-d***cret") + ); +} + +#[test] +fn list_values_fully_redacts_short_api_key() { + let config = ConfigToml { + api_key: Some("short-key".to_string()), + ..ConfigToml::default() + }; + + let values = config.list_values(); + + assert_eq!(values.get("api_key").map(String::as_str), Some("********")); +} + +#[test] +fn get_display_value_redacts_sensitive_keys() { + let mut config = ConfigToml { + api_key: Some("sk-deepseek-secret".to_string()), + ..ConfigToml::default() + }; + config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string()); + config.model = Some("deepseek-v4-pro".to_string()); + + assert_eq!( + config.get_display_value("api_key").as_deref(), + Some("sk-d***cret") + ); + assert_eq!( + config + .get_display_value("providers.openrouter.api_key") + .as_deref(), + Some("open***alue") + ); + assert_eq!( + config.get_display_value("model").as_deref(), + Some("deepseek-v4-pro") + ); +} + +#[test] +fn hook_sinks_config_uses_separate_table_from_lifecycle_hooks() -> Result<()> { + let raw = r#" +[hooks] +enabled = true +default_timeout_secs = 20 + +[[hooks.hooks]] +event = "message_submit" +command = "echo ok" + +[hook_sinks] +unix_socket_path = "/tmp/cw-hooks.sock" +"#; + + let config: ConfigToml = toml::from_str(raw)?; + + assert_eq!( + config.get_value("hook_sinks.unix_socket_path").as_deref(), + Some("/tmp/cw-hooks.sock") + ); + assert!( + config.extras.contains_key("hooks"), + "legacy lifecycle hooks table must remain an opaque extra" + ); + + let serialized = toml::to_string_pretty(&config)?; + let round_tripped: ConfigToml = toml::from_str(&serialized)?; + let hooks = round_tripped + .extras + .get("hooks") + .and_then(toml::Value::as_table) + .expect("hooks table preserved"); + + assert_eq!( + hooks.get("enabled").and_then(toml::Value::as_bool), + Some(true) + ); + assert_eq!( + hooks + .get("default_timeout_secs") + .and_then(toml::Value::as_integer), + Some(20) + ); + assert!( + hooks.get("hooks").and_then(toml::Value::as_array).is_some(), + "nested lifecycle hooks array must survive config rewrites" + ); + assert_eq!( + round_tripped + .get_value("hook_sinks.unix_socket_path") + .as_deref(), + Some("/tmp/cw-hooks.sock") + ); + + Ok(()) +} + +#[test] +fn hook_sinks_unix_socket_path_round_trips_through_key_value_api() -> Result<()> { + let mut config = ConfigToml::default(); + + config.set_value("hook_sinks.unix_socket_path", "/tmp/cw-events.sock")?; + + assert_eq!( + config.get_value("hook_sinks.unix_socket_path").as_deref(), + Some("/tmp/cw-events.sock") + ); + assert_eq!( + config + .list_values() + .get("hook_sinks.unix_socket_path") + .map(String::as_str), + Some("/tmp/cw-events.sock") + ); + + config.unset_value("hook_sinks.unix_socket_path")?; + assert_eq!(config.get_value("hook_sinks.unix_socket_path"), None); + + Ok(()) +} + +/// End-to-end smoke for the preferred Kimi Code setup path: +/// 1. Start from a fresh root config that uses DeepSeek defaults. +/// 2. Mutate it through the same key-value setters the +/// `codewhale config set providers.moonshot.*` CLI invokes. +/// 3. Switch the active provider through `CODEWHALE_PROVIDER` — +/// the public env alias — without ever touching the legacy +/// `DEEPSEEK_PROVIDER` name. +/// 4. Resolve the runtime and confirm the doctor/runtime values. +/// +/// No real API key is required; the `api_key` here is just a +/// non-empty placeholder. +#[test] +fn moonshot_kimi_code_smoke_config_set_then_resolve() -> Result<()> { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + + let mut config = ConfigToml { + provider: ProviderKind::Deepseek, + default_text_model: Some("deepseek-v4-pro".to_string()), + ..ConfigToml::default() + }; + + // Same key paths a user would run via `codewhale config set`. + config.set_value("providers.moonshot.api_key", "kimi-code-key-placeholder")?; + config.set_value("providers.moonshot.auth_mode", "api_key")?; + config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?; + config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?; + + // Public env alias for the active-provider switch. + // Safety: test-only env mutation guarded by env_lock(). + unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.auth_mode.as_deref(), Some("api_key")); + assert_eq!( + resolved.api_key.as_deref(), + Some("kimi-code-key-placeholder") + ); + assert_eq!( + resolved.api_key_source, + Some(RuntimeApiKeySource::ConfigFile) + ); + Ok(()) +} + +#[test] +fn moonshot_provider_config_values_round_trip() -> Result<()> { + let mut config = ConfigToml::default(); + + config.set_value("providers.moonshot.api_key", "moonshot-secret-value")?; + config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?; + config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?; + config.set_value("providers.moonshot.auth_mode", "api_key")?; + config.set_value("providers.moonshot.http_headers", "X-Test=ok")?; + + assert_eq!( + config + .get_display_value("providers.moonshot.api_key") + .as_deref(), + Some("moon***alue") + ); + assert_eq!( + config.get_value("providers.moonshot.base_url").as_deref(), + Some(DEFAULT_KIMI_CODE_BASE_URL) + ); + assert_eq!( + config.get_value("providers.moonshot.model").as_deref(), + Some(DEFAULT_KIMI_CODE_MODEL) + ); + assert_eq!( + config.get_value("providers.moonshot.auth_mode").as_deref(), + Some("api_key") + ); + assert_eq!( + config + .list_values() + .get("providers.moonshot.api_key") + .map(String::as_str), + Some("moon***alue") + ); + + config.unset_value("providers.moonshot.auth_mode")?; + config.unset_value("providers.moonshot.base_url")?; + config.unset_value("providers.moonshot.model")?; + + assert_eq!(config.get_value("providers.moonshot.auth_mode"), None); + assert_eq!(config.get_value("providers.moonshot.base_url"), None); + assert_eq!(config.get_value("providers.moonshot.model"), None); + Ok(()) +} + +#[test] +fn siliconflow_cn_provider_config_values_round_trip() -> Result<()> { + let mut config = ConfigToml::default(); + + config.set_value("providers.siliconflow_cn.api_key", "sf-cn-secret-value")?; + config.set_value( + "providers.siliconflow_cn.base_url", + DEFAULT_SILICONFLOW_CN_BASE_URL, + )?; + config.set_value("providers.siliconflow_cn.model", DEFAULT_SILICONFLOW_MODEL)?; + config.set_value("providers.siliconflow_cn.http_headers", "X-Test=ok")?; + + assert_eq!( + config + .get_display_value("providers.siliconflow_cn.api_key") + .as_deref(), + Some("sf-c***alue") + ); + assert_eq!( + config + .get_value("providers.siliconflow_cn.base_url") + .as_deref(), + Some(DEFAULT_SILICONFLOW_CN_BASE_URL) + ); + assert_eq!( + config + .get_value("providers.siliconflow_cn.model") + .as_deref(), + Some(DEFAULT_SILICONFLOW_MODEL) + ); + assert_eq!( + config + .list_values() + .get("providers.siliconflow_cn.api_key") + .map(String::as_str), + Some("sf-c***alue") + ); + + config.unset_value("providers.siliconflow_cn.api_key")?; + config.unset_value("providers.siliconflow_cn.base_url")?; + config.unset_value("providers.siliconflow_cn.model")?; + config.unset_value("providers.siliconflow_cn.http_headers")?; + + assert_eq!(config.get_value("providers.siliconflow_cn.api_key"), None); + assert_eq!(config.get_value("providers.siliconflow_cn.base_url"), None); + assert_eq!(config.get_value("providers.siliconflow_cn.model"), None); + assert_eq!( + config.get_value("providers.siliconflow_cn.http_headers"), + None + ); + Ok(()) +} + +#[test] +fn volcengine_provider_config_values_round_trip() -> Result<()> { + let mut config = ConfigToml::default(); + + config.set_value("providers.volcengine.api_key", "volcengine-secret-value")?; + config.set_value("providers.volcengine.base_url", DEFAULT_VOLCENGINE_BASE_URL)?; + config.set_value("providers.volcengine.model", DEFAULT_VOLCENGINE_MODEL)?; + config.set_value("providers.volcengine.http_headers", "X-Test=ok")?; + + assert_eq!( + config + .get_display_value("providers.volcengine.api_key") + .as_deref(), + Some("volc***alue") + ); + assert_eq!( + config.get_value("providers.volcengine.base_url").as_deref(), + Some(DEFAULT_VOLCENGINE_BASE_URL) + ); + assert_eq!( + config.get_value("providers.volcengine.model").as_deref(), + Some(DEFAULT_VOLCENGINE_MODEL) + ); + assert_eq!( + config + .get_value("providers.volcengine.http_headers") + .as_deref(), + Some("X-Test=ok") + ); + assert_eq!( + config + .list_values() + .get("providers.volcengine.http_headers") + .map(String::as_str), + Some("X-Test=ok") + ); + + config.unset_value("providers.volcengine.http_headers")?; + assert_eq!(config.get_value("providers.volcengine.http_headers"), None); + Ok(()) +} + +#[test] +fn provider_key_value_api_covers_all_provider_metadata_entries() -> Result<()> { + for provider in ProviderKind::ALL { + let table = provider.provider().provider_config_key(); + let mut config = ConfigToml::default(); + let api_key = format!("secret-value-for-{table}-123456"); + let api_key_path = format!("providers.{table}.api_key"); + let base_url_path = format!("providers.{table}.base_url"); + let model_path = format!("providers.{table}.model"); + let headers_path = format!("providers.{table}.http_headers"); + let mode_path = format!("providers.{table}.mode"); + let auth_mode_path = format!("providers.{table}.auth_mode"); + let insecure_path = format!("providers.{table}.insecure_skip_tls_verify"); + let path_suffix_path = format!("providers.{table}.path_suffix"); + + config.set_value(&api_key_path, &api_key)?; + config.set_value(&base_url_path, "https://gateway.example/v1")?; + config.set_value(&model_path, "provider-test-model")?; + config.set_value(&headers_path, "X-Test=ok")?; + config.set_value(&mode_path, "concise")?; + config.set_value(&auth_mode_path, "api_key")?; + config.set_value(&insecure_path, "true")?; + config.set_value(&path_suffix_path, "/chat/completions")?; + + assert_eq!( + config.get_value(&api_key_path).as_deref(), + Some(api_key.as_str()) + ); + assert_eq!( + config.get_value(&base_url_path).as_deref(), + Some("https://gateway.example/v1") + ); + assert_eq!( + config.get_value(&model_path).as_deref(), + Some("provider-test-model") + ); + assert_eq!( + config.get_value(&headers_path).as_deref(), + Some("X-Test=ok") + ); + assert_eq!(config.get_value(&mode_path).as_deref(), Some("concise")); + assert_eq!( + config.get_value(&auth_mode_path).as_deref(), + Some("api_key") + ); + assert_eq!(config.get_value(&insecure_path).as_deref(), Some("true")); + assert_eq!( + config.get_value(&path_suffix_path).as_deref(), + Some("/chat/completions") + ); + + let listed = config.list_values(); + let listed_api_key = listed + .get(&api_key_path) + .expect("provider API key is listed"); + assert!(listed_api_key.contains("***")); + assert_ne!(listed_api_key, &api_key); + assert_eq!( + listed.get(&headers_path).map(String::as_str), + Some("X-Test=ok") + ); + assert_eq!(listed.get(&insecure_path).map(String::as_str), Some("true")); + + config.unset_value(&api_key_path)?; + config.unset_value(&base_url_path)?; + config.unset_value(&model_path)?; + config.unset_value(&headers_path)?; + config.unset_value(&mode_path)?; + config.unset_value(&auth_mode_path)?; + config.unset_value(&insecure_path)?; + config.unset_value(&path_suffix_path)?; + + assert_eq!(config.get_value(&api_key_path), None); + assert_eq!(config.get_value(&base_url_path), None); + assert_eq!(config.get_value(&model_path), None); + assert_eq!(config.get_value(&headers_path), None); + assert_eq!(config.get_value(&mode_path), None); + assert_eq!(config.get_value(&auth_mode_path), None); + assert_eq!(config.get_value(&insecure_path), None); + assert_eq!(config.get_value(&path_suffix_path), None); + + if provider == ProviderKind::Deepseek { + assert_eq!(config.api_key, None); + assert_eq!(config.base_url, None); + assert_eq!(config.default_text_model, None); + assert!(config.http_headers.is_empty()); + } + } + + Ok(()) +} + +#[test] +fn project_merge_denies_credentials_endpoints_and_provider_selection() { + let mut base = ConfigToml { + provider: ProviderKind::Deepseek, + api_key: Some("user-key".to_string()), + base_url: Some("https://api.deepseek.com".to_string()), + default_text_model: Some("deepseek-v4-flash".to_string()), + ..ConfigToml::default() + }; + base.providers.openrouter.api_key = Some("user-openrouter-key".to_string()); + base.providers.openrouter.path_suffix = Some("/chat/completions".to_string()); + + let mut project = ConfigToml { + provider: ProviderKind::Openrouter, + api_key: Some("attacker-key".to_string()), + base_url: Some("https://evil.example/v1".to_string()), + default_text_model: Some("deepseek-v4-pro".to_string()), + auth_mode: Some("oauth".to_string()), + telemetry: Some(true), + ..ConfigToml::default() + }; + project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string()); + project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string()); + project.providers.openrouter.insecure_skip_tls_verify = Some(true); + project.providers.openrouter.path_suffix = Some("/attacker/chat".to_string()); + project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string()); + project.providers.volcengine.model = Some("DeepSeek-V4-Pro".to_string()); + project.providers.moonshot.model = Some("kimi-k2.6".to_string()); + + base.merge_project_overrides(project); + + assert_eq!(base.provider, ProviderKind::Deepseek); + assert_eq!(base.api_key.as_deref(), Some("user-key")); + assert_eq!(base.base_url.as_deref(), Some("https://api.deepseek.com")); + assert_eq!(base.auth_mode, None); + assert_eq!(base.telemetry, None); + assert_eq!( + base.providers.openrouter.api_key.as_deref(), + Some("user-openrouter-key") + ); + assert_eq!(base.providers.openrouter.base_url, None); + assert_eq!(base.providers.openrouter.insecure_skip_tls_verify, None); + assert_eq!( + base.providers.openrouter.path_suffix.as_deref(), + Some("/chat/completions") + ); + assert_eq!(base.default_text_model.as_deref(), Some("deepseek-v4-pro")); + assert_eq!( + base.providers.openrouter.model.as_deref(), + Some("deepseek/deepseek-v4-pro") + ); + assert_eq!( + base.providers.volcengine.model.as_deref(), + Some("DeepSeek-V4-Pro") + ); + assert_eq!(base.providers.moonshot.model.as_deref(), Some("kimi-k2.6")); +} + +#[test] +fn project_merge_forwards_all_provider_model_overrides() { + let mut project_toml = String::new(); + for provider in ProviderKind::ALL { + let key = provider.provider().provider_config_key(); + project_toml.push_str(&format!( + "[providers.{key}]\nmodel = \"project-{key}-model\"\n\n" + )); + } + + let project: ConfigToml = + toml::from_str(&project_toml).expect("project provider overrides parse"); + let mut base = ConfigToml::default(); + + base.merge_project_overrides(project); + + for provider in ProviderKind::ALL { + let key = provider.provider().provider_config_key(); + let expected = format!("project-{key}-model"); + assert_eq!( + base.providers.for_provider(provider).model.as_deref(), + Some(expected.as_str()), + "provider {key} should merge repo-local model override" + ); + } +} + +#[test] +fn project_merge_only_tightens_approval_and_sandbox_policy() { + let mut strict = ConfigToml { + approval_policy: Some("never".to_string()), + sandbox_mode: Some("read-only".to_string()), + ..ConfigToml::default() + }; + strict.merge_project_overrides(ConfigToml { + approval_policy: Some("on-request".to_string()), + sandbox_mode: Some("workspace-write".to_string()), + ..ConfigToml::default() + }); + assert_eq!(strict.approval_policy.as_deref(), Some("never")); + assert_eq!(strict.sandbox_mode.as_deref(), Some("read-only")); + + let mut permissive = ConfigToml { + approval_policy: Some("auto".to_string()), + sandbox_mode: Some("workspace-write".to_string()), + ..ConfigToml::default() + }; + permissive.merge_project_overrides(ConfigToml { + approval_policy: Some("never".to_string()), + sandbox_mode: Some("read-only".to_string()), + ..ConfigToml::default() + }); + assert_eq!(permissive.approval_policy.as_deref(), Some("never")); + assert_eq!(permissive.sandbox_mode.as_deref(), Some("read-only")); + + let mut unset = ConfigToml::default(); + unset.merge_project_overrides(ConfigToml { + approval_policy: Some("on-request".to_string()), + sandbox_mode: Some("workspace-write".to_string()), + ..ConfigToml::default() + }); + assert_eq!(unset.approval_policy, None); + assert_eq!(unset.sandbox_mode, None); +} + +#[test] +fn list_values_redacts_unicode_api_key_without_byte_slicing() { + let config = ConfigToml { + api_key: Some("密钥密钥密钥密钥123456789".to_string()), + ..ConfigToml::default() + }; + + let values = config.list_values(); + + assert_eq!( + values.get("api_key").map(String::as_str), + Some("密钥密钥***6789") + ); +} + +#[test] +fn app_homes_prefer_home_env_before_platform_home_fallback() { + let _lock = env_lock(); + struct HomeEnvGuard { + home: Option, + userprofile: Option, + codewhale_home: Option, + } + + impl Drop for HomeEnvGuard { + fn drop(&mut self) { + // Safety: test-only environment mutation is serialized by env_lock(). + unsafe { + match self.home.take() { + Some(value) => env::set_var("HOME", value), + None => env::remove_var("HOME"), + } + match self.userprofile.take() { + Some(value) => env::set_var("USERPROFILE", value), + None => env::remove_var("USERPROFILE"), + } + match self.codewhale_home.take() { + Some(value) => env::set_var("CODEWHALE_HOME", value), + None => env::remove_var("CODEWHALE_HOME"), + } + } + } + } + + let home = + std::env::temp_dir().join(format!("codewhale-config-home-env-{}", std::process::id())); + let userprofile = std::env::temp_dir().join(format!( + "codewhale-config-userprofile-{}", + std::process::id() + )); + let _env = HomeEnvGuard { + home: env::var_os("HOME"), + userprofile: env::var_os("USERPROFILE"), + codewhale_home: env::var_os("CODEWHALE_HOME"), + }; + // Safety: test-only environment mutation is serialized by env_lock(). + unsafe { + env::set_var("HOME", &home); + env::set_var("USERPROFILE", &userprofile); + env::remove_var("CODEWHALE_HOME"); + } + + assert_eq!( + codewhale_home().expect("codewhale home"), + home.join(CODEWHALE_APP_DIR) + ); + assert_eq!( + legacy_deepseek_home().expect("legacy home"), + home.join(LEGACY_APP_DIR) + ); + + let explicit = std::env::temp_dir().join(format!( + "codewhale-config-explicit-home-{}", + std::process::id() + )); + // Safety: test-only environment mutation is serialized by env_lock(). + unsafe { + env::set_var("CODEWHALE_HOME", &explicit); + } + assert_eq!(codewhale_home().expect("explicit home"), explicit); +} + +#[test] +fn migrate_config_reports_copied_legacy_path() { + let _lock = env_lock(); + struct HomeEnvGuard { + home: Option, + userprofile: Option, + codewhale_home: Option, + } + + impl Drop for HomeEnvGuard { + fn drop(&mut self) { + // Safety: test-only environment mutation is serialized by env_lock(). + unsafe { + match self.home.take() { + Some(value) => env::set_var("HOME", value), + None => env::remove_var("HOME"), + } + match self.userprofile.take() { + Some(value) => env::set_var("USERPROFILE", value), + None => env::remove_var("USERPROFILE"), + } + match self.codewhale_home.take() { + Some(value) => env::set_var("CODEWHALE_HOME", value), + None => env::remove_var("CODEWHALE_HOME"), + } + } + } + } + + struct LegacyConfigGuard { + path: PathBuf, + original: Option>, + } + + impl LegacyConfigGuard { + fn install(path: PathBuf, contents: &[u8]) -> Self { + let original = fs::read(&path).ok(); + fs::create_dir_all(path.parent().expect("legacy config parent")).expect("legacy dir"); + fs::write(&path, contents).expect("legacy config"); + Self { path, original } + } + } + + impl Drop for LegacyConfigGuard { + fn drop(&mut self) { + if let Some(original) = self.original.take() { + let _ = fs::write(&self.path, original); + } else { + let _ = fs::remove_file(&self.path); + if let Some(parent) = self.path.parent() { + let _ = fs::remove_dir(parent); + } + } + } + } + + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let home = std::env::temp_dir().join(format!( + "codewhale-config-migration-{}-{unique}", + std::process::id() + )); + let legacy_dir = home.join(LEGACY_APP_DIR); + let primary_dir = home.join(CODEWHALE_APP_DIR); + let legacy_config = legacy_dir.join(CONFIG_FILE_NAME); + let _legacy = LegacyConfigGuard::install(legacy_config.clone(), b"provider = \"deepseek\"\n"); + + let _env = HomeEnvGuard { + home: env::var_os("HOME"), + userprofile: env::var_os("USERPROFILE"), + codewhale_home: env::var_os("CODEWHALE_HOME"), + }; + // Safety: test-only environment mutation is serialized by env_lock(). + unsafe { + env::set_var("HOME", &home); + env::set_var("USERPROFILE", &home); + env::set_var("CODEWHALE_HOME", &primary_dir); + } + + let migration = migrate_config_if_needed() + .expect("migration") + .expect("legacy config should be copied"); + + assert_eq!(migration.legacy_path, legacy_config); + assert_eq!(migration.primary_path, primary_dir.join(CONFIG_FILE_NAME)); + let notice = migration.user_notice(); + assert!(notice.contains(&legacy_dir.join(CONFIG_FILE_NAME).display().to_string())); + assert!(notice.contains(&primary_dir.join(CONFIG_FILE_NAME).display().to_string())); + assert!(notice.contains(".codewhale path for future edits")); + assert!(notice.contains(".deepseek file remains only as a compatibility fallback")); + assert_eq!( + fs::read_to_string(primary_dir.join(CONFIG_FILE_NAME)).expect("primary config"), + "provider = \"deepseek\"\n" + ); + + let _ = fs::remove_dir_all(home); +} + +#[test] +fn normalize_config_file_path_rejects_traversal() { + let err = normalize_config_file_path(PathBuf::from("../config.toml")) + .expect_err("traversal path should fail"); + assert!(format!("{err:#}").contains("cannot contain '..'")); +} + +#[cfg(unix)] +#[test] +fn save_clamps_existing_config_permissions() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "deepseek-config-perms-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("mkdir"); + let path = dir.join(CONFIG_FILE_NAME); + fs::write(&path, "api_key = \"old\"\n").expect("seed config"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod seed"); + + let store = ConfigStore { + path: path.clone(), + config: ConfigToml { + api_key: Some("new-secret".to_string()), + ..ConfigToml::default() + }, + permissions: PermissionsToml::default(), + original_raw: None, + }; + store.save().expect("save"); + + let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn config_store_save_skips_identical_serialized_body() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "codewhale-config-noop-save-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("mkdir"); + let path = dir.join(CONFIG_FILE_NAME); + let config = ConfigToml { + model: Some("deepseek-v4-flash".to_string()), + ..ConfigToml::default() + }; + let body = toml::to_string_pretty(&config).expect("serialize"); + fs::write(&path, &body).expect("seed config"); + #[cfg(unix)] + fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).expect("chmod seed"); + + let store = ConfigStore { + path: path.clone(), + config, + permissions: PermissionsToml::default(), + original_raw: None, + }; + store.save().expect("identical save should not rewrite"); + + #[cfg(unix)] + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("chmod restore"); + assert_eq!(fs::read_to_string(&path).expect("read config"), body); + assert!( + !config_backup_path(&path).exists(), + "no-op save must not create a migration backup" + ); + + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn config_store_save_creates_one_time_backup_before_changed_write() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "codewhale-config-backup-save-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("mkdir"); + let path = dir.join(CONFIG_FILE_NAME); + let original = "model = \"deepseek-v4-flash\"\n"; + fs::write(&path, original).expect("seed config"); + + let store = ConfigStore { + path: path.clone(), + config: ConfigToml { + model: Some("deepseek-v4-pro".to_string()), + ..ConfigToml::default() + }, + permissions: PermissionsToml::default(), + original_raw: None, + }; + store.save().expect("changed save"); + + let backup_path = config_backup_path(&path); + assert_eq!( + fs::read_to_string(&backup_path).expect("read backup"), + original + ); + let updated = fs::read_to_string(&path).expect("read updated config"); + assert!(updated.contains("model = \"deepseek-v4-pro\"")); + + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn config_store_save_preserves_comments() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join(CONFIG_FILE_NAME); + let original = "# my model\nmodel = \"deepseek-v4-flash\"\n# end comment\n"; + fs::write(&config_path, original).expect("write config"); + + let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store"); + store.config.model = Some("deepseek-v4-pro".to_string()); + store.save().expect("save"); + + let body = fs::read_to_string(&config_path).expect("read config"); + assert!(body.contains("# my model"), "prefix comment preserved"); + assert!(body.contains("# end comment"), "suffix comment preserved"); + assert!(body.contains("model = \"deepseek-v4-pro\"")); +} + +#[test] +fn config_store_save_preserves_disabled_keys() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join(CONFIG_FILE_NAME); + fs::write( + &config_path, + "# my note\nmodel = \"deepseek-v4-flash\"\n# base_url = \"http://localhost:11434/v1\"\n", + ) + .expect("write config"); + + let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store"); + store.config.model = Some("deepseek-v4-pro".to_string()); + store.save().expect("save"); + + let body = fs::read_to_string(&config_path).expect("read config"); + assert!( + body.contains("# base_url = \"http://localhost:11434/v1\""), + "disabled key preserved as comment" + ); + assert!(body.contains("model = \"deepseek-v4-pro\"")); +} + +#[test] +fn config_store_save_preserves_comments_with_other_keys() { + // Realistic scenario: user already has api_key + model, adds a comment, + // then changes model via `codewhale config set model`. + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join(CONFIG_FILE_NAME); + fs::write( + &config_path, + "# my deepseek key\napi_key = \"sk-1234\"\n\n# my current model\nmodel = \"deepseek-v4-flash\"\n", + ) + .expect("write config"); + + let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store"); + store.config.model = Some("deepseek-v4-pro".to_string()); + store.save().expect("save"); + + let body = fs::read_to_string(&config_path).expect("read config"); + assert!(body.contains("# my deepseek key"), "api_key comment lost"); + assert!(body.contains("# my current model"), "model comment lost"); + assert!( + body.contains("model = \"deepseek-v4-pro\""), + "new model not written" + ); + assert!(body.contains("api_key = \"sk-1234\""), "api_key lost"); +} + +#[test] +fn merge_and_preserve_comments_returns_err_on_invalid_serialized() { + let err = merge_and_preserve_comments("{{{ not toml", "model = 1\n") + .expect_err("invalid serialized should fail"); + assert!( + format!("{err:#}").contains("failed to parse serialized"), + "unexpected error: {err:#}" + ); +} + +#[test] +fn merge_and_preserve_comments_returns_err_on_invalid_original() { + let err = merge_and_preserve_comments("model = 1\n", "{{{ not toml") + .expect_err("invalid original should fail"); + assert!( + format!("{err:#}").contains("failed to parse original"), + "unexpected error: {err:#}" + ); +} + +#[test] +fn config_store_save_falls_back_when_comment_merge_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join(CONFIG_FILE_NAME); + // Valid TOML so load succeeds, but the raw is corrupt so the merge + // will fail inside save() — save must still succeed and write the + // plain serialized config. + fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config"); + + // Bypass ConfigStore::load to inject a deliberately broken original_raw. + let store = ConfigStore { + path: config_path.clone(), + config: ConfigToml { + model: Some("deepseek-v4-pro".to_string()), + ..ConfigToml::default() + }, + permissions: PermissionsToml::default(), + original_raw: Some("{ broken".to_string()), + }; + store + .save() + .expect("save should succeed even when merge fails"); + + let body = fs::read_to_string(&config_path).expect("read config"); + assert!( + body.contains("deepseek-v4-pro"), + "config should be written: {body}" + ); +} + +#[test] +fn provider_kind_parses_openrouter_and_novita_aliases() { + assert_eq!( + ProviderKind::parse("openrouter"), + Some(ProviderKind::Openrouter) + ); + assert_eq!( + ProviderKind::parse("OPEN_ROUTER"), + Some(ProviderKind::Openrouter) + ); + assert_eq!( + ProviderKind::parse("xiaomi-mimo"), + Some(ProviderKind::XiaomiMimo) + ); + assert_eq!( + ProviderKind::parse("xiaomi"), + Some(ProviderKind::XiaomiMimo) + ); + assert_eq!(ProviderKind::parse("novita"), Some(ProviderKind::Novita)); + assert_eq!(ProviderKind::parse("Novita"), Some(ProviderKind::Novita)); + assert_eq!( + ProviderKind::parse("fireworks-ai"), + Some(ProviderKind::Fireworks) + ); + assert_eq!( + ProviderKind::parse("silicon-flow"), + Some(ProviderKind::Siliconflow) + ); + assert_eq!( + ProviderKind::parse("silicon_flow"), + Some(ProviderKind::Siliconflow) + ); + assert_eq!(ProviderKind::parse("kimi"), Some(ProviderKind::Moonshot)); + assert_eq!( + ProviderKind::parse("moonshot-ai"), + Some(ProviderKind::Moonshot) + ); + assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang)); + assert_eq!(ProviderKind::parse("v-llm"), Some(ProviderKind::Vllm)); + assert_eq!(ProviderKind::parse("vllm"), Some(ProviderKind::Vllm)); + assert_eq!(ProviderKind::parse("ollama"), Some(ProviderKind::Ollama)); + assert_eq!( + ProviderKind::parse("ollama-local"), + Some(ProviderKind::Ollama) + ); + assert_eq!( + ProviderKind::parse("wanjie-ark"), + Some(ProviderKind::WanjieArk) + ); + assert_eq!( + ProviderKind::parse("ark_wanjie"), + Some(ProviderKind::WanjieArk) + ); + for alias in ["huggingface", "hugging-face", "hugging_face", "hf"] { + assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Huggingface)); + + let parsed: ConfigToml = + toml::from_str(&format!("provider = \"{alias}\"")).expect("huggingface alias"); + assert_eq!(parsed.provider, ProviderKind::Huggingface); + } + + for alias in ["deepinfra", "deep-infra", "deep_infra"] { + assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepinfra)); + + let parsed: ConfigToml = + toml::from_str(&format!("provider = \"{alias}\"")).expect("deepinfra alias"); + assert_eq!(parsed.provider, ProviderKind::Deepinfra); + } + + let parsed: ConfigToml = + toml::from_str("provider = \"ark-wanjie\"").expect("wanjie provider alias"); + assert_eq!(parsed.provider, ProviderKind::WanjieArk); + + let parsed: ConfigToml = + toml::from_str("provider = \"silicon-flow\"").expect("siliconflow provider alias"); + assert_eq!(parsed.provider, ProviderKind::Siliconflow); +} + +#[test] +fn unknown_provider_error_lists_huggingface() { + let mut config = ConfigToml::default(); + let err = config + .set_value("provider", "not-a-provider") + .expect_err("unknown provider should fail"); + let message = err.to_string(); + assert!(message.contains("unknown provider 'not-a-provider'")); + assert!(message.contains("huggingface")); +} + +#[test] +fn provider_kind_accepts_legacy_deepseek_cn_aliases() { + for alias in [ + "deepseek-cn", + "deepseek_china", + "deepseekcn", + "deepseek-china", + ] { + assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepseek)); + + let parsed: ConfigToml = + toml::from_str(&format!("provider = \"{alias}\"")).expect("legacy provider alias"); + assert_eq!(parsed.provider, ProviderKind::Deepseek); + } +} + +#[test] +fn provider_metadata_registry_covers_every_provider_kind_once() { + let providers = provider::all_providers(); + assert_eq!(providers.len(), ProviderKind::ALL.len()); + + for (kind, provider) in ProviderKind::ALL.iter().zip(providers.iter()) { + assert_eq!(provider.kind(), *kind); + assert_eq!(provider.id(), kind.as_str()); + assert_eq!(kind.provider().id(), kind.as_str()); + } + + let mut ids = std::collections::BTreeSet::new(); + for provider in providers { + assert!(ids.insert(provider.id()), "duplicate provider id"); + } +} + +#[test] +fn provider_metadata_lookup_does_not_fall_back_to_deepseek() { + assert!(provider::lookup_provider("not-a-provider").is_none()); + assert!(provider::resolve_provider("not-a-provider").is_none()); + assert!(provider::lookup_provider("deepseek-cn").is_none()); + assert_eq!( + provider::resolve_provider("deepseek-cn") + .expect("legacy alias resolves") + .kind(), + ProviderKind::Deepseek + ); +} + +#[test] +fn provider_metadata_preserves_alias_and_config_key_semantics() { + assert_eq!( + provider::resolve_provider("open_router") + .expect("openrouter alias") + .kind(), + ProviderKind::Openrouter + ); + assert_eq!( + provider::resolve_provider("xiaomi") + .expect("xiaomi alias") + .kind(), + ProviderKind::XiaomiMimo + ); + assert_eq!( + provider::resolve_provider("kimi") + .expect("kimi alias") + .kind(), + ProviderKind::Moonshot + ); + assert_eq!( + provider::resolve_provider("hf") + .expect("huggingface alias") + .kind(), + ProviderKind::Huggingface + ); + + let siliconflow_cn = + provider::resolve_provider("siliconflow-cn").expect("siliconflow-cn alias resolves"); + assert_eq!(siliconflow_cn.kind(), ProviderKind::SiliconflowCN); + assert_eq!(siliconflow_cn.id(), "siliconflow-CN"); + assert_eq!(siliconflow_cn.provider_config_key(), "siliconflow_cn"); + + let config = ProvidersToml::default(); + let shared_table = config.for_provider(ProviderKind::SiliconflowCN); + assert!(!std::ptr::eq( + shared_table, + config.for_provider(ProviderKind::Siliconflow) + )); +} + +#[test] +fn provider_metadata_defaults_match_runtime_helpers() { + for kind in ProviderKind::ALL { + let provider = kind.provider(); + assert_eq!(provider.default_model(), default_model_for_provider(kind)); + assert_eq!( + provider.default_base_url(), + default_base_url_for_provider(kind) + ); + assert!(!provider.display_name().trim().is_empty()); + assert!(!provider.env_vars().is_empty()); + // OpenAI Codex (ChatGPT) speaks the Responses API and Anthropic + // speaks the native Messages API; every other built-in provider + // is OpenAI-compatible Chat Completions. + let expected_wire = match kind { + ProviderKind::OpenaiCodex => provider::WireFormat::Responses, + ProviderKind::Anthropic => provider::WireFormat::AnthropicMessages, + _ => provider::WireFormat::ChatCompletions, + }; + assert_eq!(provider.wire(), expected_wire); + } +} + +#[test] +fn openrouter_provider_defaults_to_canonical_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Openrouter, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Openrouter); + assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL); + assert_eq!(resolved.model, DEFAULT_OPENROUTER_MODEL); +} + +#[test] +fn xiaomi_mimo_provider_defaults_to_canonical_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::XiaomiMimo, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL); + assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL); +} + +#[test] +fn xiaomi_provider_alias_table_maps_to_mimo_runtime_config() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config: ConfigToml = toml::from_str( + r#" +provider = "xiaomi-mimo" +default_text_model = "deepseek/deepseek-v4-pro" + +[providers.xiaomi] +api_key = "mimo-table-key" +base_url = "https://token-plan-sgp.xiaomimimo.com/v1" +model = "mimo-v2.5-pro" +"#, + ) + .expect("xiaomi provider alias config"); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.api_key.as_deref(), Some("mimo-table-key")); + assert_eq!( + resolved.base_url, + "https://token-plan-sgp.xiaomimimo.com/v1" + ); + assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL); +} + +#[test] +fn xiaomi_token_plan_key_rewrites_saved_pay_as_you_go_base_url() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config: ConfigToml = toml::from_str( + r#" +provider = "xiaomi-mimo" + +[providers.xiaomi_mimo] +api_key = "tp-test-token-plan-key" +base_url = "https://api.xiaomimimo.com/v1" +model = "mimo-v2.5-pro" +"#, + ) + .expect("xiaomi token-plan config"); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL); + assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL); +} + +#[test] +fn xiaomi_mimo_token_plan_mode_accepts_region_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config: ConfigToml = toml::from_str( + r#" +provider = "mimo" + +[providers.mimo] +mode = "token-plan-ams" +"#, + ) + .expect("xiaomi token-plan region config"); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL); +} + +#[test] +fn xiaomi_mimo_unknown_mode_stays_on_token_plan_endpoint() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config: ConfigToml = toml::from_str( + r#" +provider = "mimo" + +[providers.mimo] +mode = "token-plan-usa" +"#, + ) + .expect("xiaomi token-plan unknown mode config"); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL); +} + +#[test] +fn xiaomi_mimo_aliases_resolve_to_canonical_models() { + assert_eq!( + normalize_model_for_provider(ProviderKind::XiaomiMimo, "omni"), + "mimo-v2.5" + ); + assert_eq!( + normalize_model_for_provider(ProviderKind::XiaomiMimo, "tts"), + "mimo-v2.5-tts" + ); + assert_eq!( + normalize_model_for_provider(ProviderKind::XiaomiMimo, "voice-design"), + "mimo-v2.5-tts-voicedesign" + ); + assert_eq!( + normalize_model_for_provider(ProviderKind::XiaomiMimo, "voiceclone"), + "mimo-v2.5-tts-voiceclone" + ); + assert_eq!( + normalize_model_for_provider(ProviderKind::XiaomiMimo, "custom-mimo-model"), + "custom-mimo-model" + ); +} + +#[test] +fn zai_aliases_resolve_to_canonical_models() { + // GLM-5.2 is the default; the glm-5.1 alias must still resolve to 5.1 + // (not to the default), and GLM-5-Turbo resolves to its own id. + assert_eq!( + normalize_model_for_provider(ProviderKind::Zai, "glm-5.1"), + ZAI_GLM_5_1_MODEL + ); + assert_eq!( + normalize_model_for_provider(ProviderKind::Zai, "glm-5-2"), + DEFAULT_ZAI_MODEL + ); + assert_eq!(DEFAULT_ZAI_MODEL, ZAI_GLM_5_2_MODEL); + assert_eq!( + normalize_model_for_provider(ProviderKind::Zai, "glm-5-turbo"), + ZAI_GLM_5_TURBO_MODEL + ); + assert_eq!( + normalize_model_for_provider(ProviderKind::Zai, "custom-glm-preview"), + "custom-glm-preview" + ); +} + +#[test] +fn novita_provider_defaults_to_canonical_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Novita, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Novita); + assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL); + assert_eq!(resolved.model, DEFAULT_NOVITA_MODEL); +} + +#[test] +fn fireworks_provider_defaults_to_canonical_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Fireworks, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Fireworks); + assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL); + assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL); +} + +#[test] +fn siliconflow_provider_defaults_to_canonical_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Siliconflow, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_BASE_URL); + assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL); +} + +#[test] +fn siliconflow_cn_config_falls_back_to_shared_table_when_unset() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::SiliconflowCN, + ..ConfigToml::default() + }; + config.providers.siliconflow.api_key = Some("sf-shared-key".to_string()); + config.providers.siliconflow.base_url = Some(DEFAULT_SILICONFLOW_BASE_URL.to_string()); + config.providers.siliconflow.model = Some("deepseek-chat".to_string()); + config.providers.siliconflow_cn.base_url = Some(DEFAULT_SILICONFLOW_CN_BASE_URL.to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::SiliconflowCN); + assert_eq!(resolved.api_key.as_deref(), Some("sf-shared-key")); + assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_CN_BASE_URL); + assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL); +} + +#[test] +fn moonshot_provider_defaults_to_kimi_k27_code() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.base_url, DEFAULT_MOONSHOT_BASE_URL); + assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL); +} + +#[test] +fn zai_stepfun_and_minimax_default_to_first_party_routes() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + + for (provider, expected_base_url, expected_model) in [ + (ProviderKind::Zai, DEFAULT_ZAI_BASE_URL, DEFAULT_ZAI_MODEL), + ( + ProviderKind::Stepfun, + DEFAULT_STEPFUN_BASE_URL, + DEFAULT_STEPFUN_MODEL, + ), + ( + ProviderKind::Minimax, + DEFAULT_MINIMAX_BASE_URL, + DEFAULT_MINIMAX_MODEL, + ), + ] { + let config = ConfigToml { + provider, + ..ConfigToml::default() + }; + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, provider); + assert_eq!(resolved.base_url, expected_base_url); + assert_eq!(resolved.model, expected_model); + } +} + +#[test] +fn first_party_provider_env_model_overrides_pass_through() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + unsafe { + env::set_var("CODEWHALE_PROVIDER", "minimax"); + env::set_var("MINIMAX_MODEL", "MiniMax-M2.7-highspeed"); + env::set_var("MINIMAX_BASE_URL", "https://minimax.example/v1"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Minimax); + assert_eq!(resolved.base_url, "https://minimax.example/v1"); + assert_eq!(resolved.model, "MiniMax-M2.7-highspeed"); +} + +#[test] +fn minimax_env_model_override_canonicalizes_known_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + unsafe { + env::set_var("CODEWHALE_PROVIDER", "minimax"); + env::set_var("MINIMAX_MODEL", "minimax-m2-5-highspeed"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Minimax); + assert_eq!(resolved.model, "MiniMax-M2.5-highspeed"); +} + +#[test] +fn moonshot_provider_preserves_explicit_kimi_k26() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + config.providers.moonshot.model = Some("kimi-k2.6".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.model, MOONSHOT_KIMI_K2_6_MODEL); +} + +#[test] +fn moonshot_kimi_oauth_uses_kimi_code_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + config.providers.moonshot.auth_mode = Some("kimi_oauth".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.auth_mode.as_deref(), Some("kimi_oauth")); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.api_key, None); + assert_eq!(resolved.api_key_source, None); +} + +#[test] +fn moonshot_kimi_code_api_key_endpoint_defaults_to_kimi_for_coding() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + config.providers.moonshot.api_key = Some("kimi-code-key".to_string()); + config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.auth_mode, None); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key")); + assert_eq!( + resolved.api_key_source, + Some(RuntimeApiKeySource::ConfigFile) + ); +} + +/// `CODEWHALE_PROVIDER` is the user-facing env alias for switching the +/// active provider. It must be honored by the runtime resolver and win +/// over a root `provider = "deepseek"` config entry. +#[test] +fn codewhale_provider_env_switches_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + } + let mut config = ConfigToml { + provider: ProviderKind::Deepseek, + ..ConfigToml::default() + }; + config.providers.moonshot.api_key = Some("kimi-code-key".to_string()); + config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!( + resolved.provider_source, + ProviderSource::Env("CODEWHALE_PROVIDER") + ); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key")); +} + +/// When both `CODEWHALE_PROVIDER` and the legacy `DEEPSEEK_PROVIDER` +/// are set, the public alias wins — a user adopting `CODEWHALE_*` in a +/// fresh shell config is not tripped up by a stale legacy export still +/// living in their dotfiles. +#[test] +fn codewhale_provider_env_wins_over_deepseek_provider_env() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("DEEPSEEK_PROVIDER", "openrouter"); + } + let config = ConfigToml { + provider: ProviderKind::Deepseek, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!( + resolved.provider_source, + ProviderSource::Env("CODEWHALE_PROVIDER") + ); +} + +#[test] +fn legacy_deepseek_provider_env_records_provider_source() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "openrouter"); + } + let config = ConfigToml { + provider: ProviderKind::Deepseek, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Openrouter); + assert_eq!( + resolved.provider_source, + ProviderSource::Env("DEEPSEEK_PROVIDER") + ); +} + +#[test] +fn cli_provider_records_provider_source() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + } + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Openai), + ..CliRuntimeOverrides::default() + }; + let config = ConfigToml { + provider: ProviderKind::Deepseek, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Openai); + assert_eq!(resolved.provider_source, ProviderSource::Cli); +} + +#[test] +fn config_provider_records_provider_source() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.provider_source, ProviderSource::Config); +} + +/// `CODEWHALE_MODEL` is the user-facing env alias for picking a model +/// against the active provider. It must be honored by the runtime +/// resolver in place of `DEEPSEEK_MODEL`. +#[test] +fn codewhale_model_env_alias_overrides_default_for_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("CODEWHALE_MODEL", "custom-kimi-test-model"); + } + let config = ConfigToml::default(); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.model, "custom-kimi-test-model"); +} + +#[test] +fn blank_codewhale_model_env_alias_does_not_override_default_for_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("CODEWHALE_MODEL", " "); + } + let config = ConfigToml::default(); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL); +} + +#[test] +fn deepseek_default_text_model_legacy_alias_still_overrides_active_provider_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("DEEPSEEK_DEFAULT_TEXT_MODEL", "legacy-env-model"); + } + let config = ConfigToml::default(); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.model, "legacy-env-model"); +} + +#[test] +fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::WanjieArk, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::WanjieArk); + assert_eq!(resolved.base_url, DEFAULT_WANJIE_ARK_BASE_URL); + assert_eq!(resolved.model, DEFAULT_WANJIE_ARK_MODEL); +} + +#[test] +fn sglang_provider_defaults_to_local_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Sglang, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Sglang); + assert_eq!(resolved.base_url, DEFAULT_SGLANG_BASE_URL); + assert_eq!(resolved.model, DEFAULT_SGLANG_MODEL); +} + +#[test] +fn vllm_provider_defaults_to_local_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Vllm, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Vllm); + assert_eq!(resolved.base_url, DEFAULT_VLLM_BASE_URL); + assert_eq!(resolved.model, DEFAULT_VLLM_MODEL); +} + +#[test] +fn ollama_provider_defaults_to_local_endpoint_and_small_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Ollama, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Ollama); + assert_eq!(resolved.base_url, DEFAULT_OLLAMA_BASE_URL); + assert_eq!(resolved.model, DEFAULT_OLLAMA_MODEL); + assert_eq!(resolved.api_key, None); +} + +#[test] +fn self_hosted_providers_do_not_probe_secret_store_by_default() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key")); + let secrets = Secrets::new(store.clone()); + + for provider in [ + ProviderKind::Sglang, + ProviderKind::Vllm, + ProviderKind::Ollama, + ] { + let config = ConfigToml { + provider, + ..ConfigToml::default() + }; + + let resolved = + config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + + assert_eq!(resolved.provider, provider); + assert_eq!(resolved.api_key, None); + } + + assert!( + store.gets.lock().unwrap().is_empty(), + "self-hosted providers should not read the secret store by default" + ); +} + +#[test] +fn self_hosted_api_key_auth_can_use_secret_store_when_requested() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key")); + let secrets = Secrets::new(store.clone()); + let config = ConfigToml { + provider: ProviderKind::Ollama, + auth_mode: Some("api_key".to_string()), + ..ConfigToml::default() + }; + + let resolved = + config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + + assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key")); + assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama"]); +} + +#[test] +fn moonshot_api_key_mode_can_use_secret_store_by_default() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key")); + let secrets = Secrets::new(store.clone()); + let config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + + let resolved = + config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + + assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring)); + assert_eq!(store.gets.lock().unwrap().as_slice(), ["moonshot"]); +} + +#[test] +fn loopback_custom_deepseek_base_url_does_not_probe_secret_store_by_default() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let store = Arc::new(RecordingSecretsStore::with_value("stale-deepseek-key")); + let secrets = Secrets::new(store.clone()); + let config = ConfigToml { + base_url: Some("http://127.0.0.1:8000/v1".to_string()), + ..ConfigToml::default() + }; + + let resolved = + config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + + assert_eq!(resolved.provider, ProviderKind::Deepseek); + assert_eq!(resolved.base_url, "http://127.0.0.1:8000/v1"); + assert_eq!(resolved.api_key, None); + assert!( + store.gets.lock().unwrap().is_empty(), + "loopback custom endpoints should not read macOS Keychain or any secret store" + ); +} + +#[test] +fn ollama_provider_preserves_model_tags() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Ollama), + model: Some("deepseek-coder-v2:16b".to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Ollama); + assert_eq!(resolved.model, "deepseek-coder-v2:16b"); +} + +#[test] +fn ollama_env_overrides_provider_base_url_and_optional_key() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "ollama-local"); + env::set_var("OLLAMA_BASE_URL", "http://ollama.example/v1"); + env::set_var("OLLAMA_API_KEY", "ollama-env-key"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Ollama); + assert_eq!(resolved.base_url, "http://ollama.example/v1"); + assert_eq!(resolved.api_key.as_deref(), Some("ollama-env-key")); +} + +#[test] +fn openrouter_env_overrides_key_and_model_when_config_missing() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "openrouter"); + env::set_var("OPENROUTER_API_KEY", "or-env-key"); + env::set_var("OPENROUTER_MODEL", "deepseek-v4-flash"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Openrouter); + assert_eq!(resolved.api_key.as_deref(), Some("or-env-key")); + assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL); + assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL); +} + +#[test] +fn xiaomi_mimo_env_overrides_provider_key_base_url_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo"); + env::set_var("MIMO_API_KEY", "mimo-env-key"); + env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1"); + env::set_var("MIMO_MODEL", "mimo-v2.5"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.api_key.as_deref(), Some("mimo-env-key")); + assert_eq!(resolved.base_url, "https://mimo-gateway.example/v1"); + assert_eq!(resolved.model, "mimo-v2.5"); +} + +#[test] +fn xiaomi_mimo_env_token_plan_mode_uses_token_plan_key_and_endpoint() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo"); + env::set_var("XIAOMI_MIMO_MODE", "token-plan-cn"); + env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key"); + env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.api_key.as_deref(), Some("tp-env-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); + assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL); +} + +#[test] +fn xiaomi_mimo_env_pay_as_you_go_mode_prefers_standard_key() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo"); + env::set_var("XIAOMI_MIMO_MODE", "pay-as-you-go"); + env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key"); + env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.api_key.as_deref(), Some("sk-env-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); + assert_eq!(resolved.base_url, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL); +} + +#[test] +fn novita_env_overrides_key_and_model_when_config_missing() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "novita"); + env::set_var("NOVITA_API_KEY", "novita-env-key"); + env::set_var("NOVITA_MODEL", "deepseek-v4-flash"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Novita); + assert_eq!(resolved.api_key.as_deref(), Some("novita-env-key")); + assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL); + assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL); +} + +#[test] +fn fireworks_env_overrides_key_and_model_when_config_missing() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "fireworks"); + env::set_var("FIREWORKS_API_KEY", "fw-env-key"); + env::set_var( + "FIREWORKS_MODEL", + "accounts/fireworks/models/account-specific-model", + ); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Fireworks); + assert_eq!(resolved.api_key.as_deref(), Some("fw-env-key")); + assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL); + assert_eq!( + resolved.model, + "accounts/fireworks/models/account-specific-model" + ); +} + +#[test] +fn siliconflow_env_overrides_key_base_url_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("CODEWHALE_PROVIDER", "siliconflow"); + env::set_var("SILICONFLOW_API_KEY", "sf-env-key"); + env::set_var("SILICONFLOW_BASE_URL", "https://sf-mirror.example/v1"); + env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.api_key.as_deref(), Some("sf-env-key")); + assert_eq!(resolved.base_url, "https://sf-mirror.example/v1"); + assert_eq!(resolved.model, "deepseek-v4-flash"); +} + +#[test] +fn arcee_provider_defaults_to_direct_api_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Arcee, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Arcee); + assert_eq!(resolved.base_url, DEFAULT_ARCEE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_ARCEE_MODEL); +} + +#[test] +fn arcee_env_overrides_key_base_url_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("CODEWHALE_PROVIDER", "arcee"); + env::set_var("ARCEE_API_KEY", "arcee-env-key"); + env::set_var("ARCEE_BASE_URL", "https://arcee-mirror.example/api/v1"); + env::set_var("ARCEE_MODEL", "trinity-large-preview"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Arcee); + assert_eq!(resolved.api_key.as_deref(), Some("arcee-env-key")); + assert_eq!(resolved.base_url, "https://arcee-mirror.example/api/v1"); + assert_eq!(resolved.model, "trinity-large-preview"); +} + +#[test] +fn arcee_provider_config_overrides_runtime_defaults() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Arcee, + ..ConfigToml::default() + }; + config.providers.arcee.api_key = Some("arcee-file-key".to_string()); + config.providers.arcee.base_url = Some(DEFAULT_ARCEE_BASE_URL.to_string()); + config.providers.arcee.model = Some("arcee-trinity-large-preview".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Arcee); + assert_eq!(resolved.api_key.as_deref(), Some("arcee-file-key")); + assert_eq!(resolved.base_url, DEFAULT_ARCEE_BASE_URL); + assert_eq!(resolved.model, ARCEE_TRINITY_LARGE_PREVIEW_MODEL); +} + +#[test] +fn huggingface_env_precedence_prefers_documented_names() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("CODEWHALE_PROVIDER", "hf"); + env::set_var("HUGGINGFACE_API_KEY", "hf-full-key"); + env::set_var("HF_TOKEN", "hf-token-fallback"); + env::set_var("HUGGINGFACE_BASE_URL", "https://hf-full.example/v1"); + env::set_var("HF_BASE_URL", "https://hf-short.example/v1"); + env::set_var("HUGGINGFACE_MODEL", "org/full-model"); + env::set_var("HF_MODEL", "org/short-model"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Huggingface); + assert_eq!(resolved.api_key.as_deref(), Some("hf-full-key")); + assert_eq!(resolved.base_url, "https://hf-full.example/v1"); + assert_eq!(resolved.model, "org/full-model"); +} + +#[test] +fn huggingface_short_env_fallbacks_resolve_when_primary_names_are_absent() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("CODEWHALE_PROVIDER", "huggingface"); + env::set_var("HF_TOKEN", "hf-token-fallback"); + env::set_var("HF_BASE_URL", "https://hf-short.example/v1"); + env::set_var("HF_MODEL", "org/short-model"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Huggingface); + assert_eq!(resolved.api_key.as_deref(), Some("hf-token-fallback")); + assert_eq!(resolved.base_url, "https://hf-short.example/v1"); + assert_eq!(resolved.model, "org/short-model"); +} + +#[test] +fn huggingface_token_fallback_resolves_when_primary_api_key_is_blank() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("CODEWHALE_PROVIDER", "huggingface"); + env::set_var("HUGGINGFACE_API_KEY", " "); + env::set_var("HF_TOKEN", "hf-token-fallback"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Huggingface); + assert_eq!(resolved.api_key.as_deref(), Some("hf-token-fallback")); +} + +#[test] +fn siliconflow_cn_base_url_env_normalizes_model_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("CODEWHALE_PROVIDER", "siliconflow"); + env::set_var("SILICONFLOW_API_KEY", "sf-env-key"); + env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1"); + } + + for (alias, expected) in [ + ("deepseek-v4-flash", DEFAULT_SILICONFLOW_FLASH_MODEL), + ("deepseek-reasoner", DEFAULT_SILICONFLOW_MODEL), + ] { + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("SILICONFLOW_MODEL", alias); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.base_url, "https://api.siliconflow.cn/v1"); + assert_eq!(resolved.model, expected); + } +} + +#[test] +fn wanjie_ark_env_api_key_and_base_url_fall_back_when_config_missing() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "wanjie-ark"); + env::set_var("WANJIE_ARK_API_KEY", "wanjie-env-key"); + env::set_var("WANJIE_ARK_BASE_URL", "https://wanjie.example/api/v1"); + env::set_var("WANJIE_ARK_MODEL", "account-model-id"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::WanjieArk); + assert_eq!(resolved.api_key.as_deref(), Some("wanjie-env-key")); + assert_eq!(resolved.base_url, "https://wanjie.example/api/v1"); + assert_eq!(resolved.model, "account-model-id"); +} + +#[test] +fn volcengine_env_aliases_override_key_base_url_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "volcengine"); + env::set_var("ARK_API_KEY", "volcengine-env-key"); + env::set_var("ARK_BASE_URL", "https://volcengine.example/api/coding/v3"); + env::set_var("VOLCENGINE_ARK_MODEL", "DeepSeek-V4-Flash"); + } + + let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Volcengine); + assert_eq!(resolved.api_key.as_deref(), Some("volcengine-env-key")); + assert_eq!( + resolved.base_url, + "https://volcengine.example/api/coding/v3" + ); + assert_eq!(resolved.model, "DeepSeek-V4-Flash"); +} + +#[test] +fn openrouter_provider_normalizes_flash_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Openrouter), + model: Some("deepseek-v4-flash".to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Openrouter); + assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL); +} + +#[test] +fn qwen3_6_plus_resolves_to_canonical_on_openrouter() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Openrouter, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides { + model: Some("qwen3.6-plus".to_string()), + ..CliRuntimeOverrides::default() + }); + + assert_eq!(resolved.provider, ProviderKind::Openrouter); + assert_eq!(resolved.model, OPENROUTER_QWEN_3_6_PLUS_MODEL); +} + +#[test] +fn qwen3_6_plus_alias_qwen_dash_resolves() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Openrouter, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides { + model: Some("qwen-3.6-plus".to_string()), + ..CliRuntimeOverrides::default() + }); + + assert_eq!(resolved.model, OPENROUTER_QWEN_3_6_PLUS_MODEL); +} + +#[test] +fn openrouter_provider_normalizes_recent_large_model_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + + for (alias, expected) in [ + ( + "trinity-large-thinking", + OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL, + ), + ("qwen3.6-flash", OPENROUTER_QWEN_3_6_FLASH_MODEL), + ("qwen3.6-35b-a3b", OPENROUTER_QWEN_3_6_35B_A3B_MODEL), + ("qwen3.6-max-preview", OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL), + ("qwen3.6-plus", OPENROUTER_QWEN_3_6_PLUS_MODEL), + ("mimo-v2.5-pro", OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL), + ("kimi-k2.7-code", OPENROUTER_KIMI_K2_7_CODE_MODEL), + ("kimi", OPENROUTER_KIMI_K2_7_CODE_MODEL), + ("kimi-k2.6", OPENROUTER_KIMI_K2_6_MODEL), + ("minimax-m3", OPENROUTER_MINIMAX_M3_MODEL), + ("minimax-2.7", OPENROUTER_MINIMAX_2_7_MODEL), + ("gemma-4-31b-it", OPENROUTER_GEMMA_4_31B_MODEL), + ("glm-5.1", OPENROUTER_GLM_5_1_MODEL), + ("glm-5.2", OPENROUTER_GLM_5_2_MODEL), + ] { + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Openrouter), + model: Some(alias.to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Openrouter); + assert_eq!(resolved.model, expected); + } +} + +#[test] +fn novita_provider_normalizes_flash_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Novita), + model: Some("deepseek-v4-flash".to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Novita); + assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL); +} + +#[test] +fn siliconflow_provider_normalizes_flash_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Siliconflow), + model: Some("deepseek-v4-flash".to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL); +} + +#[test] +fn siliconflow_provider_normalizes_reasoning_aliases_to_pro() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + + for alias in ["deepseek-reasoner", "deepseek-r1"] { + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Siliconflow), + model: Some(alias.to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL); + } +} + +#[test] +fn siliconflow_provider_preserves_deepseek_v3_2_alias() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Siliconflow), + model: Some("deepseek-v3.2".to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.model, "deepseek-v3.2"); +} + +#[test] +fn sglang_provider_normalizes_flash_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Sglang), + model: Some("deepseek-v4-flash".to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Sglang); + assert_eq!(resolved.model, DEFAULT_SGLANG_FLASH_MODEL); +} + +#[test] +fn vllm_provider_normalizes_flash_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Vllm), + model: Some("deepseek-v4-flash".to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Vllm); + assert_eq!(resolved.model, DEFAULT_VLLM_FLASH_MODEL); +} + +#[test] +fn openrouter_provider_specific_config_overrides_env() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Openrouter, + ..ConfigToml::default() + }; + config.providers.openrouter.api_key = Some("file-key".to_string()); + config.providers.openrouter.base_url = Some("https://or-mirror.example/v1".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.api_key.as_deref(), Some("file-key")); + assert_eq!(resolved.base_url, "https://or-mirror.example/v1"); +} + +#[test] +fn openrouter_custom_base_url_preserves_provider_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Openrouter, + ..ConfigToml::default() + }; + config.providers.openrouter.base_url = Some("https://gateway.example.com/v1".to_string()); + config.providers.openrouter.model = Some("DeepSeek-V4-Pro".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Openrouter); + assert_eq!(resolved.base_url, "https://gateway.example.com/v1"); + assert_eq!(resolved.model, "DeepSeek-V4-Pro"); +} + +#[test] +fn fireworks_custom_base_url_preserves_provider_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Fireworks, + ..ConfigToml::default() + }; + config.providers.fireworks.base_url = Some("https://my-gateway.example/v1".to_string()); + config.providers.fireworks.model = Some("DeepSeek-V4-Pro".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Fireworks); + assert_eq!(resolved.base_url, "https://my-gateway.example/v1"); + // Custom base URL skips provider-specific model prefixing. + assert_eq!(resolved.model, "DeepSeek-V4-Pro"); +} + +#[test] +fn siliconflow_custom_base_url_preserves_provider_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Siliconflow, + ..ConfigToml::default() + }; + config.providers.siliconflow.base_url = Some("https://my-gateway.example/v1".to_string()); + config.providers.siliconflow.model = Some("DeepSeek-V4-Pro".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.base_url, "https://my-gateway.example/v1"); + assert_eq!(resolved.model, "DeepSeek-V4-Pro"); +} + +#[test] +fn config_file_resolves_above_env_and_keyring() { + use codewhale_secrets::KeyringStore; + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: env mutation guarded by env_lock(). + unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") }; + + let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new()); + store.set("deepseek", "ring-key").unwrap(); + let secrets = Secrets::new(store); + + let mut config = ConfigToml::default(); + config.providers.deepseek.api_key = Some("file-key".to_string()); + + let resolved = + config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + assert_eq!(resolved.api_key.as_deref(), Some("file-key")); + assert_eq!( + resolved.api_key_source, + Some(RuntimeApiKeySource::ConfigFile) + ); + + // Safety: env mutation guarded by env_lock(). + unsafe { std::env::remove_var("DEEPSEEK_API_KEY") }; +} + +#[test] +fn env_resolves_when_config_file_and_keyring_empty() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: env mutation guarded by env_lock(). + unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") }; + + let secrets = Secrets::new(std::sync::Arc::new( + codewhale_secrets::InMemoryKeyringStore::new(), + )); + let config = ConfigToml::default(); + + let resolved = + config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + assert_eq!(resolved.api_key.as_deref(), Some("env-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env)); + + // Safety: env mutation guarded by env_lock(). + unsafe { std::env::remove_var("DEEPSEEK_API_KEY") }; +} + +#[test] +fn config_file_resolves_when_keyring_and_env_empty() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + + let secrets = Secrets::new(std::sync::Arc::new( + codewhale_secrets::InMemoryKeyringStore::new(), + )); + let mut config = ConfigToml::default(); + config.providers.deepseek.api_key = Some("file-key".to_string()); + + let resolved = + config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + assert_eq!(resolved.api_key.as_deref(), Some("file-key")); + assert_eq!( + resolved.api_key_source, + Some(RuntimeApiKeySource::ConfigFile) + ); +} + +#[test] +fn keyring_resolves_when_config_file_empty_even_if_env_is_set() { + use codewhale_secrets::KeyringStore; + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: env mutation guarded by env_lock(). + unsafe { std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key") }; + + let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new()); + store.set("deepseek", "ring-key").unwrap(); + let secrets = Secrets::new(store); + + let resolved = ConfigToml::default() + .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + assert_eq!(resolved.api_key.as_deref(), Some("ring-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring)); + + // Safety: env mutation guarded by env_lock(). + unsafe { std::env::remove_var("DEEPSEEK_API_KEY") }; +} + +#[test] +fn cli_flag_still_overrides_keyring() { + use codewhale_secrets::KeyringStore; + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + + let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new()); + store.set("deepseek", "ring-key").unwrap(); + let secrets = Secrets::new(store); + + let cli = CliRuntimeOverrides { + api_key: Some("cli-key".to_string()), + ..CliRuntimeOverrides::default() + }; + let resolved = ConfigToml::default().resolve_runtime_options_with_secrets(&cli, &secrets); + assert_eq!(resolved.api_key.as_deref(), Some("cli-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli)); +} + +#[test] +fn provider_chain_initial_current_is_active() { + let chain = ProviderChain::new( + ProviderKind::NvidiaNim, + &[ProviderKind::Deepseek, ProviderKind::Openrouter], + ); + + assert_eq!(chain.current(), ProviderKind::NvidiaNim); + assert_eq!(chain.position(), 0); + assert_eq!( + chain.providers(), + &[ + ProviderKind::NvidiaNim, + ProviderKind::Deepseek, + ProviderKind::Openrouter, + ] + ); + assert!(!chain.is_fallback_active()); +} + +#[test] +fn provider_chain_advance_switches_to_fallback() { + let mut chain = ProviderChain::new( + ProviderKind::NvidiaNim, + &[ProviderKind::Deepseek, ProviderKind::Openrouter], + ); + + assert!(chain.has_next()); + assert_eq!(chain.advance(), Some(ProviderKind::Deepseek)); + assert_eq!(chain.current(), ProviderKind::Deepseek); + assert!(chain.is_fallback_active()); +} + +#[test] +fn provider_chain_exhausts_returns_none() { + let mut chain = ProviderChain::new(ProviderKind::Deepseek, &[ProviderKind::Openrouter]); + + assert_eq!(chain.advance(), Some(ProviderKind::Openrouter)); + assert!(!chain.has_next()); + assert_eq!(chain.advance(), None); +} + +#[test] +fn provider_chain_skips_duplicates() { + let chain = ProviderChain::new( + ProviderKind::Deepseek, + &[ + ProviderKind::Deepseek, + ProviderKind::NvidiaNim, + ProviderKind::Deepseek, + ], + ); + + assert_eq!( + chain.providers(), + &[ProviderKind::Deepseek, ProviderKind::NvidiaNim] + ); +} + +#[test] +fn provider_chain_remaining_counts_current_and_untried_entries() { + let mut chain = ProviderChain::new( + ProviderKind::Deepseek, + &[ProviderKind::NvidiaNim, ProviderKind::Openrouter], + ); + + assert_eq!(chain.remaining(), 3); + assert_eq!(chain.advance(), Some(ProviderKind::NvidiaNim)); + assert_eq!(chain.remaining(), 2); +} + +#[test] +fn config_toml_parses_fallback_providers() { + let config: ConfigToml = toml::from_str( + r#" +provider = "nvidia-nim" +fallback_providers = ["deepseek", "openrouter"] +"#, + ) + .expect("fallback providers config"); + + assert_eq!(config.provider, ProviderKind::NvidiaNim); + assert_eq!( + config.fallback_providers, + [ProviderKind::Deepseek, ProviderKind::Openrouter] + ); +} + +#[test] +fn empty_fallback_providers_do_not_serialize() { + let serialized = toml::to_string_pretty(&ConfigToml::default()).expect("config serializes"); + + assert!(!serialized.contains("fallback_providers")); +} + +#[test] +fn fleet_exec_config_default_matches_subagent_depth() { + // Fleet workers and standalone sub-agents share one recursion axis: + // the fleet default equals DEFAULT_SPAWN_DEPTH (3) and affords >=3 + // nested delegation levels out of the box. + assert_eq!( + FleetExecConfig::default().max_spawn_depth, + DEFAULT_SPAWN_DEPTH + ); + assert_eq!(FleetExecConfig::default().max_spawn_depth, 3); + const { assert!(DEFAULT_SPAWN_DEPTH <= MAX_SPAWN_DEPTH_CEILING) }; +} + +#[test] +fn fleet_exec_config_parses_max_spawn_depth() { + let config: ConfigToml = toml::from_str( + r#" +[fleet.exec] +max_spawn_depth = 2 +"#, + ) + .expect("fleet exec config should parse"); + + assert_eq!(config.fleet.expect("fleet config").exec.max_spawn_depth, 2); +} + +#[test] +fn fallback_providers_do_not_change_runtime_resolution() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::NvidiaNim, + fallback_providers: vec![ProviderKind::Deepseek], + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::NvidiaNim); +} + +#[test] +fn harness_posture_default_is_standard() { + let posture = HarnessPosture::default(); + + assert_eq!( + posture, + HarnessPosture { + kind: HarnessPostureKind::Standard, + max_subagents: 0, + prefer_codebase_search: false, + compaction_strategy: HarnessCompactionStrategy::Default, + tool_surface: HarnessToolSurface::Full, + safety_posture: HarnessSafetyPosture::Standard, + } + ); +} + +#[test] +fn harness_posture_factories_are_typed() { + assert_eq!( + HarnessPosture::cache_heavy(), + HarnessPosture { + kind: HarnessPostureKind::CacheHeavy, + max_subagents: 10, + prefer_codebase_search: false, + compaction_strategy: HarnessCompactionStrategy::PrefixCache, + tool_surface: HarnessToolSurface::Full, + safety_posture: HarnessSafetyPosture::Standard, + } + ); + assert_eq!( + HarnessPosture::lean(), + HarnessPosture { + kind: HarnessPostureKind::Lean, + max_subagents: 20, + prefer_codebase_search: true, + compaction_strategy: HarnessCompactionStrategy::Aggressive, + tool_surface: HarnessToolSurface::Full, + safety_posture: HarnessSafetyPosture::Standard, + } + ); +} + +#[test] +fn harness_profile_serde_round_trips_as_a_whole_struct() { + let profile = HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4.*".to_string(), + posture: HarnessPosture::cache_heavy(), + }; + + let json = serde_json::to_string(&profile).expect("serialize profile"); + let round_tripped: HarnessProfile = serde_json::from_str(&json).expect("deserialize profile"); + + assert_eq!(round_tripped, profile); +} + +#[test] +fn config_toml_accepts_harness_profiles() { + let config: ConfigToml = toml::from_str( + r#" +provider = "deepseek" +model = "deepseek-v4-pro" + +[[harness_profiles]] +provider_route = "deepseek" +model_pattern = "deepseek-v4.*" + +[harness_profiles.posture] +kind = "cache-heavy" +max_subagents = 10 +compaction_strategy = "prefix-cache" +tool_surface = "read-only" +safety_posture = "strict" +"#, + ) + .expect("parse harness profiles"); + + assert_eq!( + config.harness_profiles, + vec![HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4.*".to_string(), + posture: HarnessPosture { + kind: HarnessPostureKind::CacheHeavy, + max_subagents: 10, + prefer_codebase_search: false, + compaction_strategy: HarnessCompactionStrategy::PrefixCache, + tool_surface: HarnessToolSurface::ReadOnly, + safety_posture: HarnessSafetyPosture::Strict, + }, + }] + ); +} + +#[test] +fn harness_profile_matches_provider_alias_and_model_wildcard() { + let profile = HarnessProfile { + provider_route: "xiaomi-mimo".to_string(), + model_pattern: "mimo-v2.?-pro".to_string(), + posture: HarnessPosture::cache_heavy(), + }; + + assert!(profile.matches_route("mimo", "mimo-v2.5-pro")); + assert!(!profile.matches_route("mimo", "mimo-v2.50-pro")); + assert!(!profile.matches_route("deepseek", "mimo-v2.5-pro")); +} + +#[test] +fn resolve_harness_profile_returns_first_matching_profile() { + let config = ConfigToml { + harness_profiles: vec![ + HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4-flash".to_string(), + posture: HarnessPosture::lean(), + }, + HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4-*".to_string(), + posture: HarnessPosture::cache_heavy(), + }, + ], + ..ConfigToml::default() + }; + + let flash = config + .resolve_harness_profile("deepseek-cn", "deepseek-v4-flash") + .expect("exact profile should match first"); + assert_eq!(flash.posture.kind, HarnessPostureKind::Lean); + + let pro = config + .resolve_harness_profile("deepseek", "deepseek-v4-pro") + .expect("wildcard profile should match pro model"); + assert_eq!(pro.posture.kind, HarnessPostureKind::CacheHeavy); +} + +#[test] +fn resolve_harness_profile_uses_built_in_seed_when_config_has_no_match() { + let config = ConfigToml::default(); + + let xiaomi = config + .resolve_harness_profile("xiaomi", "mimo-v2.5-pro") + .expect("direct Xiaomi MiMo seed should resolve"); + assert_eq!(xiaomi.provider_route, "xiaomi-mimo"); + assert_eq!(xiaomi.posture.kind, HarnessPostureKind::CacheHeavy); + + let arcee = config + .resolve_harness_profile("arcee", "trinity-large-thinking") + .expect("direct Arcee seed should resolve"); + assert_eq!(arcee.posture.kind, HarnessPostureKind::CacheHeavy); + + let local = config + .resolve_harness_profile("vllm", "Qwen/Qwen3.6-Coder") + .expect("local seed should resolve"); + assert_eq!(local.posture.kind, HarnessPostureKind::Lean); + assert!(local.posture.prefer_codebase_search); +} + +#[test] +fn configured_harness_profile_overrides_built_in_seed() { + let config = ConfigToml { + harness_profiles: vec![HarnessProfile { + provider_route: "xiaomi-mimo".to_string(), + model_pattern: "mimo-v2.5-pro".to_string(), + posture: HarnessPosture { + kind: HarnessPostureKind::Custom, + max_subagents: 3, + prefer_codebase_search: true, + compaction_strategy: HarnessCompactionStrategy::Default, + tool_surface: HarnessToolSurface::Auto, + safety_posture: HarnessSafetyPosture::Strict, + }, + }], + ..ConfigToml::default() + }; + + let profile = config + .resolve_harness_profile("xiaomi-mimo", "mimo-v2.5-pro") + .expect("configured profile should match first"); + + assert_eq!(profile.posture.kind, HarnessPostureKind::Custom); + assert_eq!(profile.posture.max_subagents, 3); + assert_eq!(profile.posture.tool_surface, HarnessToolSurface::Auto); + assert_eq!(profile.posture.safety_posture, HarnessSafetyPosture::Strict); +} + +#[test] +fn resolve_harness_profile_returns_none_when_route_or_model_misses() { + let config = ConfigToml { + harness_profiles: vec![HarnessProfile { + provider_route: "huggingface".to_string(), + model_pattern: "deepseek-ai/*".to_string(), + posture: HarnessPosture::lean(), + }], + ..ConfigToml::default() + }; + + assert!( + config + .resolve_harness_profile("openrouter", "deepseek-ai/DeepSeek-V4-Pro") + .is_none() + ); + assert!( + config + .resolve_harness_profile("deepseek", "Qwen/Qwen3.6-Coder") + .is_none() + ); + assert!( + config + .resolve_harness_profile("openai", "mimo-v2.5-pro") + .is_none() + ); +} + +#[test] +fn resolving_harness_profile_does_not_change_runtime_options() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Deepseek, + model: Some("deepseek-v4-pro".to_string()), + harness_profiles: vec![HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4-*".to_string(), + posture: HarnessPosture::lean(), + }], + ..ConfigToml::default() + }; + + let profile = config + .resolve_harness_profile("deepseek", "deepseek-v4-pro") + .expect("profile should resolve for display/future runtime"); + assert_eq!(profile.posture.kind, HarnessPostureKind::Lean); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + assert_eq!(resolved.provider, ProviderKind::Deepseek); + assert_eq!(resolved.model, "deepseek-v4-pro"); +} + +#[test] +fn harness_posture_kind_rejects_unknown_values() { + let err = toml::from_str::( + r#" +[[harness_profiles]] +provider_route = "deepseek" +model_pattern = "deepseek-v4.*" + +[harness_profiles.posture] +kind = "cahce-heavy" +"#, + ) + .expect_err("misspelled kind should not deserialize as custom"); + + assert!(err.to_string().contains("cahce-heavy")); +} + +#[test] +fn harness_posture_rejects_unknown_policy_keys() { + let err = toml::from_str::( + r#" +[[harness_profiles]] +provider_route = "deepseek" +model_pattern = "deepseek-v4.*" + +[harness_profiles.posture] +kind = "custom" +unknown_policy = "surprise" +"#, + ) + .expect_err("unknown posture keys should not be ignored"); + + assert!(err.to_string().contains("unknown_policy")); +} + +#[test] +fn test_verbosity_resolution() { + let _lock = env_lock(); + // Test TOML parsing + let toml_str = r#" + verbosity = "concise" + "#; + let config: ConfigToml = toml::from_str(toml_str).unwrap(); + assert_eq!(config.verbosity, Some("concise".to_string())); + + // Test Env overrides + let _env = EnvGuard::without_deepseek_runtime_overrides(); + unsafe { + std::env::set_var("CODEWHALE_VERBOSITY", "normal"); + } + let env_overrides = EnvRuntimeOverrides::load(); + assert_eq!(env_overrides.verbosity, Some("normal".to_string())); + unsafe { + std::env::remove_var("CODEWHALE_VERBOSITY"); + } + + // Test fallback to DEEPSEEK_VERBOSITY + unsafe { + std::env::set_var("DEEPSEEK_VERBOSITY", "concise"); + } + let env_overrides = EnvRuntimeOverrides::load(); + assert_eq!(env_overrides.verbosity, Some("concise".to_string())); + unsafe { + std::env::remove_var("DEEPSEEK_VERBOSITY"); + } +}