Skip to main content

codewhale_config/
lib.rs

1pub mod provider;
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5use std::fs;
6use std::io::Write;
7use std::path::{Component, Path, PathBuf};
8use std::sync::OnceLock;
9
10use anyhow::{Context, Result, bail};
11pub use codewhale_execpolicy::ToolAskRule;
12use codewhale_execpolicy::{ExecPolicyEngine, Ruleset};
13use codewhale_secrets::SecretSource;
14pub use codewhale_secrets::Secrets;
15use serde::{Deserialize, Serialize};
16
17#[cfg(unix)]
18use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
19
20pub const CONFIG_FILE_NAME: &str = "config.toml";
21pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
22const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro";
23const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
24const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
25const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro";
26const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
27const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
28const DEFAULT_OPENAI_CODEX_MODEL: &str = "gpt-5.5";
29const DEFAULT_ANTHROPIC_MODEL: &str = "claude-sonnet-4-6";
30const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com";
31const DEFAULT_OPENAI_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api";
32const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
33const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
34const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1";
35const DEFAULT_WANJIE_ARK_MODEL: &str = "deepseek-reasoner";
36const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.com/api/v1";
37const DEFAULT_VOLCENGINE_MODEL: &str = "DeepSeek-V4-Pro";
38const DEFAULT_VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3";
39const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
40const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
41const OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL: &str = "arcee-ai/trinity-large-thinking";
42const OPENROUTER_GEMMA_4_31B_MODEL: &str = "google/gemma-4-31b-it";
43const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it";
44const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1";
45const OPENROUTER_GLM_5_2_MODEL: &str = "z-ai/glm-5.2";
46const OPENROUTER_KIMI_K2_7_CODE_MODEL: &str = "moonshotai/kimi-k2.7-code";
47const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6";
48const OPENROUTER_MINIMAX_M3_MODEL: &str = "minimax/minimax-m3";
49const OPENROUTER_MINIMAX_2_7_MODEL: &str = "minimax/minimax-2.7";
50const OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL: &str =
51    "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free";
52const OPENROUTER_QWEN_3_6_FLASH_MODEL: &str = "qwen/qwen3.6-flash";
53const OPENROUTER_QWEN_3_6_35B_A3B_MODEL: &str = "qwen/qwen3.6-35b-a3b";
54const OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL: &str = "qwen/qwen3.6-max-preview";
55const OPENROUTER_QWEN_3_6_27B_MODEL: &str = "qwen/qwen3.6-27b";
56const OPENROUTER_QWEN_3_6_PLUS_MODEL: &str = "qwen/qwen3.6-plus";
57const OPENROUTER_QWEN_3_7_MAX_MODEL: &str = "qwen/qwen3.7-max";
58const OPENROUTER_TENCENT_HY3_PREVIEW_MODEL: &str = "tencent/hy3-preview";
59const OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL: &str = "xiaomi/mimo-v2.5-pro";
60const OPENROUTER_XIAOMI_MIMO_V2_5_MODEL: &str = "xiaomi/mimo-v2.5";
61const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro";
62const XIAOMI_MIMO_V2_5_OMNI_MODEL: &str = "mimo-v2.5";
63const XIAOMI_MIMO_ASR_MODEL: &str = "mimo-v2.5-asr";
64const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts";
65const XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL: &str = "mimo-v2.5-tts-voicedesign";
66const XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL: &str = "mimo-v2.5-tts-voiceclone";
67const XIAOMI_MIMO_V2_TTS_MODEL: &str = "mimo-v2-tts";
68const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
69const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
70const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro";
71const DEFAULT_SILICONFLOW_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
72const DEFAULT_SILICONFLOW_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
73const DEFAULT_ARCEE_MODEL: &str = "trinity-large-thinking";
74const ARCEE_TRINITY_LARGE_PREVIEW_MODEL: &str = "trinity-large-preview";
75const ARCEE_TRINITY_MINI_MODEL: &str = "trinity-mini";
76const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.7-code";
77const MOONSHOT_KIMI_K2_6_MODEL: &str = "kimi-k2.6";
78const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
79const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding";
80const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1";
81const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
82const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
83const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
84const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1";
85const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1";
86const XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL: &str = "https://token-plan-cn.xiaomimimo.com/v1";
87const XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL: &str = DEFAULT_XIAOMI_MIMO_BASE_URL;
88const XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL: &str = "https://token-plan-ams.xiaomimimo.com/v1";
89const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/openai/v1";
90const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
91const DEFAULT_SILICONFLOW_BASE_URL: &str = "https://api.siliconflow.com/v1";
92const DEFAULT_SILICONFLOW_CN_BASE_URL: &str = "https://api.siliconflow.cn/v1";
93const DEFAULT_ARCEE_BASE_URL: &str = "https://api.arcee.ai/api/v1";
94const DEFAULT_HUGGINGFACE_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
95const DEFAULT_HUGGINGFACE_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
96const DEFAULT_HUGGINGFACE_BASE_URL: &str = "https://router.huggingface.co/v1";
97const DEFAULT_TOGETHER_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
98const DEFAULT_TOGETHER_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
99const DEFAULT_TOGETHER_BASE_URL: &str = "https://api.together.xyz/v1";
100const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
101const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
102const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
103const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
104const DEFAULT_OLLAMA_MODEL: &str = "deepseek-coder:1.3b";
105const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
106
107// Z.ai (GLM Coding Plan) defaults
108const DEFAULT_ZAI_MODEL: &str = "GLM-5.2";
109const ZAI_GLM_5_1_MODEL: &str = "GLM-5.1";
110// GLM-5.2 is both the default and a named tier; the alias arm resolves the
111// `glm-5.2` spelling to DEFAULT_ZAI_MODEL directly, so this constant is only
112// referenced by the invariant test below.
113#[allow(dead_code)]
114const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2";
115const ZAI_GLM_5_TURBO_MODEL: &str = "GLM-5-Turbo";
116const DEFAULT_ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
117// StepFun / StepFlash defaults
118const DEFAULT_STEPFUN_MODEL: &str = "step-3.7-flash";
119const DEFAULT_STEPFUN_BASE_URL: &str = "https://api.stepfun.ai/v1";
120// MiniMax defaults
121const DEFAULT_MINIMAX_MODEL: &str = "MiniMax-M3";
122const MINIMAX_M2_7_MODEL: &str = "MiniMax-M2.7";
123const MINIMAX_M2_7_HIGHSPEED_MODEL: &str = "MiniMax-M2.7-highspeed";
124const MINIMAX_M2_5_MODEL: &str = "MiniMax-M2.5";
125const MINIMAX_M2_5_HIGHSPEED_MODEL: &str = "MiniMax-M2.5-highspeed";
126const MINIMAX_M2_1_MODEL: &str = "MiniMax-M2.1";
127const MINIMAX_M2_1_HIGHSPEED_MODEL: &str = "MiniMax-M2.1-highspeed";
128const MINIMAX_M2_MODEL: &str = "MiniMax-M2";
129const DEFAULT_MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1";
130const DEFAULT_DEEPINFRA_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
131const DEFAULT_DEEPINFRA_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
132const DEFAULT_DEEPINFRA_BASE_URL: &str = "https://api.deepinfra.com/v1/openai";
133
134#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
135#[serde(rename_all = "kebab-case")]
136pub enum ProviderKind {
137    #[default]
138    #[serde(
139        alias = "deepseek-cn",
140        alias = "deepseek_china",
141        alias = "deepseekcn",
142        alias = "deepseek-china"
143    )]
144    Deepseek,
145    NvidiaNim,
146    #[serde(alias = "open-ai")]
147    Openai,
148    Atlascloud,
149    #[serde(
150        alias = "wanjie",
151        alias = "wanjie_ark",
152        alias = "ark-wanjie",
153        alias = "ark_wanjie",
154        alias = "wanjie-maas",
155        alias = "wanjie_maas"
156    )]
157    WanjieArk,
158    #[serde(alias = "volcengine-ark", alias = "volcengine_ark", alias = "ark")]
159    Volcengine,
160    Openrouter,
161    #[serde(alias = "mimo", alias = "xiaomi", alias = "xiaomi_mimo")]
162    XiaomiMimo,
163    Novita,
164    Fireworks,
165    #[serde(alias = "silicon-flow", alias = "silicon_flow")]
166    Siliconflow,
167    #[serde(alias = "arcee-ai", alias = "arcee_ai")]
168    Arcee,
169    #[serde(alias = "siliconflow-cn", alias = "siliconflow-CN")]
170    SiliconflowCN,
171    Moonshot,
172    Sglang,
173    Vllm,
174    Ollama,
175    #[serde(alias = "hugging-face", alias = "hugging_face", alias = "hf")]
176    Huggingface,
177    #[serde(alias = "together-ai", alias = "together_ai")]
178    Together,
179    #[serde(
180        alias = "openai-codex",
181        alias = "openai_codex",
182        alias = "codex",
183        alias = "chatgpt",
184        alias = "chatgpt-codex",
185        alias = "chatgpt_codex"
186    )]
187    OpenaiCodex,
188    #[serde(alias = "claude")]
189    Anthropic,
190    #[serde(alias = "z-ai", alias = "z_ai", alias = "z.ai")]
191    Zai,
192    #[serde(
193        alias = "step-fun",
194        alias = "step_fun",
195        alias = "stepfun",
196        alias = "stepflash",
197        alias = "step-flash",
198        alias = "step_flash"
199    )]
200    Stepfun,
201    #[serde(alias = "mini-max", alias = "mini_max", alias = "minimax")]
202    Minimax,
203    #[serde(alias = "deep-infra", alias = "deep_infra")]
204    Deepinfra,
205}
206
207impl ProviderKind {
208    pub const ALL: [Self; 25] = [
209        Self::Deepseek,
210        Self::NvidiaNim,
211        Self::Openai,
212        Self::Atlascloud,
213        Self::WanjieArk,
214        Self::Volcengine,
215        Self::Openrouter,
216        Self::XiaomiMimo,
217        Self::Novita,
218        Self::Fireworks,
219        Self::Siliconflow,
220        Self::Arcee,
221        Self::SiliconflowCN,
222        Self::Moonshot,
223        Self::Sglang,
224        Self::Vllm,
225        Self::Ollama,
226        Self::Huggingface,
227        Self::Together,
228        Self::OpenaiCodex,
229        Self::Anthropic,
230        Self::Zai,
231        Self::Stepfun,
232        Self::Minimax,
233        Self::Deepinfra,
234    ];
235
236    #[must_use]
237    pub fn all() -> &'static [Self] {
238        &Self::ALL
239    }
240
241    #[must_use]
242    pub fn names_hint() -> String {
243        Self::all()
244            .iter()
245            .map(|provider| provider.as_str())
246            .collect::<Vec<_>>()
247            .join(", ")
248    }
249
250    #[must_use]
251    pub fn as_str(self) -> &'static str {
252        self.provider().id()
253    }
254
255    #[must_use]
256    pub fn parse(value: &str) -> Option<Self> {
257        let trimmed = value.trim();
258        provider::all_providers()
259            .iter()
260            .find(|p| {
261                trimmed.eq_ignore_ascii_case(p.id())
262                    || p.aliases().iter().any(|a| trimmed.eq_ignore_ascii_case(a))
263            })
264            .map(|p| p.kind())
265    }
266
267    #[must_use]
268    pub fn is_siliconflow(self) -> bool {
269        matches!(self, Self::Siliconflow | Self::SiliconflowCN)
270    }
271
272    /// Return the built-in metadata entry for this provider.
273    ///
274    /// This is a metadata foundation only; runtime routing still resolves
275    /// through [`ConfigToml::resolve_runtime_options`].
276    #[must_use]
277    pub fn provider(self) -> &'static dyn provider::Provider {
278        provider::provider_for_kind(self)
279    }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, Default)]
283pub struct ProviderConfigToml {
284    pub api_key: Option<String>,
285    pub base_url: Option<String>,
286    pub model: Option<String>,
287    pub mode: Option<String>,
288    pub auth_mode: Option<String>,
289    pub insecure_skip_tls_verify: Option<bool>,
290    #[serde(default)]
291    pub http_headers: BTreeMap<String, String>,
292    pub path_suffix: Option<String>,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, Default)]
296pub struct ProvidersToml {
297    #[serde(default)]
298    pub deepseek: ProviderConfigToml,
299    #[serde(default)]
300    pub nvidia_nim: ProviderConfigToml,
301    #[serde(default)]
302    pub openai: ProviderConfigToml,
303    #[serde(default)]
304    pub atlascloud: ProviderConfigToml,
305    #[serde(default)]
306    pub wanjie_ark: ProviderConfigToml,
307    #[serde(default)]
308    pub volcengine: ProviderConfigToml,
309    #[serde(default)]
310    pub openrouter: ProviderConfigToml,
311    #[serde(default, alias = "xiaomi", alias = "mimo", alias = "xiaomimimo")]
312    pub xiaomi_mimo: ProviderConfigToml,
313    #[serde(default)]
314    pub novita: ProviderConfigToml,
315    #[serde(default)]
316    pub fireworks: ProviderConfigToml,
317    #[serde(default)]
318    pub siliconflow: ProviderConfigToml,
319    #[serde(default, alias = "siliconflow-CN", alias = "siliconflow-cn")]
320    pub siliconflow_cn: ProviderConfigToml,
321    #[serde(default)]
322    pub arcee: ProviderConfigToml,
323    #[serde(default)]
324    pub moonshot: ProviderConfigToml,
325    #[serde(default)]
326    pub sglang: ProviderConfigToml,
327    #[serde(default)]
328    pub vllm: ProviderConfigToml,
329    #[serde(default)]
330    pub ollama: ProviderConfigToml,
331    #[serde(default)]
332    pub huggingface: ProviderConfigToml,
333    #[serde(default)]
334    pub together: ProviderConfigToml,
335    #[serde(
336        default,
337        alias = "openai-codex",
338        alias = "openai_codex",
339        alias = "codex",
340        alias = "chatgpt",
341        alias = "chatgpt-codex"
342    )]
343    pub openai_codex: ProviderConfigToml,
344    #[serde(default)]
345    pub anthropic: ProviderConfigToml,
346    #[serde(default, alias = "z-ai", alias = "z_ai", alias = "z.ai")]
347    pub zai: ProviderConfigToml,
348    #[serde(
349        default,
350        alias = "step-fun",
351        alias = "step_fun",
352        alias = "stepfun",
353        alias = "stepflash",
354        alias = "step-flash",
355        alias = "step_flash"
356    )]
357    pub stepfun: ProviderConfigToml,
358    #[serde(default, alias = "mini-max", alias = "mini_max", alias = "minimax")]
359    pub minimax: ProviderConfigToml,
360    #[serde(default, alias = "deep-infra", alias = "deep_infra")]
361    pub deepinfra: ProviderConfigToml,
362}
363
364/// Sibling `permissions.toml` schema.
365///
366/// This slice is intentionally ask-only: each rule is a typed condition that
367/// means "ask before this tool invocation." Typed allow/deny records and UI
368/// actions are expected to land in follow-up PRs.
369#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
370#[serde(deny_unknown_fields)]
371pub struct PermissionsToml {
372    #[serde(default, skip_serializing_if = "Vec::is_empty")]
373    pub rules: Vec<ToolAskRule>,
374}
375
376impl PermissionsToml {
377    #[must_use]
378    pub fn is_empty(&self) -> bool {
379        self.rules.is_empty()
380    }
381
382    #[must_use]
383    pub fn ruleset(&self) -> Ruleset {
384        Ruleset::user(Vec::new(), Vec::new()).with_ask_rules(self.rules.clone())
385    }
386}
387
388impl ProvidersToml {
389    #[must_use]
390    pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
391        match provider {
392            ProviderKind::Deepseek => &self.deepseek,
393            ProviderKind::NvidiaNim => &self.nvidia_nim,
394            ProviderKind::Openai => &self.openai,
395            ProviderKind::Atlascloud => &self.atlascloud,
396            ProviderKind::WanjieArk => &self.wanjie_ark,
397            ProviderKind::Volcengine => &self.volcengine,
398            ProviderKind::Openrouter => &self.openrouter,
399            ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
400            ProviderKind::Novita => &self.novita,
401            ProviderKind::Fireworks => &self.fireworks,
402            ProviderKind::Siliconflow => &self.siliconflow,
403            ProviderKind::SiliconflowCN => &self.siliconflow_cn,
404            ProviderKind::Arcee => &self.arcee,
405            ProviderKind::Moonshot => &self.moonshot,
406            ProviderKind::Sglang => &self.sglang,
407            ProviderKind::Vllm => &self.vllm,
408            ProviderKind::Ollama => &self.ollama,
409            ProviderKind::Huggingface => &self.huggingface,
410            ProviderKind::Together => &self.together,
411            ProviderKind::OpenaiCodex => &self.openai_codex,
412            ProviderKind::Anthropic => &self.anthropic,
413            ProviderKind::Zai => &self.zai,
414            ProviderKind::Stepfun => &self.stepfun,
415            ProviderKind::Minimax => &self.minimax,
416            ProviderKind::Deepinfra => &self.deepinfra,
417        }
418    }
419
420    pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
421        match provider {
422            ProviderKind::Deepseek => &mut self.deepseek,
423            ProviderKind::NvidiaNim => &mut self.nvidia_nim,
424            ProviderKind::Openai => &mut self.openai,
425            ProviderKind::Atlascloud => &mut self.atlascloud,
426            ProviderKind::WanjieArk => &mut self.wanjie_ark,
427            ProviderKind::Volcengine => &mut self.volcengine,
428            ProviderKind::Openrouter => &mut self.openrouter,
429            ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
430            ProviderKind::Novita => &mut self.novita,
431            ProviderKind::Fireworks => &mut self.fireworks,
432            ProviderKind::Siliconflow => &mut self.siliconflow,
433            ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
434            ProviderKind::Arcee => &mut self.arcee,
435            ProviderKind::Moonshot => &mut self.moonshot,
436            ProviderKind::Sglang => &mut self.sglang,
437            ProviderKind::Vllm => &mut self.vllm,
438            ProviderKind::Ollama => &mut self.ollama,
439            ProviderKind::Huggingface => &mut self.huggingface,
440            ProviderKind::Together => &mut self.together,
441            ProviderKind::OpenaiCodex => &mut self.openai_codex,
442            ProviderKind::Anthropic => &mut self.anthropic,
443            ProviderKind::Zai => &mut self.zai,
444            ProviderKind::Stepfun => &mut self.stepfun,
445            ProviderKind::Minimax => &mut self.minimax,
446            ProviderKind::Deepinfra => &mut self.deepinfra,
447        }
448    }
449}
450
451/// Kinds of built-in harness postures.
452///
453/// A posture names the runtime strategy CodeWhale should use for a
454/// provider/model route: how much context to preload, how aggressively to lean
455/// on sub-agents, and how to balance prompt-cache stability against quick
456/// exploration. Runtime selection is wired in later v0.9 slices; this config
457/// model intentionally keeps the policy data explicit first.
458#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
459#[serde(rename_all = "kebab-case")]
460pub enum HarnessPostureKind {
461    /// Full-featured default: rich constitution, broad tool catalog, and normal
462    /// sub-agent posture.
463    #[default]
464    Standard,
465    /// Cache-heavy: deeper prompt layering and prefix-cache-oriented context.
466    CacheHeavy,
467    /// Lean: smaller starting context, faster compaction, and stronger
468    /// exploration/delegation bias.
469    Lean,
470    /// User-defined posture assembled from explicit knobs below.
471    Custom,
472}
473
474/// How this posture should approach compaction and prompt-cache stability.
475#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
476#[serde(rename_all = "kebab-case")]
477pub enum HarnessCompactionStrategy {
478    #[default]
479    Default,
480    PrefixCache,
481    Aggressive,
482}
483
484/// Which tool catalog shape this posture prefers.
485#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
486#[serde(rename_all = "kebab-case")]
487pub enum HarnessToolSurface {
488    #[default]
489    Full,
490    ReadOnly,
491    Auto,
492}
493
494/// Safety posture applied when the runtime consumes a harness profile.
495#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
496#[serde(rename_all = "kebab-case")]
497pub enum HarnessSafetyPosture {
498    #[default]
499    Standard,
500    Strict,
501    Permissive,
502}
503
504/// A concrete harness posture with policy knobs.
505#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
506#[serde(deny_unknown_fields)]
507pub struct HarnessPosture {
508    /// Named posture kind.
509    #[serde(default)]
510    pub kind: HarnessPostureKind,
511    /// Maximum number of concurrent sub-agents (0 = runtime default).
512    #[serde(default)]
513    pub max_subagents: usize,
514    /// Prefer search-based/on-demand context over always-on documentation.
515    #[serde(default)]
516    pub prefer_codebase_search: bool,
517    /// Compaction and prompt-cache strategy.
518    #[serde(default)]
519    pub compaction_strategy: HarnessCompactionStrategy,
520    /// Preferred tool catalog shape.
521    #[serde(default)]
522    pub tool_surface: HarnessToolSurface,
523    /// Safety posture for runtime consumers.
524    #[serde(default)]
525    pub safety_posture: HarnessSafetyPosture,
526}
527
528impl Default for HarnessPosture {
529    fn default() -> Self {
530        Self {
531            kind: HarnessPostureKind::Standard,
532            max_subagents: 0,
533            prefer_codebase_search: false,
534            compaction_strategy: HarnessCompactionStrategy::default(),
535            tool_surface: HarnessToolSurface::default(),
536            safety_posture: HarnessSafetyPosture::default(),
537        }
538    }
539}
540
541impl HarnessPosture {
542    /// A cache-heavy posture tuned for DeepSeek V4 / MiMo-style models.
543    #[must_use]
544    pub fn cache_heavy() -> Self {
545        Self {
546            kind: HarnessPostureKind::CacheHeavy,
547            max_subagents: 10,
548            prefer_codebase_search: false,
549            compaction_strategy: HarnessCompactionStrategy::PrefixCache,
550            tool_surface: HarnessToolSurface::Full,
551            safety_posture: HarnessSafetyPosture::Standard,
552        }
553    }
554
555    /// A lean posture for smaller-context or weaker tool-use models.
556    #[must_use]
557    pub fn lean() -> Self {
558        Self {
559            kind: HarnessPostureKind::Lean,
560            max_subagents: 20,
561            prefer_codebase_search: true,
562            compaction_strategy: HarnessCompactionStrategy::Aggressive,
563            tool_surface: HarnessToolSurface::Full,
564            safety_posture: HarnessSafetyPosture::Standard,
565        }
566    }
567}
568
569/// A harness profile binds a posture to a provider route and model pattern.
570#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
571#[serde(deny_unknown_fields)]
572pub struct HarnessProfile {
573    /// Provider route this profile applies to, e.g. "deepseek" or
574    /// "xiaomi-mimo".
575    pub provider_route: String,
576    /// Regex or glob pattern for model names, e.g. "deepseek-v4.*".
577    pub model_pattern: String,
578    /// The posture to apply.
579    #[serde(default)]
580    pub posture: HarnessPosture,
581}
582
583impl HarnessProfile {
584    /// Return true when this profile applies to the provider/model route.
585    ///
586    /// This is a pure config helper: matching a profile must not mutate runtime
587    /// provider selection, prompts, auth, tools, context, or persisted config.
588    #[must_use]
589    pub fn matches_route(&self, provider_route: &str, model: &str) -> bool {
590        provider_routes_equal(&self.provider_route, provider_route)
591            && wildcard_pattern_matches(&self.model_pattern, model)
592    }
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize, Default)]
596pub struct ConfigToml {
597    /// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek`
598    /// and `codewhale-tui` can share a single config file.
599    pub api_key: Option<String>,
600    /// TUI-compatible DeepSeek base URL.
601    pub base_url: Option<String>,
602    /// Optional extra HTTP headers forwarded to model API requests.
603    #[serde(default)]
604    pub http_headers: BTreeMap<String, String>,
605    /// TUI-compatible default DeepSeek model.
606    pub default_text_model: Option<String>,
607    #[serde(default)]
608    pub provider: ProviderKind,
609    pub model: Option<String>,
610    pub auth_mode: Option<String>,
611    pub output_mode: Option<String>,
612    pub verbosity: Option<String>,
613    pub log_level: Option<String>,
614    pub telemetry: Option<bool>,
615    pub approval_policy: Option<String>,
616    pub sandbox_mode: Option<String>,
617    /// Native tool catalog controls shared with `codewhale-tui`.
618    #[serde(default)]
619    pub tools: Option<ToolsToml>,
620    #[serde(default)]
621    pub providers: ProvidersToml,
622    /// Provider fallback chain (#2574). TUI runtime code may advance through
623    /// these providers after recoverable provider errors; config resolution
624    /// itself still reports the selected primary provider.
625    #[serde(default, skip_serializing_if = "Vec::is_empty")]
626    pub fallback_providers: Vec<ProviderKind>,
627    /// Per-domain network policy (#135). When absent, network tools fall back
628    /// to a permissive default that mirrors pre-v0.7.0 behavior.
629    #[serde(default)]
630    pub network: Option<NetworkPolicyToml>,
631    /// Community skill installer settings (#140). Mirrors
632    /// [`SkillsToml`] from the TUI side; the dispatcher consults
633    /// `registry_url` when running `deepseek skill install`.
634    #[serde(default)]
635    pub skills: Option<SkillsToml>,
636    /// Workspace side-git snapshots (#137). The live TUI defaults this to
637    /// enabled with 7-day retention when absent.
638    #[serde(default)]
639    pub snapshots: Option<SnapshotsToml>,
640    /// Post-edit LSP diagnostics injection (#136). When absent, the engine
641    /// applies the defaults documented in [`LspConfigToml`].
642    #[serde(default)]
643    pub lsp: Option<LspConfigToml>,
644    /// Per-model harness profiles (#2693). Runtime wiring lands in follow-up
645    /// v0.9 slices; this is the durable config data model.
646    #[serde(default)]
647    pub harness_profiles: Vec<HarnessProfile>,
648    /// Optional 1-8 hotbar slot bindings (#2064). When absent, the TUI falls
649    /// back to the built-in default slots.
650    #[serde(default, skip_serializing_if = "Option::is_none")]
651    pub hotbar: Option<Vec<HotbarBindingToml>>,
652    /// App-server hook sink configuration. Kept separate from the TUI
653    /// lifecycle `[hooks]` table so config rewrites preserve existing hooks.
654    #[serde(default)]
655    pub hook_sinks: Option<HookSinksToml>,
656    /// Agent Fleet trust and security policy (#3165). When absent, fleet
657    /// workers inherit conservative Sandbox defaults.
658    #[serde(default)]
659    pub fleet: Option<FleetConfigToml>,
660    #[serde(flatten)]
661    pub extras: BTreeMap<String, toml::Value>,
662}
663
664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
665enum ProviderConfigField {
666    ApiKey,
667    BaseUrl,
668    Model,
669    Mode,
670    AuthMode,
671    InsecureSkipTlsVerify,
672    HttpHeaders,
673    PathSuffix,
674}
675
676impl ProviderConfigField {
677    fn parse(key: &str) -> Option<Self> {
678        Some(match key {
679            "api_key" => Self::ApiKey,
680            "base_url" => Self::BaseUrl,
681            "model" => Self::Model,
682            "mode" => Self::Mode,
683            "auth_mode" => Self::AuthMode,
684            "insecure_skip_tls_verify" => Self::InsecureSkipTlsVerify,
685            "http_headers" => Self::HttpHeaders,
686            "path_suffix" => Self::PathSuffix,
687            _ => return None,
688        })
689    }
690
691    fn key(self) -> &'static str {
692        match self {
693            Self::ApiKey => "api_key",
694            Self::BaseUrl => "base_url",
695            Self::Model => "model",
696            Self::Mode => "mode",
697            Self::AuthMode => "auth_mode",
698            Self::InsecureSkipTlsVerify => "insecure_skip_tls_verify",
699            Self::HttpHeaders => "http_headers",
700            Self::PathSuffix => "path_suffix",
701        }
702    }
703}
704
705fn parse_provider_config_key(key: &str) -> Option<(ProviderKind, ProviderConfigField)> {
706    let suffix = key.strip_prefix("providers.")?;
707    let (provider_key, field_key) = suffix.split_once('.')?;
708    let field = ProviderConfigField::parse(field_key)?;
709    let provider = ProviderKind::ALL
710        .iter()
711        .copied()
712        .find(|kind| kind.provider().provider_config_key() == provider_key)?;
713    Some((provider, field))
714}
715
716fn provider_config_key(provider: ProviderKind, field: ProviderConfigField) -> String {
717    format!(
718        "providers.{}.{}",
719        provider.provider().provider_config_key(),
720        field.key()
721    )
722}
723
724fn get_provider_config_value(
725    config: &ProviderConfigToml,
726    field: ProviderConfigField,
727) -> Option<String> {
728    match field {
729        ProviderConfigField::ApiKey => config.api_key.clone(),
730        ProviderConfigField::BaseUrl => config.base_url.clone(),
731        ProviderConfigField::Model => config.model.clone(),
732        ProviderConfigField::Mode => config.mode.clone(),
733        ProviderConfigField::AuthMode => config.auth_mode.clone(),
734        ProviderConfigField::InsecureSkipTlsVerify => config
735            .insecure_skip_tls_verify
736            .map(|value| value.to_string()),
737        ProviderConfigField::HttpHeaders => serialize_http_headers(&config.http_headers),
738        ProviderConfigField::PathSuffix => config.path_suffix.clone(),
739    }
740}
741
742fn get_provider_config_display_value(
743    config: &ProviderConfigToml,
744    field: ProviderConfigField,
745) -> Option<String> {
746    match field {
747        ProviderConfigField::ApiKey => config.api_key.as_deref().map(redact_secret),
748        ProviderConfigField::HttpHeaders => {
749            serialize_http_headers_for_display(&config.http_headers)
750        }
751        _ => get_provider_config_value(config, field),
752    }
753}
754
755fn set_provider_config_value(
756    config: &mut ConfigToml,
757    provider: ProviderKind,
758    field: ProviderConfigField,
759    value: &str,
760) -> Result<()> {
761    match field {
762        ProviderConfigField::ApiKey => {
763            let value = value.to_string();
764            config.providers.for_provider_mut(provider).api_key = Some(value.clone());
765            if provider == ProviderKind::Deepseek {
766                config.api_key = Some(value);
767            }
768        }
769        ProviderConfigField::BaseUrl => {
770            let value = value.to_string();
771            config.providers.for_provider_mut(provider).base_url = Some(value.clone());
772            if provider == ProviderKind::Deepseek {
773                config.base_url = Some(value);
774            }
775        }
776        ProviderConfigField::Model => {
777            let value = value.to_string();
778            config.providers.for_provider_mut(provider).model = Some(value.clone());
779            if provider == ProviderKind::Deepseek {
780                config.default_text_model = Some(value);
781            }
782        }
783        ProviderConfigField::Mode => {
784            config.providers.for_provider_mut(provider).mode = Some(value.to_string());
785        }
786        ProviderConfigField::AuthMode => {
787            config.providers.for_provider_mut(provider).auth_mode = Some(value.to_string());
788        }
789        ProviderConfigField::InsecureSkipTlsVerify => {
790            config
791                .providers
792                .for_provider_mut(provider)
793                .insecure_skip_tls_verify = Some(parse_bool(value)?);
794        }
795        ProviderConfigField::HttpHeaders => {
796            let headers = parse_http_headers(value)?;
797            config.providers.for_provider_mut(provider).http_headers = headers.clone();
798            if provider == ProviderKind::Deepseek {
799                config.http_headers = headers;
800            }
801        }
802        ProviderConfigField::PathSuffix => {
803            config.providers.for_provider_mut(provider).path_suffix = Some(value.to_string());
804        }
805    }
806    Ok(())
807}
808
809fn unset_provider_config_value(
810    config: &mut ConfigToml,
811    provider: ProviderKind,
812    field: ProviderConfigField,
813) {
814    match field {
815        ProviderConfigField::ApiKey => {
816            config.providers.for_provider_mut(provider).api_key = None;
817            if provider == ProviderKind::Deepseek {
818                config.api_key = None;
819            }
820        }
821        ProviderConfigField::BaseUrl => {
822            config.providers.for_provider_mut(provider).base_url = None;
823            if provider == ProviderKind::Deepseek {
824                config.base_url = None;
825            }
826        }
827        ProviderConfigField::Model => {
828            config.providers.for_provider_mut(provider).model = None;
829            if provider == ProviderKind::Deepseek {
830                config.default_text_model = None;
831            }
832        }
833        ProviderConfigField::Mode => {
834            config.providers.for_provider_mut(provider).mode = None;
835        }
836        ProviderConfigField::AuthMode => {
837            config.providers.for_provider_mut(provider).auth_mode = None;
838        }
839        ProviderConfigField::InsecureSkipTlsVerify => {
840            config
841                .providers
842                .for_provider_mut(provider)
843                .insecure_skip_tls_verify = None;
844        }
845        ProviderConfigField::HttpHeaders => {
846            config
847                .providers
848                .for_provider_mut(provider)
849                .http_headers
850                .clear();
851            if provider == ProviderKind::Deepseek {
852                config.http_headers.clear();
853            }
854        }
855        ProviderConfigField::PathSuffix => {
856            config.providers.for_provider_mut(provider).path_suffix = None;
857        }
858    }
859}
860
861fn insert_provider_config_values(
862    out: &mut BTreeMap<String, String>,
863    provider: ProviderKind,
864    config: &ProviderConfigToml,
865) {
866    if let Some(v) = config.api_key.as_ref() {
867        out.insert(
868            provider_config_key(provider, ProviderConfigField::ApiKey),
869            redact_secret(v),
870        );
871    }
872    if let Some(v) = config.base_url.as_ref() {
873        out.insert(
874            provider_config_key(provider, ProviderConfigField::BaseUrl),
875            v.clone(),
876        );
877    }
878    if let Some(v) = config.model.as_ref() {
879        out.insert(
880            provider_config_key(provider, ProviderConfigField::Model),
881            v.clone(),
882        );
883    }
884    if let Some(v) = config.mode.as_ref() {
885        out.insert(
886            provider_config_key(provider, ProviderConfigField::Mode),
887            v.clone(),
888        );
889    }
890    if let Some(v) = config.auth_mode.as_ref() {
891        out.insert(
892            provider_config_key(provider, ProviderConfigField::AuthMode),
893            v.clone(),
894        );
895    }
896    if let Some(v) = config.insecure_skip_tls_verify {
897        out.insert(
898            provider_config_key(provider, ProviderConfigField::InsecureSkipTlsVerify),
899            v.to_string(),
900        );
901    }
902    if let Some(v) = serialize_http_headers_for_display(&config.http_headers) {
903        out.insert(
904            provider_config_key(provider, ProviderConfigField::HttpHeaders),
905            v,
906        );
907    }
908    if let Some(v) = config.path_suffix.as_ref() {
909        out.insert(
910            provider_config_key(provider, ProviderConfigField::PathSuffix),
911            v.clone(),
912        );
913    }
914}
915
916impl ConfigToml {
917    /// Resolve the first configured harness profile for a provider/model route.
918    ///
919    /// This helper is deliberately dormant for v0.9: callers may display or
920    /// test the resolved profile, but runtime provider/model routing and prompt
921    /// shaping remain unchanged until a later, explicit integration slice.
922    #[must_use]
923    pub fn resolve_harness_profile(
924        &self,
925        provider_route: &str,
926        model: &str,
927    ) -> Option<&HarnessProfile> {
928        self.harness_profiles
929            .iter()
930            .chain(built_in_harness_profiles().iter())
931            .find(|profile| profile.matches_route(provider_route, model))
932    }
933
934    /// Resolve durable hotbar config into normalized 1-8 slot bindings.
935    ///
936    /// `known_action_ids` is supplied by the TUI action registry in later
937    /// slices. Unknown actions are preserved so the UI can render a disabled
938    /// `?` cell instead of silently deleting user config.
939    #[must_use]
940    pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
941        resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
942    }
943}
944
945/// Built-in profile seeds for common provider/model families.
946///
947/// User-configured profiles are always checked first; these seeds only provide
948/// a stable resolver result when config has no narrower match.
949#[must_use]
950pub fn built_in_harness_profiles() -> &'static [HarnessProfile] {
951    static PROFILES: OnceLock<Vec<HarnessProfile>> = OnceLock::new();
952    PROFILES.get_or_init(|| {
953        vec![
954            HarnessProfile {
955                provider_route: "deepseek".to_string(),
956                model_pattern: "deepseek-v4*".to_string(),
957                posture: HarnessPosture::cache_heavy(),
958            },
959            HarnessProfile {
960                provider_route: "xiaomi-mimo".to_string(),
961                model_pattern: "mimo-v2.5*".to_string(),
962                posture: HarnessPosture::cache_heavy(),
963            },
964            HarnessProfile {
965                provider_route: "arcee".to_string(),
966                model_pattern: "trinity-large-thinking".to_string(),
967                posture: HarnessPosture::cache_heavy(),
968            },
969            HarnessProfile {
970                provider_route: "huggingface".to_string(),
971                model_pattern: "*".to_string(),
972                posture: HarnessPosture::lean(),
973            },
974            HarnessProfile {
975                provider_route: "sglang".to_string(),
976                model_pattern: "*".to_string(),
977                posture: HarnessPosture::lean(),
978            },
979            HarnessProfile {
980                provider_route: "vllm".to_string(),
981                model_pattern: "*".to_string(),
982                posture: HarnessPosture::lean(),
983            },
984            HarnessProfile {
985                provider_route: "ollama".to_string(),
986                model_pattern: "*".to_string(),
987                posture: HarnessPosture::lean(),
988            },
989        ]
990    })
991}
992
993fn provider_routes_equal(expected: &str, actual: &str) -> bool {
994    match (ProviderKind::parse(expected), ProviderKind::parse(actual)) {
995        (Some(expected), Some(actual)) => expected == actual,
996        _ => expected.trim().eq_ignore_ascii_case(actual.trim()),
997    }
998}
999
1000fn wildcard_pattern_matches(pattern: &str, value: &str) -> bool {
1001    wildcard_chars_match(
1002        &pattern.chars().collect::<Vec<_>>(),
1003        &value.chars().collect::<Vec<_>>(),
1004    )
1005}
1006
1007fn wildcard_chars_match(pattern: &[char], value: &[char]) -> bool {
1008    let (mut pattern_idx, mut value_idx) = (0, 0);
1009    let mut star_idx: Option<usize> = None;
1010    let mut star_value_idx = 0;
1011
1012    while value_idx < value.len() {
1013        if pattern_idx < pattern.len()
1014            && (pattern[pattern_idx] == '?' || pattern[pattern_idx] == value[value_idx])
1015        {
1016            pattern_idx += 1;
1017            value_idx += 1;
1018        } else if pattern_idx < pattern.len() && pattern[pattern_idx] == '*' {
1019            star_idx = Some(pattern_idx);
1020            pattern_idx += 1;
1021            star_value_idx = value_idx;
1022        } else if let Some(star) = star_idx {
1023            pattern_idx = star + 1;
1024            star_value_idx += 1;
1025            value_idx = star_value_idx;
1026        } else {
1027            return false;
1028        }
1029    }
1030
1031    pattern[pattern_idx..].iter().all(|ch| *ch == '*')
1032}
1033
1034/// Ordered primary-plus-fallback provider list for future provider routing.
1035///
1036/// The helper is intentionally dormant: constructing or parsing a chain does
1037/// not change [`ConfigToml::resolve_runtime_options`].
1038#[derive(Debug, Clone, PartialEq, Eq)]
1039pub struct ProviderChain {
1040    providers: Vec<ProviderKind>,
1041    position: usize,
1042}
1043
1044pub const HOTBAR_SLOT_COUNT: u8 = 8;
1045
1046pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
1047    "voice.toggle",
1048    "session.compact",
1049    "mode.plan",
1050    "mode.agent",
1051    "mode.yolo",
1052    "palette.open",
1053    "sidebar.toggle",
1054    "trust.toggle",
1055];
1056
1057/// On-disk schema for one `[[hotbar]]` table.
1058#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1059#[serde(deny_unknown_fields)]
1060pub struct HotbarBindingToml {
1061    pub slot: u8,
1062    pub action: String,
1063    #[serde(default)]
1064    pub label: Option<String>,
1065}
1066
1067/// Validated hotbar binding used by future render/dispatch layers.
1068#[derive(Debug, Clone, PartialEq, Eq)]
1069pub struct HotbarBinding {
1070    pub slot: u8,
1071    pub action: String,
1072    pub label: Option<String>,
1073}
1074
1075/// Non-fatal hotbar config issue. Invalid slots are skipped; duplicate slots
1076/// use the last binding; unknown actions are kept for UI feedback.
1077#[derive(Debug, Clone, PartialEq, Eq)]
1078pub enum HotbarConfigWarning {
1079    SlotOutOfRange {
1080        slot: u8,
1081        action: String,
1082    },
1083    DuplicateSlot {
1084        slot: u8,
1085        previous_action: String,
1086        replacement_action: String,
1087    },
1088    UnknownAction {
1089        slot: u8,
1090        action: String,
1091    },
1092}
1093
1094impl fmt::Display for HotbarConfigWarning {
1095    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1096        match self {
1097            Self::SlotOutOfRange { slot, action } => write!(
1098                f,
1099                "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
1100            ),
1101            Self::DuplicateSlot {
1102                slot,
1103                previous_action,
1104                replacement_action,
1105            } => write!(
1106                f,
1107                "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
1108            ),
1109            Self::UnknownAction { slot, action } => write!(
1110                f,
1111                "hotbar slot {slot} references unknown action '{action}'; keeping binding"
1112            ),
1113        }
1114    }
1115}
1116
1117#[derive(Debug, Clone, PartialEq, Eq)]
1118pub struct HotbarConfigResolution {
1119    pub bindings: Vec<HotbarBinding>,
1120    pub warnings: Vec<HotbarConfigWarning>,
1121}
1122
1123#[must_use]
1124pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
1125    DEFAULT_HOTBAR_ACTIONS
1126        .iter()
1127        .enumerate()
1128        .map(|(idx, action)| HotbarBinding {
1129            slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
1130            action: (*action).to_string(),
1131            label: None,
1132        })
1133        .collect()
1134}
1135
1136#[must_use]
1137pub fn resolve_hotbar_bindings(
1138    configured: Option<&[HotbarBindingToml]>,
1139    known_action_ids: &[&str],
1140) -> HotbarConfigResolution {
1141    let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
1142    let mut warnings = Vec::new();
1143
1144    let source = match configured {
1145        Some(bindings) => bindings
1146            .iter()
1147            .map(|binding| HotbarBinding {
1148                slot: binding.slot,
1149                action: binding.action.clone(),
1150                label: binding.label.clone(),
1151            })
1152            .collect::<Vec<_>>(),
1153        None => default_hotbar_bindings(),
1154    };
1155
1156    let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
1157    for binding in source {
1158        if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
1159            warnings.push(HotbarConfigWarning::SlotOutOfRange {
1160                slot: binding.slot,
1161                action: binding.action,
1162            });
1163            continue;
1164        }
1165        if !known.is_empty() && !known.contains(binding.action.as_str()) {
1166            warnings.push(HotbarConfigWarning::UnknownAction {
1167                slot: binding.slot,
1168                action: binding.action.clone(),
1169            });
1170        }
1171        if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
1172            warnings.push(HotbarConfigWarning::DuplicateSlot {
1173                slot: binding.slot,
1174                previous_action: previous.action,
1175                replacement_action: binding.action,
1176            });
1177        }
1178    }
1179
1180    HotbarConfigResolution {
1181        bindings: by_slot.into_values().collect(),
1182        warnings,
1183    }
1184}
1185
1186impl ProviderChain {
1187    #[must_use]
1188    pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
1189        let mut providers = vec![active];
1190        for fallback in fallbacks {
1191            if *fallback != active && !providers.contains(fallback) {
1192                providers.push(*fallback);
1193            }
1194        }
1195        Self {
1196            providers,
1197            position: 0,
1198        }
1199    }
1200
1201    #[must_use]
1202    pub fn providers(&self) -> &[ProviderKind] {
1203        &self.providers
1204    }
1205
1206    #[must_use]
1207    pub fn position(&self) -> usize {
1208        self.position
1209    }
1210
1211    #[must_use]
1212    pub fn current(&self) -> ProviderKind {
1213        self.providers
1214            .get(self.position)
1215            .copied()
1216            .unwrap_or(self.providers[0])
1217    }
1218
1219    #[must_use]
1220    pub fn has_next(&self) -> bool {
1221        self.position + 1 < self.providers.len()
1222    }
1223
1224    pub fn advance(&mut self) -> Option<ProviderKind> {
1225        if !self.has_next() {
1226            return None;
1227        }
1228        self.position += 1;
1229        Some(self.current())
1230    }
1231
1232    pub fn reset(&mut self) {
1233        self.position = 0;
1234    }
1235
1236    #[must_use]
1237    pub fn is_fallback_active(&self) -> bool {
1238        self.position > 0
1239    }
1240
1241    /// Count the current provider plus untried chain entries.
1242    #[must_use]
1243    pub fn remaining(&self) -> usize {
1244        self.providers.len() - self.position
1245    }
1246}
1247
1248/// On-disk schema for the `[hook_sinks]` table.
1249#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1250pub struct HookSinksToml {
1251    /// Unix domain socket path used by the app-server event sink.
1252    ///
1253    /// When unset, no Unix socket sink is registered. There is deliberately no
1254    /// shared `/tmp` default because socket ownership should be explicit.
1255    #[serde(default)]
1256    pub unix_socket_path: Option<PathBuf>,
1257}
1258
1259/// On-disk schema for the `[skills]` table (#140). See `config.example.toml`
1260/// for documentation.
1261#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1262pub struct SkillsToml {
1263    /// Curated registry index URL. When unset, the TUI falls back to the
1264    /// bundled default (community-curated GitHub raw).
1265    #[serde(default)]
1266    pub registry_url: Option<String>,
1267    /// Per-skill maximum *uncompressed* size in bytes. When unset, the TUI
1268    /// uses 5 MiB.
1269    #[serde(default)]
1270    pub max_install_size_bytes: Option<u64>,
1271}
1272
1273/// On-disk schema for the `[tools]` table (#2076).
1274#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1275pub struct ToolsToml {
1276    /// Native tool names to keep loaded outside the default core catalog.
1277    #[serde(default)]
1278    pub always_load: Vec<String>,
1279}
1280
1281/// On-disk schema for the `[snapshots]` table (#137). See
1282/// `config.example.toml` for documentation.
1283#[derive(Debug, Clone, Serialize, Deserialize)]
1284pub struct SnapshotsToml {
1285    #[serde(default = "default_snapshots_enabled")]
1286    pub enabled: bool,
1287    #[serde(default = "default_snapshot_max_age_days")]
1288    pub max_age_days: u64,
1289}
1290
1291fn default_snapshots_enabled() -> bool {
1292    true
1293}
1294
1295fn default_snapshot_max_age_days() -> u64 {
1296    7
1297}
1298
1299impl Default for SnapshotsToml {
1300    fn default() -> Self {
1301        Self {
1302            enabled: default_snapshots_enabled(),
1303            max_age_days: default_snapshot_max_age_days(),
1304        }
1305    }
1306}
1307
1308/// On-disk schema for the `[fleet]` table (#3165). See `config.example.toml`
1309/// and `docs/FLEET.md` for documentation.
1310#[derive(Debug, Clone, Serialize, Deserialize)]
1311pub struct FleetConfigToml {
1312    /// Default trust level for fleet workers. One of `"sandbox"`, `"local"`,
1313    /// `"remote-verified"`, or `"operator"`. Defaults to `"sandbox"`.
1314    #[serde(default = "default_fleet_trust_level_str")]
1315    pub default_trust_level: String,
1316    /// Require identity verification for remote (SSH) workers before
1317    /// granting them `remote-verified` trust. Defaults to true.
1318    #[serde(default = "default_fleet_require_identity")]
1319    pub require_identity_verification: bool,
1320    /// Maximum trust level any worker may have (`"sandbox"`, `"local"`,
1321    /// `"remote-verified"`, or `"operator"`). Defaults to `"operator"`.
1322    #[serde(default = "default_fleet_max_trust_level_str")]
1323    pub max_trust_level: String,
1324    /// User-defined and built-in role presets.
1325    ///
1326    /// Each role defines default tool profiles, capabilities, budgets, and
1327    /// trust settings that task specs can reference by name. Built-in roles
1328    /// (`smoke-runner`, `reviewer`, `builder`, `read-only`) are always
1329    /// available; user-defined roles in config override or extend them.
1330    #[serde(default)]
1331    pub roles: BTreeMap<String, FleetRolePreset>,
1332    /// Headless worker execution hardening (#3027).
1333    #[serde(default)]
1334    pub exec: FleetExecConfig,
1335}
1336
1337/// Canonical recursion-depth policy for the headless worker runtime.
1338///
1339/// Single source of truth shared by BOTH standalone sub-agents and fleet
1340/// workers so the two cannot drift into "two moving targets":
1341/// - [`DEFAULT_SPAWN_DEPTH`] is the default recursion budget (the sub-agent
1342///   runtime's `DEFAULT_MAX_SPAWN_DEPTH` is defined as this value).
1343/// - [`MAX_SPAWN_DEPTH_CEILING`] is the opt-in safety cap; every configured
1344///   value (fleet `max_spawn_depth`, the `agent` tool's `max_depth`) clamps to it.
1345///
1346/// A worker runs at `spawn_depth = 0` and may spawn while
1347/// `spawn_depth + 1 <= max_spawn_depth`, so a depth of N affords N nested
1348/// delegation levels below the root worker. The default of 3 affords at least
1349/// three recursion levels out of the box; the root worker still runs at
1350/// depth 0 even when the budget is 0.
1351pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
1352
1353/// Hard ceiling on recursion depth for any worker/sub-agent. The default stays
1354/// conservative at [`DEFAULT_SPAWN_DEPTH`], while explicit config can opt into
1355/// deeper trees for direct-API providers that can tolerate the fanout.
1356/// Raising this single constant lifts the limit everywhere (the fleet clamp
1357/// and `agent` validation both read it).
1358pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
1359
1360/// Headless worker execution constraints (#3027).
1361///
1362/// These limits apply to all fleet workers and sub-agents spawned through
1363/// the headless worker runtime. Task specs can tighten but not loosen them.
1364#[derive(Debug, Clone, Serialize, Deserialize)]
1365pub struct FleetExecConfig {
1366    /// Tools that are always allowed regardless of role or task spec.
1367    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1368    pub allowed_tools: Vec<String>,
1369    /// Tools that are always disallowed, overriding role and task spec.
1370    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1371    pub disallowed_tools: Vec<String>,
1372    /// Hard ceiling on sub-agent steps (tool calls + model turns).
1373    /// Workers that exceed this are terminated. Default: unbounded (u32::MAX).
1374    #[serde(default = "default_fleet_max_turns")]
1375    pub max_turns: u32,
1376    /// Recursive child-agent budget for headless fleet workers.
1377    /// Defaults to [`DEFAULT_SPAWN_DEPTH`] (3) so a fleet worker has the SAME
1378    /// recursion budget as a standalone sub-agent — fleet and sub-agents are one
1379    /// substrate, not two. Set 0 to block child `agent` calls (the root worker
1380    /// still runs); the value is clamped to [`MAX_SPAWN_DEPTH_CEILING`].
1381    #[serde(default = "default_fleet_max_spawn_depth")]
1382    pub max_spawn_depth: u32,
1383    /// Extra system prompt text appended to every headless worker.
1384    /// Useful for injecting org-wide policy or behavior constraints.
1385    #[serde(default, skip_serializing_if = "String::is_empty")]
1386    pub append_system_prompt: String,
1387    /// Output format for fleet worker results.
1388    /// `"text"` (default) or `"stream-json"` for newline-delimited JSON events.
1389    #[serde(default = "default_fleet_output_format")]
1390    pub output_format: String,
1391}
1392
1393fn default_fleet_max_turns() -> u32 {
1394    u32::MAX
1395}
1396
1397fn default_fleet_max_spawn_depth() -> u32 {
1398    DEFAULT_SPAWN_DEPTH
1399}
1400
1401fn default_fleet_output_format() -> String {
1402    "text".to_string()
1403}
1404
1405impl Default for FleetExecConfig {
1406    fn default() -> Self {
1407        Self {
1408            allowed_tools: Vec::new(),
1409            disallowed_tools: Vec::new(),
1410            max_turns: default_fleet_max_turns(),
1411            max_spawn_depth: default_fleet_max_spawn_depth(),
1412            append_system_prompt: String::new(),
1413            output_format: default_fleet_output_format(),
1414        }
1415    }
1416}
1417
1418/// A named role preset that bundles common worker settings.
1419///
1420/// Task specs reference a role name (e.g. `"role": "reviewer"`), and the
1421/// fleet manager fills in any missing fields from the preset. User-defined
1422/// roles in `[fleet.roles]` override built-in defaults with the same name.
1423///
1424/// Token budgets and tool-call limits are task-level decisions — they don't
1425/// belong on role presets. Use `timeout_seconds` as the safety bound.
1426#[derive(Debug, Clone, Serialize, Deserialize)]
1427pub struct FleetRolePreset {
1428    /// Short description of what this role is for.
1429    #[serde(skip_serializing_if = "Option::is_none")]
1430    pub description: Option<String>,
1431    /// Default tool profile (`"read-only"`, `"read-write"`, or `"custom"`).
1432    #[serde(skip_serializing_if = "Option::is_none")]
1433    pub tool_profile: Option<String>,
1434    /// Default set of tool names available to this role.
1435    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1436    pub tools: Vec<String>,
1437    /// Default capability tags (e.g. `"rust"`, `"git"`, `"gh"`).
1438    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1439    pub capabilities: Vec<String>,
1440    /// Default timeout in seconds for tasks using this role.
1441    #[serde(skip_serializing_if = "Option::is_none")]
1442    pub timeout_seconds: Option<u64>,
1443    /// Default trust level override for this role.
1444    #[serde(skip_serializing_if = "Option::is_none")]
1445    pub trust_level: Option<String>,
1446}
1447
1448fn default_fleet_trust_level_str() -> String {
1449    "sandbox".to_string()
1450}
1451
1452fn default_fleet_require_identity() -> bool {
1453    true
1454}
1455
1456fn default_fleet_max_trust_level_str() -> String {
1457    "operator".to_string()
1458}
1459
1460impl Default for FleetConfigToml {
1461    fn default() -> Self {
1462        Self {
1463            default_trust_level: default_fleet_trust_level_str(),
1464            require_identity_verification: default_fleet_require_identity(),
1465            max_trust_level: default_fleet_max_trust_level_str(),
1466            roles: BTreeMap::new(),
1467            exec: FleetExecConfig::default(),
1468        }
1469    }
1470}
1471
1472impl FleetConfigToml {
1473    /// Resolve a role preset by name. Checks user-defined roles first,
1474    /// then falls back to built-in role defaults.
1475    #[must_use]
1476    pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
1477        self.roles
1478            .get(name)
1479            .cloned()
1480            .or_else(|| built_in_role_presets().get(name).cloned())
1481    }
1482}
1483
1484/// Built-in role presets that are always available without config.
1485#[must_use]
1486pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
1487    [
1488        (
1489            "smoke-runner".to_string(),
1490            FleetRolePreset {
1491                description: Some("Lightweight read-only smoke check worker".to_string()),
1492                tool_profile: Some("read-only".to_string()),
1493                tools: vec![],
1494                capabilities: vec![],
1495                timeout_seconds: Some(300),
1496                trust_level: Some("local".to_string()),
1497            },
1498        ),
1499        (
1500            "reviewer".to_string(),
1501            FleetRolePreset {
1502                description: Some("Read-only code and documentation review".to_string()),
1503                tool_profile: Some("read-only".to_string()),
1504                tools: vec![],
1505                capabilities: vec![],
1506                timeout_seconds: Some(600),
1507                trust_level: None,
1508            },
1509        ),
1510        (
1511            "builder".to_string(),
1512            FleetRolePreset {
1513                description: Some(
1514                    "Read-write builder with compilation and test access".to_string(),
1515                ),
1516                tool_profile: Some("read-write".to_string()),
1517                tools: vec![],
1518                capabilities: vec![],
1519                timeout_seconds: Some(1800),
1520                trust_level: Some("local".to_string()),
1521            },
1522        ),
1523        (
1524            "read-only".to_string(),
1525            FleetRolePreset {
1526                description: Some(
1527                    "Minimal read-only observer with no writes or secrets".to_string(),
1528                ),
1529                tool_profile: Some("read-only".to_string()),
1530                tools: vec![],
1531                capabilities: vec![],
1532                timeout_seconds: Some(300),
1533                trust_level: Some("sandbox".to_string()),
1534            },
1535        ),
1536    ]
1537    .into()
1538}
1539
1540/// On-disk schema for the `[network]` table (#135). See `config.example.toml`
1541/// for documentation.
1542#[derive(Debug, Clone, Serialize, Deserialize)]
1543pub struct NetworkPolicyToml {
1544    /// Decision for hosts that are not in `allow` or `deny`. One of
1545    /// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`.
1546    #[serde(default = "default_network_decision")]
1547    pub default: String,
1548    /// Hosts that are always allowed. Subdomain rules: a leading dot
1549    /// (`.example.com`) matches subdomains but not the apex.
1550    #[serde(default)]
1551    pub allow: Vec<String>,
1552    /// Hosts that are always denied. Deny entries win over allow entries.
1553    #[serde(default)]
1554    pub deny: Vec<String>,
1555    /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an
1556    /// explicitly trusted proxy setup. Literal IP URLs remain blocked.
1557    #[serde(default)]
1558    pub proxy: Vec<String>,
1559    /// Whether to record one audit-log line per outbound network call.
1560    #[serde(default = "default_network_audit")]
1561    pub audit: bool,
1562}
1563
1564fn default_network_decision() -> String {
1565    "prompt".to_string()
1566}
1567
1568fn default_network_audit() -> bool {
1569    true
1570}
1571
1572impl Default for NetworkPolicyToml {
1573    fn default() -> Self {
1574        Self {
1575            default: default_network_decision(),
1576            allow: Vec::new(),
1577            deny: Vec::new(),
1578            proxy: Vec::new(),
1579            audit: default_network_audit(),
1580        }
1581    }
1582}
1583
1584/// On-disk schema for the `[lsp]` table (#136). See `config.example.toml`
1585/// for documentation. All fields are optional so the TUI runtime can fall
1586/// back to its own defaults when keys are absent.
1587#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1588pub struct LspConfigToml {
1589    /// Master switch.
1590    pub enabled: Option<bool>,
1591    /// Maximum time to wait for diagnostics after an edit, in milliseconds.
1592    pub poll_after_edit_ms: Option<u64>,
1593    /// Cap on diagnostics surfaced per file.
1594    pub max_diagnostics_per_file: Option<usize>,
1595    /// When `true`, warnings (severity 2) are surfaced in addition to errors.
1596    pub include_warnings: Option<bool>,
1597    /// Optional override for the `language -> [cmd, ...args]` table.
1598    pub servers: Option<BTreeMap<String, Vec<String>>>,
1599}
1600
1601impl ConfigToml {
1602    /// Merge safe project-level overrides from `$WORKSPACE/.codewhale/config.toml`
1603    /// or legacy `$WORKSPACE/.deepseek/config.toml`.
1604    ///
1605    /// Repo-local config is untrusted input. This helper intentionally ignores
1606    /// credentials, endpoints, provider selection, auth/session values, telemetry,
1607    /// network policy, skill registry, LSP command tables, and unknown extras.
1608    /// Approval and sandbox values may only tighten the existing user/global
1609    /// posture.
1610    pub fn merge_project_overrides(&mut self, project: ConfigToml) {
1611        if project.default_text_model.is_some() {
1612            self.default_text_model = project.default_text_model;
1613        }
1614        if project.model.is_some() {
1615            self.model = project.model;
1616        }
1617        if project.output_mode.is_some() {
1618            self.output_mode = project.output_mode;
1619        }
1620        if project.verbosity.is_some() {
1621            self.verbosity = project.verbosity;
1622        }
1623        if project.log_level.is_some() {
1624            self.log_level = project.log_level;
1625        }
1626        if let Some(policy) = project.approval_policy
1627            && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
1628        {
1629            self.approval_policy = Some(policy);
1630        }
1631        if let Some(mode) = project.sandbox_mode
1632            && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
1633        {
1634            self.sandbox_mode = Some(mode);
1635        }
1636        if project.tools.is_some() {
1637            self.tools = project.tools;
1638        }
1639        for provider in ProviderKind::ALL {
1640            merge_project_provider_config(
1641                self.providers.for_provider_mut(provider),
1642                project.providers.for_provider(provider),
1643            );
1644        }
1645    }
1646
1647    #[must_use]
1648    pub fn get_value(&self, key: &str) -> Option<String> {
1649        if let Some((provider, field)) = parse_provider_config_key(key) {
1650            return get_provider_config_value(self.providers.for_provider(provider), field);
1651        }
1652
1653        match key {
1654            "provider" => Some(self.provider.as_str().to_string()),
1655            "api_key" => self.api_key.clone(),
1656            "base_url" => self.base_url.clone(),
1657            "http_headers" => serialize_http_headers(&self.http_headers),
1658            "default_text_model" => self.default_text_model.clone(),
1659            "model" => self.model.clone(),
1660            "auth.mode" => self.auth_mode.clone(),
1661            "output_mode" => self.output_mode.clone(),
1662            "verbosity" => self.verbosity.clone(),
1663            "log_level" => self.log_level.clone(),
1664            "telemetry" => self.telemetry.map(|v| v.to_string()),
1665            "approval_policy" => self.approval_policy.clone(),
1666            "sandbox_mode" => self.sandbox_mode.clone(),
1667            "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
1668            "hook_sinks.unix_socket_path" => self
1669                .hook_sinks
1670                .as_ref()
1671                .and_then(|sinks| sinks.unix_socket_path.as_ref())
1672                .map(|path| path.display().to_string()),
1673            _ => self.extras.get(key).map(toml::Value::to_string),
1674        }
1675    }
1676
1677    #[must_use]
1678    pub fn get_display_value(&self, key: &str) -> Option<String> {
1679        if let Some((provider, field)) = parse_provider_config_key(key) {
1680            return get_provider_config_display_value(self.providers.for_provider(provider), field);
1681        }
1682
1683        if key == "http_headers" {
1684            return serialize_http_headers_for_display(&self.http_headers);
1685        }
1686
1687        if let Some(value) = self.extras.get(key) {
1688            return Some(redact_toml_value_for_display(key, value));
1689        }
1690
1691        self.get_value(key).map(|value| {
1692            if is_sensitive_config_key(key) {
1693                redact_secret(&value)
1694            } else {
1695                value
1696            }
1697        })
1698    }
1699
1700    pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
1701        if let Some((provider, field)) = parse_provider_config_key(key) {
1702            return set_provider_config_value(self, provider, field, value);
1703        }
1704
1705        match key {
1706            "provider" => {
1707                self.provider = ProviderKind::parse(value).with_context(|| {
1708                    format!(
1709                        "unknown provider '{value}': expected {}",
1710                        ProviderKind::names_hint()
1711                    )
1712                })?;
1713            }
1714            "api_key" => self.api_key = Some(value.to_string()),
1715            "base_url" => self.base_url = Some(value.to_string()),
1716            "http_headers" => self.http_headers = parse_http_headers(value)?,
1717            "default_text_model" => self.default_text_model = Some(value.to_string()),
1718            "model" => self.model = Some(value.to_string()),
1719            "auth.mode" => self.auth_mode = Some(value.to_string()),
1720            "output_mode" => self.output_mode = Some(value.to_string()),
1721            "verbosity" => self.verbosity = Some(value.to_string()),
1722            "log_level" => self.log_level = Some(value.to_string()),
1723            "telemetry" => {
1724                self.telemetry = Some(parse_bool(value)?);
1725            }
1726            "approval_policy" => self.approval_policy = Some(value.to_string()),
1727            "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
1728            "hook_sinks.unix_socket_path" => {
1729                self.hook_sinks
1730                    .get_or_insert_with(HookSinksToml::default)
1731                    .unix_socket_path = Some(PathBuf::from(value));
1732            }
1733            _ => {
1734                self.extras
1735                    .insert(key.to_string(), toml::Value::String(value.to_string()));
1736            }
1737        }
1738        Ok(())
1739    }
1740
1741    pub fn unset_value(&mut self, key: &str) -> Result<()> {
1742        if let Some((provider, field)) = parse_provider_config_key(key) {
1743            unset_provider_config_value(self, provider, field);
1744            return Ok(());
1745        }
1746
1747        match key {
1748            "provider" => self.provider = ProviderKind::Deepseek,
1749            "api_key" => self.api_key = None,
1750            "base_url" => self.base_url = None,
1751            "http_headers" => self.http_headers.clear(),
1752            "default_text_model" => self.default_text_model = None,
1753            "model" => self.model = None,
1754            "auth.mode" => self.auth_mode = None,
1755            "output_mode" => self.output_mode = None,
1756            "verbosity" => self.verbosity = None,
1757            "log_level" => self.log_level = None,
1758            "telemetry" => self.telemetry = None,
1759            "approval_policy" => self.approval_policy = None,
1760            "sandbox_mode" => self.sandbox_mode = None,
1761            "hook_sinks.unix_socket_path" => {
1762                if let Some(sinks) = self.hook_sinks.as_mut() {
1763                    sinks.unix_socket_path = None;
1764                }
1765            }
1766            _ => {
1767                self.extras.remove(key);
1768            }
1769        }
1770        Ok(())
1771    }
1772
1773    #[must_use]
1774    pub fn list_values(&self) -> BTreeMap<String, String> {
1775        let mut out = BTreeMap::new();
1776        out.insert("provider".to_string(), self.provider.as_str().to_string());
1777
1778        if let Some(v) = self.api_key.as_ref() {
1779            out.insert("api_key".to_string(), redact_secret(v));
1780        }
1781        if let Some(v) = self.base_url.as_ref() {
1782            out.insert("base_url".to_string(), v.clone());
1783        }
1784        if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
1785            out.insert("http_headers".to_string(), v);
1786        }
1787        if let Some(v) = self.default_text_model.as_ref() {
1788            out.insert("default_text_model".to_string(), v.clone());
1789        }
1790        if let Some(v) = self.model.as_ref() {
1791            out.insert("model".to_string(), v.clone());
1792        }
1793        if let Some(v) = self.auth_mode.as_ref() {
1794            out.insert("auth.mode".to_string(), v.clone());
1795        }
1796        if let Some(v) = self.output_mode.as_ref() {
1797            out.insert("output_mode".to_string(), v.clone());
1798        }
1799        if let Some(v) = self.verbosity.as_ref() {
1800            out.insert("verbosity".to_string(), v.clone());
1801        }
1802        if let Some(v) = self.log_level.as_ref() {
1803            out.insert("log_level".to_string(), v.clone());
1804        }
1805        if let Some(v) = self.telemetry {
1806            out.insert("telemetry".to_string(), v.to_string());
1807        }
1808        if let Some(v) = self.approval_policy.as_ref() {
1809            out.insert("approval_policy".to_string(), v.clone());
1810        }
1811        if let Some(v) = self.sandbox_mode.as_ref() {
1812            out.insert("sandbox_mode".to_string(), v.clone());
1813        }
1814        if let Some(v) = self
1815            .hook_sinks
1816            .as_ref()
1817            .and_then(|sinks| sinks.unix_socket_path.as_ref())
1818        {
1819            out.insert(
1820                "hook_sinks.unix_socket_path".to_string(),
1821                v.display().to_string(),
1822            );
1823        }
1824
1825        for provider in ProviderKind::ALL {
1826            insert_provider_config_values(
1827                &mut out,
1828                provider,
1829                self.providers.for_provider(provider),
1830            );
1831        }
1832
1833        for (k, v) in &self.extras {
1834            out.insert(k.clone(), redact_toml_value_for_display(k, v));
1835        }
1836        out
1837    }
1838
1839    /// Resolve runtime options without touching platform credential stores.
1840    ///
1841    /// This method keeps library callers prompt-free: CLI flag → config file
1842    /// → environment. Call `resolve_runtime_options_with_secrets` when a
1843    /// user-facing dispatcher should recover credentials from the configured
1844    /// secret store.
1845    #[must_use]
1846    pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
1847        let no_keyring = Secrets::new(std::sync::Arc::new(
1848            codewhale_secrets::InMemoryKeyringStore::new(),
1849        ));
1850        self.resolve_runtime_options_with_secrets(cli, &no_keyring)
1851    }
1852
1853    /// Resolve runtime options using an explicit secrets façade.
1854    ///
1855    /// API-key precedence is **CLI flag → config-file → secret store → environment**.
1856    #[must_use]
1857    pub fn resolve_runtime_options_with_secrets(
1858        &self,
1859        cli: &CliRuntimeOverrides,
1860        secrets: &Secrets,
1861    ) -> ResolvedRuntimeOptions {
1862        let env = EnvRuntimeOverrides::load();
1863        let (provider, provider_source) = if let Some(provider) = cli.provider {
1864            (provider, ProviderSource::Cli)
1865        } else if let Some(provider) = env.provider {
1866            (
1867                provider,
1868                ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
1869            )
1870        } else {
1871            (self.provider, ProviderSource::Config)
1872        };
1873
1874        let mut provider_cfg = self.providers.for_provider(provider).clone();
1875        if provider == ProviderKind::SiliconflowCN {
1876            let fb = &self.providers.siliconflow;
1877            if provider_cfg.api_key.is_none() {
1878                provider_cfg.api_key = fb.api_key.clone();
1879            }
1880            if provider_cfg.base_url.is_none() {
1881                provider_cfg.base_url = fb.base_url.clone();
1882            }
1883            if provider_cfg.model.is_none() {
1884                provider_cfg.model = fb.model.clone();
1885            }
1886        }
1887        let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
1888            .then(|| self.api_key.clone())
1889            .flatten();
1890        let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
1891            .then(|| self.base_url.clone())
1892            .flatten();
1893        let root_deepseek_model = (provider == ProviderKind::Deepseek)
1894            .then(|| self.default_text_model.clone())
1895            .flatten();
1896        let auth_mode = cli
1897            .auth_mode
1898            .clone()
1899            .or_else(|| env.auth_mode.clone())
1900            .or_else(|| provider_cfg.auth_mode.clone())
1901            .or_else(|| self.auth_mode.clone());
1902        let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
1903        let configured_base_url = cli
1904            .base_url
1905            .clone()
1906            .or_else(|| env.base_url_for(provider))
1907            .or_else(|| provider_cfg.base_url.clone())
1908            .or(root_deepseek_base_url);
1909        let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
1910            env.xiaomi_mimo_mode
1911                .clone()
1912                .or_else(|| provider_cfg.mode.clone())
1913        } else {
1914            None
1915        };
1916        let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
1917            xiaomi_mimo_env_api_key_for_runtime(
1918                xiaomi_mimo_mode.as_deref(),
1919                configured_base_url.as_deref(),
1920            )
1921        } else {
1922            None
1923        };
1924        let explicit_api_key_for_endpoint = cli
1925            .api_key
1926            .as_deref()
1927            .or(from_file.as_deref())
1928            .or(xiaomi_mimo_env_api_key.as_deref());
1929        let base_url = if provider == ProviderKind::XiaomiMimo {
1930            resolve_xiaomi_mimo_base_url(
1931                configured_base_url,
1932                explicit_api_key_for_endpoint,
1933                xiaomi_mimo_mode.as_deref(),
1934            )
1935        } else {
1936            configured_base_url.unwrap_or_else(|| match provider {
1937                ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
1938                ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
1939                ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
1940                ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
1941                ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
1942                ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
1943                ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
1944                ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
1945                ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
1946                ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
1947                ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
1948                ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
1949                ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
1950                ProviderKind::Moonshot => {
1951                    if auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) {
1952                        DEFAULT_KIMI_CODE_BASE_URL.to_string()
1953                    } else {
1954                        DEFAULT_MOONSHOT_BASE_URL.to_string()
1955                    }
1956                }
1957                ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
1958                ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
1959                ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
1960                ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
1961                ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
1962                ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
1963                ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
1964                ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
1965                ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
1966                ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
1967                ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
1968            })
1969        };
1970        // CLI flag wins outright. Otherwise: config-file → injected secrets/env.
1971        // This makes `deepseek auth set` a reliable fix even when the user's
1972        // shell still exports an old key. When the file is empty, the injected
1973        // secrets façade recovers configured secret-store credentials before
1974        // falling back to ambient env.
1975        let uses_kimi_oauth = provider == ProviderKind::Moonshot
1976            && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth);
1977        let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() {
1978            (Some(value), Some(RuntimeApiKeySource::Cli))
1979        } else if uses_kimi_oauth {
1980            (None, None)
1981        } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) {
1982            (Some(value), Some(RuntimeApiKeySource::ConfigFile))
1983        } else if let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty()) {
1984            (Some(value), Some(RuntimeApiKeySource::Env))
1985        } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
1986            match env_api_key_for_provider(provider) {
1987                Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
1988                None => (None, None),
1989            }
1990        } else {
1991            match secrets.resolve_with_source(provider.as_str()) {
1992                Some((value, source)) => {
1993                    let source = match source {
1994                        SecretSource::Keyring => RuntimeApiKeySource::Keyring,
1995                        SecretSource::Env => RuntimeApiKeySource::Env,
1996                    };
1997                    (Some(value), Some(source))
1998                }
1999                None => match env_api_key_for_provider(provider) {
2000                    Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2001                    None => (None, None),
2002                },
2003            }
2004        };
2005
2006        let env_provider_model = env.model_for(provider, &base_url);
2007        let explicit_model = cli.model.is_some()
2008            || env.model.is_some()
2009            || env_provider_model.is_some()
2010            || provider_cfg.model.is_some()
2011            || root_deepseek_model.is_some()
2012            || self.model.is_some();
2013        let model = cli
2014            .model
2015            .clone()
2016            .or_else(|| env.model.clone())
2017            .or(env_provider_model)
2018            .or_else(|| provider_cfg.model.clone())
2019            .or(root_deepseek_model)
2020            .or_else(|| self.model.clone())
2021            .unwrap_or_else(|| {
2022                if provider == ProviderKind::Moonshot
2023                    && (auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth)
2024                        || moonshot_base_url_uses_kimi_code(&base_url))
2025                {
2026                    DEFAULT_KIMI_CODE_MODEL.to_string()
2027                } else {
2028                    default_model_for_provider(provider).to_string()
2029                }
2030            });
2031        let model =
2032            if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
2033                model.trim().to_string()
2034            } else {
2035                normalize_model_for_provider(provider, &model)
2036            };
2037
2038        let mut http_headers = self.http_headers.clone();
2039        http_headers.extend(provider_cfg.http_headers.clone());
2040        if let Some(env_headers) = env.http_headers {
2041            http_headers.extend(env_headers);
2042        }
2043        http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
2044
2045        let output_mode = cli
2046            .output_mode
2047            .clone()
2048            .or_else(|| env.output_mode.clone())
2049            .or_else(|| self.output_mode.clone());
2050        let log_level = cli
2051            .log_level
2052            .clone()
2053            .or_else(|| env.log_level.clone())
2054            .or_else(|| self.log_level.clone());
2055        let telemetry = cli
2056            .telemetry
2057            .or(env.telemetry)
2058            .or(self.telemetry)
2059            .unwrap_or(false);
2060        let approval_policy = cli
2061            .approval_policy
2062            .clone()
2063            .or_else(|| env.approval_policy.clone())
2064            .or_else(|| self.approval_policy.clone());
2065        let sandbox_mode = cli
2066            .sandbox_mode
2067            .clone()
2068            .or_else(|| env.sandbox_mode.clone())
2069            .or_else(|| self.sandbox_mode.clone());
2070        let yolo = cli.yolo.or(env.yolo);
2071        let verbosity = cli
2072            .verbosity
2073            .clone()
2074            .or_else(|| env.verbosity.clone())
2075            .or_else(|| self.verbosity.clone());
2076
2077        ResolvedRuntimeOptions {
2078            provider,
2079            provider_source,
2080            model,
2081            api_key,
2082            api_key_source,
2083            base_url,
2084            auth_mode,
2085            insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
2086            output_mode,
2087            log_level,
2088            telemetry,
2089            approval_policy,
2090            sandbox_mode,
2091            yolo,
2092            verbosity,
2093            http_headers,
2094        }
2095    }
2096}
2097
2098fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
2099    if source.model.is_some() {
2100        target.model = source.model.clone();
2101    }
2102}
2103
2104#[must_use]
2105pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
2106    let Some(project_rank) = approval_policy_rank(project) else {
2107        return false;
2108    };
2109    match current.and_then(approval_policy_rank) {
2110        Some(current_rank) => project_rank >= current_rank,
2111        None => project_rank >= 2,
2112    }
2113}
2114
2115#[must_use]
2116pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
2117    let normalized_project = project.trim().to_ascii_lowercase();
2118    if normalized_project == "external-sandbox" {
2119        return current
2120            .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
2121            .unwrap_or(false);
2122    }
2123
2124    let Some(project_rank) = sandbox_mode_rank(project) else {
2125        return false;
2126    };
2127    match current.and_then(sandbox_mode_rank) {
2128        Some(current_rank) => project_rank >= current_rank,
2129        None => project_rank >= 2,
2130    }
2131}
2132
2133fn approval_policy_rank(value: &str) -> Option<u8> {
2134    match value.trim().to_ascii_lowercase().as_str() {
2135        "auto" => Some(0),
2136        "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
2137        "never" | "deny" | "denied" => Some(2),
2138        _ => None,
2139    }
2140}
2141
2142fn sandbox_mode_rank(value: &str) -> Option<u8> {
2143    match value.trim().to_ascii_lowercase().as_str() {
2144        "danger-full-access" => Some(0),
2145        "external-sandbox" => Some(0),
2146        "workspace-write" => Some(1),
2147        "read-only" => Some(2),
2148        _ => None,
2149    }
2150}
2151
2152/// Load a project-level config from the workspace.
2153///
2154/// Checks `$WORKSPACE/.codewhale/config.toml` first, falling back to
2155/// `$WORKSPACE/.deepseek/config.toml` for backward compatibility.
2156/// Returns `None` if neither file exists or can't be parsed.
2157pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
2158    for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
2159        let path = workspace.join(dir).join(CONFIG_FILE_NAME);
2160        if path.exists()
2161            && let Ok(raw) = fs::read_to_string(&path)
2162        {
2163            match toml::from_str(&raw) {
2164                Ok(config) => return Some(config),
2165                Err(e) => {
2166                    tracing::warn!("Failed to parse project config {}: {e}", path.display());
2167                    return None;
2168                }
2169            }
2170        }
2171    }
2172    None
2173}
2174
2175fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
2176    if matches!(provider, ProviderKind::XiaomiMimo)
2177        && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
2178    {
2179        return canonical.to_string();
2180    }
2181    if matches!(provider, ProviderKind::Minimax)
2182        && let Some(canonical) = canonical_minimax_model_id(model)
2183    {
2184        return canonical.to_string();
2185    }
2186    if matches!(provider, ProviderKind::Zai)
2187        && let Some(canonical) = canonical_zai_model_id(model)
2188    {
2189        return canonical.to_string();
2190    }
2191
2192    if matches!(
2193        provider,
2194        ProviderKind::Atlascloud
2195            | ProviderKind::WanjieArk
2196            | ProviderKind::Volcengine
2197            | ProviderKind::XiaomiMimo
2198            | ProviderKind::Zai
2199            | ProviderKind::Stepfun
2200            | ProviderKind::Minimax
2201            | ProviderKind::Ollama
2202    ) {
2203        return model.to_string();
2204    }
2205
2206    let normalized = model.trim().to_ascii_lowercase();
2207    if provider == ProviderKind::Openrouter
2208        && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
2209    {
2210        return canonical.to_string();
2211    }
2212    match (provider, normalized.as_str()) {
2213        (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
2214            DEFAULT_NVIDIA_NIM_MODEL.to_string()
2215        }
2216        (
2217            ProviderKind::NvidiaNim,
2218            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2219            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2220        ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
2221        (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
2222            DEFAULT_OPENROUTER_MODEL.to_string()
2223        }
2224        (
2225            ProviderKind::Openrouter,
2226            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2227            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2228        ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
2229        (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
2230            DEFAULT_NOVITA_MODEL.to_string()
2231        }
2232        (
2233            ProviderKind::Novita,
2234            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2235            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2236        ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
2237        (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
2238            DEFAULT_FIREWORKS_MODEL.to_string()
2239        }
2240        (
2241            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2242            "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
2243        ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
2244        (
2245            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2246            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
2247        ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
2248        (
2249            ProviderKind::Arcee,
2250            "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
2251        ) => DEFAULT_ARCEE_MODEL.to_string(),
2252        (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
2253            ARCEE_TRINITY_MINI_MODEL.to_string()
2254        }
2255        (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
2256            ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
2257        }
2258        (
2259            ProviderKind::Moonshot,
2260            "kimi"
2261            | "kimi-k2"
2262            | "kimi-k2.7"
2263            | "kimi-k2-7"
2264            | "kimi-k2.7-code"
2265            | "kimi-k2-7-code"
2266            | "kimi-code"
2267            | "moonshot-kimi-k2.7-code",
2268        ) => DEFAULT_MOONSHOT_MODEL.to_string(),
2269        (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
2270            MOONSHOT_KIMI_K2_6_MODEL.to_string()
2271        }
2272        (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
2273            DEFAULT_SGLANG_MODEL.to_string()
2274        }
2275        (
2276            ProviderKind::Sglang,
2277            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2278            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2279        ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
2280        (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
2281            DEFAULT_VLLM_MODEL.to_string()
2282        }
2283        (
2284            ProviderKind::Vllm,
2285            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2286            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2287        ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
2288        (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
2289            DEFAULT_HUGGINGFACE_MODEL.to_string()
2290        }
2291        (
2292            ProviderKind::Huggingface,
2293            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2294            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2295        ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
2296        (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
2297            DEFAULT_TOGETHER_MODEL.to_string()
2298        }
2299        (
2300            ProviderKind::Together,
2301            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2302            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2303        ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
2304        (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
2305            DEFAULT_DEEPINFRA_MODEL.to_string()
2306        }
2307        (
2308            ProviderKind::Deepinfra,
2309            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2310            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2311        ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
2312        _ => model.to_string(),
2313    }
2314}
2315
2316fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
2317    let normalized = model.trim().to_ascii_lowercase();
2318    let normalized = normalized.replace(['_', ' '], "-");
2319    match normalized.as_str() {
2320        "mimo"
2321        | DEFAULT_XIAOMI_MIMO_MODEL
2322        | "mimo-v2-5-pro"
2323        | "xiaomi-mimo-v2.5-pro"
2324        | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
2325        "omni"
2326        | "mimo-omni"
2327        | "v2.5-omni"
2328        | "v25-omni"
2329        | "mimo-v2.5"
2330        | "mimo-v25"
2331        | "mimo-v2-5"
2332        | "mimo-v2.5-omni"
2333        | "mimo-v25-omni"
2334        | "mimo-v2-5-omni"
2335        | "xiaomi-mimo-v2.5"
2336        | "xiaomi-mimo-v2-5"
2337        | "xiaomi-mimo-v2.5-omni"
2338        | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
2339        "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
2340            Some(XIAOMI_MIMO_ASR_MODEL)
2341        }
2342        "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
2343            Some(XIAOMI_MIMO_TTS_MODEL)
2344        }
2345        "mimo-tts-voicedesign"
2346        | "mimo-voice-design"
2347        | "mimo-v25-tts-voicedesign"
2348        | "mimo-v2.5-tts-voicedesign"
2349        | "voicedesign"
2350        | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
2351        "mimo-tts-voiceclone"
2352        | "mimo-voice-clone"
2353        | "mimo-v25-tts-voiceclone"
2354        | "mimo-v2.5-tts-voiceclone"
2355        | "voiceclone"
2356        | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
2357        "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
2358        _ => None,
2359    }
2360}
2361
2362fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
2363    let normalized = model.trim().to_ascii_lowercase();
2364    let normalized = normalized.replace(['_', ' '], "-");
2365    match normalized.as_str() {
2366        "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
2367            Some(DEFAULT_MINIMAX_MODEL)
2368        }
2369        "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
2370            Some(MINIMAX_M2_7_MODEL)
2371        }
2372        "minimax-m2.7-highspeed"
2373        | "minimax-m2-7-highspeed"
2374        | "minimax-m-2.7-highspeed"
2375        | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
2376        "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
2377            Some(MINIMAX_M2_5_MODEL)
2378        }
2379        "minimax-m2.5-highspeed"
2380        | "minimax-m2-5-highspeed"
2381        | "minimax-m-2.5-highspeed"
2382        | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
2383        "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
2384            Some(MINIMAX_M2_1_MODEL)
2385        }
2386        "minimax-m2.1-highspeed"
2387        | "minimax-m2-1-highspeed"
2388        | "minimax-m-2.1-highspeed"
2389        | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
2390        "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
2391        _ => None,
2392    }
2393}
2394
2395fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
2396    let normalized = model.trim().to_ascii_lowercase();
2397    let normalized = normalized.replace(['_', ' '], "-");
2398    match normalized.as_str() {
2399        "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
2400        "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(DEFAULT_ZAI_MODEL),
2401        "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
2402        _ => None,
2403    }
2404}
2405
2406fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
2407    let normalized = model.trim().to_ascii_lowercase();
2408    let normalized = normalized.replace(['_', ' '], "-");
2409    match normalized.as_str() {
2410        OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
2411        | "trinity"
2412        | "trinity-large-thinking"
2413        | "arcee-trinity"
2414        | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
2415        OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
2416            Some(OPENROUTER_GEMMA_4_31B_MODEL)
2417        }
2418        OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
2419            Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
2420        }
2421        OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
2422            Some(OPENROUTER_GLM_5_1_MODEL)
2423        }
2424        OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
2425            Some(OPENROUTER_GLM_5_2_MODEL)
2426        }
2427        OPENROUTER_KIMI_K2_7_CODE_MODEL
2428        | "kimi"
2429        | "kimi-k2"
2430        | "kimi-k2.7"
2431        | "kimi-k2-7"
2432        | "kimi-k2.7-code"
2433        | "kimi-k2-7-code"
2434        | "kimi-code"
2435        | "moonshot-kimi-k2.7-code"
2436        | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
2437        OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
2438            Some(OPENROUTER_KIMI_K2_6_MODEL)
2439        }
2440        OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
2441            Some(OPENROUTER_MINIMAX_M3_MODEL)
2442        }
2443        OPENROUTER_MINIMAX_2_7_MODEL
2444        | "minimax-2.7"
2445        | "minimax-2-7"
2446        | "minimax-m2.7"
2447        | "minimax-m2-7"
2448        | "minimax-m-2.7"
2449        | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_2_7_MODEL),
2450        OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
2451        | "nemotron-3-nano-omni"
2452        | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
2453        OPENROUTER_QWEN_3_6_35B_A3B_MODEL
2454        | "qwen3.6-35b-a3b"
2455        | "qwen-3.6-35b-a3b"
2456        | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
2457        OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
2458            Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
2459        }
2460        OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
2461        | "qwen3.6-max-preview"
2462        | "qwen-3.6-max-preview"
2463        | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
2464        OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
2465            Some(OPENROUTER_QWEN_3_6_27B_MODEL)
2466        }
2467        OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
2468            Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
2469        }
2470        OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
2471            Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
2472        }
2473        OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
2474            Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
2475        }
2476        OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
2477        | "mimo-v2.5-pro"
2478        | "mimo-v2-5-pro"
2479        | "xiaomi-mimo-v2.5-pro"
2480        | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
2481        OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
2482        | "mimo-v2.5"
2483        | "mimo-v2-5"
2484        | "xiaomi-mimo-v2.5"
2485        | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
2486        _ => None,
2487    }
2488}
2489
2490fn default_model_for_provider(provider: ProviderKind) -> &'static str {
2491    match provider {
2492        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
2493        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
2494        ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
2495        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
2496        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
2497        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
2498        ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
2499        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
2500        ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
2501        ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
2502        ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
2503        ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
2504        ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
2505        ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
2506        ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
2507        ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
2508        ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
2509        ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
2510        ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
2511        ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
2512        ProviderKind::Zai => DEFAULT_ZAI_MODEL,
2513        ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
2514        ProviderKind::Minimax => DEFAULT_MINIMAX_MODEL,
2515        ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
2516    }
2517}
2518
2519fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
2520    match provider {
2521        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
2522        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
2523        ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
2524        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
2525        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
2526        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
2527        ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
2528        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
2529        ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
2530        ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
2531        ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
2532        ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
2533        ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
2534        ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
2535        ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
2536        ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
2537        ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
2538        ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
2539        ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
2540        ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
2541        ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
2542        ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
2543        ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
2544        ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
2545        ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
2546    }
2547}
2548
2549fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
2550    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
2551    normalized == DEFAULT_KIMI_CODE_BASE_URL
2552        || normalized == "https://api.kimi.com/coding"
2553        || normalized.starts_with("https://api.kimi.com/coding/")
2554}
2555
2556fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
2557    let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
2558    if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
2559        return None;
2560    }
2561    Some(match normalized.as_str() {
2562        "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
2563            DEFAULT_XIAOMI_MIMO_BASE_URL
2564        }
2565        "token-plan-cn"
2566        | "token-plan-china"
2567        | "token-plan-mainland"
2568        | "token-plan-mainland-china"
2569        | "cn"
2570        | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
2571        "token-plan-sgp"
2572        | "token-plan-sg"
2573        | "token-plan-singapore"
2574        | "sgp"
2575        | "sg"
2576        | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
2577        "token-plan-ams"
2578        | "token-plan-eu"
2579        | "token-plan-europe"
2580        | "token-plan-amsterdam"
2581        | "ams"
2582        | "eu"
2583        | "europe"
2584        | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
2585        _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
2586    })
2587}
2588
2589fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
2590    matches!(
2591        normalized_mode,
2592        "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
2593    )
2594}
2595
2596fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
2597    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
2598    normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
2599        || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
2600        || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
2601}
2602
2603fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
2604    candidates.iter().find_map(|name| {
2605        std::env::var(name)
2606            .ok()
2607            .filter(|value| !value.trim().is_empty())
2608    })
2609}
2610
2611fn xiaomi_mimo_env_api_key_for_runtime(
2612    mode: Option<&str>,
2613    base_url: Option<&str>,
2614) -> Option<String> {
2615    const TOKEN_PLAN_ENV_VARS: &[&str] =
2616        &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
2617    const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
2618
2619    let normalized_mode =
2620        mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
2621    let standard_selected = normalized_mode
2622        .as_deref()
2623        .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
2624        || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
2625    if standard_selected {
2626        return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
2627    }
2628
2629    let token_plan_selected = normalized_mode
2630        .as_deref()
2631        .and_then(xiaomi_mimo_base_url_for_mode)
2632        .is_some()
2633        || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
2634    if token_plan_selected {
2635        return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
2636    }
2637
2638    xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
2639}
2640
2641fn resolve_xiaomi_mimo_base_url(
2642    configured: Option<String>,
2643    api_key: Option<&str>,
2644    mode: Option<&str>,
2645) -> String {
2646    let normalized_mode =
2647        mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
2648    let uses_standard_mode = normalized_mode
2649        .as_deref()
2650        .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
2651    let mode_base_url = normalized_mode
2652        .as_deref()
2653        .and_then(xiaomi_mimo_base_url_for_mode);
2654    let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
2655    match configured {
2656        Some(base_url) if uses_standard_mode => base_url,
2657        Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
2658            mode_base_url
2659                .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
2660                .to_string()
2661        }
2662        Some(base_url) => base_url,
2663        None => {
2664            if let Some(base_url) = mode_base_url {
2665                base_url.to_string()
2666            } else if uses_standard_mode {
2667                XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
2668            } else if uses_token_plan || api_key.is_none() {
2669                DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
2670            } else {
2671                XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
2672            }
2673        }
2674    }
2675}
2676
2677fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
2678    api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
2679}
2680
2681fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
2682    matches!(
2683        base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
2684        "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
2685    )
2686}
2687
2688fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
2689    if provider.is_siliconflow() && siliconflow_base_url_is_official(base_url) {
2690        return false;
2691    }
2692    if provider == ProviderKind::XiaomiMimo
2693        && (xiaomi_mimo_base_url_uses_token_plan(base_url)
2694            || xiaomi_mimo_base_url_is_pay_as_you_go(base_url))
2695    {
2696        return false;
2697    }
2698    let actual = base_url.trim_end_matches('/');
2699    let default = default_base_url_for_provider(provider).trim_end_matches('/');
2700    actual != default
2701}
2702
2703fn siliconflow_base_url_is_official(base_url: &str) -> bool {
2704    matches!(
2705        base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
2706        "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
2707    )
2708}
2709
2710fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
2711    base_url_is_custom_for_provider(provider, base_url)
2712}
2713
2714fn should_skip_secret_store_for_provider(
2715    provider: ProviderKind,
2716    base_url: &str,
2717    auth_mode: Option<&str>,
2718) -> bool {
2719    if auth_mode_requires_api_key(auth_mode) {
2720        return false;
2721    }
2722    if auth_mode_disables_api_key(auth_mode) {
2723        return true;
2724    }
2725
2726    matches!(
2727        provider,
2728        ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama
2729    ) || base_url_uses_local_host(base_url)
2730}
2731
2732fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
2733    if provider == ProviderKind::Huggingface {
2734        return std::env::var("HUGGINGFACE_API_KEY")
2735            .ok()
2736            .filter(|value| !value.trim().is_empty())
2737            .or_else(|| {
2738                std::env::var("HF_TOKEN")
2739                    .ok()
2740                    .filter(|value| !value.trim().is_empty())
2741            });
2742    }
2743
2744    codewhale_secrets::env_for(provider.as_str())
2745}
2746
2747fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
2748    matches!(
2749        auth_mode
2750            .map(str::trim)
2751            .filter(|value| !value.is_empty())
2752            .map(|value| value.to_ascii_lowercase()),
2753        Some(value)
2754            if matches!(
2755                value.as_str(),
2756                "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
2757            )
2758    )
2759}
2760
2761fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
2762    matches!(
2763        auth_mode
2764            .map(str::trim)
2765            .filter(|value| !value.is_empty())
2766            .map(|value| value.to_ascii_lowercase()),
2767        Some(value)
2768            if matches!(
2769                value.as_str(),
2770                "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
2771            )
2772    )
2773}
2774
2775fn auth_mode_uses_kimi_oauth(auth_mode: &str) -> bool {
2776    matches!(
2777        auth_mode
2778            .trim()
2779            .to_ascii_lowercase()
2780            .replace('-', "_")
2781            .as_str(),
2782        "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
2783    )
2784}
2785
2786fn base_url_uses_local_host(base_url: &str) -> bool {
2787    let Some(host) = base_url_host(base_url) else {
2788        return false;
2789    };
2790    let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
2791    if matches!(host.as_str(), "localhost" | "0.0.0.0") {
2792        return true;
2793    }
2794    host.parse::<std::net::IpAddr>()
2795        .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
2796}
2797
2798fn base_url_host(base_url: &str) -> Option<&str> {
2799    let without_scheme = base_url
2800        .split_once("://")
2801        .map_or(base_url, |(_, rest)| rest);
2802    let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
2803    if let Some(rest) = authority.strip_prefix('[') {
2804        return rest.split_once(']').map(|(host, _)| host);
2805    }
2806    authority.split(':').next().filter(|host| !host.is_empty())
2807}
2808
2809#[derive(Debug, Clone, Default)]
2810pub struct CliRuntimeOverrides {
2811    pub provider: Option<ProviderKind>,
2812    pub model: Option<String>,
2813    pub api_key: Option<String>,
2814    pub base_url: Option<String>,
2815    pub auth_mode: Option<String>,
2816    pub output_mode: Option<String>,
2817    pub log_level: Option<String>,
2818    pub telemetry: Option<bool>,
2819    pub approval_policy: Option<String>,
2820    pub sandbox_mode: Option<String>,
2821    pub yolo: Option<bool>,
2822    pub verbosity: Option<String>,
2823}
2824
2825#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2826pub enum RuntimeApiKeySource {
2827    Cli,
2828    ConfigFile,
2829    Keyring,
2830    Env,
2831}
2832
2833impl RuntimeApiKeySource {
2834    #[must_use]
2835    pub fn as_env_value(self) -> &'static str {
2836        match self {
2837            Self::Cli => "cli",
2838            Self::ConfigFile => "config",
2839            Self::Keyring => "keyring",
2840            Self::Env => "env",
2841        }
2842    }
2843}
2844
2845#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2846pub enum ProviderSource {
2847    Cli,
2848    Env(&'static str),
2849    Config,
2850}
2851
2852#[derive(Debug, Clone)]
2853pub struct ResolvedRuntimeOptions {
2854    pub provider: ProviderKind,
2855    pub provider_source: ProviderSource,
2856    pub model: String,
2857    pub api_key: Option<String>,
2858    pub api_key_source: Option<RuntimeApiKeySource>,
2859    pub base_url: String,
2860    pub auth_mode: Option<String>,
2861    pub insecure_skip_tls_verify: bool,
2862    pub output_mode: Option<String>,
2863    pub log_level: Option<String>,
2864    pub telemetry: bool,
2865    pub approval_policy: Option<String>,
2866    pub sandbox_mode: Option<String>,
2867    pub yolo: Option<bool>,
2868    pub verbosity: Option<String>,
2869    pub http_headers: BTreeMap<String, String>,
2870}
2871
2872#[derive(Debug, Clone)]
2873pub struct ConfigStore {
2874    path: PathBuf,
2875    pub config: ConfigToml,
2876    permissions: PermissionsToml,
2877    /// Original file text, retained so [`save`](Self::save) can merge
2878    /// comments back after serialisation.
2879    original_raw: Option<String>,
2880}
2881
2882impl ConfigStore {
2883    pub fn load(path: Option<PathBuf>) -> Result<Self> {
2884        let path = resolve_config_path(path)?;
2885        let (config, original_raw) = if path.exists() {
2886            let raw = fs::read_to_string(&path)
2887                .with_context(|| format!("failed to read config at {}", path.display()))?;
2888            let parsed: ConfigToml = toml::from_str(&raw)
2889                .with_context(|| format!("failed to parse config at {}", path.display()))?;
2890            (parsed, Some(raw))
2891        } else {
2892            (ConfigToml::default(), None)
2893        };
2894        let permissions = load_sibling_permissions(&path)?;
2895
2896        Ok(Self {
2897            path,
2898            config,
2899            permissions,
2900            original_raw,
2901        })
2902    }
2903
2904    pub fn save(&self) -> Result<()> {
2905        if let Some(parent) = self.path.parent() {
2906            fs::create_dir_all(parent).with_context(|| {
2907                format!("failed to create config directory {}", parent.display())
2908            })?;
2909        }
2910        let body = if let Some(ref original_raw) = self.original_raw {
2911            let serialized =
2912                toml::to_string_pretty(&self.config).context("failed to serialize config")?;
2913            merge_and_preserve_comments(&serialized, original_raw).unwrap_or_else(|e| {
2914                tracing::warn!("failed to merge config comments, saving without them: {e:#}");
2915                serialized
2916            })
2917        } else {
2918            toml::to_string_pretty(&self.config).context("failed to serialize config")?
2919        };
2920        match fs::read_to_string(&self.path) {
2921            Ok(existing) => {
2922                if existing == body {
2923                    return Ok(());
2924                }
2925                write_one_time_config_backup(&self.path)?;
2926            }
2927            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
2928            Err(err) => {
2929                return Err(err)
2930                    .with_context(|| format!("failed to read config at {}", self.path.display()));
2931            }
2932        }
2933        #[cfg(unix)]
2934        {
2935            let mut file = fs::OpenOptions::new()
2936                .write(true)
2937                .create(true)
2938                .truncate(true)
2939                .mode(0o600)
2940                .open(&self.path)
2941                .with_context(|| format!("failed to write config at {}", self.path.display()))?;
2942            file.write_all(body.as_bytes())
2943                .with_context(|| format!("failed to write config at {}", self.path.display()))?;
2944            file.set_permissions(fs::Permissions::from_mode(0o600))
2945                .with_context(|| {
2946                    format!(
2947                        "failed to set config permissions at {}",
2948                        self.path.display()
2949                    )
2950                })?;
2951        }
2952        #[cfg(not(unix))]
2953        {
2954            fs::write(&self.path, body)
2955                .with_context(|| format!("failed to write config at {}", self.path.display()))?;
2956        }
2957        Ok(())
2958    }
2959
2960    #[must_use]
2961    pub fn path(&self) -> &Path {
2962        &self.path
2963    }
2964
2965    #[must_use]
2966    pub fn permissions(&self) -> &PermissionsToml {
2967        &self.permissions
2968    }
2969
2970    #[must_use]
2971    pub fn permissions_path(&self) -> PathBuf {
2972        permissions_path_for_config_path(&self.path)
2973    }
2974
2975    #[must_use]
2976    pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
2977        if self.permissions.is_empty() {
2978            ExecPolicyEngine::new(Vec::new(), Vec::new())
2979        } else {
2980            ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
2981        }
2982    }
2983
2984    /// Atomically append ask-only permission rules to the sibling
2985    /// `permissions.toml` file.
2986    ///
2987    /// Existing comments and formatting are preserved. Exact duplicate rules
2988    /// are ignored, and the in-memory permissions snapshot is refreshed after
2989    /// a successful write.
2990    pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
2991        if rules.is_empty() {
2992            return Ok(0);
2993        }
2994
2995        let path = self.permissions_path();
2996        let raw = if path.exists() {
2997            fs::read_to_string(&path)
2998                .with_context(|| format!("failed to read permissions at {}", path.display()))?
2999        } else {
3000            String::new()
3001        };
3002        let mut permissions = if raw.trim().is_empty() {
3003            PermissionsToml::default()
3004        } else {
3005            toml::from_str(&raw)
3006                .with_context(|| format!("failed to parse permissions at {}", path.display()))?
3007        };
3008        let mut document = if raw.trim().is_empty() {
3009            toml_edit::DocumentMut::new()
3010        } else {
3011            raw.parse::<toml_edit::DocumentMut>()
3012                .with_context(|| format!("failed to edit permissions at {}", path.display()))?
3013        };
3014
3015        if !document.contains_key("rules") {
3016            document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
3017        }
3018        let rules_item = document
3019            .get_mut("rules")
3020            .expect("rules entry was inserted above");
3021
3022        let mut added = 0;
3023        for rule in rules {
3024            if permissions.rules.contains(rule) {
3025                continue;
3026            }
3027            append_ask_rule(rules_item, rule)?;
3028            permissions.rules.push(rule.clone());
3029            added += 1;
3030        }
3031        if added == 0 {
3032            self.permissions = permissions;
3033            return Ok(0);
3034        }
3035
3036        let body = document.to_string();
3037        let persisted: PermissionsToml = toml::from_str(&body).with_context(|| {
3038            format!(
3039                "generated invalid permissions document for {}",
3040                path.display()
3041            )
3042        })?;
3043        write_permissions_atomic(&path, body.as_bytes())?;
3044        self.permissions = persisted;
3045        Ok(added)
3046    }
3047}
3048
3049fn config_backup_path(path: &Path) -> PathBuf {
3050    let mut file_name = path
3051        .file_name()
3052        .map(std::ffi::OsString::from)
3053        .unwrap_or_else(|| std::ffi::OsString::from(CONFIG_FILE_NAME));
3054    file_name.push(".bak");
3055    path.with_file_name(file_name)
3056}
3057
3058fn write_one_time_config_backup(path: &Path) -> Result<()> {
3059    let backup = config_backup_path(path);
3060    if backup.exists() {
3061        return Ok(());
3062    }
3063    fs::copy(path, &backup).with_context(|| {
3064        format!(
3065            "failed to create config backup {} from {}",
3066            backup.display(),
3067            path.display()
3068        )
3069    })?;
3070    #[cfg(unix)]
3071    {
3072        fs::set_permissions(&backup, fs::Permissions::from_mode(0o600)).with_context(|| {
3073            format!(
3074                "failed to set config backup permissions at {}",
3075                backup.display()
3076            )
3077        })?;
3078    }
3079    Ok(())
3080}
3081
3082/// Merge comments and formatting from an original TOML file into a
3083/// freshly serialized document so user annotations (comments, whitespace,
3084/// disabled keys) survive config rewrites.
3085///
3086/// `original_raw` is the raw text of the file before the change; the
3087/// function parses it internally with [`toml_edit`] so callers stay free
3088/// of that dependency.
3089pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
3090    let original = original_raw
3091        .parse::<toml_edit::DocumentMut>()
3092        .context("failed to parse original config for comment merge")?;
3093
3094    let mut new_doc = serialized
3095        .parse::<toml_edit::DocumentMut>()
3096        .context("failed to parse serialized config for comment merge")?;
3097
3098    // Reuse the original document’s trailing text (file-footer comments /
3099    // disabled keys) so they survive the rewrite.
3100    new_doc.set_trailing(original.trailing().clone());
3101
3102    // Copy the top-level table's decor (document-header comments, whitespace
3103    // before the first key) which `toml_edit` stores on the root `Table` itself.
3104    *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
3105
3106    merge_decor_table(new_doc.as_table_mut(), original.as_table());
3107
3108    Ok(new_doc.to_string())
3109}
3110
3111/// Recursively copy `decor` (prefix/suffix comments and whitespace) from
3112/// every key in `source` that also exists in `target`.
3113fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3114    // Collect keys first — the borrow checker won't let us hold
3115    // `get_key_value_mut` while iterating.
3116    let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
3117    for key in &keys {
3118        let Some((source_key, source_item)) = source.get_key_value(key) else {
3119            continue;
3120        };
3121        let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
3122            continue;
3123        };
3124
3125        // Copy the key-level decor (comments before the key itself)
3126        *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
3127
3128        copy_item_decor(target_item, source_item);
3129
3130        if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
3131            merge_decor_table(tt, st);
3132        }
3133
3134        if let (Some(ta), Some(sa)) = (
3135            target_item.as_array_of_tables_mut(),
3136            source_item.as_array_of_tables(),
3137        ) {
3138            for (i, source_table) in sa.iter().enumerate() {
3139                if let Some(target_table) = ta.get_mut(i) {
3140                    copy_item_decor_table(target_table, source_table);
3141                    merge_decor_table(target_table, source_table);
3142                }
3143            }
3144        }
3145    }
3146}
3147
3148/// Copy the decor (comments and surrounding whitespace) from `source` to `target`,
3149/// respecting the concrete item type since [`toml_edit::Item`] has no uniform
3150/// `decor` accessor.
3151fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
3152    match (target, source) {
3153        (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
3154            *tt.decor_mut() = st.decor().clone();
3155        }
3156        (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
3157            *tv.decor_mut() = sv.decor().clone();
3158        }
3159        _ => {}
3160    }
3161}
3162
3163fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3164    *target.decor_mut() = source.decor().clone();
3165}
3166
3167/// Process-wide default [`Secrets`] façade. The first caller wins; the
3168/// lock is exposed so test or CLI code can install an explicit
3169/// backend (e.g. an [`codewhale_secrets::InMemoryKeyringStore`]) before
3170/// any resolver runs.
3171pub fn default_secrets() -> &'static Secrets {
3172    static SECRETS: OnceLock<Secrets> = OnceLock::new();
3173    SECRETS.get_or_init(|| {
3174        // Tests should never poke real platform credential stores. Cargo sets the
3175        // `RUST_TEST_*` family of env vars (and `CARGO_PKG_NAME` is
3176        // always populated), but the `cfg(test)` flag is the canonical
3177        // signal here. See `install_test_secrets` for explicit installs.
3178        #[cfg(test)]
3179        {
3180            Secrets::new(std::sync::Arc::new(
3181                codewhale_secrets::InMemoryKeyringStore::new(),
3182            ))
3183        }
3184        #[cfg(not(test))]
3185        {
3186            Secrets::auto_detect()
3187        }
3188    })
3189}
3190
3191// ── CodeWhale state root (v0.8.44) ──────────────────────────────────
3192//
3193// v0.8.44 migrates product-owned app state from ~/.deepseek/ to
3194// ~/.codewhale/ while keeping ~/.deepseek/ as a compatibility fallback.
3195// New installs write to ~/.codewhale/. Existing installs with only
3196// ~/.deepseek/ continue working without data loss.
3197
3198/// Canonical CodeWhale app directory name under $HOME.
3199pub const CODEWHALE_APP_DIR: &str = ".codewhale";
3200
3201/// Legacy DeepSeek-branded app directory name (compatibility fallback).
3202pub const LEGACY_APP_DIR: &str = ".deepseek";
3203
3204/// Resolve the primary CodeWhale home directory.
3205///
3206/// `$CODEWHALE_HOME` takes precedence when set. Otherwise defaults to
3207/// `$HOME/.codewhale`. This is the write target for new product state.
3208pub fn codewhale_home() -> Result<PathBuf> {
3209    if let Ok(val) = std::env::var("CODEWHALE_HOME") {
3210        let trimmed = val.trim();
3211        if !trimmed.is_empty() {
3212            return Ok(PathBuf::from(trimmed));
3213        }
3214    }
3215    let home = effective_home_dir().context("failed to resolve home directory")?;
3216    Ok(home.join(CODEWHALE_APP_DIR))
3217}
3218
3219/// Resolve the legacy DeepSeek home directory (`$HOME/.deepseek`).
3220///
3221/// Always returns the legacy path regardless of whether it exists.
3222pub fn legacy_deepseek_home() -> Result<PathBuf> {
3223    let home = effective_home_dir().context("failed to resolve home directory")?;
3224    Ok(home.join(LEGACY_APP_DIR))
3225}
3226
3227fn effective_home_dir() -> Option<PathBuf> {
3228    std::env::var_os("HOME")
3229        .filter(|value| !value.is_empty())
3230        .map(PathBuf::from)
3231        .or_else(dirs::home_dir)
3232}
3233
3234/// Reject state subdirs that could escape the state root via path injection.
3235///
3236/// `ensure_state_dir` / `resolve_state_dir` are public APIs taking an arbitrary
3237/// subdir string; every in-tree caller passes a hardcoded single component
3238/// (e.g. `"sessions"`, `"."`). This validates defensively so a future caller
3239/// can never traverse out of the state root via `..` components or an absolute
3240/// path. Nested relative paths such as `"a/b"` are permitted.
3241fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
3242    if subdir.is_empty() {
3243        bail!("state subdir must not be empty");
3244    }
3245    let path = std::path::Path::new(subdir);
3246    if path.is_absolute() {
3247        bail!("state subdir must not be an absolute path: {subdir}");
3248    }
3249    if path.components().any(|c| {
3250        matches!(
3251            c,
3252            std::path::Component::RootDir | std::path::Component::Prefix(_)
3253        )
3254    }) {
3255        bail!("state subdir must not contain a root or prefix: {subdir}");
3256    }
3257    if path
3258        .components()
3259        .any(|c| matches!(c, std::path::Component::ParentDir))
3260    {
3261        bail!("state subdir must not contain parent-dir (..) components: {subdir}");
3262    }
3263    Ok(())
3264}
3265
3266/// Resolve a state subdirectory, preferring the CodeWhale root if
3267/// it already exists, otherwise falling back to the legacy root.
3268///
3269/// This is the read-path resolver: it returns the primary path when
3270/// migration has occurred or on a fresh install, but keeps reading
3271/// from the legacy path for users who haven't migrated yet.
3272pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
3273    ensure_safe_state_subdir(subdir)?;
3274    let primary = codewhale_home()?.join(subdir);
3275    if primary.exists() {
3276        return Ok(primary);
3277    }
3278    let legacy = legacy_deepseek_home()?.join(subdir);
3279    if legacy.exists() {
3280        return Ok(legacy);
3281    }
3282    // Neither exists — return primary for first-write creation.
3283    Ok(primary)
3284}
3285
3286/// Ensure a state subdirectory exists under the primary CodeWhale root,
3287/// creating it if necessary. This is the write-path resolver.
3288///
3289/// On the first creation of a real subdirectory (not the root sentinel `"."`),
3290/// if a legacy `~/.deepseek/<subdir>` exists but the primary
3291/// `~/.codewhale/<subdir>` does not, the legacy directory is relocated into
3292/// the primary location so the user keeps their data and the legacy tree
3293/// stops growing (#3240). After migration, [`resolve_state_dir`] finds the
3294/// data in the primary location; the read resolver itself is unchanged.
3295pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
3296    ensure_safe_state_subdir(subdir)?;
3297    let dir = codewhale_home()?.join(subdir);
3298    migrate_legacy_state_dir(&dir, subdir)?;
3299    std::fs::create_dir_all(&dir)
3300        .with_context(|| format!("failed to create {}/", dir.display()))?;
3301    Ok(dir)
3302}
3303
3304/// One-time relocation of a legacy `~/.deepseek/<subdir>` state directory into
3305/// the primary `~/.codewhale/<subdir>` location (#3240). No-op once the primary
3306/// exists, for the root sentinel `"."` (a whole-tree move is owned by the
3307/// config-file migration), or when no legacy directory is present.
3308fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<()> {
3309    if primary.exists() || subdir == "." || subdir.is_empty() {
3310        return Ok(());
3311    }
3312    let legacy = match legacy_deepseek_home() {
3313        Ok(home) => home.join(subdir),
3314        Err(_) => return Ok(()),
3315    };
3316    if !legacy.exists() {
3317        return Ok(());
3318    }
3319    // The primary's parent (the ~/.codewhale root) must exist for the rename.
3320    if let Some(parent) = primary.parent() {
3321        if let Err(err) = std::fs::create_dir_all(parent) {
3322            tracing::warn!(
3323                target: "config::migration",
3324                "Could not create {} for state migration ({}); writing to primary anyway",
3325                parent.display(),
3326                err
3327            );
3328        }
3329    }
3330    match std::fs::rename(&legacy, primary) {
3331        Ok(()) => {
3332            tracing::info!(
3333                target: "config::migration",
3334                "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
3335                legacy.display(),
3336                primary.display()
3337            );
3338        }
3339        Err(err) => {
3340            // Cross-device rename or permission issue: fall back to a
3341            // recursive copy so the user keeps their data. The legacy tree is
3342            // left in place; it stops growing because writes now target the
3343            // primary path.
3344            match copy_dir_recursive(&legacy, primary) {
3345                Ok(()) => {
3346                    tracing::info!(
3347                        target: "config::migration",
3348                        "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
3349                         The legacy .deepseek copy was left in place.",
3350                        legacy.display(),
3351                        primary.display()
3352                    );
3353                }
3354                Err(copy_err) => {
3355                    tracing::warn!(
3356                        target: "config::migration",
3357                        "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
3358                         New data is written to the primary path; the legacy tree remains untouched.",
3359                        legacy.display(),
3360                        primary.display()
3361                    );
3362                }
3363            }
3364        }
3365    }
3366    Ok(())
3367}
3368
3369/// Recursively copy a directory tree from `src` to `dst`, creating `dst`.
3370/// Symlinks and other non-file/non-dir entries are skipped (rare in state dirs).
3371fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
3372    std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
3373    for entry in
3374        std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
3375    {
3376        let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
3377        let path = entry.path();
3378        let target = dst.join(entry.file_name());
3379        let file_type = entry
3380            .file_type()
3381            .with_context(|| format!("failed to read file type for {}", path.display()))?;
3382        if file_type.is_dir() {
3383            copy_dir_recursive(&path, &target)?;
3384        } else if file_type.is_file() {
3385            std::fs::copy(&path, &target).with_context(|| {
3386                format!("failed to copy {} -> {}", path.display(), target.display())
3387            })?;
3388        }
3389    }
3390    Ok(())
3391}
3392
3393/// Resolve a project-local state subdirectory, preferring `.codewhale/`
3394/// when it exists, falling back to `.deepseek/` for legacy projects.
3395///
3396/// Returns `(true, path)` when the primary `.codewhale/` path is used,
3397/// `(false, path)` for the legacy fallback. The boolean helps callers
3398/// emit a deprecation notice on legacy paths.
3399pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> (bool, PathBuf) {
3400    let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3401    if primary.exists() {
3402        return (true, primary);
3403    }
3404    let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
3405    (false, legacy)
3406}
3407
3408/// Ensure a project-local state subdirectory exists under `.codewhale/`,
3409/// creating it if necessary. Returns the directory path.
3410pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
3411    let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3412    std::fs::create_dir_all(&dir)
3413        .with_context(|| format!("failed to create {}/", dir.display()))?;
3414    Ok(dir)
3415}
3416
3417pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
3418    let path = if let Some(path) = explicit {
3419        path
3420    } else if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
3421        let trimmed = path.trim();
3422        if !trimmed.is_empty() {
3423            PathBuf::from(trimmed)
3424        } else {
3425            return default_config_path();
3426        }
3427    } else if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
3428        let trimmed = path.trim();
3429        if !trimmed.is_empty() {
3430            PathBuf::from(trimmed)
3431        } else {
3432            return default_config_path();
3433        }
3434    } else {
3435        return default_config_path();
3436    };
3437    normalize_config_file_path(path)
3438}
3439
3440#[must_use]
3441pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
3442    config_path.with_file_name(PERMISSIONS_FILE_NAME)
3443}
3444
3445pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
3446    Ok(permissions_path_for_config_path(&resolve_config_path(
3447        config_path,
3448    )?))
3449}
3450
3451fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
3452    let permissions_path = permissions_path_for_config_path(config_path);
3453    if !permissions_path.exists() {
3454        return Ok(PermissionsToml::default());
3455    }
3456
3457    let raw = fs::read_to_string(&permissions_path).with_context(|| {
3458        format!(
3459            "failed to read permissions at {}",
3460            permissions_path.display()
3461        )
3462    })?;
3463    toml::from_str(&raw).with_context(|| {
3464        format!(
3465            "failed to parse permissions at {}",
3466            permissions_path.display()
3467        )
3468    })
3469}
3470
3471fn append_ask_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
3472    match item {
3473        toml_edit::Item::ArrayOfTables(rules) => {
3474            rules.push(ask_rule_table(rule));
3475            Ok(())
3476        }
3477        toml_edit::Item::Value(value) => {
3478            let Some(rules) = value.as_array_mut() else {
3479                bail!("`rules` in permissions.toml must be an array");
3480            };
3481            rules.push(toml_edit::Value::InlineTable(ask_rule_inline_table(rule)));
3482            Ok(())
3483        }
3484        _ => bail!("`rules` in permissions.toml must be an array"),
3485    }
3486}
3487
3488fn ask_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
3489    let mut table = toml_edit::Table::new();
3490    table["tool"] = toml_edit::value(rule.tool.clone());
3491    if let Some(command) = rule.command.as_deref() {
3492        table["command"] = toml_edit::value(command);
3493    }
3494    if let Some(path) = rule.path.as_deref() {
3495        table["path"] = toml_edit::value(path);
3496    }
3497    table
3498}
3499
3500fn ask_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
3501    let mut table = toml_edit::InlineTable::new();
3502    table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
3503    if let Some(command) = rule.command.as_deref() {
3504        table.insert("command", toml_edit::Value::from(command));
3505    }
3506    if let Some(path) = rule.path.as_deref() {
3507        table.insert("path", toml_edit::Value::from(path));
3508    }
3509    table
3510}
3511
3512fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
3513    let parent = path.parent().with_context(|| {
3514        format!(
3515            "permissions path has no parent directory: {}",
3516            path.display()
3517        )
3518    })?;
3519    fs::create_dir_all(parent).with_context(|| {
3520        format!(
3521            "failed to create permissions directory {}",
3522            parent.display()
3523        )
3524    })?;
3525
3526    let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
3527        format!(
3528            "failed to create temporary permissions file in {}",
3529            parent.display()
3530        )
3531    })?;
3532    #[cfg(unix)]
3533    temporary
3534        .as_file()
3535        .set_permissions(fs::Permissions::from_mode(0o600))
3536        .with_context(|| {
3537            format!(
3538                "failed to secure temporary permissions file for {}",
3539                path.display()
3540            )
3541        })?;
3542    temporary
3543        .write_all(body)
3544        .with_context(|| format!("failed to write permissions at {}", path.display()))?;
3545    temporary
3546        .as_file()
3547        .sync_all()
3548        .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
3549    temporary
3550        .persist(path)
3551        .map_err(|error| error.error)
3552        .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
3553    Ok(())
3554}
3555
3556pub fn default_config_path() -> Result<PathBuf> {
3557    // Prefer ~/.codewhale/config.toml when it exists (fresh install or
3558    // migrated), otherwise fall back to ~/.deepseek/config.toml.
3559    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
3560    if primary.exists() {
3561        return Ok(primary);
3562    }
3563    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
3564    if legacy.exists() {
3565        return Ok(legacy);
3566    }
3567    // Neither exists — return primary so first write creates it there.
3568    Ok(primary)
3569}
3570
3571#[derive(Debug, Clone, PartialEq, Eq)]
3572pub struct ConfigMigration {
3573    pub legacy_path: PathBuf,
3574    pub primary_path: PathBuf,
3575}
3576
3577impl ConfigMigration {
3578    pub fn user_notice(&self) -> String {
3579        format!(
3580            "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
3581            self.legacy_path.display(),
3582            self.primary_path.display()
3583        )
3584    }
3585}
3586
3587/// v0.8.44: one-time migration from `~/.deepseek/config.toml` to
3588/// `~/.codewhale/config.toml`. Called on first launch after the config
3589/// is loaded; copies the legacy file if the primary doesn't exist yet.
3590/// Never overwrites an existing primary config.
3591pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
3592    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
3593    if primary.exists() {
3594        return Ok(None);
3595    }
3596    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
3597    if !legacy.exists() {
3598        return Ok(None);
3599    }
3600    // Copy the config to the new home.
3601    if let Some(parent) = primary.parent() {
3602        std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
3603    }
3604    std::fs::copy(&legacy, &primary)
3605        .context("failed to migrate config from deepseek to codewhale home")?;
3606    tracing::info!(
3607        "Migrated config from {} to {}",
3608        legacy.display(),
3609        primary.display()
3610    );
3611    Ok(Some(ConfigMigration {
3612        legacy_path: legacy,
3613        primary_path: primary,
3614    }))
3615}
3616
3617fn parse_bool(raw: &str) -> Result<bool> {
3618    match raw.trim().to_ascii_lowercase().as_str() {
3619        "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
3620        "0" | "false" | "no" | "off" | "disabled" => Ok(false),
3621        _ => bail!("invalid boolean '{raw}'"),
3622    }
3623}
3624
3625fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
3626    let mut headers = BTreeMap::new();
3627    for pair in raw.trim().split(',') {
3628        let pair = pair.trim();
3629        if pair.is_empty() {
3630            continue;
3631        }
3632        let Some((name, value)) = pair.split_once('=') else {
3633            bail!("invalid header pair '{pair}', expected name=value");
3634        };
3635        let name = name.trim();
3636        let value = value.trim();
3637        if name.is_empty() {
3638            bail!("header name cannot be empty");
3639        }
3640        if value.is_empty() {
3641            continue;
3642        }
3643        headers.insert(name.to_string(), value.to_string());
3644    }
3645    Ok(headers)
3646}
3647
3648fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
3649    if headers.is_empty() {
3650        return None;
3651    }
3652    Some(
3653        headers
3654            .iter()
3655            .map(|(name, value)| format!("{name}={value}"))
3656            .collect::<Vec<_>>()
3657            .join(","),
3658    )
3659}
3660
3661fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
3662    if headers.is_empty() {
3663        return None;
3664    }
3665    Some(
3666        headers
3667            .iter()
3668            .map(|(name, value)| {
3669                let display_value = if is_sensitive_config_key(name) {
3670                    redact_secret(value)
3671                } else {
3672                    value.clone()
3673                };
3674                format!("{name}={display_value}")
3675            })
3676            .collect::<Vec<_>>()
3677            .join(","),
3678    )
3679}
3680
3681fn redact_secret(secret: &str) -> String {
3682    let chars: Vec<char> = secret.chars().collect();
3683    if chars.len() <= 16 {
3684        return "********".to_string();
3685    }
3686    let prefix: String = chars.iter().take(4).collect();
3687    let suffix: String = chars
3688        .iter()
3689        .rev()
3690        .take(4)
3691        .collect::<Vec<_>>()
3692        .into_iter()
3693        .rev()
3694        .collect();
3695    format!("{prefix}***{suffix}")
3696}
3697
3698#[must_use]
3699pub fn is_sensitive_config_key(key: &str) -> bool {
3700    let Some(segment) = key.rsplit('.').next() else {
3701        return false;
3702    };
3703    let normalized = segment
3704        .trim()
3705        .trim_matches('"')
3706        .replace('-', "_")
3707        .to_ascii_lowercase();
3708
3709    matches!(
3710        normalized.as_str(),
3711        "api_key"
3712            | "apikey"
3713            | "api_keys"
3714            | "authorization"
3715            | "bearer"
3716            | "client_secret"
3717            | "credential"
3718            | "credentials"
3719            | "id_token"
3720            | "password"
3721            | "passwords"
3722            | "passwd"
3723            | "proxy_authorization"
3724            | "refresh_token"
3725            | "secret"
3726            | "secrets"
3727            | "token"
3728            | "tokens"
3729    ) || normalized.ends_with("_api_key")
3730        || normalized.ends_with("_authorization")
3731        || normalized.ends_with("_password")
3732        || normalized.ends_with("_secret")
3733        || normalized.ends_with("_token")
3734}
3735
3736fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
3737    redact_toml_value_for_display_inner(key, false, value).to_string()
3738}
3739
3740fn redact_toml_value_for_display_inner(
3741    key: &str,
3742    sensitive_ancestor: bool,
3743    value: &toml::Value,
3744) -> toml::Value {
3745    let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
3746    match value {
3747        toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
3748        toml::Value::Array(values) => toml::Value::Array(
3749            values
3750                .iter()
3751                .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
3752                .collect(),
3753        ),
3754        toml::Value::Table(table) => {
3755            let mut redacted = toml::map::Map::new();
3756            for (child_key, child_value) in table {
3757                let path = if key.is_empty() {
3758                    child_key.clone()
3759                } else {
3760                    format!("{key}.{child_key}")
3761                };
3762                redacted.insert(
3763                    child_key.clone(),
3764                    redact_toml_value_for_display_inner(&path, sensitive, child_value),
3765                );
3766            }
3767            toml::Value::Table(redacted)
3768        }
3769        _ if sensitive => toml::Value::String("********".to_string()),
3770        _ => value.clone(),
3771    }
3772}
3773
3774fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
3775    if path.as_os_str().is_empty() {
3776        bail!("config path cannot be empty");
3777    }
3778    if path
3779        .components()
3780        .any(|component| matches!(component, Component::ParentDir))
3781    {
3782        bail!("config path cannot contain '..' components");
3783    }
3784    if path.file_name().is_none() {
3785        bail!("config path must include a file name");
3786    }
3787    if path.is_absolute() {
3788        return Ok(path);
3789    }
3790    Ok(std::env::current_dir()
3791        .context("failed to resolve current directory for config path")?
3792        .join(path))
3793}
3794
3795#[derive(Debug, Clone, Default)]
3796struct EnvRuntimeOverrides {
3797    provider: Option<ProviderKind>,
3798    provider_source: Option<&'static str>,
3799    model: Option<String>,
3800    volcengine_model: Option<String>,
3801    wanjie_ark_model: Option<String>,
3802    openrouter_model: Option<String>,
3803    moonshot_model: Option<String>,
3804    xiaomi_mimo_model: Option<String>,
3805    xiaomi_mimo_mode: Option<String>,
3806    novita_model: Option<String>,
3807    fireworks_model: Option<String>,
3808    arcee_model: Option<String>,
3809    output_mode: Option<String>,
3810    auth_mode: Option<String>,
3811    log_level: Option<String>,
3812    telemetry: Option<bool>,
3813    approval_policy: Option<String>,
3814    sandbox_mode: Option<String>,
3815    yolo: Option<bool>,
3816    verbosity: Option<String>,
3817    http_headers: Option<BTreeMap<String, String>>,
3818    deepseek_base_url: Option<String>,
3819    nvidia_base_url: Option<String>,
3820    openai_base_url: Option<String>,
3821    atlascloud_base_url: Option<String>,
3822    volcengine_base_url: Option<String>,
3823    wanjie_ark_base_url: Option<String>,
3824    openrouter_base_url: Option<String>,
3825    xiaomi_mimo_base_url: Option<String>,
3826    novita_base_url: Option<String>,
3827    fireworks_base_url: Option<String>,
3828    siliconflow_base_url: Option<String>,
3829    siliconflow_model: Option<String>,
3830    arcee_base_url: Option<String>,
3831    moonshot_base_url: Option<String>,
3832    sglang_base_url: Option<String>,
3833    vllm_base_url: Option<String>,
3834    ollama_base_url: Option<String>,
3835    huggingface_base_url: Option<String>,
3836    huggingface_model: Option<String>,
3837    together_base_url: Option<String>,
3838    together_model: Option<String>,
3839    openai_codex_base_url: Option<String>,
3840    openai_codex_model: Option<String>,
3841    anthropic_base_url: Option<String>,
3842    anthropic_model: Option<String>,
3843    zai_base_url: Option<String>,
3844    zai_model: Option<String>,
3845    stepfun_base_url: Option<String>,
3846    stepfun_model: Option<String>,
3847    minimax_base_url: Option<String>,
3848    minimax_model: Option<String>,
3849    deepinfra_base_url: Option<String>,
3850    deepinfra_model: Option<String>,
3851}
3852
3853impl EnvRuntimeOverrides {
3854    fn load() -> Self {
3855        let (provider, provider_source) = Self::load_provider();
3856        Self {
3857            provider,
3858            provider_source,
3859            model: std::env::var("CODEWHALE_MODEL")
3860                .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
3861                .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
3862                .ok()
3863                .filter(|v| !v.trim().is_empty()),
3864            volcengine_model: std::env::var("VOLCENGINE_MODEL")
3865                .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
3866                .ok()
3867                .filter(|v| !v.trim().is_empty()),
3868            wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
3869                .or_else(|_| std::env::var("WANJIE_MODEL"))
3870                .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
3871                .ok()
3872                .filter(|v| !v.trim().is_empty()),
3873            openrouter_model: std::env::var("OPENROUTER_MODEL")
3874                .ok()
3875                .filter(|v| !v.trim().is_empty()),
3876            moonshot_model: std::env::var("MOONSHOT_MODEL")
3877                .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
3878                .or_else(|_| std::env::var("KIMI_MODEL"))
3879                .ok()
3880                .filter(|v| !v.trim().is_empty()),
3881            xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
3882                .or_else(|_| std::env::var("MIMO_MODEL"))
3883                .ok()
3884                .filter(|v| !v.trim().is_empty()),
3885            xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
3886                .or_else(|_| std::env::var("MIMO_MODE"))
3887                .ok()
3888                .filter(|v| !v.trim().is_empty()),
3889            novita_model: std::env::var("NOVITA_MODEL")
3890                .ok()
3891                .filter(|v| !v.trim().is_empty()),
3892            fireworks_model: std::env::var("FIREWORKS_MODEL")
3893                .ok()
3894                .filter(|v| !v.trim().is_empty()),
3895            arcee_model: std::env::var("ARCEE_MODEL")
3896                .ok()
3897                .filter(|v| !v.trim().is_empty()),
3898            verbosity: std::env::var("CODEWHALE_VERBOSITY")
3899                .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
3900                .ok(),
3901            output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
3902            auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
3903            log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
3904            telemetry: std::env::var("DEEPSEEK_TELEMETRY")
3905                .ok()
3906                .and_then(|v| match parse_bool(&v) {
3907                    Ok(b) => Some(b),
3908                    Err(_) => {
3909                        tracing::warn!("Invalid DEEPSEEK_TELEMETRY value '{v}', expected true/false");
3910                        None
3911                    }
3912                }),
3913            approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
3914            sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
3915            yolo: std::env::var("DEEPSEEK_YOLO")
3916                .ok()
3917                .and_then(|v| match parse_bool(&v) {
3918                    Ok(b) => Some(b),
3919                    Err(_) => {
3920                        tracing::warn!("Invalid DEEPSEEK_YOLO value '{v}', expected true/false");
3921                        None
3922                    }
3923                }),
3924            http_headers: std::env::var("DEEPSEEK_HTTP_HEADERS")
3925                .ok()
3926                .and_then(|value| match parse_http_headers(&value) {
3927                    Ok(h) => Some(h),
3928                    Err(_) => {
3929                        tracing::warn!("Invalid DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
3930                        None
3931                    }
3932                })
3933                .filter(|headers| !headers.is_empty()),
3934            deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
3935                .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
3936                .ok()
3937                .filter(|v| !v.trim().is_empty()),
3938            nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
3939                .or_else(|_| std::env::var("NIM_BASE_URL"))
3940                .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
3941                .ok()
3942                .filter(|v| !v.trim().is_empty()),
3943            openai_base_url: std::env::var("OPENAI_BASE_URL")
3944                .ok()
3945                .filter(|v| !v.trim().is_empty()),
3946            atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
3947                .ok()
3948                .filter(|v| !v.trim().is_empty()),
3949            volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
3950                .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
3951                .or_else(|_| std::env::var("ARK_BASE_URL"))
3952                .ok()
3953                .filter(|v| !v.trim().is_empty()),
3954            wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
3955                .or_else(|_| std::env::var("WANJIE_BASE_URL"))
3956                .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
3957                .ok()
3958                .filter(|v| !v.trim().is_empty()),
3959            openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
3960                .ok()
3961                .filter(|v| !v.trim().is_empty()),
3962            xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
3963                .or_else(|_| std::env::var("MIMO_BASE_URL"))
3964                .ok()
3965                .filter(|v| !v.trim().is_empty()),
3966            novita_base_url: std::env::var("NOVITA_BASE_URL")
3967                .ok()
3968                .filter(|v| !v.trim().is_empty()),
3969            fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
3970                .ok()
3971                .filter(|v| !v.trim().is_empty()),
3972            siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
3973                .ok()
3974                .filter(|v| !v.trim().is_empty()),
3975            siliconflow_model: std::env::var("SILICONFLOW_MODEL")
3976                .ok()
3977                .filter(|v| !v.trim().is_empty()),
3978            arcee_base_url: std::env::var("ARCEE_BASE_URL")
3979                .ok()
3980                .filter(|v| !v.trim().is_empty()),
3981            moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
3982                .or_else(|_| std::env::var("KIMI_BASE_URL"))
3983                .ok()
3984                .filter(|v| !v.trim().is_empty()),
3985            sglang_base_url: std::env::var("SGLANG_BASE_URL")
3986                .ok()
3987                .filter(|v| !v.trim().is_empty()),
3988            vllm_base_url: std::env::var("VLLM_BASE_URL")
3989                .ok()
3990                .filter(|v| !v.trim().is_empty()),
3991            ollama_base_url: std::env::var("OLLAMA_BASE_URL")
3992                .ok()
3993                .filter(|v| !v.trim().is_empty()),
3994            huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
3995                .or_else(|_| std::env::var("HF_BASE_URL"))
3996                .ok()
3997                .filter(|v| !v.trim().is_empty()),
3998            huggingface_model: std::env::var("HUGGINGFACE_MODEL")
3999                .or_else(|_| std::env::var("HF_MODEL"))
4000                .ok()
4001                .filter(|v| !v.trim().is_empty()),
4002            together_base_url: std::env::var("TOGETHER_BASE_URL")
4003                .ok()
4004                .filter(|v| !v.trim().is_empty()),
4005            together_model: std::env::var("TOGETHER_MODEL")
4006                .ok()
4007                .filter(|v| !v.trim().is_empty()),
4008            openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
4009                .or_else(|_| std::env::var("CODEX_BASE_URL"))
4010                .ok()
4011                .filter(|v| !v.trim().is_empty()),
4012            openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
4013                .or_else(|_| std::env::var("CODEX_MODEL"))
4014                .ok()
4015                .filter(|v| !v.trim().is_empty()),
4016            anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
4017                .ok()
4018                .filter(|v| !v.trim().is_empty()),
4019            anthropic_model: std::env::var("ANTHROPIC_MODEL")
4020                .ok()
4021                .filter(|v| !v.trim().is_empty()),
4022            zai_base_url: std::env::var("ZAI_BASE_URL")
4023                .or_else(|_| std::env::var("Z_AI_BASE_URL"))
4024                .ok()
4025                .filter(|v| !v.trim().is_empty()),
4026            zai_model: std::env::var("ZAI_MODEL")
4027                .or_else(|_| std::env::var("Z_AI_MODEL"))
4028                .ok()
4029                .filter(|v| !v.trim().is_empty()),
4030            stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
4031                .or_else(|_| std::env::var("STEP_BASE_URL"))
4032                .ok()
4033                .filter(|v| !v.trim().is_empty()),
4034            stepfun_model: std::env::var("STEPFUN_MODEL")
4035                .or_else(|_| std::env::var("STEP_MODEL"))
4036                .ok()
4037                .filter(|v| !v.trim().is_empty()),
4038            minimax_base_url: std::env::var("MINIMAX_BASE_URL")
4039                .ok()
4040                .filter(|v| !v.trim().is_empty()),
4041            minimax_model: std::env::var("MINIMAX_MODEL")
4042                .ok()
4043                .filter(|v| !v.trim().is_empty()),
4044            deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
4045                .ok()
4046                .filter(|v| !v.trim().is_empty()),
4047            deepinfra_model: std::env::var("DEEPINFRA_MODEL")
4048                .ok()
4049                .filter(|v| !v.trim().is_empty()),
4050        }
4051    }
4052
4053    fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
4054        if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
4055            let parsed = ProviderKind::parse(&value);
4056            return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
4057        }
4058
4059        if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
4060            let parsed = ProviderKind::parse(&value);
4061            return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
4062        }
4063
4064        (None, None)
4065    }
4066
4067    fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
4068        // Defaults belong in the resolver's final fallback so config-file
4069        // values (`providers.<name>.base_url`) still win when env is unset.
4070        match provider {
4071            ProviderKind::Deepseek => self.deepseek_base_url.clone(),
4072            ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
4073            ProviderKind::Openai => self.openai_base_url.clone(),
4074            ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
4075            ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
4076            ProviderKind::Volcengine => self.volcengine_base_url.clone(),
4077            ProviderKind::Openrouter => self.openrouter_base_url.clone(),
4078            ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
4079            ProviderKind::Novita => self.novita_base_url.clone(),
4080            ProviderKind::Fireworks => self.fireworks_base_url.clone(),
4081            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4082                self.siliconflow_base_url.clone()
4083            }
4084            ProviderKind::Arcee => self.arcee_base_url.clone(),
4085            ProviderKind::Moonshot => self.moonshot_base_url.clone(),
4086            ProviderKind::Sglang => self.sglang_base_url.clone(),
4087            ProviderKind::Vllm => self.vllm_base_url.clone(),
4088            ProviderKind::Ollama => self.ollama_base_url.clone(),
4089            ProviderKind::Huggingface => self.huggingface_base_url.clone(),
4090            ProviderKind::Together => self.together_base_url.clone(),
4091            ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
4092            ProviderKind::Anthropic => self.anthropic_base_url.clone(),
4093            ProviderKind::Zai => self.zai_base_url.clone(),
4094            ProviderKind::Stepfun => self.stepfun_base_url.clone(),
4095            ProviderKind::Minimax => self.minimax_base_url.clone(),
4096            ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
4097        }
4098    }
4099
4100    fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
4101        let model = match provider {
4102            ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
4103            ProviderKind::Volcengine => self.volcengine_model.clone(),
4104            ProviderKind::Openrouter => self.openrouter_model.clone(),
4105            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4106                self.siliconflow_model.clone()
4107            }
4108            ProviderKind::Arcee => self.arcee_model.clone(),
4109            ProviderKind::Moonshot => self.moonshot_model.clone(),
4110            ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
4111            ProviderKind::Novita => self.novita_model.clone(),
4112            ProviderKind::Fireworks => self.fireworks_model.clone(),
4113            ProviderKind::Huggingface => self.huggingface_model.clone(),
4114            ProviderKind::Together => self.together_model.clone(),
4115            ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
4116            ProviderKind::Anthropic => self.anthropic_model.clone(),
4117            ProviderKind::Zai => self.zai_model.clone(),
4118            ProviderKind::Stepfun => self.stepfun_model.clone(),
4119            ProviderKind::Minimax => self.minimax_model.clone(),
4120            ProviderKind::Deepinfra => self.deepinfra_model.clone(),
4121            _ => None,
4122        }?;
4123
4124        if provider_preserves_custom_base_url_model(provider, base_url) {
4125            Some(model.trim().to_string())
4126        } else {
4127            Some(normalize_model_for_provider(provider, &model))
4128        }
4129    }
4130}
4131
4132#[cfg(test)]
4133mod tests {
4134    use super::*;
4135    use std::env;
4136    use std::ffi::OsString;
4137    use std::sync::Arc;
4138    use std::sync::{Mutex, OnceLock};
4139
4140    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
4141        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
4142        LOCK.get_or_init(|| Mutex::new(()))
4143            .lock()
4144            .unwrap_or_else(std::sync::PoisonError::into_inner)
4145    }
4146
4147    #[test]
4148    fn network_policy_toml_deserializes_proxy_hosts() {
4149        let policy: NetworkPolicyToml = toml::from_str(
4150            r#"
4151            default = "allow"
4152            proxy = ["github.com", ".githubusercontent.com"]
4153            "#,
4154        )
4155        .expect("network policy toml");
4156
4157        assert_eq!(policy.default, "allow");
4158        assert_eq!(policy.proxy, ["github.com", ".githubusercontent.com"]);
4159        assert!(policy.audit);
4160    }
4161
4162    #[test]
4163    fn permissions_toml_deserializes_typed_ask_rules() {
4164        let permissions: PermissionsToml = toml::from_str(
4165            r#"
4166            [[rules]]
4167            tool = "exec_shell"
4168            command = "cargo test"
4169
4170            [[rules]]
4171            tool = "read_file"
4172            path = "secrets/api_key.txt"
4173            "#,
4174        )
4175        .expect("permissions toml");
4176
4177        assert_eq!(
4178            permissions.rules,
4179            vec![
4180                ToolAskRule::exec_shell("cargo test"),
4181                ToolAskRule::file_path("read_file", "secrets/api_key.txt"),
4182            ]
4183        );
4184    }
4185
4186    #[test]
4187    fn permissions_toml_rejects_typed_allow_deny_shape() {
4188        let err = toml::from_str::<PermissionsToml>(
4189            r#"
4190            [[rules]]
4191            tool = "exec_shell"
4192            decision = "allow"
4193            command = "cargo test"
4194            "#,
4195        )
4196        .expect_err("permissions.toml should be ask-only in this slice");
4197
4198        assert!(err.message().contains("unknown field"));
4199    }
4200
4201    #[test]
4202    fn hotbar_defaults_when_config_is_absent() {
4203        let config = ConfigToml::default();
4204
4205        let resolved = config.resolve_hotbar_bindings(&DEFAULT_HOTBAR_ACTIONS);
4206
4207        assert_eq!(resolved.warnings, Vec::new());
4208        assert_eq!(resolved.bindings, default_hotbar_bindings());
4209        assert_eq!(
4210            resolved
4211                .bindings
4212                .iter()
4213                .map(|binding| (binding.slot, binding.action.as_str()))
4214                .collect::<Vec<_>>(),
4215            vec![
4216                (1, "voice.toggle"),
4217                (2, "session.compact"),
4218                (3, "mode.plan"),
4219                (4, "mode.agent"),
4220                (5, "mode.yolo"),
4221                (6, "palette.open"),
4222                (7, "sidebar.toggle"),
4223                (8, "trust.toggle"),
4224            ]
4225        );
4226    }
4227
4228    #[test]
4229    fn hotbar_tables_parse_and_round_trip() {
4230        let config: ConfigToml = toml::from_str(
4231            r#"
4232[[hotbar]]
4233slot = 1
4234label = "Plan"
4235action = "mode.plan"
4236
4237[[hotbar]]
4238slot = 2
4239action = "session.compact"
4240"#,
4241        )
4242        .expect("parse hotbar tables");
4243
4244        let resolved = config.resolve_hotbar_bindings(&["mode.plan", "session.compact"]);
4245
4246        assert_eq!(
4247            resolved.bindings,
4248            vec![
4249                HotbarBinding {
4250                    slot: 1,
4251                    action: "mode.plan".to_string(),
4252                    label: Some("Plan".to_string()),
4253                },
4254                HotbarBinding {
4255                    slot: 2,
4256                    action: "session.compact".to_string(),
4257                    label: None,
4258                },
4259            ]
4260        );
4261        assert_eq!(resolved.warnings, Vec::new());
4262
4263        let serialized = toml::to_string_pretty(&config).expect("serialize config");
4264        let round_tripped: ConfigToml =
4265            toml::from_str(&serialized).expect("deserialize serialized config");
4266        assert_eq!(round_tripped.hotbar, config.hotbar);
4267    }
4268
4269    #[test]
4270    fn hotbar_validation_warns_without_dropping_unknown_actions() {
4271        let config: ConfigToml = toml::from_str(
4272            r#"
4273[[hotbar]]
4274slot = 0
4275action = "mode.plan"
4276
4277[[hotbar]]
4278slot = 2
4279action = "mode.plan"
4280
4281[[hotbar]]
4282slot = 2
4283action = "custom.action"
4284
4285[[hotbar]]
4286slot = 9
4287action = "mode.agent"
4288"#,
4289        )
4290        .expect("parse hotbar tables");
4291
4292        let resolved = config.resolve_hotbar_bindings(&["mode.plan", "mode.agent"]);
4293
4294        assert_eq!(
4295            resolved.bindings,
4296            vec![HotbarBinding {
4297                slot: 2,
4298                action: "custom.action".to_string(),
4299                label: None,
4300            }]
4301        );
4302        assert_eq!(
4303            resolved.warnings,
4304            vec![
4305                HotbarConfigWarning::SlotOutOfRange {
4306                    slot: 0,
4307                    action: "mode.plan".to_string(),
4308                },
4309                HotbarConfigWarning::UnknownAction {
4310                    slot: 2,
4311                    action: "custom.action".to_string(),
4312                },
4313                HotbarConfigWarning::DuplicateSlot {
4314                    slot: 2,
4315                    previous_action: "mode.plan".to_string(),
4316                    replacement_action: "custom.action".to_string(),
4317                },
4318                HotbarConfigWarning::SlotOutOfRange {
4319                    slot: 9,
4320                    action: "mode.agent".to_string(),
4321                },
4322            ]
4323        );
4324        assert!(resolved.warnings[1].to_string().contains("keeping binding"));
4325    }
4326
4327    #[test]
4328    fn config_store_loads_sibling_permissions_toml() {
4329        use std::time::{SystemTime, UNIX_EPOCH};
4330
4331        let unique = SystemTime::now()
4332            .duration_since(UNIX_EPOCH)
4333            .expect("clock")
4334            .as_nanos();
4335        let dir = std::env::temp_dir().join(format!(
4336            "codewhale-permissions-schema-{}-{unique}",
4337            std::process::id()
4338        ));
4339        fs::create_dir_all(&dir).expect("mkdir");
4340        let config_path = dir.join(CONFIG_FILE_NAME);
4341        fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
4342        fs::write(
4343            dir.join(PERMISSIONS_FILE_NAME),
4344            r#"
4345            [[rules]]
4346            tool = "exec_shell"
4347            command = "cargo test"
4348
4349            [[rules]]
4350            tool = "read_file"
4351            path = "secrets/api_key.txt"
4352            "#,
4353        )
4354        .expect("write permissions");
4355
4356        let store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
4357
4358        assert_eq!(store.config.model.as_deref(), Some("deepseek-v4-flash"));
4359        assert_eq!(
4360            store.permissions().rules.as_slice(),
4361            &[
4362                ToolAskRule::exec_shell("cargo test"),
4363                ToolAskRule::file_path("read_file", "secrets/api_key.txt"),
4364            ]
4365        );
4366        assert_eq!(
4367            store.permissions_path(),
4368            config_path.with_file_name(PERMISSIONS_FILE_NAME)
4369        );
4370
4371        let _ = fs::remove_dir_all(dir);
4372    }
4373
4374    #[test]
4375    fn config_store_loads_permissions_even_when_config_is_absent() {
4376        use std::time::{SystemTime, UNIX_EPOCH};
4377
4378        let unique = SystemTime::now()
4379            .duration_since(UNIX_EPOCH)
4380            .expect("clock")
4381            .as_nanos();
4382        let dir = std::env::temp_dir().join(format!(
4383            "codewhale-permissions-only-{}-{unique}",
4384            std::process::id()
4385        ));
4386        fs::create_dir_all(&dir).expect("mkdir");
4387        let config_path = dir.join(CONFIG_FILE_NAME);
4388        fs::write(
4389            dir.join(PERMISSIONS_FILE_NAME),
4390            r#"
4391            [[rules]]
4392            tool = "exec_shell"
4393            command = "cargo check"
4394            "#,
4395        )
4396        .expect("write permissions");
4397
4398        let store = ConfigStore::load(Some(config_path)).expect("load config store");
4399
4400        assert!(store.config.model.is_none());
4401        assert_eq!(
4402            store.permissions().rules.as_slice(),
4403            &[ToolAskRule::exec_shell("cargo check")]
4404        );
4405
4406        let _ = fs::remove_dir_all(dir);
4407    }
4408
4409    #[test]
4410    fn config_store_exec_policy_engine_uses_sibling_permissions() {
4411        use std::time::{SystemTime, UNIX_EPOCH};
4412
4413        let unique = SystemTime::now()
4414            .duration_since(UNIX_EPOCH)
4415            .expect("clock")
4416            .as_nanos();
4417        let dir = std::env::temp_dir().join(format!(
4418            "codewhale-permissions-engine-{}-{unique}",
4419            std::process::id()
4420        ));
4421        fs::create_dir_all(&dir).expect("mkdir");
4422        let config_path = dir.join(CONFIG_FILE_NAME);
4423        fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
4424        fs::write(
4425            dir.join(PERMISSIONS_FILE_NAME),
4426            r#"
4427            [[rules]]
4428            tool = "exec_shell"
4429            command = "cargo test"
4430            "#,
4431        )
4432        .expect("write permissions");
4433
4434        let store = ConfigStore::load(Some(config_path)).expect("load config store");
4435        let decision = store
4436            .exec_policy_engine()
4437            .check(codewhale_execpolicy::ExecPolicyContext {
4438                command: "cargo test --workspace",
4439                cwd: "/workspace",
4440                tool: Some("exec_shell"),
4441                path: None,
4442                ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
4443                sandbox_mode: Some("workspace-write"),
4444            })
4445            .expect("policy check");
4446
4447        assert!(decision.allow);
4448        assert!(decision.requires_approval);
4449        assert_eq!(
4450            decision.matched_rule.as_deref(),
4451            Some("tool=exec_shell command=cargo test")
4452        );
4453
4454        let _ = fs::remove_dir_all(dir);
4455    }
4456
4457    #[test]
4458    fn config_store_appends_ask_rules_without_losing_comments_or_duplicates() {
4459        let dir = tempfile::tempdir().expect("tempdir");
4460        let config_path = dir.path().join(CONFIG_FILE_NAME);
4461        let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4462        fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
4463        fs::write(
4464            &permissions_path,
4465            r#"# keep this permission note
4466[[rules]]
4467tool = "exec_shell"
4468command = "cargo check"
4469"#,
4470        )
4471        .expect("write permissions");
4472
4473        let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4474        let existing = ToolAskRule::exec_shell("cargo check");
4475        let added_rule = ToolAskRule::file_path("read_file", "docs/README.md");
4476        let added = store
4477            .append_ask_rules(&[existing, added_rule.clone(), added_rule.clone()])
4478            .expect("append ask rules");
4479
4480        assert_eq!(added, 1);
4481        assert_eq!(
4482            store.permissions().rules,
4483            vec![ToolAskRule::exec_shell("cargo check"), added_rule.clone(),]
4484        );
4485        let body = fs::read_to_string(&permissions_path).expect("read permissions");
4486        assert!(body.contains("# keep this permission note"));
4487        assert_eq!(body.matches("docs/README.md").count(), 1);
4488        assert!(!body.contains("decision"));
4489
4490        let before_duplicate_append = body;
4491        assert_eq!(
4492            store
4493                .append_ask_rules(&[added_rule])
4494                .expect("dedupe ask rule"),
4495            0
4496        );
4497        assert_eq!(
4498            fs::read_to_string(&permissions_path).expect("read unchanged permissions"),
4499            before_duplicate_append
4500        );
4501
4502        let reloaded = ConfigStore::load(Some(dir.path().join(CONFIG_FILE_NAME)))
4503            .expect("reload config store");
4504        assert_eq!(reloaded.permissions(), store.permissions());
4505    }
4506
4507    #[test]
4508    fn config_store_appends_ask_rule_to_inline_rules_array() {
4509        let dir = tempfile::tempdir().expect("tempdir");
4510        let config_path = dir.path().join(CONFIG_FILE_NAME);
4511        let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4512        fs::write(
4513            &permissions_path,
4514            "# inline rules stay valid\nrules = [{ tool = \"exec_shell\", command = \"cargo check\" }]\n",
4515        )
4516        .expect("write permissions");
4517
4518        let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4519        assert_eq!(
4520            store
4521                .append_ask_rules(&[ToolAskRule::file_path("read_file", "README.md")])
4522                .expect("append inline ask rule"),
4523            1
4524        );
4525
4526        let body = fs::read_to_string(&permissions_path).expect("read permissions");
4527        assert!(body.contains("# inline rules stay valid"));
4528        let parsed: PermissionsToml = toml::from_str(&body).expect("parse persisted permissions");
4529        assert_eq!(
4530            parsed.rules,
4531            vec![
4532                ToolAskRule::exec_shell("cargo check"),
4533                ToolAskRule::file_path("read_file", "README.md"),
4534            ]
4535        );
4536    }
4537
4538    #[test]
4539    fn config_store_does_not_overwrite_invalid_permissions_file() {
4540        let dir = tempfile::tempdir().expect("tempdir");
4541        let config_path = dir.path().join(CONFIG_FILE_NAME);
4542        let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4543        let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4544        let invalid = "rules = \"not-an-array\"\n";
4545        fs::write(&permissions_path, invalid).expect("write invalid permissions");
4546
4547        let error = store
4548            .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")])
4549            .expect_err("invalid permissions should fail");
4550
4551        assert!(error.to_string().contains("failed to parse permissions"));
4552        assert_eq!(
4553            fs::read_to_string(&permissions_path).expect("read invalid permissions"),
4554            invalid
4555        );
4556        assert!(store.permissions().is_empty());
4557    }
4558
4559    #[test]
4560    fn duplicate_append_refreshes_permissions_changed_on_disk() {
4561        let dir = tempfile::tempdir().expect("tempdir");
4562        let config_path = dir.path().join(CONFIG_FILE_NAME);
4563        let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4564        let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4565        fs::write(
4566            permissions_path,
4567            "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo check\"\n",
4568        )
4569        .expect("write external permissions update");
4570
4571        assert_eq!(
4572            store
4573                .append_ask_rules(&[ToolAskRule::exec_shell("cargo check")])
4574                .expect("dedupe external ask rule"),
4575            0
4576        );
4577        assert_eq!(
4578            store.permissions().rules,
4579            vec![ToolAskRule::exec_shell("cargo check")]
4580        );
4581    }
4582
4583    #[cfg(unix)]
4584    #[test]
4585    fn config_store_secures_persisted_permissions_file() {
4586        let dir = tempfile::tempdir().expect("tempdir");
4587        let config_path = dir.path().join(CONFIG_FILE_NAME);
4588        let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4589        let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4590
4591        store
4592            .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")])
4593            .expect("append ask rule");
4594
4595        let mode = fs::metadata(permissions_path)
4596            .expect("permissions metadata")
4597            .permissions()
4598            .mode()
4599            & 0o777;
4600        assert_eq!(mode, 0o600);
4601    }
4602
4603    struct EnvGuard {
4604        deepseek_api_key: Option<OsString>,
4605        deepseek_base_url: Option<OsString>,
4606        deepseek_http_headers: Option<OsString>,
4607        deepseek_model: Option<OsString>,
4608        deepseek_default_text_model: Option<OsString>,
4609        deepseek_provider: Option<OsString>,
4610        deepseek_auth_mode: Option<OsString>,
4611        nvidia_api_key: Option<OsString>,
4612        nvidia_nim_api_key: Option<OsString>,
4613        nim_base_url: Option<OsString>,
4614        nvidia_base_url: Option<OsString>,
4615        nvidia_nim_base_url: Option<OsString>,
4616        openrouter_api_key: Option<OsString>,
4617        openrouter_base_url: Option<OsString>,
4618        openrouter_model: Option<OsString>,
4619        xiaomi_mimo_token_plan_api_key: Option<OsString>,
4620        mimo_token_plan_api_key: Option<OsString>,
4621        xiaomi_mimo_api_key: Option<OsString>,
4622        xiaomi_api_key: Option<OsString>,
4623        mimo_api_key: Option<OsString>,
4624        xiaomi_mimo_base_url: Option<OsString>,
4625        mimo_base_url: Option<OsString>,
4626        xiaomi_mimo_model: Option<OsString>,
4627        mimo_model: Option<OsString>,
4628        xiaomi_mimo_mode: Option<OsString>,
4629        mimo_mode: Option<OsString>,
4630        wanjie_ark_api_key: Option<OsString>,
4631        volcengine_api_key: Option<OsString>,
4632        volcengine_ark_api_key: Option<OsString>,
4633        ark_api_key: Option<OsString>,
4634        volcengine_base_url: Option<OsString>,
4635        volcengine_ark_base_url: Option<OsString>,
4636        ark_base_url: Option<OsString>,
4637        wanjie_ark_base_url: Option<OsString>,
4638        wanjie_base_url: Option<OsString>,
4639        wanjie_maas_base_url: Option<OsString>,
4640        volcengine_model: Option<OsString>,
4641        volcengine_ark_model: Option<OsString>,
4642        wanjie_ark_model: Option<OsString>,
4643        wanjie_model: Option<OsString>,
4644        wanjie_maas_model: Option<OsString>,
4645        novita_api_key: Option<OsString>,
4646        novita_base_url: Option<OsString>,
4647        novita_model: Option<OsString>,
4648        fireworks_api_key: Option<OsString>,
4649        fireworks_base_url: Option<OsString>,
4650        fireworks_model: Option<OsString>,
4651        siliconflow_api_key: Option<OsString>,
4652        siliconflow_base_url: Option<OsString>,
4653        siliconflow_model: Option<OsString>,
4654        arcee_api_key: Option<OsString>,
4655        arcee_base_url: Option<OsString>,
4656        arcee_model: Option<OsString>,
4657        moonshot_api_key: Option<OsString>,
4658        moonshot_base_url: Option<OsString>,
4659        moonshot_model: Option<OsString>,
4660        kimi_api_key: Option<OsString>,
4661        kimi_base_url: Option<OsString>,
4662        kimi_model: Option<OsString>,
4663        kimi_model_name: Option<OsString>,
4664        zai_api_key: Option<OsString>,
4665        z_ai_api_key: Option<OsString>,
4666        zai_base_url: Option<OsString>,
4667        zai_model: Option<OsString>,
4668        stepfun_api_key: Option<OsString>,
4669        step_api_key: Option<OsString>,
4670        stepfun_base_url: Option<OsString>,
4671        stepfun_model: Option<OsString>,
4672        minimax_api_key: Option<OsString>,
4673        minimax_base_url: Option<OsString>,
4674        minimax_model: Option<OsString>,
4675        sglang_api_key: Option<OsString>,
4676        sglang_base_url: Option<OsString>,
4677        vllm_api_key: Option<OsString>,
4678        vllm_base_url: Option<OsString>,
4679        ollama_api_key: Option<OsString>,
4680        ollama_base_url: Option<OsString>,
4681        huggingface_api_key: Option<OsString>,
4682        huggingface_token: Option<OsString>,
4683        huggingface_base_url: Option<OsString>,
4684        hf_base_url: Option<OsString>,
4685        huggingface_model: Option<OsString>,
4686        hf_model: Option<OsString>,
4687        codewhale_provider: Option<OsString>,
4688        codewhale_model: Option<OsString>,
4689        codewhale_base_url: Option<OsString>,
4690    }
4691
4692    impl EnvGuard {
4693        fn without_deepseek_runtime_overrides() -> Self {
4694            let guard = Self {
4695                deepseek_api_key: env::var_os("DEEPSEEK_API_KEY"),
4696                deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"),
4697                deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"),
4698                deepseek_model: env::var_os("DEEPSEEK_MODEL"),
4699                deepseek_default_text_model: env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"),
4700                deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"),
4701                deepseek_auth_mode: env::var_os("DEEPSEEK_AUTH_MODE"),
4702                codewhale_provider: env::var_os("CODEWHALE_PROVIDER"),
4703                codewhale_model: env::var_os("CODEWHALE_MODEL"),
4704                codewhale_base_url: env::var_os("CODEWHALE_BASE_URL"),
4705                nvidia_api_key: env::var_os("NVIDIA_API_KEY"),
4706                nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"),
4707                nim_base_url: env::var_os("NIM_BASE_URL"),
4708                nvidia_base_url: env::var_os("NVIDIA_BASE_URL"),
4709                nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"),
4710                openrouter_api_key: env::var_os("OPENROUTER_API_KEY"),
4711                openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"),
4712                openrouter_model: env::var_os("OPENROUTER_MODEL"),
4713                xiaomi_mimo_token_plan_api_key: env::var_os("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"),
4714                mimo_token_plan_api_key: env::var_os("MIMO_TOKEN_PLAN_API_KEY"),
4715                xiaomi_mimo_api_key: env::var_os("XIAOMI_MIMO_API_KEY"),
4716                xiaomi_api_key: env::var_os("XIAOMI_API_KEY"),
4717                mimo_api_key: env::var_os("MIMO_API_KEY"),
4718                xiaomi_mimo_base_url: env::var_os("XIAOMI_MIMO_BASE_URL"),
4719                mimo_base_url: env::var_os("MIMO_BASE_URL"),
4720                xiaomi_mimo_model: env::var_os("XIAOMI_MIMO_MODEL"),
4721                mimo_model: env::var_os("MIMO_MODEL"),
4722                xiaomi_mimo_mode: env::var_os("XIAOMI_MIMO_MODE"),
4723                mimo_mode: env::var_os("MIMO_MODE"),
4724                wanjie_ark_api_key: env::var_os("WANJIE_ARK_API_KEY"),
4725                volcengine_api_key: env::var_os("VOLCENGINE_API_KEY"),
4726                volcengine_ark_api_key: env::var_os("VOLCENGINE_ARK_API_KEY"),
4727                ark_api_key: env::var_os("ARK_API_KEY"),
4728                volcengine_base_url: env::var_os("VOLCENGINE_BASE_URL"),
4729                volcengine_ark_base_url: env::var_os("VOLCENGINE_ARK_BASE_URL"),
4730                ark_base_url: env::var_os("ARK_BASE_URL"),
4731                wanjie_ark_base_url: env::var_os("WANJIE_ARK_BASE_URL"),
4732                wanjie_base_url: env::var_os("WANJIE_BASE_URL"),
4733                wanjie_maas_base_url: env::var_os("WANJIE_MAAS_BASE_URL"),
4734                volcengine_model: env::var_os("VOLCENGINE_MODEL"),
4735                volcengine_ark_model: env::var_os("VOLCENGINE_ARK_MODEL"),
4736                wanjie_ark_model: env::var_os("WANJIE_ARK_MODEL"),
4737                wanjie_model: env::var_os("WANJIE_MODEL"),
4738                wanjie_maas_model: env::var_os("WANJIE_MAAS_MODEL"),
4739                novita_api_key: env::var_os("NOVITA_API_KEY"),
4740                novita_base_url: env::var_os("NOVITA_BASE_URL"),
4741                novita_model: env::var_os("NOVITA_MODEL"),
4742                fireworks_api_key: env::var_os("FIREWORKS_API_KEY"),
4743                fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"),
4744                fireworks_model: env::var_os("FIREWORKS_MODEL"),
4745                siliconflow_api_key: env::var_os("SILICONFLOW_API_KEY"),
4746                siliconflow_base_url: env::var_os("SILICONFLOW_BASE_URL"),
4747                siliconflow_model: env::var_os("SILICONFLOW_MODEL"),
4748                arcee_api_key: env::var_os("ARCEE_API_KEY"),
4749                arcee_base_url: env::var_os("ARCEE_BASE_URL"),
4750                arcee_model: env::var_os("ARCEE_MODEL"),
4751                moonshot_api_key: env::var_os("MOONSHOT_API_KEY"),
4752                moonshot_base_url: env::var_os("MOONSHOT_BASE_URL"),
4753                moonshot_model: env::var_os("MOONSHOT_MODEL"),
4754                kimi_api_key: env::var_os("KIMI_API_KEY"),
4755                kimi_base_url: env::var_os("KIMI_BASE_URL"),
4756                kimi_model: env::var_os("KIMI_MODEL"),
4757                kimi_model_name: env::var_os("KIMI_MODEL_NAME"),
4758                zai_api_key: env::var_os("ZAI_API_KEY"),
4759                z_ai_api_key: env::var_os("Z_AI_API_KEY"),
4760                zai_base_url: env::var_os("ZAI_BASE_URL"),
4761                zai_model: env::var_os("ZAI_MODEL"),
4762                stepfun_api_key: env::var_os("STEPFUN_API_KEY"),
4763                step_api_key: env::var_os("STEP_API_KEY"),
4764                stepfun_base_url: env::var_os("STEPFUN_BASE_URL"),
4765                stepfun_model: env::var_os("STEPFUN_MODEL"),
4766                minimax_api_key: env::var_os("MINIMAX_API_KEY"),
4767                minimax_base_url: env::var_os("MINIMAX_BASE_URL"),
4768                minimax_model: env::var_os("MINIMAX_MODEL"),
4769                sglang_api_key: env::var_os("SGLANG_API_KEY"),
4770                sglang_base_url: env::var_os("SGLANG_BASE_URL"),
4771                vllm_api_key: env::var_os("VLLM_API_KEY"),
4772                vllm_base_url: env::var_os("VLLM_BASE_URL"),
4773                ollama_api_key: env::var_os("OLLAMA_API_KEY"),
4774                ollama_base_url: env::var_os("OLLAMA_BASE_URL"),
4775                huggingface_api_key: env::var_os("HUGGINGFACE_API_KEY"),
4776                huggingface_token: env::var_os("HF_TOKEN"),
4777                huggingface_base_url: env::var_os("HUGGINGFACE_BASE_URL"),
4778                hf_base_url: env::var_os("HF_BASE_URL"),
4779                huggingface_model: env::var_os("HUGGINGFACE_MODEL"),
4780                hf_model: env::var_os("HF_MODEL"),
4781            };
4782            // Safety: test-only environment mutation guarded by a module mutex.
4783            unsafe {
4784                env::remove_var("DEEPSEEK_API_KEY");
4785                env::remove_var("DEEPSEEK_BASE_URL");
4786                env::remove_var("DEEPSEEK_HTTP_HEADERS");
4787                env::remove_var("DEEPSEEK_MODEL");
4788                env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL");
4789                env::remove_var("DEEPSEEK_PROVIDER");
4790                env::remove_var("DEEPSEEK_AUTH_MODE");
4791                env::remove_var("CODEWHALE_PROVIDER");
4792                env::remove_var("CODEWHALE_MODEL");
4793                env::remove_var("CODEWHALE_BASE_URL");
4794                env::remove_var("NVIDIA_API_KEY");
4795                env::remove_var("NVIDIA_NIM_API_KEY");
4796                env::remove_var("NIM_BASE_URL");
4797                env::remove_var("NVIDIA_BASE_URL");
4798                env::remove_var("NVIDIA_NIM_BASE_URL");
4799                env::remove_var("OPENROUTER_API_KEY");
4800                env::remove_var("OPENROUTER_BASE_URL");
4801                env::remove_var("OPENROUTER_MODEL");
4802                env::remove_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY");
4803                env::remove_var("MIMO_TOKEN_PLAN_API_KEY");
4804                env::remove_var("XIAOMI_MIMO_API_KEY");
4805                env::remove_var("XIAOMI_API_KEY");
4806                env::remove_var("MIMO_API_KEY");
4807                env::remove_var("XIAOMI_MIMO_BASE_URL");
4808                env::remove_var("MIMO_BASE_URL");
4809                env::remove_var("XIAOMI_MIMO_MODEL");
4810                env::remove_var("MIMO_MODEL");
4811                env::remove_var("XIAOMI_MIMO_MODE");
4812                env::remove_var("MIMO_MODE");
4813                env::remove_var("WANJIE_ARK_API_KEY");
4814                env::remove_var("VOLCENGINE_API_KEY");
4815                env::remove_var("VOLCENGINE_ARK_API_KEY");
4816                env::remove_var("ARK_API_KEY");
4817                env::remove_var("VOLCENGINE_BASE_URL");
4818                env::remove_var("VOLCENGINE_ARK_BASE_URL");
4819                env::remove_var("ARK_BASE_URL");
4820                env::remove_var("WANJIE_ARK_BASE_URL");
4821                env::remove_var("WANJIE_BASE_URL");
4822                env::remove_var("WANJIE_MAAS_BASE_URL");
4823                env::remove_var("VOLCENGINE_MODEL");
4824                env::remove_var("VOLCENGINE_ARK_MODEL");
4825                env::remove_var("WANJIE_ARK_MODEL");
4826                env::remove_var("WANJIE_MODEL");
4827                env::remove_var("WANJIE_MAAS_MODEL");
4828                env::remove_var("NOVITA_API_KEY");
4829                env::remove_var("NOVITA_BASE_URL");
4830                env::remove_var("NOVITA_MODEL");
4831                env::remove_var("FIREWORKS_API_KEY");
4832                env::remove_var("FIREWORKS_BASE_URL");
4833                env::remove_var("FIREWORKS_MODEL");
4834                env::remove_var("SILICONFLOW_API_KEY");
4835                env::remove_var("SILICONFLOW_BASE_URL");
4836                env::remove_var("SILICONFLOW_MODEL");
4837                env::remove_var("ARCEE_API_KEY");
4838                env::remove_var("ARCEE_BASE_URL");
4839                env::remove_var("ARCEE_MODEL");
4840                env::remove_var("MOONSHOT_API_KEY");
4841                env::remove_var("MOONSHOT_BASE_URL");
4842                env::remove_var("MOONSHOT_MODEL");
4843                env::remove_var("KIMI_API_KEY");
4844                env::remove_var("KIMI_BASE_URL");
4845                env::remove_var("KIMI_MODEL");
4846                env::remove_var("KIMI_MODEL_NAME");
4847                env::remove_var("ZAI_API_KEY");
4848                env::remove_var("Z_AI_API_KEY");
4849                env::remove_var("ZAI_BASE_URL");
4850                env::remove_var("ZAI_MODEL");
4851                env::remove_var("STEPFUN_API_KEY");
4852                env::remove_var("STEP_API_KEY");
4853                env::remove_var("STEPFUN_BASE_URL");
4854                env::remove_var("STEPFUN_MODEL");
4855                env::remove_var("MINIMAX_API_KEY");
4856                env::remove_var("MINIMAX_BASE_URL");
4857                env::remove_var("MINIMAX_MODEL");
4858                env::remove_var("SGLANG_API_KEY");
4859                env::remove_var("SGLANG_BASE_URL");
4860                env::remove_var("VLLM_API_KEY");
4861                env::remove_var("VLLM_BASE_URL");
4862                env::remove_var("OLLAMA_API_KEY");
4863                env::remove_var("OLLAMA_BASE_URL");
4864                env::remove_var("HUGGINGFACE_API_KEY");
4865                env::remove_var("HF_TOKEN");
4866                env::remove_var("HUGGINGFACE_BASE_URL");
4867                env::remove_var("HF_BASE_URL");
4868                env::remove_var("HUGGINGFACE_MODEL");
4869                env::remove_var("HF_MODEL");
4870            }
4871            guard
4872        }
4873
4874        unsafe fn restore_var(key: &str, value: Option<OsString>) {
4875            if let Some(value) = value {
4876                unsafe { env::set_var(key, value) };
4877            } else {
4878                unsafe { env::remove_var(key) };
4879            }
4880        }
4881    }
4882
4883    impl Drop for EnvGuard {
4884        fn drop(&mut self) {
4885            // Safety: test-only environment mutation guarded by a module mutex.
4886            unsafe {
4887                Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
4888                Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
4889                Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take());
4890                Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
4891                Self::restore_var(
4892                    "DEEPSEEK_DEFAULT_TEXT_MODEL",
4893                    self.deepseek_default_text_model.take(),
4894                );
4895                Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
4896                Self::restore_var("DEEPSEEK_AUTH_MODE", self.deepseek_auth_mode.take());
4897                Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take());
4898                Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take());
4899                Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take());
4900                Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
4901                Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
4902                Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
4903                Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
4904                Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
4905                Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take());
4906                Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take());
4907                Self::restore_var("OPENROUTER_MODEL", self.openrouter_model.take());
4908                Self::restore_var(
4909                    "XIAOMI_MIMO_TOKEN_PLAN_API_KEY",
4910                    self.xiaomi_mimo_token_plan_api_key.take(),
4911                );
4912                Self::restore_var(
4913                    "MIMO_TOKEN_PLAN_API_KEY",
4914                    self.mimo_token_plan_api_key.take(),
4915                );
4916                Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take());
4917                Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take());
4918                Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take());
4919                Self::restore_var("XIAOMI_MIMO_BASE_URL", self.xiaomi_mimo_base_url.take());
4920                Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take());
4921                Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take());
4922                Self::restore_var("MIMO_MODEL", self.mimo_model.take());
4923                Self::restore_var("XIAOMI_MIMO_MODE", self.xiaomi_mimo_mode.take());
4924                Self::restore_var("MIMO_MODE", self.mimo_mode.take());
4925                Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take());
4926                Self::restore_var("VOLCENGINE_API_KEY", self.volcengine_api_key.take());
4927                Self::restore_var("VOLCENGINE_ARK_API_KEY", self.volcengine_ark_api_key.take());
4928                Self::restore_var("ARK_API_KEY", self.ark_api_key.take());
4929                Self::restore_var("VOLCENGINE_BASE_URL", self.volcengine_base_url.take());
4930                Self::restore_var(
4931                    "VOLCENGINE_ARK_BASE_URL",
4932                    self.volcengine_ark_base_url.take(),
4933                );
4934                Self::restore_var("ARK_BASE_URL", self.ark_base_url.take());
4935                Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take());
4936                Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take());
4937                Self::restore_var("WANJIE_MAAS_BASE_URL", self.wanjie_maas_base_url.take());
4938                Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take());
4939                Self::restore_var("VOLCENGINE_ARK_MODEL", self.volcengine_ark_model.take());
4940                Self::restore_var("WANJIE_ARK_MODEL", self.wanjie_ark_model.take());
4941                Self::restore_var("WANJIE_MODEL", self.wanjie_model.take());
4942                Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take());
4943                Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take());
4944                Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take());
4945                Self::restore_var("NOVITA_MODEL", self.novita_model.take());
4946                Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take());
4947                Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take());
4948                Self::restore_var("FIREWORKS_MODEL", self.fireworks_model.take());
4949                Self::restore_var("SILICONFLOW_API_KEY", self.siliconflow_api_key.take());
4950                Self::restore_var("SILICONFLOW_BASE_URL", self.siliconflow_base_url.take());
4951                Self::restore_var("SILICONFLOW_MODEL", self.siliconflow_model.take());
4952                Self::restore_var("ARCEE_API_KEY", self.arcee_api_key.take());
4953                Self::restore_var("ARCEE_BASE_URL", self.arcee_base_url.take());
4954                Self::restore_var("ARCEE_MODEL", self.arcee_model.take());
4955                Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take());
4956                Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take());
4957                Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take());
4958                Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take());
4959                Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take());
4960                Self::restore_var("KIMI_MODEL", self.kimi_model.take());
4961                Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take());
4962                Self::restore_var("ZAI_API_KEY", self.zai_api_key.take());
4963                Self::restore_var("Z_AI_API_KEY", self.z_ai_api_key.take());
4964                Self::restore_var("ZAI_BASE_URL", self.zai_base_url.take());
4965                Self::restore_var("ZAI_MODEL", self.zai_model.take());
4966                Self::restore_var("STEPFUN_API_KEY", self.stepfun_api_key.take());
4967                Self::restore_var("STEP_API_KEY", self.step_api_key.take());
4968                Self::restore_var("STEPFUN_BASE_URL", self.stepfun_base_url.take());
4969                Self::restore_var("STEPFUN_MODEL", self.stepfun_model.take());
4970                Self::restore_var("MINIMAX_API_KEY", self.minimax_api_key.take());
4971                Self::restore_var("MINIMAX_BASE_URL", self.minimax_base_url.take());
4972                Self::restore_var("MINIMAX_MODEL", self.minimax_model.take());
4973                Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take());
4974                Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take());
4975                Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take());
4976                Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take());
4977                Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take());
4978                Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take());
4979                Self::restore_var("HUGGINGFACE_API_KEY", self.huggingface_api_key.take());
4980                Self::restore_var("HF_TOKEN", self.huggingface_token.take());
4981                Self::restore_var("HUGGINGFACE_BASE_URL", self.huggingface_base_url.take());
4982                Self::restore_var("HF_BASE_URL", self.hf_base_url.take());
4983                Self::restore_var("HUGGINGFACE_MODEL", self.huggingface_model.take());
4984                Self::restore_var("HF_MODEL", self.hf_model.take());
4985            }
4986        }
4987    }
4988
4989    struct RecordingSecretsStore {
4990        gets: Mutex<Vec<String>>,
4991        value: Option<String>,
4992    }
4993
4994    impl RecordingSecretsStore {
4995        fn with_value(value: &str) -> Self {
4996            Self {
4997                gets: Mutex::new(Vec::new()),
4998                value: Some(value.to_string()),
4999            }
5000        }
5001    }
5002
5003    impl codewhale_secrets::KeyringStore for RecordingSecretsStore {
5004        fn get(&self, key: &str) -> Result<Option<String>, codewhale_secrets::SecretsError> {
5005            self.gets.lock().unwrap().push(key.to_string());
5006            Ok(self.value.clone())
5007        }
5008
5009        fn set(&self, _key: &str, _value: &str) -> Result<(), codewhale_secrets::SecretsError> {
5010            Ok(())
5011        }
5012
5013        fn delete(&self, _key: &str) -> Result<(), codewhale_secrets::SecretsError> {
5014            Ok(())
5015        }
5016
5017        fn backend_name(&self) -> &'static str {
5018            "recording"
5019        }
5020    }
5021
5022    #[test]
5023    fn root_deepseek_fields_are_runtime_fallbacks() {
5024        let _lock = env_lock();
5025        let _env = EnvGuard::without_deepseek_runtime_overrides();
5026        let config = ConfigToml {
5027            api_key: Some("root-key".to_string()),
5028            base_url: Some("https://api.deepseek.com".to_string()),
5029            default_text_model: Some("deepseek-v4-pro".to_string()),
5030            ..ConfigToml::default()
5031        };
5032
5033        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5034
5035        assert_eq!(resolved.provider, ProviderKind::Deepseek);
5036        assert_eq!(resolved.api_key.as_deref(), Some("root-key"));
5037        assert_eq!(resolved.base_url, "https://api.deepseek.com");
5038        assert_eq!(resolved.model, "deepseek-v4-pro");
5039    }
5040
5041    #[test]
5042    fn deepseek_runtime_defaults_to_beta_endpoint() {
5043        let _lock = env_lock();
5044        let _env = EnvGuard::without_deepseek_runtime_overrides();
5045        let config = ConfigToml::default();
5046
5047        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5048
5049        assert_eq!(resolved.provider, ProviderKind::Deepseek);
5050        assert_eq!(resolved.base_url, DEFAULT_DEEPSEEK_BASE_URL);
5051        assert_eq!(resolved.model, DEFAULT_DEEPSEEK_MODEL);
5052    }
5053
5054    #[test]
5055    fn provider_specific_deepseek_fields_override_tui_compat_fields() {
5056        let _lock = env_lock();
5057        let _env = EnvGuard::without_deepseek_runtime_overrides();
5058        let mut config = ConfigToml {
5059            api_key: Some("root-key".to_string()),
5060            base_url: Some("https://api.deepseek.com".to_string()),
5061            default_text_model: Some("deepseek-v4-pro".to_string()),
5062            ..ConfigToml::default()
5063        };
5064        config.providers.deepseek.api_key = Some("provider-key".to_string());
5065        config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string());
5066        config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
5067
5068        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5069
5070        assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
5071        assert_eq!(resolved.base_url, "https://gateway.example/v1");
5072        assert_eq!(resolved.model, "deepseek-v4-flash");
5073    }
5074
5075    #[test]
5076    fn provider_http_headers_override_root_headers() {
5077        let _lock = env_lock();
5078        let _env = EnvGuard::without_deepseek_runtime_overrides();
5079        let mut config = ConfigToml {
5080            api_key: Some("root-key".to_string()),
5081            base_url: Some("https://api.deepseek.com".to_string()),
5082            default_text_model: Some("deepseek-v4-pro".to_string()),
5083            ..ConfigToml::default()
5084        };
5085        config.providers.deepseek.api_key = Some("provider-key".to_string());
5086        config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string());
5087        config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
5088        config
5089            .http_headers
5090            .insert("X-Shared".to_string(), "root".to_string());
5091        config
5092            .providers
5093            .deepseek
5094            .http_headers
5095            .insert("X-Model-Provider-Id".to_string(), "tongyi".to_string());
5096        config
5097            .providers
5098            .deepseek
5099            .http_headers
5100            .insert("X-Shared".to_string(), "provider".to_string());
5101
5102        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5103
5104        assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
5105        assert_eq!(resolved.base_url, "https://gateway.example/v1");
5106        assert_eq!(resolved.model, "deepseek-v4-flash");
5107        assert_eq!(
5108            resolved
5109                .http_headers
5110                .get("X-Model-Provider-Id")
5111                .map(String::as_str),
5112            Some("tongyi")
5113        );
5114        assert_eq!(
5115            resolved.http_headers.get("X-Shared").map(String::as_str),
5116            Some("provider")
5117        );
5118    }
5119
5120    #[test]
5121    fn insecure_skip_tls_verify_resolves_only_for_active_provider() {
5122        let _lock = env_lock();
5123        let _env = EnvGuard::without_deepseek_runtime_overrides();
5124        let mut config = ConfigToml {
5125            provider: ProviderKind::Openai,
5126            ..ConfigToml::default()
5127        };
5128        config.providers.deepseek.insecure_skip_tls_verify = Some(true);
5129
5130        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5131
5132        assert_eq!(resolved.provider, ProviderKind::Openai);
5133        assert!(!resolved.insecure_skip_tls_verify);
5134
5135        config.providers.openai.insecure_skip_tls_verify = Some(true);
5136        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5137
5138        assert_eq!(resolved.provider, ProviderKind::Openai);
5139        assert!(resolved.insecure_skip_tls_verify);
5140    }
5141
5142    #[test]
5143    fn http_headers_env_overrides_config() {
5144        let _lock = env_lock();
5145        let _env = EnvGuard::without_deepseek_runtime_overrides();
5146        let mut config = ConfigToml::default();
5147        config
5148            .http_headers
5149            .insert("X-Model-Provider-Id".to_string(), "from-file".to_string());
5150        // Safety: test-only environment mutation guarded by a module mutex.
5151        unsafe {
5152            env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Model-Provider-Id=from-env");
5153        }
5154
5155        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5156
5157        assert_eq!(
5158            resolved
5159                .http_headers
5160                .get("X-Model-Provider-Id")
5161                .map(String::as_str),
5162            Some("from-env")
5163        );
5164    }
5165
5166    #[test]
5167    fn nvidia_nim_provider_defaults_to_catalog_endpoint_and_model() {
5168        let _lock = env_lock();
5169        let _env = EnvGuard::without_deepseek_runtime_overrides();
5170        let config = ConfigToml {
5171            provider: ProviderKind::NvidiaNim,
5172            ..ConfigToml::default()
5173        };
5174
5175        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5176
5177        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5178        assert_eq!(resolved.base_url, DEFAULT_NVIDIA_NIM_BASE_URL);
5179        assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
5180    }
5181
5182    #[test]
5183    fn nvidia_nim_provider_uses_provider_specific_credentials() {
5184        let _lock = env_lock();
5185        let _env = EnvGuard::without_deepseek_runtime_overrides();
5186        let mut config = ConfigToml {
5187            provider: ProviderKind::NvidiaNim,
5188            ..ConfigToml::default()
5189        };
5190        config.providers.nvidia_nim.api_key = Some("nim-key".to_string());
5191        config.providers.nvidia_nim.base_url = Some("https://nim.example/v1".to_string());
5192        config.providers.nvidia_nim.model = Some("deepseek-ai/deepseek-v4-pro".to_string());
5193
5194        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5195
5196        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5197        assert_eq!(resolved.api_key.as_deref(), Some("nim-key"));
5198        assert_eq!(resolved.base_url, "https://nim.example/v1");
5199        assert_eq!(resolved.model, "deepseek-ai/deepseek-v4-pro");
5200    }
5201
5202    #[test]
5203    fn nvidia_nim_provider_normalizes_flash_aliases() {
5204        let _lock = env_lock();
5205        let _env = EnvGuard::without_deepseek_runtime_overrides();
5206        let cli = CliRuntimeOverrides {
5207            provider: Some(ProviderKind::NvidiaNim),
5208            model: Some("deepseek-v4-flash".to_string()),
5209            ..CliRuntimeOverrides::default()
5210        };
5211
5212        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
5213
5214        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5215        assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_FLASH_MODEL);
5216    }
5217
5218    #[test]
5219    fn nvidia_nim_provider_uses_nvidia_env_credentials() {
5220        let _lock = env_lock();
5221        let _env = EnvGuard::without_deepseek_runtime_overrides();
5222        // Safety: test-only environment mutation guarded by a module mutex.
5223        unsafe {
5224            env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
5225            env::set_var("NVIDIA_API_KEY", "nim-env-key");
5226            env::set_var("NVIDIA_NIM_BASE_URL", "https://nim-env.example/v1");
5227        }
5228
5229        let config = ConfigToml::default();
5230        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5231
5232        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5233        assert_eq!(resolved.api_key.as_deref(), Some("nim-env-key"));
5234        assert_eq!(resolved.base_url, "https://nim-env.example/v1");
5235        assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
5236    }
5237
5238    #[test]
5239    fn nvidia_nim_provider_accepts_short_nim_base_url_alias() {
5240        let _lock = env_lock();
5241        let _env = EnvGuard::without_deepseek_runtime_overrides();
5242        // Safety: test-only environment mutation guarded by a module mutex.
5243        unsafe {
5244            env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
5245            env::set_var("NVIDIA_API_KEY", "nim-env-key");
5246            env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
5247        }
5248
5249        let config = ConfigToml::default();
5250        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5251
5252        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5253        assert_eq!(resolved.base_url, "https://short-nim.example/v1");
5254    }
5255
5256    #[test]
5257    fn nvidia_nim_provider_can_fallback_to_deepseek_api_key_env() {
5258        let _lock = env_lock();
5259        let _env = EnvGuard::without_deepseek_runtime_overrides();
5260        // Safety: test-only environment mutation guarded by a module mutex.
5261        unsafe {
5262            env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
5263            env::set_var("DEEPSEEK_API_KEY", "deepseek-compat-key");
5264        }
5265
5266        let config = ConfigToml::default();
5267        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5268
5269        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5270        assert_eq!(resolved.api_key.as_deref(), Some("deepseek-compat-key"));
5271    }
5272
5273    #[test]
5274    fn list_values_redacts_root_api_key() {
5275        let config = ConfigToml {
5276            api_key: Some("sk-deepseek-secret".to_string()),
5277            ..ConfigToml::default()
5278        };
5279
5280        let values = config.list_values();
5281
5282        assert_eq!(
5283            values.get("api_key").map(String::as_str),
5284            Some("sk-d***cret")
5285        );
5286    }
5287
5288    #[test]
5289    fn list_values_fully_redacts_short_api_key() {
5290        let config = ConfigToml {
5291            api_key: Some("short-key".to_string()),
5292            ..ConfigToml::default()
5293        };
5294
5295        let values = config.list_values();
5296
5297        assert_eq!(values.get("api_key").map(String::as_str), Some("********"));
5298    }
5299
5300    #[test]
5301    fn get_display_value_redacts_sensitive_keys() {
5302        let mut config = ConfigToml {
5303            api_key: Some("sk-deepseek-secret".to_string()),
5304            ..ConfigToml::default()
5305        };
5306        config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string());
5307        config.model = Some("deepseek-v4-pro".to_string());
5308
5309        assert_eq!(
5310            config.get_display_value("api_key").as_deref(),
5311            Some("sk-d***cret")
5312        );
5313        assert_eq!(
5314            config
5315                .get_display_value("providers.openrouter.api_key")
5316                .as_deref(),
5317            Some("open***alue")
5318        );
5319        assert_eq!(
5320            config.get_display_value("model").as_deref(),
5321            Some("deepseek-v4-pro")
5322        );
5323    }
5324
5325    #[test]
5326    fn config_display_redacts_nested_extra_secrets() {
5327        let mut config = ConfigToml::default();
5328        let mut profile = toml::map::Map::new();
5329        profile.insert(
5330            "chatgpt_access_token".to_string(),
5331            toml::Value::String("raw-chatgpt-access-token-value".to_string()),
5332        );
5333        profile.insert(
5334            "safe_label".to_string(),
5335            toml::Value::String("visible".to_string()),
5336        );
5337
5338        let mut nested = toml::map::Map::new();
5339        nested.insert(
5340            "refresh_token".to_string(),
5341            toml::Value::String("raw-refresh-token-value".to_string()),
5342        );
5343        nested.insert("expires_at".to_string(), toml::Value::Integer(1234));
5344        profile.insert("session".to_string(), toml::Value::Table(nested));
5345
5346        config
5347            .extras
5348            .insert("extras".to_string(), toml::Value::Table(profile));
5349
5350        let listed = config.list_values();
5351        let rendered = listed.get("extras").expect("extras are listed");
5352
5353        assert!(rendered.contains("chatgpt_access_token"));
5354        assert!(rendered.contains("refresh_token"));
5355        assert!(rendered.contains("safe_label = \"visible\""));
5356        assert!(!rendered.contains("raw-chatgpt-access-token-value"));
5357        assert!(!rendered.contains("raw-refresh-token-value"));
5358
5359        let display = config
5360            .get_display_value("extras")
5361            .expect("extras display value");
5362        assert!(!display.contains("raw-chatgpt-access-token-value"));
5363        assert!(!display.contains("raw-refresh-token-value"));
5364    }
5365
5366    #[test]
5367    fn config_display_redacts_sensitive_extra_leaf_keys_and_headers() {
5368        let mut config = ConfigToml::default();
5369        config.extras.insert(
5370            "chatgpt_access_token".to_string(),
5371            toml::Value::String("raw-chatgpt-token-value".to_string()),
5372        );
5373        config.http_headers.insert(
5374            "Authorization".to_string(),
5375            "Bearer raw-header-token".to_string(),
5376        );
5377        config
5378            .http_headers
5379            .insert("X-Test".to_string(), "ok".to_string());
5380
5381        assert_eq!(
5382            config.get_display_value("chatgpt_access_token").as_deref(),
5383            Some("\"raw-***alue\"")
5384        );
5385
5386        let headers = config
5387            .list_values()
5388            .get("http_headers")
5389            .expect("headers are listed")
5390            .clone();
5391        assert!(headers.contains("Authorization=Bear***oken"));
5392        assert!(headers.contains("X-Test=ok"));
5393        assert!(!headers.contains("raw-header-token"));
5394    }
5395
5396    #[test]
5397    fn hook_sinks_config_uses_separate_table_from_lifecycle_hooks() -> Result<()> {
5398        let raw = r#"
5399[hooks]
5400enabled = true
5401default_timeout_secs = 20
5402
5403[[hooks.hooks]]
5404event = "message_submit"
5405command = "echo ok"
5406
5407[hook_sinks]
5408unix_socket_path = "/tmp/cw-hooks.sock"
5409"#;
5410
5411        let config: ConfigToml = toml::from_str(raw)?;
5412
5413        assert_eq!(
5414            config.get_value("hook_sinks.unix_socket_path").as_deref(),
5415            Some("/tmp/cw-hooks.sock")
5416        );
5417        assert!(
5418            config.extras.contains_key("hooks"),
5419            "legacy lifecycle hooks table must remain an opaque extra"
5420        );
5421
5422        let serialized = toml::to_string_pretty(&config)?;
5423        let round_tripped: ConfigToml = toml::from_str(&serialized)?;
5424        let hooks = round_tripped
5425            .extras
5426            .get("hooks")
5427            .and_then(toml::Value::as_table)
5428            .expect("hooks table preserved");
5429
5430        assert_eq!(
5431            hooks.get("enabled").and_then(toml::Value::as_bool),
5432            Some(true)
5433        );
5434        assert_eq!(
5435            hooks
5436                .get("default_timeout_secs")
5437                .and_then(toml::Value::as_integer),
5438            Some(20)
5439        );
5440        assert!(
5441            hooks.get("hooks").and_then(toml::Value::as_array).is_some(),
5442            "nested lifecycle hooks array must survive config rewrites"
5443        );
5444        assert_eq!(
5445            round_tripped
5446                .get_value("hook_sinks.unix_socket_path")
5447                .as_deref(),
5448            Some("/tmp/cw-hooks.sock")
5449        );
5450
5451        Ok(())
5452    }
5453
5454    #[test]
5455    fn hook_sinks_unix_socket_path_round_trips_through_key_value_api() -> Result<()> {
5456        let mut config = ConfigToml::default();
5457
5458        config.set_value("hook_sinks.unix_socket_path", "/tmp/cw-events.sock")?;
5459
5460        assert_eq!(
5461            config.get_value("hook_sinks.unix_socket_path").as_deref(),
5462            Some("/tmp/cw-events.sock")
5463        );
5464        assert_eq!(
5465            config
5466                .list_values()
5467                .get("hook_sinks.unix_socket_path")
5468                .map(String::as_str),
5469            Some("/tmp/cw-events.sock")
5470        );
5471
5472        config.unset_value("hook_sinks.unix_socket_path")?;
5473        assert_eq!(config.get_value("hook_sinks.unix_socket_path"), None);
5474
5475        Ok(())
5476    }
5477
5478    /// End-to-end smoke for the preferred Kimi Code setup path:
5479    ///   1. Start from a fresh root config that uses DeepSeek defaults.
5480    ///   2. Mutate it through the same key-value setters the
5481    ///      `codewhale config set providers.moonshot.*` CLI invokes.
5482    ///   3. Switch the active provider through `CODEWHALE_PROVIDER` —
5483    ///      the public env alias — without ever touching the legacy
5484    ///      `DEEPSEEK_PROVIDER` name.
5485    ///   4. Resolve the runtime and confirm the doctor/runtime values.
5486    ///
5487    /// No real API key is required; the `api_key` here is just a
5488    /// non-empty placeholder.
5489    #[test]
5490    fn moonshot_kimi_code_smoke_config_set_then_resolve() -> Result<()> {
5491        let _lock = env_lock();
5492        let _env = EnvGuard::without_deepseek_runtime_overrides();
5493
5494        let mut config = ConfigToml {
5495            provider: ProviderKind::Deepseek,
5496            default_text_model: Some("deepseek-v4-pro".to_string()),
5497            ..ConfigToml::default()
5498        };
5499
5500        // Same key paths a user would run via `codewhale config set`.
5501        config.set_value("providers.moonshot.api_key", "kimi-code-key-placeholder")?;
5502        config.set_value("providers.moonshot.auth_mode", "api_key")?;
5503        config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?;
5504        config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?;
5505
5506        // Public env alias for the active-provider switch.
5507        // Safety: test-only env mutation guarded by env_lock().
5508        unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") };
5509
5510        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5511
5512        assert_eq!(resolved.provider, ProviderKind::Moonshot);
5513        assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
5514        assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
5515        assert_eq!(resolved.auth_mode.as_deref(), Some("api_key"));
5516        assert_eq!(
5517            resolved.api_key.as_deref(),
5518            Some("kimi-code-key-placeholder")
5519        );
5520        assert_eq!(
5521            resolved.api_key_source,
5522            Some(RuntimeApiKeySource::ConfigFile)
5523        );
5524        Ok(())
5525    }
5526
5527    #[test]
5528    fn moonshot_provider_config_values_round_trip() -> Result<()> {
5529        let mut config = ConfigToml::default();
5530
5531        config.set_value("providers.moonshot.api_key", "moonshot-secret-value")?;
5532        config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?;
5533        config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?;
5534        config.set_value("providers.moonshot.auth_mode", "api_key")?;
5535        config.set_value("providers.moonshot.http_headers", "X-Test=ok")?;
5536
5537        assert_eq!(
5538            config
5539                .get_display_value("providers.moonshot.api_key")
5540                .as_deref(),
5541            Some("moon***alue")
5542        );
5543        assert_eq!(
5544            config.get_value("providers.moonshot.base_url").as_deref(),
5545            Some(DEFAULT_KIMI_CODE_BASE_URL)
5546        );
5547        assert_eq!(
5548            config.get_value("providers.moonshot.model").as_deref(),
5549            Some(DEFAULT_KIMI_CODE_MODEL)
5550        );
5551        assert_eq!(
5552            config.get_value("providers.moonshot.auth_mode").as_deref(),
5553            Some("api_key")
5554        );
5555        assert_eq!(
5556            config
5557                .list_values()
5558                .get("providers.moonshot.api_key")
5559                .map(String::as_str),
5560            Some("moon***alue")
5561        );
5562
5563        config.unset_value("providers.moonshot.auth_mode")?;
5564        config.unset_value("providers.moonshot.base_url")?;
5565        config.unset_value("providers.moonshot.model")?;
5566
5567        assert_eq!(config.get_value("providers.moonshot.auth_mode"), None);
5568        assert_eq!(config.get_value("providers.moonshot.base_url"), None);
5569        assert_eq!(config.get_value("providers.moonshot.model"), None);
5570        Ok(())
5571    }
5572
5573    #[test]
5574    fn siliconflow_cn_provider_config_values_round_trip() -> Result<()> {
5575        let mut config = ConfigToml::default();
5576
5577        config.set_value("providers.siliconflow_cn.api_key", "sf-cn-secret-value")?;
5578        config.set_value(
5579            "providers.siliconflow_cn.base_url",
5580            DEFAULT_SILICONFLOW_CN_BASE_URL,
5581        )?;
5582        config.set_value("providers.siliconflow_cn.model", DEFAULT_SILICONFLOW_MODEL)?;
5583        config.set_value("providers.siliconflow_cn.http_headers", "X-Test=ok")?;
5584
5585        assert_eq!(
5586            config
5587                .get_display_value("providers.siliconflow_cn.api_key")
5588                .as_deref(),
5589            Some("sf-c***alue")
5590        );
5591        assert_eq!(
5592            config
5593                .get_value("providers.siliconflow_cn.base_url")
5594                .as_deref(),
5595            Some(DEFAULT_SILICONFLOW_CN_BASE_URL)
5596        );
5597        assert_eq!(
5598            config
5599                .get_value("providers.siliconflow_cn.model")
5600                .as_deref(),
5601            Some(DEFAULT_SILICONFLOW_MODEL)
5602        );
5603        assert_eq!(
5604            config
5605                .list_values()
5606                .get("providers.siliconflow_cn.api_key")
5607                .map(String::as_str),
5608            Some("sf-c***alue")
5609        );
5610
5611        config.unset_value("providers.siliconflow_cn.api_key")?;
5612        config.unset_value("providers.siliconflow_cn.base_url")?;
5613        config.unset_value("providers.siliconflow_cn.model")?;
5614        config.unset_value("providers.siliconflow_cn.http_headers")?;
5615
5616        assert_eq!(config.get_value("providers.siliconflow_cn.api_key"), None);
5617        assert_eq!(config.get_value("providers.siliconflow_cn.base_url"), None);
5618        assert_eq!(config.get_value("providers.siliconflow_cn.model"), None);
5619        assert_eq!(
5620            config.get_value("providers.siliconflow_cn.http_headers"),
5621            None
5622        );
5623        Ok(())
5624    }
5625
5626    #[test]
5627    fn volcengine_provider_config_values_round_trip() -> Result<()> {
5628        let mut config = ConfigToml::default();
5629
5630        config.set_value("providers.volcengine.api_key", "volcengine-secret-value")?;
5631        config.set_value("providers.volcengine.base_url", DEFAULT_VOLCENGINE_BASE_URL)?;
5632        config.set_value("providers.volcengine.model", DEFAULT_VOLCENGINE_MODEL)?;
5633        config.set_value("providers.volcengine.http_headers", "X-Test=ok")?;
5634
5635        assert_eq!(
5636            config
5637                .get_display_value("providers.volcengine.api_key")
5638                .as_deref(),
5639            Some("volc***alue")
5640        );
5641        assert_eq!(
5642            config.get_value("providers.volcengine.base_url").as_deref(),
5643            Some(DEFAULT_VOLCENGINE_BASE_URL)
5644        );
5645        assert_eq!(
5646            config.get_value("providers.volcengine.model").as_deref(),
5647            Some(DEFAULT_VOLCENGINE_MODEL)
5648        );
5649        assert_eq!(
5650            config
5651                .get_value("providers.volcengine.http_headers")
5652                .as_deref(),
5653            Some("X-Test=ok")
5654        );
5655        assert_eq!(
5656            config
5657                .list_values()
5658                .get("providers.volcengine.http_headers")
5659                .map(String::as_str),
5660            Some("X-Test=ok")
5661        );
5662
5663        config.unset_value("providers.volcengine.http_headers")?;
5664        assert_eq!(config.get_value("providers.volcengine.http_headers"), None);
5665        Ok(())
5666    }
5667
5668    #[test]
5669    fn provider_key_value_api_covers_all_provider_metadata_entries() -> Result<()> {
5670        for provider in ProviderKind::ALL {
5671            let table = provider.provider().provider_config_key();
5672            let mut config = ConfigToml::default();
5673            let api_key = format!("secret-value-for-{table}-123456");
5674            let api_key_path = format!("providers.{table}.api_key");
5675            let base_url_path = format!("providers.{table}.base_url");
5676            let model_path = format!("providers.{table}.model");
5677            let headers_path = format!("providers.{table}.http_headers");
5678            let mode_path = format!("providers.{table}.mode");
5679            let auth_mode_path = format!("providers.{table}.auth_mode");
5680            let insecure_path = format!("providers.{table}.insecure_skip_tls_verify");
5681            let path_suffix_path = format!("providers.{table}.path_suffix");
5682
5683            config.set_value(&api_key_path, &api_key)?;
5684            config.set_value(&base_url_path, "https://gateway.example/v1")?;
5685            config.set_value(&model_path, "provider-test-model")?;
5686            config.set_value(&headers_path, "X-Test=ok")?;
5687            config.set_value(&mode_path, "concise")?;
5688            config.set_value(&auth_mode_path, "api_key")?;
5689            config.set_value(&insecure_path, "true")?;
5690            config.set_value(&path_suffix_path, "/chat/completions")?;
5691
5692            assert_eq!(
5693                config.get_value(&api_key_path).as_deref(),
5694                Some(api_key.as_str())
5695            );
5696            assert_eq!(
5697                config.get_value(&base_url_path).as_deref(),
5698                Some("https://gateway.example/v1")
5699            );
5700            assert_eq!(
5701                config.get_value(&model_path).as_deref(),
5702                Some("provider-test-model")
5703            );
5704            assert_eq!(
5705                config.get_value(&headers_path).as_deref(),
5706                Some("X-Test=ok")
5707            );
5708            assert_eq!(config.get_value(&mode_path).as_deref(), Some("concise"));
5709            assert_eq!(
5710                config.get_value(&auth_mode_path).as_deref(),
5711                Some("api_key")
5712            );
5713            assert_eq!(config.get_value(&insecure_path).as_deref(), Some("true"));
5714            assert_eq!(
5715                config.get_value(&path_suffix_path).as_deref(),
5716                Some("/chat/completions")
5717            );
5718
5719            let listed = config.list_values();
5720            let listed_api_key = listed
5721                .get(&api_key_path)
5722                .expect("provider API key is listed");
5723            assert!(listed_api_key.contains("***"));
5724            assert_ne!(listed_api_key, &api_key);
5725            assert_eq!(
5726                listed.get(&headers_path).map(String::as_str),
5727                Some("X-Test=ok")
5728            );
5729            assert_eq!(listed.get(&insecure_path).map(String::as_str), Some("true"));
5730
5731            config.unset_value(&api_key_path)?;
5732            config.unset_value(&base_url_path)?;
5733            config.unset_value(&model_path)?;
5734            config.unset_value(&headers_path)?;
5735            config.unset_value(&mode_path)?;
5736            config.unset_value(&auth_mode_path)?;
5737            config.unset_value(&insecure_path)?;
5738            config.unset_value(&path_suffix_path)?;
5739
5740            assert_eq!(config.get_value(&api_key_path), None);
5741            assert_eq!(config.get_value(&base_url_path), None);
5742            assert_eq!(config.get_value(&model_path), None);
5743            assert_eq!(config.get_value(&headers_path), None);
5744            assert_eq!(config.get_value(&mode_path), None);
5745            assert_eq!(config.get_value(&auth_mode_path), None);
5746            assert_eq!(config.get_value(&insecure_path), None);
5747            assert_eq!(config.get_value(&path_suffix_path), None);
5748
5749            if provider == ProviderKind::Deepseek {
5750                assert_eq!(config.api_key, None);
5751                assert_eq!(config.base_url, None);
5752                assert_eq!(config.default_text_model, None);
5753                assert!(config.http_headers.is_empty());
5754            }
5755        }
5756
5757        Ok(())
5758    }
5759
5760    #[test]
5761    fn project_merge_denies_credentials_endpoints_and_provider_selection() {
5762        let mut base = ConfigToml {
5763            provider: ProviderKind::Deepseek,
5764            api_key: Some("user-key".to_string()),
5765            base_url: Some("https://api.deepseek.com".to_string()),
5766            default_text_model: Some("deepseek-v4-flash".to_string()),
5767            ..ConfigToml::default()
5768        };
5769        base.providers.openrouter.api_key = Some("user-openrouter-key".to_string());
5770        base.providers.openrouter.path_suffix = Some("/chat/completions".to_string());
5771
5772        let mut project = ConfigToml {
5773            provider: ProviderKind::Openrouter,
5774            api_key: Some("attacker-key".to_string()),
5775            base_url: Some("https://evil.example/v1".to_string()),
5776            default_text_model: Some("deepseek-v4-pro".to_string()),
5777            auth_mode: Some("oauth".to_string()),
5778            telemetry: Some(true),
5779            ..ConfigToml::default()
5780        };
5781        project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string());
5782        project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string());
5783        project.providers.openrouter.insecure_skip_tls_verify = Some(true);
5784        project.providers.openrouter.path_suffix = Some("/attacker/chat".to_string());
5785        project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string());
5786        project.providers.volcengine.model = Some("DeepSeek-V4-Pro".to_string());
5787        project.providers.moonshot.model = Some("kimi-k2.6".to_string());
5788
5789        base.merge_project_overrides(project);
5790
5791        assert_eq!(base.provider, ProviderKind::Deepseek);
5792        assert_eq!(base.api_key.as_deref(), Some("user-key"));
5793        assert_eq!(base.base_url.as_deref(), Some("https://api.deepseek.com"));
5794        assert_eq!(base.auth_mode, None);
5795        assert_eq!(base.telemetry, None);
5796        assert_eq!(
5797            base.providers.openrouter.api_key.as_deref(),
5798            Some("user-openrouter-key")
5799        );
5800        assert_eq!(base.providers.openrouter.base_url, None);
5801        assert_eq!(base.providers.openrouter.insecure_skip_tls_verify, None);
5802        assert_eq!(
5803            base.providers.openrouter.path_suffix.as_deref(),
5804            Some("/chat/completions")
5805        );
5806        assert_eq!(base.default_text_model.as_deref(), Some("deepseek-v4-pro"));
5807        assert_eq!(
5808            base.providers.openrouter.model.as_deref(),
5809            Some("deepseek/deepseek-v4-pro")
5810        );
5811        assert_eq!(
5812            base.providers.volcengine.model.as_deref(),
5813            Some("DeepSeek-V4-Pro")
5814        );
5815        assert_eq!(base.providers.moonshot.model.as_deref(), Some("kimi-k2.6"));
5816    }
5817
5818    #[test]
5819    fn project_merge_forwards_all_provider_model_overrides() {
5820        let mut project_toml = String::new();
5821        for provider in ProviderKind::ALL {
5822            let key = provider.provider().provider_config_key();
5823            project_toml.push_str(&format!(
5824                "[providers.{key}]\nmodel = \"project-{key}-model\"\n\n"
5825            ));
5826        }
5827
5828        let project: ConfigToml =
5829            toml::from_str(&project_toml).expect("project provider overrides parse");
5830        let mut base = ConfigToml::default();
5831
5832        base.merge_project_overrides(project);
5833
5834        for provider in ProviderKind::ALL {
5835            let key = provider.provider().provider_config_key();
5836            let expected = format!("project-{key}-model");
5837            assert_eq!(
5838                base.providers.for_provider(provider).model.as_deref(),
5839                Some(expected.as_str()),
5840                "provider {key} should merge repo-local model override"
5841            );
5842        }
5843    }
5844
5845    #[test]
5846    fn project_merge_only_tightens_approval_and_sandbox_policy() {
5847        let mut strict = ConfigToml {
5848            approval_policy: Some("never".to_string()),
5849            sandbox_mode: Some("read-only".to_string()),
5850            ..ConfigToml::default()
5851        };
5852        strict.merge_project_overrides(ConfigToml {
5853            approval_policy: Some("on-request".to_string()),
5854            sandbox_mode: Some("workspace-write".to_string()),
5855            ..ConfigToml::default()
5856        });
5857        assert_eq!(strict.approval_policy.as_deref(), Some("never"));
5858        assert_eq!(strict.sandbox_mode.as_deref(), Some("read-only"));
5859
5860        let mut permissive = ConfigToml {
5861            approval_policy: Some("auto".to_string()),
5862            sandbox_mode: Some("workspace-write".to_string()),
5863            ..ConfigToml::default()
5864        };
5865        permissive.merge_project_overrides(ConfigToml {
5866            approval_policy: Some("never".to_string()),
5867            sandbox_mode: Some("read-only".to_string()),
5868            ..ConfigToml::default()
5869        });
5870        assert_eq!(permissive.approval_policy.as_deref(), Some("never"));
5871        assert_eq!(permissive.sandbox_mode.as_deref(), Some("read-only"));
5872
5873        let mut unset = ConfigToml::default();
5874        unset.merge_project_overrides(ConfigToml {
5875            approval_policy: Some("on-request".to_string()),
5876            sandbox_mode: Some("workspace-write".to_string()),
5877            ..ConfigToml::default()
5878        });
5879        assert_eq!(unset.approval_policy, None);
5880        assert_eq!(unset.sandbox_mode, None);
5881    }
5882
5883    #[test]
5884    fn list_values_redacts_unicode_api_key_without_byte_slicing() {
5885        let config = ConfigToml {
5886            api_key: Some("密钥密钥密钥密钥123456789".to_string()),
5887            ..ConfigToml::default()
5888        };
5889
5890        let values = config.list_values();
5891
5892        assert_eq!(
5893            values.get("api_key").map(String::as_str),
5894            Some("密钥密钥***6789")
5895        );
5896    }
5897
5898    #[test]
5899    fn app_homes_prefer_home_env_before_platform_home_fallback() {
5900        let _lock = env_lock();
5901        struct HomeEnvGuard {
5902            home: Option<OsString>,
5903            userprofile: Option<OsString>,
5904            codewhale_home: Option<OsString>,
5905        }
5906
5907        impl Drop for HomeEnvGuard {
5908            fn drop(&mut self) {
5909                // Safety: test-only environment mutation is serialized by env_lock().
5910                unsafe {
5911                    match self.home.take() {
5912                        Some(value) => env::set_var("HOME", value),
5913                        None => env::remove_var("HOME"),
5914                    }
5915                    match self.userprofile.take() {
5916                        Some(value) => env::set_var("USERPROFILE", value),
5917                        None => env::remove_var("USERPROFILE"),
5918                    }
5919                    match self.codewhale_home.take() {
5920                        Some(value) => env::set_var("CODEWHALE_HOME", value),
5921                        None => env::remove_var("CODEWHALE_HOME"),
5922                    }
5923                }
5924            }
5925        }
5926
5927        let home =
5928            std::env::temp_dir().join(format!("codewhale-config-home-env-{}", std::process::id()));
5929        let userprofile = std::env::temp_dir().join(format!(
5930            "codewhale-config-userprofile-{}",
5931            std::process::id()
5932        ));
5933        let _env = HomeEnvGuard {
5934            home: env::var_os("HOME"),
5935            userprofile: env::var_os("USERPROFILE"),
5936            codewhale_home: env::var_os("CODEWHALE_HOME"),
5937        };
5938        // Safety: test-only environment mutation is serialized by env_lock().
5939        unsafe {
5940            env::set_var("HOME", &home);
5941            env::set_var("USERPROFILE", &userprofile);
5942            env::remove_var("CODEWHALE_HOME");
5943        }
5944
5945        assert_eq!(
5946            codewhale_home().expect("codewhale home"),
5947            home.join(CODEWHALE_APP_DIR)
5948        );
5949        assert_eq!(
5950            legacy_deepseek_home().expect("legacy home"),
5951            home.join(LEGACY_APP_DIR)
5952        );
5953
5954        let explicit = std::env::temp_dir().join(format!(
5955            "codewhale-config-explicit-home-{}",
5956            std::process::id()
5957        ));
5958        // Safety: test-only environment mutation is serialized by env_lock().
5959        unsafe {
5960            env::set_var("CODEWHALE_HOME", &explicit);
5961        }
5962        assert_eq!(codewhale_home().expect("explicit home"), explicit);
5963    }
5964
5965    #[test]
5966    fn migrate_config_reports_copied_legacy_path() {
5967        let _lock = env_lock();
5968        struct HomeEnvGuard {
5969            home: Option<OsString>,
5970            userprofile: Option<OsString>,
5971            codewhale_home: Option<OsString>,
5972        }
5973
5974        impl Drop for HomeEnvGuard {
5975            fn drop(&mut self) {
5976                // Safety: test-only environment mutation is serialized by env_lock().
5977                unsafe {
5978                    match self.home.take() {
5979                        Some(value) => env::set_var("HOME", value),
5980                        None => env::remove_var("HOME"),
5981                    }
5982                    match self.userprofile.take() {
5983                        Some(value) => env::set_var("USERPROFILE", value),
5984                        None => env::remove_var("USERPROFILE"),
5985                    }
5986                    match self.codewhale_home.take() {
5987                        Some(value) => env::set_var("CODEWHALE_HOME", value),
5988                        None => env::remove_var("CODEWHALE_HOME"),
5989                    }
5990                }
5991            }
5992        }
5993
5994        struct LegacyConfigGuard {
5995            path: PathBuf,
5996            original: Option<Vec<u8>>,
5997        }
5998
5999        impl LegacyConfigGuard {
6000            fn install(path: PathBuf, contents: &[u8]) -> Self {
6001                let original = fs::read(&path).ok();
6002                fs::create_dir_all(path.parent().expect("legacy config parent"))
6003                    .expect("legacy dir");
6004                fs::write(&path, contents).expect("legacy config");
6005                Self { path, original }
6006            }
6007        }
6008
6009        impl Drop for LegacyConfigGuard {
6010            fn drop(&mut self) {
6011                if let Some(original) = self.original.take() {
6012                    let _ = fs::write(&self.path, original);
6013                } else {
6014                    let _ = fs::remove_file(&self.path);
6015                    if let Some(parent) = self.path.parent() {
6016                        let _ = fs::remove_dir(parent);
6017                    }
6018                }
6019            }
6020        }
6021
6022        let unique = std::time::SystemTime::now()
6023            .duration_since(std::time::UNIX_EPOCH)
6024            .expect("clock")
6025            .as_nanos();
6026        let home = std::env::temp_dir().join(format!(
6027            "codewhale-config-migration-{}-{unique}",
6028            std::process::id()
6029        ));
6030        let legacy_dir = home.join(LEGACY_APP_DIR);
6031        let primary_dir = home.join(CODEWHALE_APP_DIR);
6032        let legacy_config = legacy_dir.join(CONFIG_FILE_NAME);
6033        let _legacy =
6034            LegacyConfigGuard::install(legacy_config.clone(), b"provider = \"deepseek\"\n");
6035
6036        let _env = HomeEnvGuard {
6037            home: env::var_os("HOME"),
6038            userprofile: env::var_os("USERPROFILE"),
6039            codewhale_home: env::var_os("CODEWHALE_HOME"),
6040        };
6041        // Safety: test-only environment mutation is serialized by env_lock().
6042        unsafe {
6043            env::set_var("HOME", &home);
6044            env::set_var("USERPROFILE", &home);
6045            env::set_var("CODEWHALE_HOME", &primary_dir);
6046        }
6047
6048        let migration = migrate_config_if_needed()
6049            .expect("migration")
6050            .expect("legacy config should be copied");
6051
6052        assert_eq!(migration.legacy_path, legacy_config);
6053        assert_eq!(migration.primary_path, primary_dir.join(CONFIG_FILE_NAME));
6054        let notice = migration.user_notice();
6055        assert!(notice.contains(&legacy_dir.join(CONFIG_FILE_NAME).display().to_string()));
6056        assert!(notice.contains(&primary_dir.join(CONFIG_FILE_NAME).display().to_string()));
6057        assert!(notice.contains(".codewhale path for future edits"));
6058        assert!(notice.contains(".deepseek file remains only as a compatibility fallback"));
6059        assert_eq!(
6060            fs::read_to_string(primary_dir.join(CONFIG_FILE_NAME)).expect("primary config"),
6061            "provider = \"deepseek\"\n"
6062        );
6063
6064        let _ = fs::remove_dir_all(home);
6065    }
6066
6067    // ── ensure_state_dir legacy migration (#3240) ───────────────────────
6068
6069    /// Saves and restores the env vars that the state-resolvers read.
6070    struct StateEnvRestore {
6071        home: Option<OsString>,
6072        userprofile: Option<OsString>,
6073        codewhale_home: Option<OsString>,
6074    }
6075
6076    impl Drop for StateEnvRestore {
6077        fn drop(&mut self) {
6078            // Safety: test-only environment mutation is serialized by env_lock().
6079            unsafe {
6080                match self.home.take() {
6081                    Some(value) => env::set_var("HOME", value),
6082                    None => env::remove_var("HOME"),
6083                }
6084                match self.userprofile.take() {
6085                    Some(value) => env::set_var("USERPROFILE", value),
6086                    None => env::remove_var("USERPROFILE"),
6087                }
6088                match self.codewhale_home.take() {
6089                    Some(value) => env::set_var("CODEWHALE_HOME", value),
6090                    None => env::remove_var("CODEWHALE_HOME"),
6091                }
6092            }
6093        }
6094    }
6095
6096    /// Points `HOME`/`USERPROFILE`/`CODEWHALE_HOME` at a fresh temp tree so
6097    /// `codewhale_home()` -> `<home>/.codewhale` and `legacy_deepseek_home()`
6098    /// -> `<home>/.deepseek`. Env is restored on drop.
6099    struct StateDirEnv {
6100        home: PathBuf,
6101        _restore: StateEnvRestore,
6102    }
6103
6104    impl StateDirEnv {
6105        fn install(unique: u128) -> Self {
6106            let home = std::env::temp_dir().join(format!(
6107                "codewhale-state-migration-{}-{unique}",
6108                std::process::id()
6109            ));
6110            let restore = StateEnvRestore {
6111                home: env::var_os("HOME"),
6112                userprofile: env::var_os("USERPROFILE"),
6113                codewhale_home: env::var_os("CODEWHALE_HOME"),
6114            };
6115            // Safety: test-only environment mutation is serialized by env_lock().
6116            unsafe {
6117                env::set_var("HOME", &home);
6118                env::set_var("USERPROFILE", &home);
6119                env::set_var("CODEWHALE_HOME", home.join(CODEWHALE_APP_DIR));
6120            }
6121            Self {
6122                home,
6123                _restore: restore,
6124            }
6125        }
6126        fn legacy(&self, sub: &str) -> PathBuf {
6127            self.home.join(LEGACY_APP_DIR).join(sub)
6128        }
6129        fn primary(&self, sub: &str) -> PathBuf {
6130            self.home.join(CODEWHALE_APP_DIR).join(sub)
6131        }
6132    }
6133
6134    #[test]
6135    fn ensure_state_dir_relocates_legacy_subdir_on_first_write() {
6136        let _lock = env_lock();
6137        let unique = std::time::SystemTime::now()
6138            .duration_since(std::time::UNIX_EPOCH)
6139            .expect("clock")
6140            .as_nanos();
6141        let state_env = StateDirEnv::install(unique);
6142        // Seed a legacy subdir; primary must not exist yet.
6143        fs::create_dir_all(state_env.legacy("slop_ledger")).expect("legacy dir");
6144        fs::write(
6145            state_env.legacy("slop_ledger").join("slop_ledger.json"),
6146            b"legacy",
6147        )
6148        .expect("legacy file");
6149        assert!(!state_env.primary("slop_ledger").exists());
6150
6151        let dir = ensure_state_dir("slop_ledger").expect("ensure_state_dir");
6152        assert_eq!(dir, state_env.primary("slop_ledger"));
6153        // Legacy contents relocated into primary.
6154        assert_eq!(
6155            fs::read_to_string(state_env.primary("slop_ledger").join("slop_ledger.json"))
6156                .expect("migrated file"),
6157            "legacy"
6158        );
6159        // The legacy subdir was relocated (moved), so .deepseek stops growing.
6160        assert!(
6161            !state_env.legacy("slop_ledger").exists(),
6162            "legacy subdir should be removed after relocation"
6163        );
6164        // Idempotent: a second call is a no-op now that primary exists.
6165        ensure_state_dir("slop_ledger").expect("idempotent ensure");
6166        let _ = fs::remove_dir_all(&state_env.home);
6167    }
6168
6169    #[test]
6170    fn ensure_state_dir_writes_to_primary_when_both_exist() {
6171        let _lock = env_lock();
6172        let unique = std::time::SystemTime::now()
6173            .duration_since(std::time::UNIX_EPOCH)
6174            .expect("clock")
6175            .as_nanos();
6176        let state_env = StateDirEnv::install(unique);
6177        // Migrated user: primary already exists; a legacy orphan also remains.
6178        fs::create_dir_all(state_env.primary("sessions")).expect("primary dir");
6179        fs::write(state_env.primary("sessions").join("a.json"), b"primary").expect("primary file");
6180        fs::create_dir_all(state_env.legacy("sessions")).expect("legacy dir");
6181        fs::write(state_env.legacy("sessions").join("old.json"), b"legacy").expect("legacy file");
6182
6183        let dir = ensure_state_dir("sessions").expect("ensure_state_dir");
6184        assert_eq!(dir, state_env.primary("sessions"));
6185        // Primary untouched; legacy orphan left as-is (not migrated, not deleted).
6186        assert_eq!(
6187            fs::read_to_string(state_env.primary("sessions").join("a.json")).expect("primary"),
6188            "primary"
6189        );
6190        assert!(
6191            state_env.legacy("sessions").exists(),
6192            "existing legacy orphan must not be deleted when primary exists"
6193        );
6194        let _ = fs::remove_dir_all(&state_env.home);
6195    }
6196
6197    #[test]
6198    fn resolve_state_dir_still_finds_legacy_for_backfill() {
6199        let _lock = env_lock();
6200        let unique = std::time::SystemTime::now()
6201            .duration_since(std::time::UNIX_EPOCH)
6202            .expect("clock")
6203            .as_nanos();
6204        let state_env = StateDirEnv::install(unique);
6205        // Only legacy exists -> read resolver returns legacy (backfill).
6206        fs::create_dir_all(state_env.legacy("catalog")).expect("legacy dir");
6207        assert_eq!(
6208            resolve_state_dir("catalog").expect("resolve"),
6209            state_env.legacy("catalog")
6210        );
6211        // After the primary is created (e.g. via a write), the read resolver
6212        // returns primary — legacy is reachable only while primary is absent.
6213        ensure_state_dir("catalog").expect("ensure");
6214        assert_eq!(
6215            resolve_state_dir("catalog").expect("resolve after migrate"),
6216            state_env.primary("catalog")
6217        );
6218        let _ = fs::remove_dir_all(&state_env.home);
6219    }
6220
6221    #[test]
6222    fn state_resolvers_reject_path_traversal_subdirs() {
6223        // Defense against path injection (#3240 hardening): the public state
6224        // resolvers must refuse subdirs that could escape the state root.
6225        for bad in ["..", "../secret", "/etc", "a/../../b"] {
6226            let err = ensure_state_dir(bad)
6227                .err()
6228                .unwrap_or_else(|| panic!("expected {bad:?} to be rejected"));
6229            assert!(
6230                format!("{err:#}").contains("state subdir"),
6231                "expected rejection of {bad:?}, got {err:#}"
6232            );
6233            assert!(
6234                resolve_state_dir(bad).is_err(),
6235                "read resolver must also reject {bad:?}"
6236            );
6237        }
6238        // Safe values are accepted (including the root sentinel ".").
6239        assert!(ensure_safe_state_subdir(".").is_ok());
6240        assert!(ensure_safe_state_subdir("sessions").is_ok());
6241        assert!(ensure_safe_state_subdir("a/b").is_ok());
6242        assert!(ensure_safe_state_subdir("").is_err());
6243    }
6244
6245    #[test]
6246    fn normalize_config_file_path_rejects_traversal() {
6247        let err = normalize_config_file_path(PathBuf::from("../config.toml"))
6248            .expect_err("traversal path should fail");
6249        assert!(format!("{err:#}").contains("cannot contain '..'"));
6250    }
6251
6252    #[cfg(unix)]
6253    #[test]
6254    fn save_clamps_existing_config_permissions() {
6255        use std::time::{SystemTime, UNIX_EPOCH};
6256
6257        let unique = SystemTime::now()
6258            .duration_since(UNIX_EPOCH)
6259            .expect("clock")
6260            .as_nanos();
6261        let dir = std::env::temp_dir().join(format!(
6262            "deepseek-config-perms-{}-{unique}",
6263            std::process::id()
6264        ));
6265        fs::create_dir_all(&dir).expect("mkdir");
6266        let path = dir.join(CONFIG_FILE_NAME);
6267        fs::write(&path, "api_key = \"old\"\n").expect("seed config");
6268        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod seed");
6269
6270        let store = ConfigStore {
6271            path: path.clone(),
6272            config: ConfigToml {
6273                api_key: Some("new-secret".to_string()),
6274                ..ConfigToml::default()
6275            },
6276            permissions: PermissionsToml::default(),
6277            original_raw: None,
6278        };
6279        store.save().expect("save");
6280
6281        let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
6282        assert_eq!(mode, 0o600);
6283
6284        let _ = fs::remove_dir_all(dir);
6285    }
6286
6287    #[test]
6288    fn config_store_save_skips_identical_serialized_body() {
6289        use std::time::{SystemTime, UNIX_EPOCH};
6290
6291        let unique = SystemTime::now()
6292            .duration_since(UNIX_EPOCH)
6293            .expect("clock")
6294            .as_nanos();
6295        let dir = std::env::temp_dir().join(format!(
6296            "codewhale-config-noop-save-{}-{unique}",
6297            std::process::id()
6298        ));
6299        fs::create_dir_all(&dir).expect("mkdir");
6300        let path = dir.join(CONFIG_FILE_NAME);
6301        let config = ConfigToml {
6302            model: Some("deepseek-v4-flash".to_string()),
6303            ..ConfigToml::default()
6304        };
6305        let body = toml::to_string_pretty(&config).expect("serialize");
6306        fs::write(&path, &body).expect("seed config");
6307        #[cfg(unix)]
6308        fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).expect("chmod seed");
6309
6310        let store = ConfigStore {
6311            path: path.clone(),
6312            config,
6313            permissions: PermissionsToml::default(),
6314            original_raw: None,
6315        };
6316        store.save().expect("identical save should not rewrite");
6317
6318        #[cfg(unix)]
6319        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("chmod restore");
6320        assert_eq!(fs::read_to_string(&path).expect("read config"), body);
6321        assert!(
6322            !config_backup_path(&path).exists(),
6323            "no-op save must not create a migration backup"
6324        );
6325
6326        let _ = fs::remove_dir_all(dir);
6327    }
6328
6329    #[test]
6330    fn config_store_save_creates_one_time_backup_before_changed_write() {
6331        use std::time::{SystemTime, UNIX_EPOCH};
6332
6333        let unique = SystemTime::now()
6334            .duration_since(UNIX_EPOCH)
6335            .expect("clock")
6336            .as_nanos();
6337        let dir = std::env::temp_dir().join(format!(
6338            "codewhale-config-backup-save-{}-{unique}",
6339            std::process::id()
6340        ));
6341        fs::create_dir_all(&dir).expect("mkdir");
6342        let path = dir.join(CONFIG_FILE_NAME);
6343        let original = "model = \"deepseek-v4-flash\"\n";
6344        fs::write(&path, original).expect("seed config");
6345
6346        let store = ConfigStore {
6347            path: path.clone(),
6348            config: ConfigToml {
6349                model: Some("deepseek-v4-pro".to_string()),
6350                ..ConfigToml::default()
6351            },
6352            permissions: PermissionsToml::default(),
6353            original_raw: None,
6354        };
6355        store.save().expect("changed save");
6356
6357        let backup_path = config_backup_path(&path);
6358        assert_eq!(
6359            fs::read_to_string(&backup_path).expect("read backup"),
6360            original
6361        );
6362        let updated = fs::read_to_string(&path).expect("read updated config");
6363        assert!(updated.contains("model = \"deepseek-v4-pro\""));
6364
6365        let _ = fs::remove_dir_all(dir);
6366    }
6367
6368    #[test]
6369    fn config_store_save_preserves_comments() {
6370        let dir = tempfile::tempdir().expect("tempdir");
6371        let config_path = dir.path().join(CONFIG_FILE_NAME);
6372        let original = "# my model\nmodel = \"deepseek-v4-flash\"\n# end comment\n";
6373        fs::write(&config_path, original).expect("write config");
6374
6375        let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
6376        store.config.model = Some("deepseek-v4-pro".to_string());
6377        store.save().expect("save");
6378
6379        let body = fs::read_to_string(&config_path).expect("read config");
6380        assert!(body.contains("# my model"), "prefix comment preserved");
6381        assert!(body.contains("# end comment"), "suffix comment preserved");
6382        assert!(body.contains("model = \"deepseek-v4-pro\""));
6383    }
6384
6385    #[test]
6386    fn config_store_save_preserves_disabled_keys() {
6387        let dir = tempfile::tempdir().expect("tempdir");
6388        let config_path = dir.path().join(CONFIG_FILE_NAME);
6389        fs::write(
6390            &config_path,
6391            "# my note\nmodel = \"deepseek-v4-flash\"\n# base_url = \"http://localhost:11434/v1\"\n",
6392        )
6393        .expect("write config");
6394
6395        let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
6396        store.config.model = Some("deepseek-v4-pro".to_string());
6397        store.save().expect("save");
6398
6399        let body = fs::read_to_string(&config_path).expect("read config");
6400        assert!(
6401            body.contains("# base_url = \"http://localhost:11434/v1\""),
6402            "disabled key preserved as comment"
6403        );
6404        assert!(body.contains("model = \"deepseek-v4-pro\""));
6405    }
6406
6407    #[test]
6408    fn config_store_save_preserves_comments_with_other_keys() {
6409        // Realistic scenario: user already has api_key + model, adds a comment,
6410        // then changes model via `codewhale config set model`.
6411        let dir = tempfile::tempdir().expect("tempdir");
6412        let config_path = dir.path().join(CONFIG_FILE_NAME);
6413        fs::write(
6414            &config_path,
6415            "# my deepseek key\napi_key = \"sk-1234\"\n\n# my current model\nmodel = \"deepseek-v4-flash\"\n",
6416        )
6417        .expect("write config");
6418
6419        let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
6420        store.config.model = Some("deepseek-v4-pro".to_string());
6421        store.save().expect("save");
6422
6423        let body = fs::read_to_string(&config_path).expect("read config");
6424        assert!(body.contains("# my deepseek key"), "api_key comment lost");
6425        assert!(body.contains("# my current model"), "model comment lost");
6426        assert!(
6427            body.contains("model = \"deepseek-v4-pro\""),
6428            "new model not written"
6429        );
6430        assert!(body.contains("api_key = \"sk-1234\""), "api_key lost");
6431    }
6432
6433    #[test]
6434    fn merge_and_preserve_comments_returns_err_on_invalid_serialized() {
6435        let err = merge_and_preserve_comments("{{{ not toml", "model = 1\n")
6436            .expect_err("invalid serialized should fail");
6437        assert!(
6438            format!("{err:#}").contains("failed to parse serialized"),
6439            "unexpected error: {err:#}"
6440        );
6441    }
6442
6443    #[test]
6444    fn merge_and_preserve_comments_returns_err_on_invalid_original() {
6445        let err = merge_and_preserve_comments("model = 1\n", "{{{ not toml")
6446            .expect_err("invalid original should fail");
6447        assert!(
6448            format!("{err:#}").contains("failed to parse original"),
6449            "unexpected error: {err:#}"
6450        );
6451    }
6452
6453    #[test]
6454    fn config_store_save_falls_back_when_comment_merge_fails() {
6455        let dir = tempfile::tempdir().expect("tempdir");
6456        let config_path = dir.path().join(CONFIG_FILE_NAME);
6457        // Valid TOML so load succeeds, but the raw is corrupt so the merge
6458        // will fail inside save() — save must still succeed and write the
6459        // plain serialized config.
6460        fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
6461
6462        // Bypass ConfigStore::load to inject a deliberately broken original_raw.
6463        let store = ConfigStore {
6464            path: config_path.clone(),
6465            config: ConfigToml {
6466                model: Some("deepseek-v4-pro".to_string()),
6467                ..ConfigToml::default()
6468            },
6469            permissions: PermissionsToml::default(),
6470            original_raw: Some("{ broken".to_string()),
6471        };
6472        store
6473            .save()
6474            .expect("save should succeed even when merge fails");
6475
6476        let body = fs::read_to_string(&config_path).expect("read config");
6477        assert!(
6478            body.contains("deepseek-v4-pro"),
6479            "config should be written: {body}"
6480        );
6481    }
6482
6483    #[test]
6484    fn provider_kind_parses_openrouter_and_novita_aliases() {
6485        assert_eq!(
6486            ProviderKind::parse("openrouter"),
6487            Some(ProviderKind::Openrouter)
6488        );
6489        assert_eq!(
6490            ProviderKind::parse("OPEN_ROUTER"),
6491            Some(ProviderKind::Openrouter)
6492        );
6493        assert_eq!(
6494            ProviderKind::parse("xiaomi-mimo"),
6495            Some(ProviderKind::XiaomiMimo)
6496        );
6497        assert_eq!(
6498            ProviderKind::parse("xiaomi"),
6499            Some(ProviderKind::XiaomiMimo)
6500        );
6501        assert_eq!(ProviderKind::parse("novita"), Some(ProviderKind::Novita));
6502        assert_eq!(ProviderKind::parse("Novita"), Some(ProviderKind::Novita));
6503        assert_eq!(
6504            ProviderKind::parse("fireworks-ai"),
6505            Some(ProviderKind::Fireworks)
6506        );
6507        assert_eq!(
6508            ProviderKind::parse("silicon-flow"),
6509            Some(ProviderKind::Siliconflow)
6510        );
6511        assert_eq!(
6512            ProviderKind::parse("silicon_flow"),
6513            Some(ProviderKind::Siliconflow)
6514        );
6515        assert_eq!(ProviderKind::parse("kimi"), Some(ProviderKind::Moonshot));
6516        assert_eq!(
6517            ProviderKind::parse("moonshot-ai"),
6518            Some(ProviderKind::Moonshot)
6519        );
6520        assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang));
6521        assert_eq!(ProviderKind::parse("v-llm"), Some(ProviderKind::Vllm));
6522        assert_eq!(ProviderKind::parse("vllm"), Some(ProviderKind::Vllm));
6523        assert_eq!(ProviderKind::parse("ollama"), Some(ProviderKind::Ollama));
6524        assert_eq!(
6525            ProviderKind::parse("ollama-local"),
6526            Some(ProviderKind::Ollama)
6527        );
6528        assert_eq!(
6529            ProviderKind::parse("wanjie-ark"),
6530            Some(ProviderKind::WanjieArk)
6531        );
6532        assert_eq!(
6533            ProviderKind::parse("ark_wanjie"),
6534            Some(ProviderKind::WanjieArk)
6535        );
6536        for alias in ["huggingface", "hugging-face", "hugging_face", "hf"] {
6537            assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Huggingface));
6538
6539            let parsed: ConfigToml =
6540                toml::from_str(&format!("provider = \"{alias}\"")).expect("huggingface alias");
6541            assert_eq!(parsed.provider, ProviderKind::Huggingface);
6542        }
6543
6544        for alias in ["deepinfra", "deep-infra", "deep_infra"] {
6545            assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepinfra));
6546
6547            let parsed: ConfigToml =
6548                toml::from_str(&format!("provider = \"{alias}\"")).expect("deepinfra alias");
6549            assert_eq!(parsed.provider, ProviderKind::Deepinfra);
6550        }
6551
6552        let parsed: ConfigToml =
6553            toml::from_str("provider = \"ark-wanjie\"").expect("wanjie provider alias");
6554        assert_eq!(parsed.provider, ProviderKind::WanjieArk);
6555
6556        let parsed: ConfigToml =
6557            toml::from_str("provider = \"silicon-flow\"").expect("siliconflow provider alias");
6558        assert_eq!(parsed.provider, ProviderKind::Siliconflow);
6559    }
6560
6561    #[test]
6562    fn unknown_provider_error_lists_huggingface() {
6563        let mut config = ConfigToml::default();
6564        let err = config
6565            .set_value("provider", "not-a-provider")
6566            .expect_err("unknown provider should fail");
6567        let message = err.to_string();
6568        assert!(message.contains("unknown provider 'not-a-provider'"));
6569        assert!(message.contains("huggingface"));
6570    }
6571
6572    #[test]
6573    fn provider_kind_accepts_legacy_deepseek_cn_aliases() {
6574        for alias in [
6575            "deepseek-cn",
6576            "deepseek_china",
6577            "deepseekcn",
6578            "deepseek-china",
6579        ] {
6580            assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepseek));
6581
6582            let parsed: ConfigToml =
6583                toml::from_str(&format!("provider = \"{alias}\"")).expect("legacy provider alias");
6584            assert_eq!(parsed.provider, ProviderKind::Deepseek);
6585        }
6586    }
6587
6588    #[test]
6589    fn provider_metadata_registry_covers_every_provider_kind_once() {
6590        let providers = provider::all_providers();
6591        assert_eq!(providers.len(), ProviderKind::ALL.len());
6592
6593        for (kind, provider) in ProviderKind::ALL.iter().zip(providers.iter()) {
6594            assert_eq!(provider.kind(), *kind);
6595            assert_eq!(provider.id(), kind.as_str());
6596            assert_eq!(kind.provider().id(), kind.as_str());
6597        }
6598
6599        let mut ids = std::collections::BTreeSet::new();
6600        for provider in providers {
6601            assert!(ids.insert(provider.id()), "duplicate provider id");
6602        }
6603    }
6604
6605    #[test]
6606    fn provider_metadata_lookup_does_not_fall_back_to_deepseek() {
6607        assert!(provider::lookup_provider("not-a-provider").is_none());
6608        assert!(provider::resolve_provider("not-a-provider").is_none());
6609        assert!(provider::lookup_provider("deepseek-cn").is_none());
6610        assert_eq!(
6611            provider::resolve_provider("deepseek-cn")
6612                .expect("legacy alias resolves")
6613                .kind(),
6614            ProviderKind::Deepseek
6615        );
6616    }
6617
6618    #[test]
6619    fn provider_metadata_preserves_alias_and_config_key_semantics() {
6620        assert_eq!(
6621            provider::resolve_provider("open_router")
6622                .expect("openrouter alias")
6623                .kind(),
6624            ProviderKind::Openrouter
6625        );
6626        assert_eq!(
6627            provider::resolve_provider("xiaomi")
6628                .expect("xiaomi alias")
6629                .kind(),
6630            ProviderKind::XiaomiMimo
6631        );
6632        assert_eq!(
6633            provider::resolve_provider("kimi")
6634                .expect("kimi alias")
6635                .kind(),
6636            ProviderKind::Moonshot
6637        );
6638        assert_eq!(
6639            provider::resolve_provider("hf")
6640                .expect("huggingface alias")
6641                .kind(),
6642            ProviderKind::Huggingface
6643        );
6644
6645        let siliconflow_cn =
6646            provider::resolve_provider("siliconflow-cn").expect("siliconflow-cn alias resolves");
6647        assert_eq!(siliconflow_cn.kind(), ProviderKind::SiliconflowCN);
6648        assert_eq!(siliconflow_cn.id(), "siliconflow-CN");
6649        assert_eq!(siliconflow_cn.provider_config_key(), "siliconflow_cn");
6650
6651        let config = ProvidersToml::default();
6652        let shared_table = config.for_provider(ProviderKind::SiliconflowCN);
6653        assert!(!std::ptr::eq(
6654            shared_table,
6655            config.for_provider(ProviderKind::Siliconflow)
6656        ));
6657    }
6658
6659    #[test]
6660    fn provider_metadata_defaults_match_runtime_helpers() {
6661        for kind in ProviderKind::ALL {
6662            let provider = kind.provider();
6663            assert_eq!(provider.default_model(), default_model_for_provider(kind));
6664            assert_eq!(
6665                provider.default_base_url(),
6666                default_base_url_for_provider(kind)
6667            );
6668            assert!(!provider.display_name().trim().is_empty());
6669            assert!(!provider.env_vars().is_empty());
6670            // OpenAI Codex (ChatGPT) speaks the Responses API and Anthropic
6671            // speaks the native Messages API; every other built-in provider
6672            // is OpenAI-compatible Chat Completions.
6673            let expected_wire = match kind {
6674                ProviderKind::OpenaiCodex => provider::WireFormat::Responses,
6675                ProviderKind::Anthropic => provider::WireFormat::AnthropicMessages,
6676                _ => provider::WireFormat::ChatCompletions,
6677            };
6678            assert_eq!(provider.wire(), expected_wire);
6679        }
6680    }
6681
6682    #[test]
6683    fn openrouter_provider_defaults_to_canonical_endpoint_and_model() {
6684        let _lock = env_lock();
6685        let _env = EnvGuard::without_deepseek_runtime_overrides();
6686        let config = ConfigToml {
6687            provider: ProviderKind::Openrouter,
6688            ..ConfigToml::default()
6689        };
6690
6691        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6692
6693        assert_eq!(resolved.provider, ProviderKind::Openrouter);
6694        assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
6695        assert_eq!(resolved.model, DEFAULT_OPENROUTER_MODEL);
6696    }
6697
6698    #[test]
6699    fn xiaomi_mimo_provider_defaults_to_canonical_endpoint_and_model() {
6700        let _lock = env_lock();
6701        let _env = EnvGuard::without_deepseek_runtime_overrides();
6702        let config = ConfigToml {
6703            provider: ProviderKind::XiaomiMimo,
6704            ..ConfigToml::default()
6705        };
6706
6707        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6708
6709        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6710        assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
6711        assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
6712    }
6713
6714    #[test]
6715    fn xiaomi_provider_alias_table_maps_to_mimo_runtime_config() {
6716        let _lock = env_lock();
6717        let _env = EnvGuard::without_deepseek_runtime_overrides();
6718        let config: ConfigToml = toml::from_str(
6719            r#"
6720provider = "xiaomi-mimo"
6721default_text_model = "deepseek/deepseek-v4-pro"
6722
6723[providers.xiaomi]
6724api_key = "mimo-table-key"
6725base_url = "https://token-plan-sgp.xiaomimimo.com/v1"
6726model = "mimo-v2.5-pro"
6727"#,
6728        )
6729        .expect("xiaomi provider alias config");
6730
6731        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6732
6733        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6734        assert_eq!(resolved.api_key.as_deref(), Some("mimo-table-key"));
6735        assert_eq!(
6736            resolved.base_url,
6737            "https://token-plan-sgp.xiaomimimo.com/v1"
6738        );
6739        assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
6740    }
6741
6742    #[test]
6743    fn xiaomi_token_plan_key_rewrites_saved_pay_as_you_go_base_url() {
6744        let _lock = env_lock();
6745        let _env = EnvGuard::without_deepseek_runtime_overrides();
6746        let config: ConfigToml = toml::from_str(
6747            r#"
6748provider = "xiaomi-mimo"
6749
6750[providers.xiaomi_mimo]
6751api_key = "tp-test-token-plan-key"
6752base_url = "https://api.xiaomimimo.com/v1"
6753model = "mimo-v2.5-pro"
6754"#,
6755        )
6756        .expect("xiaomi token-plan config");
6757
6758        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6759
6760        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6761        assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
6762        assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
6763    }
6764
6765    #[test]
6766    fn xiaomi_mimo_token_plan_mode_accepts_region_aliases() {
6767        let _lock = env_lock();
6768        let _env = EnvGuard::without_deepseek_runtime_overrides();
6769        let config: ConfigToml = toml::from_str(
6770            r#"
6771provider = "mimo"
6772
6773[providers.mimo]
6774mode = "token-plan-ams"
6775"#,
6776        )
6777        .expect("xiaomi token-plan region config");
6778
6779        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6780
6781        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6782        assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL);
6783    }
6784
6785    #[test]
6786    fn xiaomi_mimo_unknown_mode_stays_on_token_plan_endpoint() {
6787        let _lock = env_lock();
6788        let _env = EnvGuard::without_deepseek_runtime_overrides();
6789        let config: ConfigToml = toml::from_str(
6790            r#"
6791provider = "mimo"
6792
6793[providers.mimo]
6794mode = "token-plan-usa"
6795"#,
6796        )
6797        .expect("xiaomi token-plan unknown mode config");
6798
6799        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6800
6801        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6802        assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
6803    }
6804
6805    #[test]
6806    fn xiaomi_mimo_aliases_resolve_to_canonical_models() {
6807        assert_eq!(
6808            normalize_model_for_provider(ProviderKind::XiaomiMimo, "omni"),
6809            "mimo-v2.5"
6810        );
6811        assert_eq!(
6812            normalize_model_for_provider(ProviderKind::XiaomiMimo, "tts"),
6813            "mimo-v2.5-tts"
6814        );
6815        assert_eq!(
6816            normalize_model_for_provider(ProviderKind::XiaomiMimo, "voice-design"),
6817            "mimo-v2.5-tts-voicedesign"
6818        );
6819        assert_eq!(
6820            normalize_model_for_provider(ProviderKind::XiaomiMimo, "voiceclone"),
6821            "mimo-v2.5-tts-voiceclone"
6822        );
6823        assert_eq!(
6824            normalize_model_for_provider(ProviderKind::XiaomiMimo, "custom-mimo-model"),
6825            "custom-mimo-model"
6826        );
6827    }
6828
6829    #[test]
6830    fn zai_aliases_resolve_to_canonical_models() {
6831        // GLM-5.2 is the default; the glm-5.1 alias must still resolve to 5.1
6832        // (not to the default), and GLM-5-Turbo resolves to its own id.
6833        assert_eq!(
6834            normalize_model_for_provider(ProviderKind::Zai, "glm-5.1"),
6835            ZAI_GLM_5_1_MODEL
6836        );
6837        assert_eq!(
6838            normalize_model_for_provider(ProviderKind::Zai, "glm-5-2"),
6839            DEFAULT_ZAI_MODEL
6840        );
6841        assert_eq!(DEFAULT_ZAI_MODEL, ZAI_GLM_5_2_MODEL);
6842        assert_eq!(
6843            normalize_model_for_provider(ProviderKind::Zai, "glm-5-turbo"),
6844            ZAI_GLM_5_TURBO_MODEL
6845        );
6846        assert_eq!(
6847            normalize_model_for_provider(ProviderKind::Zai, "custom-glm-preview"),
6848            "custom-glm-preview"
6849        );
6850    }
6851
6852    #[test]
6853    fn novita_provider_defaults_to_canonical_endpoint_and_model() {
6854        let _lock = env_lock();
6855        let _env = EnvGuard::without_deepseek_runtime_overrides();
6856        let config = ConfigToml {
6857            provider: ProviderKind::Novita,
6858            ..ConfigToml::default()
6859        };
6860
6861        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6862
6863        assert_eq!(resolved.provider, ProviderKind::Novita);
6864        assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
6865        assert_eq!(resolved.model, DEFAULT_NOVITA_MODEL);
6866    }
6867
6868    #[test]
6869    fn fireworks_provider_defaults_to_canonical_endpoint_and_model() {
6870        let _lock = env_lock();
6871        let _env = EnvGuard::without_deepseek_runtime_overrides();
6872        let config = ConfigToml {
6873            provider: ProviderKind::Fireworks,
6874            ..ConfigToml::default()
6875        };
6876
6877        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6878
6879        assert_eq!(resolved.provider, ProviderKind::Fireworks);
6880        assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
6881        assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL);
6882    }
6883
6884    #[test]
6885    fn siliconflow_provider_defaults_to_canonical_endpoint_and_model() {
6886        let _lock = env_lock();
6887        let _env = EnvGuard::without_deepseek_runtime_overrides();
6888        let config = ConfigToml {
6889            provider: ProviderKind::Siliconflow,
6890            ..ConfigToml::default()
6891        };
6892
6893        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6894
6895        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
6896        assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_BASE_URL);
6897        assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
6898    }
6899
6900    #[test]
6901    fn siliconflow_cn_config_falls_back_to_shared_table_when_unset() {
6902        let _lock = env_lock();
6903        let _env = EnvGuard::without_deepseek_runtime_overrides();
6904        let mut config = ConfigToml {
6905            provider: ProviderKind::SiliconflowCN,
6906            ..ConfigToml::default()
6907        };
6908        config.providers.siliconflow.api_key = Some("sf-shared-key".to_string());
6909        config.providers.siliconflow.base_url = Some(DEFAULT_SILICONFLOW_BASE_URL.to_string());
6910        config.providers.siliconflow.model = Some("deepseek-chat".to_string());
6911        config.providers.siliconflow_cn.base_url =
6912            Some(DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
6913
6914        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6915
6916        assert_eq!(resolved.provider, ProviderKind::SiliconflowCN);
6917        assert_eq!(resolved.api_key.as_deref(), Some("sf-shared-key"));
6918        assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_CN_BASE_URL);
6919        assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL);
6920    }
6921
6922    #[test]
6923    fn moonshot_provider_defaults_to_kimi_k27_code() {
6924        let _lock = env_lock();
6925        let _env = EnvGuard::without_deepseek_runtime_overrides();
6926        let config = ConfigToml {
6927            provider: ProviderKind::Moonshot,
6928            ..ConfigToml::default()
6929        };
6930
6931        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6932
6933        assert_eq!(resolved.provider, ProviderKind::Moonshot);
6934        assert_eq!(resolved.base_url, DEFAULT_MOONSHOT_BASE_URL);
6935        assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL);
6936    }
6937
6938    #[test]
6939    fn zai_stepfun_and_minimax_default_to_first_party_routes() {
6940        let _lock = env_lock();
6941        let _env = EnvGuard::without_deepseek_runtime_overrides();
6942
6943        for (provider, expected_base_url, expected_model) in [
6944            (ProviderKind::Zai, DEFAULT_ZAI_BASE_URL, DEFAULT_ZAI_MODEL),
6945            (
6946                ProviderKind::Stepfun,
6947                DEFAULT_STEPFUN_BASE_URL,
6948                DEFAULT_STEPFUN_MODEL,
6949            ),
6950            (
6951                ProviderKind::Minimax,
6952                DEFAULT_MINIMAX_BASE_URL,
6953                DEFAULT_MINIMAX_MODEL,
6954            ),
6955        ] {
6956            let config = ConfigToml {
6957                provider,
6958                ..ConfigToml::default()
6959            };
6960            let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6961
6962            assert_eq!(resolved.provider, provider);
6963            assert_eq!(resolved.base_url, expected_base_url);
6964            assert_eq!(resolved.model, expected_model);
6965        }
6966    }
6967
6968    #[test]
6969    fn first_party_provider_env_model_overrides_pass_through() {
6970        let _lock = env_lock();
6971        let _env = EnvGuard::without_deepseek_runtime_overrides();
6972        unsafe {
6973            env::set_var("CODEWHALE_PROVIDER", "minimax");
6974            env::set_var("MINIMAX_MODEL", "MiniMax-M2.7-highspeed");
6975            env::set_var("MINIMAX_BASE_URL", "https://minimax.example/v1");
6976        }
6977
6978        let resolved =
6979            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6980
6981        assert_eq!(resolved.provider, ProviderKind::Minimax);
6982        assert_eq!(resolved.base_url, "https://minimax.example/v1");
6983        assert_eq!(resolved.model, "MiniMax-M2.7-highspeed");
6984    }
6985
6986    #[test]
6987    fn minimax_env_model_override_canonicalizes_known_aliases() {
6988        let _lock = env_lock();
6989        let _env = EnvGuard::without_deepseek_runtime_overrides();
6990        unsafe {
6991            env::set_var("CODEWHALE_PROVIDER", "minimax");
6992            env::set_var("MINIMAX_MODEL", "minimax-m2-5-highspeed");
6993        }
6994
6995        let resolved =
6996            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6997
6998        assert_eq!(resolved.provider, ProviderKind::Minimax);
6999        assert_eq!(resolved.model, "MiniMax-M2.5-highspeed");
7000    }
7001
7002    #[test]
7003    fn moonshot_provider_preserves_explicit_kimi_k26() {
7004        let _lock = env_lock();
7005        let _env = EnvGuard::without_deepseek_runtime_overrides();
7006        let mut config = ConfigToml {
7007            provider: ProviderKind::Moonshot,
7008            ..ConfigToml::default()
7009        };
7010        config.providers.moonshot.model = Some("kimi-k2.6".to_string());
7011
7012        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7013
7014        assert_eq!(resolved.provider, ProviderKind::Moonshot);
7015        assert_eq!(resolved.model, MOONSHOT_KIMI_K2_6_MODEL);
7016    }
7017
7018    #[test]
7019    fn moonshot_kimi_oauth_uses_kimi_code_endpoint_and_model() {
7020        let _lock = env_lock();
7021        let _env = EnvGuard::without_deepseek_runtime_overrides();
7022        let mut config = ConfigToml {
7023            provider: ProviderKind::Moonshot,
7024            ..ConfigToml::default()
7025        };
7026        config.providers.moonshot.auth_mode = Some("kimi_oauth".to_string());
7027
7028        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7029
7030        assert_eq!(resolved.provider, ProviderKind::Moonshot);
7031        assert_eq!(resolved.auth_mode.as_deref(), Some("kimi_oauth"));
7032        assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
7033        assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
7034        assert_eq!(resolved.api_key, None);
7035        assert_eq!(resolved.api_key_source, None);
7036    }
7037
7038    #[test]
7039    fn moonshot_kimi_code_api_key_endpoint_defaults_to_kimi_for_coding() {
7040        let _lock = env_lock();
7041        let _env = EnvGuard::without_deepseek_runtime_overrides();
7042        let mut config = ConfigToml {
7043            provider: ProviderKind::Moonshot,
7044            ..ConfigToml::default()
7045        };
7046        config.providers.moonshot.api_key = Some("kimi-code-key".to_string());
7047        config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string());
7048
7049        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7050
7051        assert_eq!(resolved.provider, ProviderKind::Moonshot);
7052        assert_eq!(resolved.auth_mode, None);
7053        assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
7054        assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
7055        assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
7056        assert_eq!(
7057            resolved.api_key_source,
7058            Some(RuntimeApiKeySource::ConfigFile)
7059        );
7060    }
7061
7062    /// `CODEWHALE_PROVIDER` is the user-facing env alias for switching the
7063    /// active provider. It must be honored by the runtime resolver and win
7064    /// over a root `provider = "deepseek"` config entry.
7065    #[test]
7066    fn codewhale_provider_env_switches_active_provider() {
7067        let _lock = env_lock();
7068        let _env = EnvGuard::without_deepseek_runtime_overrides();
7069        // Safety: test-only env mutation guarded by env_lock().
7070        unsafe {
7071            env::set_var("CODEWHALE_PROVIDER", "moonshot");
7072        }
7073        let mut config = ConfigToml {
7074            provider: ProviderKind::Deepseek,
7075            ..ConfigToml::default()
7076        };
7077        config.providers.moonshot.api_key = Some("kimi-code-key".to_string());
7078        config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string());
7079
7080        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7081
7082        assert_eq!(resolved.provider, ProviderKind::Moonshot);
7083        assert_eq!(
7084            resolved.provider_source,
7085            ProviderSource::Env("CODEWHALE_PROVIDER")
7086        );
7087        assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
7088        assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
7089        assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
7090    }
7091
7092    /// When both `CODEWHALE_PROVIDER` and the legacy `DEEPSEEK_PROVIDER`
7093    /// are set, the public alias wins — a user adopting `CODEWHALE_*` in a
7094    /// fresh shell config is not tripped up by a stale legacy export still
7095    /// living in their dotfiles.
7096    #[test]
7097    fn codewhale_provider_env_wins_over_deepseek_provider_env() {
7098        let _lock = env_lock();
7099        let _env = EnvGuard::without_deepseek_runtime_overrides();
7100        // Safety: test-only env mutation guarded by env_lock().
7101        unsafe {
7102            env::set_var("CODEWHALE_PROVIDER", "moonshot");
7103            env::set_var("DEEPSEEK_PROVIDER", "openrouter");
7104        }
7105        let config = ConfigToml {
7106            provider: ProviderKind::Deepseek,
7107            ..ConfigToml::default()
7108        };
7109
7110        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7111
7112        assert_eq!(resolved.provider, ProviderKind::Moonshot);
7113        assert_eq!(
7114            resolved.provider_source,
7115            ProviderSource::Env("CODEWHALE_PROVIDER")
7116        );
7117    }
7118
7119    #[test]
7120    fn legacy_deepseek_provider_env_records_provider_source() {
7121        let _lock = env_lock();
7122        let _env = EnvGuard::without_deepseek_runtime_overrides();
7123        // Safety: test-only env mutation guarded by env_lock().
7124        unsafe {
7125            env::set_var("DEEPSEEK_PROVIDER", "openrouter");
7126        }
7127        let config = ConfigToml {
7128            provider: ProviderKind::Deepseek,
7129            ..ConfigToml::default()
7130        };
7131
7132        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7133
7134        assert_eq!(resolved.provider, ProviderKind::Openrouter);
7135        assert_eq!(
7136            resolved.provider_source,
7137            ProviderSource::Env("DEEPSEEK_PROVIDER")
7138        );
7139    }
7140
7141    #[test]
7142    fn cli_provider_records_provider_source() {
7143        let _lock = env_lock();
7144        let _env = EnvGuard::without_deepseek_runtime_overrides();
7145        // Safety: test-only env mutation guarded by env_lock().
7146        unsafe {
7147            env::set_var("CODEWHALE_PROVIDER", "moonshot");
7148        }
7149        let cli = CliRuntimeOverrides {
7150            provider: Some(ProviderKind::Openai),
7151            ..CliRuntimeOverrides::default()
7152        };
7153        let config = ConfigToml {
7154            provider: ProviderKind::Deepseek,
7155            ..ConfigToml::default()
7156        };
7157
7158        let resolved = config.resolve_runtime_options(&cli);
7159
7160        assert_eq!(resolved.provider, ProviderKind::Openai);
7161        assert_eq!(resolved.provider_source, ProviderSource::Cli);
7162    }
7163
7164    #[test]
7165    fn config_provider_records_provider_source() {
7166        let _lock = env_lock();
7167        let _env = EnvGuard::without_deepseek_runtime_overrides();
7168        let config = ConfigToml {
7169            provider: ProviderKind::Moonshot,
7170            ..ConfigToml::default()
7171        };
7172
7173        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7174
7175        assert_eq!(resolved.provider, ProviderKind::Moonshot);
7176        assert_eq!(resolved.provider_source, ProviderSource::Config);
7177    }
7178
7179    /// `CODEWHALE_MODEL` is the user-facing env alias for picking a model
7180    /// against the active provider. It must be honored by the runtime
7181    /// resolver in place of `DEEPSEEK_MODEL`.
7182    #[test]
7183    fn codewhale_model_env_alias_overrides_default_for_active_provider() {
7184        let _lock = env_lock();
7185        let _env = EnvGuard::without_deepseek_runtime_overrides();
7186        // Safety: test-only env mutation guarded by env_lock().
7187        unsafe {
7188            env::set_var("CODEWHALE_PROVIDER", "moonshot");
7189            env::set_var("CODEWHALE_MODEL", "custom-kimi-test-model");
7190        }
7191        let config = ConfigToml::default();
7192
7193        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7194
7195        assert_eq!(resolved.provider, ProviderKind::Moonshot);
7196        assert_eq!(resolved.model, "custom-kimi-test-model");
7197    }
7198
7199    #[test]
7200    fn blank_codewhale_model_env_alias_does_not_override_default_for_active_provider() {
7201        let _lock = env_lock();
7202        let _env = EnvGuard::without_deepseek_runtime_overrides();
7203        // Safety: test-only env mutation guarded by env_lock().
7204        unsafe {
7205            env::set_var("CODEWHALE_PROVIDER", "moonshot");
7206            env::set_var("CODEWHALE_MODEL", "   ");
7207        }
7208        let config = ConfigToml::default();
7209
7210        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7211
7212        assert_eq!(resolved.provider, ProviderKind::Moonshot);
7213        assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL);
7214    }
7215
7216    #[test]
7217    fn deepseek_default_text_model_legacy_alias_still_overrides_active_provider_model() {
7218        let _lock = env_lock();
7219        let _env = EnvGuard::without_deepseek_runtime_overrides();
7220        // Safety: test-only env mutation guarded by env_lock().
7221        unsafe {
7222            env::set_var("CODEWHALE_PROVIDER", "moonshot");
7223            env::set_var("DEEPSEEK_DEFAULT_TEXT_MODEL", "legacy-env-model");
7224        }
7225        let config = ConfigToml::default();
7226
7227        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7228
7229        assert_eq!(resolved.provider, ProviderKind::Moonshot);
7230        assert_eq!(resolved.model, "legacy-env-model");
7231    }
7232
7233    #[test]
7234    fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() {
7235        let _lock = env_lock();
7236        let _env = EnvGuard::without_deepseek_runtime_overrides();
7237        let config = ConfigToml {
7238            provider: ProviderKind::WanjieArk,
7239            ..ConfigToml::default()
7240        };
7241
7242        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7243
7244        assert_eq!(resolved.provider, ProviderKind::WanjieArk);
7245        assert_eq!(resolved.base_url, DEFAULT_WANJIE_ARK_BASE_URL);
7246        assert_eq!(resolved.model, DEFAULT_WANJIE_ARK_MODEL);
7247    }
7248
7249    #[test]
7250    fn sglang_provider_defaults_to_local_endpoint_and_model() {
7251        let _lock = env_lock();
7252        let _env = EnvGuard::without_deepseek_runtime_overrides();
7253        let config = ConfigToml {
7254            provider: ProviderKind::Sglang,
7255            ..ConfigToml::default()
7256        };
7257
7258        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7259
7260        assert_eq!(resolved.provider, ProviderKind::Sglang);
7261        assert_eq!(resolved.base_url, DEFAULT_SGLANG_BASE_URL);
7262        assert_eq!(resolved.model, DEFAULT_SGLANG_MODEL);
7263    }
7264
7265    #[test]
7266    fn vllm_provider_defaults_to_local_endpoint_and_model() {
7267        let _lock = env_lock();
7268        let _env = EnvGuard::without_deepseek_runtime_overrides();
7269        let config = ConfigToml {
7270            provider: ProviderKind::Vllm,
7271            ..ConfigToml::default()
7272        };
7273
7274        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7275
7276        assert_eq!(resolved.provider, ProviderKind::Vllm);
7277        assert_eq!(resolved.base_url, DEFAULT_VLLM_BASE_URL);
7278        assert_eq!(resolved.model, DEFAULT_VLLM_MODEL);
7279    }
7280
7281    #[test]
7282    fn ollama_provider_defaults_to_local_endpoint_and_small_model() {
7283        let _lock = env_lock();
7284        let _env = EnvGuard::without_deepseek_runtime_overrides();
7285        let config = ConfigToml {
7286            provider: ProviderKind::Ollama,
7287            ..ConfigToml::default()
7288        };
7289
7290        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7291
7292        assert_eq!(resolved.provider, ProviderKind::Ollama);
7293        assert_eq!(resolved.base_url, DEFAULT_OLLAMA_BASE_URL);
7294        assert_eq!(resolved.model, DEFAULT_OLLAMA_MODEL);
7295        assert_eq!(resolved.api_key, None);
7296    }
7297
7298    #[test]
7299    fn self_hosted_providers_do_not_probe_secret_store_by_default() {
7300        let _lock = env_lock();
7301        let _env = EnvGuard::without_deepseek_runtime_overrides();
7302        let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
7303        let secrets = Secrets::new(store.clone());
7304
7305        for provider in [
7306            ProviderKind::Sglang,
7307            ProviderKind::Vllm,
7308            ProviderKind::Ollama,
7309        ] {
7310            let config = ConfigToml {
7311                provider,
7312                ..ConfigToml::default()
7313            };
7314
7315            let resolved = config
7316                .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7317
7318            assert_eq!(resolved.provider, provider);
7319            assert_eq!(resolved.api_key, None);
7320        }
7321
7322        assert!(
7323            store.gets.lock().unwrap().is_empty(),
7324            "self-hosted providers should not read the secret store by default"
7325        );
7326    }
7327
7328    #[test]
7329    fn self_hosted_api_key_auth_can_use_secret_store_when_requested() {
7330        let _lock = env_lock();
7331        let _env = EnvGuard::without_deepseek_runtime_overrides();
7332        let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
7333        let secrets = Secrets::new(store.clone());
7334        let config = ConfigToml {
7335            provider: ProviderKind::Ollama,
7336            auth_mode: Some("api_key".to_string()),
7337            ..ConfigToml::default()
7338        };
7339
7340        let resolved =
7341            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7342
7343        assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
7344        assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama"]);
7345    }
7346
7347    #[test]
7348    fn moonshot_api_key_mode_can_use_secret_store_by_default() {
7349        let _lock = env_lock();
7350        let _env = EnvGuard::without_deepseek_runtime_overrides();
7351        let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
7352        let secrets = Secrets::new(store.clone());
7353        let config = ConfigToml {
7354            provider: ProviderKind::Moonshot,
7355            ..ConfigToml::default()
7356        };
7357
7358        let resolved =
7359            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7360
7361        assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
7362        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
7363        assert_eq!(store.gets.lock().unwrap().as_slice(), ["moonshot"]);
7364    }
7365
7366    #[test]
7367    fn loopback_custom_deepseek_base_url_does_not_probe_secret_store_by_default() {
7368        let _lock = env_lock();
7369        let _env = EnvGuard::without_deepseek_runtime_overrides();
7370        let store = Arc::new(RecordingSecretsStore::with_value("stale-deepseek-key"));
7371        let secrets = Secrets::new(store.clone());
7372        let config = ConfigToml {
7373            base_url: Some("http://127.0.0.1:8000/v1".to_string()),
7374            ..ConfigToml::default()
7375        };
7376
7377        let resolved =
7378            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7379
7380        assert_eq!(resolved.provider, ProviderKind::Deepseek);
7381        assert_eq!(resolved.base_url, "http://127.0.0.1:8000/v1");
7382        assert_eq!(resolved.api_key, None);
7383        assert!(
7384            store.gets.lock().unwrap().is_empty(),
7385            "loopback custom endpoints should not read macOS Keychain or any secret store"
7386        );
7387    }
7388
7389    #[test]
7390    fn ollama_provider_preserves_model_tags() {
7391        let _lock = env_lock();
7392        let _env = EnvGuard::without_deepseek_runtime_overrides();
7393        let cli = CliRuntimeOverrides {
7394            provider: Some(ProviderKind::Ollama),
7395            model: Some("deepseek-coder-v2:16b".to_string()),
7396            ..CliRuntimeOverrides::default()
7397        };
7398
7399        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7400
7401        assert_eq!(resolved.provider, ProviderKind::Ollama);
7402        assert_eq!(resolved.model, "deepseek-coder-v2:16b");
7403    }
7404
7405    #[test]
7406    fn ollama_env_overrides_provider_base_url_and_optional_key() {
7407        let _lock = env_lock();
7408        let _env = EnvGuard::without_deepseek_runtime_overrides();
7409        // Safety: test-only environment mutation guarded by a module mutex.
7410        unsafe {
7411            env::set_var("DEEPSEEK_PROVIDER", "ollama-local");
7412            env::set_var("OLLAMA_BASE_URL", "http://ollama.example/v1");
7413            env::set_var("OLLAMA_API_KEY", "ollama-env-key");
7414        }
7415
7416        let resolved =
7417            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7418
7419        assert_eq!(resolved.provider, ProviderKind::Ollama);
7420        assert_eq!(resolved.base_url, "http://ollama.example/v1");
7421        assert_eq!(resolved.api_key.as_deref(), Some("ollama-env-key"));
7422    }
7423
7424    #[test]
7425    fn openrouter_env_overrides_key_and_model_when_config_missing() {
7426        let _lock = env_lock();
7427        let _env = EnvGuard::without_deepseek_runtime_overrides();
7428        // Safety: test-only environment mutation guarded by a module mutex.
7429        unsafe {
7430            env::set_var("DEEPSEEK_PROVIDER", "openrouter");
7431            env::set_var("OPENROUTER_API_KEY", "or-env-key");
7432            env::set_var("OPENROUTER_MODEL", "deepseek-v4-flash");
7433        }
7434
7435        let resolved =
7436            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7437
7438        assert_eq!(resolved.provider, ProviderKind::Openrouter);
7439        assert_eq!(resolved.api_key.as_deref(), Some("or-env-key"));
7440        assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
7441        assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL);
7442    }
7443
7444    #[test]
7445    fn xiaomi_mimo_env_overrides_provider_key_base_url_and_model() {
7446        let _lock = env_lock();
7447        let _env = EnvGuard::without_deepseek_runtime_overrides();
7448        // Safety: test-only environment mutation guarded by a module mutex.
7449        unsafe {
7450            env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7451            env::set_var("MIMO_API_KEY", "mimo-env-key");
7452            env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1");
7453            env::set_var("MIMO_MODEL", "mimo-v2.5");
7454        }
7455
7456        let resolved =
7457            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7458
7459        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
7460        assert_eq!(resolved.api_key.as_deref(), Some("mimo-env-key"));
7461        assert_eq!(resolved.base_url, "https://mimo-gateway.example/v1");
7462        assert_eq!(resolved.model, "mimo-v2.5");
7463    }
7464
7465    #[test]
7466    fn xiaomi_mimo_env_token_plan_mode_uses_token_plan_key_and_endpoint() {
7467        let _lock = env_lock();
7468        let _env = EnvGuard::without_deepseek_runtime_overrides();
7469        // Safety: test-only environment mutation guarded by a module mutex.
7470        unsafe {
7471            env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7472            env::set_var("XIAOMI_MIMO_MODE", "token-plan-cn");
7473            env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key");
7474            env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key");
7475        }
7476
7477        let resolved =
7478            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7479
7480        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
7481        assert_eq!(resolved.api_key.as_deref(), Some("tp-env-key"));
7482        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
7483        assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL);
7484    }
7485
7486    #[test]
7487    fn xiaomi_mimo_env_pay_as_you_go_mode_prefers_standard_key() {
7488        let _lock = env_lock();
7489        let _env = EnvGuard::without_deepseek_runtime_overrides();
7490        // Safety: test-only environment mutation guarded by a module mutex.
7491        unsafe {
7492            env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7493            env::set_var("XIAOMI_MIMO_MODE", "pay-as-you-go");
7494            env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key");
7495            env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key");
7496        }
7497
7498        let resolved =
7499            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7500
7501        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
7502        assert_eq!(resolved.api_key.as_deref(), Some("sk-env-key"));
7503        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
7504        assert_eq!(resolved.base_url, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL);
7505    }
7506
7507    #[test]
7508    fn novita_env_overrides_key_and_model_when_config_missing() {
7509        let _lock = env_lock();
7510        let _env = EnvGuard::without_deepseek_runtime_overrides();
7511        // Safety: test-only environment mutation guarded by a module mutex.
7512        unsafe {
7513            env::set_var("DEEPSEEK_PROVIDER", "novita");
7514            env::set_var("NOVITA_API_KEY", "novita-env-key");
7515            env::set_var("NOVITA_MODEL", "deepseek-v4-flash");
7516        }
7517
7518        let resolved =
7519            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7520
7521        assert_eq!(resolved.provider, ProviderKind::Novita);
7522        assert_eq!(resolved.api_key.as_deref(), Some("novita-env-key"));
7523        assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
7524        assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL);
7525    }
7526
7527    #[test]
7528    fn fireworks_env_overrides_key_and_model_when_config_missing() {
7529        let _lock = env_lock();
7530        let _env = EnvGuard::without_deepseek_runtime_overrides();
7531        // Safety: test-only environment mutation guarded by a module mutex.
7532        unsafe {
7533            env::set_var("DEEPSEEK_PROVIDER", "fireworks");
7534            env::set_var("FIREWORKS_API_KEY", "fw-env-key");
7535            env::set_var(
7536                "FIREWORKS_MODEL",
7537                "accounts/fireworks/models/account-specific-model",
7538            );
7539        }
7540
7541        let resolved =
7542            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7543
7544        assert_eq!(resolved.provider, ProviderKind::Fireworks);
7545        assert_eq!(resolved.api_key.as_deref(), Some("fw-env-key"));
7546        assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
7547        assert_eq!(
7548            resolved.model,
7549            "accounts/fireworks/models/account-specific-model"
7550        );
7551    }
7552
7553    #[test]
7554    fn siliconflow_env_overrides_key_base_url_and_model() {
7555        let _lock = env_lock();
7556        let _env = EnvGuard::without_deepseek_runtime_overrides();
7557        // Safety: test-only environment mutation guarded by a module mutex.
7558        unsafe {
7559            env::set_var("CODEWHALE_PROVIDER", "siliconflow");
7560            env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
7561            env::set_var("SILICONFLOW_BASE_URL", "https://sf-mirror.example/v1");
7562            env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash");
7563        }
7564
7565        let resolved =
7566            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7567
7568        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7569        assert_eq!(resolved.api_key.as_deref(), Some("sf-env-key"));
7570        assert_eq!(resolved.base_url, "https://sf-mirror.example/v1");
7571        assert_eq!(resolved.model, "deepseek-v4-flash");
7572    }
7573
7574    #[test]
7575    fn arcee_provider_defaults_to_direct_api_endpoint_and_model() {
7576        let _lock = env_lock();
7577        let _env = EnvGuard::without_deepseek_runtime_overrides();
7578        let config = ConfigToml {
7579            provider: ProviderKind::Arcee,
7580            ..ConfigToml::default()
7581        };
7582
7583        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7584
7585        assert_eq!(resolved.provider, ProviderKind::Arcee);
7586        assert_eq!(resolved.base_url, DEFAULT_ARCEE_BASE_URL);
7587        assert_eq!(resolved.model, DEFAULT_ARCEE_MODEL);
7588    }
7589
7590    #[test]
7591    fn arcee_env_overrides_key_base_url_and_model() {
7592        let _lock = env_lock();
7593        let _env = EnvGuard::without_deepseek_runtime_overrides();
7594        // Safety: test-only environment mutation guarded by a module mutex.
7595        unsafe {
7596            env::set_var("CODEWHALE_PROVIDER", "arcee");
7597            env::set_var("ARCEE_API_KEY", "arcee-env-key");
7598            env::set_var("ARCEE_BASE_URL", "https://arcee-mirror.example/api/v1");
7599            env::set_var("ARCEE_MODEL", "trinity-large-preview");
7600        }
7601
7602        let resolved =
7603            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7604
7605        assert_eq!(resolved.provider, ProviderKind::Arcee);
7606        assert_eq!(resolved.api_key.as_deref(), Some("arcee-env-key"));
7607        assert_eq!(resolved.base_url, "https://arcee-mirror.example/api/v1");
7608        assert_eq!(resolved.model, "trinity-large-preview");
7609    }
7610
7611    #[test]
7612    fn arcee_provider_config_overrides_runtime_defaults() {
7613        let _lock = env_lock();
7614        let _env = EnvGuard::without_deepseek_runtime_overrides();
7615        let mut config = ConfigToml {
7616            provider: ProviderKind::Arcee,
7617            ..ConfigToml::default()
7618        };
7619        config.providers.arcee.api_key = Some("arcee-file-key".to_string());
7620        config.providers.arcee.base_url = Some(DEFAULT_ARCEE_BASE_URL.to_string());
7621        config.providers.arcee.model = Some("arcee-trinity-large-preview".to_string());
7622
7623        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7624
7625        assert_eq!(resolved.provider, ProviderKind::Arcee);
7626        assert_eq!(resolved.api_key.as_deref(), Some("arcee-file-key"));
7627        assert_eq!(resolved.base_url, DEFAULT_ARCEE_BASE_URL);
7628        assert_eq!(resolved.model, ARCEE_TRINITY_LARGE_PREVIEW_MODEL);
7629    }
7630
7631    #[test]
7632    fn huggingface_env_precedence_prefers_documented_names() {
7633        let _lock = env_lock();
7634        let _env = EnvGuard::without_deepseek_runtime_overrides();
7635        // Safety: test-only environment mutation guarded by a module mutex.
7636        unsafe {
7637            env::set_var("CODEWHALE_PROVIDER", "hf");
7638            env::set_var("HUGGINGFACE_API_KEY", "hf-full-key");
7639            env::set_var("HF_TOKEN", "hf-token-fallback");
7640            env::set_var("HUGGINGFACE_BASE_URL", "https://hf-full.example/v1");
7641            env::set_var("HF_BASE_URL", "https://hf-short.example/v1");
7642            env::set_var("HUGGINGFACE_MODEL", "org/full-model");
7643            env::set_var("HF_MODEL", "org/short-model");
7644        }
7645
7646        let resolved =
7647            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7648
7649        assert_eq!(resolved.provider, ProviderKind::Huggingface);
7650        assert_eq!(resolved.api_key.as_deref(), Some("hf-full-key"));
7651        assert_eq!(resolved.base_url, "https://hf-full.example/v1");
7652        assert_eq!(resolved.model, "org/full-model");
7653    }
7654
7655    #[test]
7656    fn huggingface_short_env_fallbacks_resolve_when_primary_names_are_absent() {
7657        let _lock = env_lock();
7658        let _env = EnvGuard::without_deepseek_runtime_overrides();
7659        // Safety: test-only environment mutation guarded by a module mutex.
7660        unsafe {
7661            env::set_var("CODEWHALE_PROVIDER", "huggingface");
7662            env::set_var("HF_TOKEN", "hf-token-fallback");
7663            env::set_var("HF_BASE_URL", "https://hf-short.example/v1");
7664            env::set_var("HF_MODEL", "org/short-model");
7665        }
7666
7667        let resolved =
7668            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7669
7670        assert_eq!(resolved.provider, ProviderKind::Huggingface);
7671        assert_eq!(resolved.api_key.as_deref(), Some("hf-token-fallback"));
7672        assert_eq!(resolved.base_url, "https://hf-short.example/v1");
7673        assert_eq!(resolved.model, "org/short-model");
7674    }
7675
7676    #[test]
7677    fn huggingface_token_fallback_resolves_when_primary_api_key_is_blank() {
7678        let _lock = env_lock();
7679        let _env = EnvGuard::without_deepseek_runtime_overrides();
7680        // Safety: test-only environment mutation guarded by a module mutex.
7681        unsafe {
7682            env::set_var("CODEWHALE_PROVIDER", "huggingface");
7683            env::set_var("HUGGINGFACE_API_KEY", " ");
7684            env::set_var("HF_TOKEN", "hf-token-fallback");
7685        }
7686
7687        let resolved =
7688            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7689
7690        assert_eq!(resolved.provider, ProviderKind::Huggingface);
7691        assert_eq!(resolved.api_key.as_deref(), Some("hf-token-fallback"));
7692    }
7693
7694    #[test]
7695    fn siliconflow_cn_base_url_env_normalizes_model_aliases() {
7696        let _lock = env_lock();
7697        let _env = EnvGuard::without_deepseek_runtime_overrides();
7698        // Safety: test-only environment mutation guarded by a module mutex.
7699        unsafe {
7700            env::set_var("CODEWHALE_PROVIDER", "siliconflow");
7701            env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
7702            env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1");
7703        }
7704
7705        for (alias, expected) in [
7706            ("deepseek-v4-flash", DEFAULT_SILICONFLOW_FLASH_MODEL),
7707            ("deepseek-reasoner", DEFAULT_SILICONFLOW_MODEL),
7708        ] {
7709            // Safety: test-only environment mutation guarded by a module mutex.
7710            unsafe {
7711                env::set_var("SILICONFLOW_MODEL", alias);
7712            }
7713
7714            let resolved =
7715                ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7716
7717            assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7718            assert_eq!(resolved.base_url, "https://api.siliconflow.cn/v1");
7719            assert_eq!(resolved.model, expected);
7720        }
7721    }
7722
7723    #[test]
7724    fn wanjie_ark_env_api_key_and_base_url_fall_back_when_config_missing() {
7725        let _lock = env_lock();
7726        let _env = EnvGuard::without_deepseek_runtime_overrides();
7727        // Safety: test-only environment mutation guarded by a module mutex.
7728        unsafe {
7729            env::set_var("DEEPSEEK_PROVIDER", "wanjie-ark");
7730            env::set_var("WANJIE_ARK_API_KEY", "wanjie-env-key");
7731            env::set_var("WANJIE_ARK_BASE_URL", "https://wanjie.example/api/v1");
7732            env::set_var("WANJIE_ARK_MODEL", "account-model-id");
7733        }
7734
7735        let resolved =
7736            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7737
7738        assert_eq!(resolved.provider, ProviderKind::WanjieArk);
7739        assert_eq!(resolved.api_key.as_deref(), Some("wanjie-env-key"));
7740        assert_eq!(resolved.base_url, "https://wanjie.example/api/v1");
7741        assert_eq!(resolved.model, "account-model-id");
7742    }
7743
7744    #[test]
7745    fn volcengine_env_aliases_override_key_base_url_and_model() {
7746        let _lock = env_lock();
7747        let _env = EnvGuard::without_deepseek_runtime_overrides();
7748        // Safety: test-only environment mutation guarded by a module mutex.
7749        unsafe {
7750            env::set_var("DEEPSEEK_PROVIDER", "volcengine");
7751            env::set_var("ARK_API_KEY", "volcengine-env-key");
7752            env::set_var("ARK_BASE_URL", "https://volcengine.example/api/coding/v3");
7753            env::set_var("VOLCENGINE_ARK_MODEL", "DeepSeek-V4-Flash");
7754        }
7755
7756        let resolved =
7757            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7758
7759        assert_eq!(resolved.provider, ProviderKind::Volcengine);
7760        assert_eq!(resolved.api_key.as_deref(), Some("volcengine-env-key"));
7761        assert_eq!(
7762            resolved.base_url,
7763            "https://volcengine.example/api/coding/v3"
7764        );
7765        assert_eq!(resolved.model, "DeepSeek-V4-Flash");
7766    }
7767
7768    #[test]
7769    fn openrouter_provider_normalizes_flash_aliases() {
7770        let _lock = env_lock();
7771        let _env = EnvGuard::without_deepseek_runtime_overrides();
7772        let cli = CliRuntimeOverrides {
7773            provider: Some(ProviderKind::Openrouter),
7774            model: Some("deepseek-v4-flash".to_string()),
7775            ..CliRuntimeOverrides::default()
7776        };
7777
7778        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7779
7780        assert_eq!(resolved.provider, ProviderKind::Openrouter);
7781        assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL);
7782    }
7783
7784    #[test]
7785    fn qwen3_6_plus_resolves_to_canonical_on_openrouter() {
7786        let _lock = env_lock();
7787        let _env = EnvGuard::without_deepseek_runtime_overrides();
7788        let config = ConfigToml {
7789            provider: ProviderKind::Openrouter,
7790            ..ConfigToml::default()
7791        };
7792
7793        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides {
7794            model: Some("qwen3.6-plus".to_string()),
7795            ..CliRuntimeOverrides::default()
7796        });
7797
7798        assert_eq!(resolved.provider, ProviderKind::Openrouter);
7799        assert_eq!(resolved.model, OPENROUTER_QWEN_3_6_PLUS_MODEL);
7800    }
7801
7802    #[test]
7803    fn qwen3_6_plus_alias_qwen_dash_resolves() {
7804        let _lock = env_lock();
7805        let _env = EnvGuard::without_deepseek_runtime_overrides();
7806        let config = ConfigToml {
7807            provider: ProviderKind::Openrouter,
7808            ..ConfigToml::default()
7809        };
7810
7811        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides {
7812            model: Some("qwen-3.6-plus".to_string()),
7813            ..CliRuntimeOverrides::default()
7814        });
7815
7816        assert_eq!(resolved.model, OPENROUTER_QWEN_3_6_PLUS_MODEL);
7817    }
7818
7819    #[test]
7820    fn openrouter_provider_normalizes_recent_large_model_aliases() {
7821        let _lock = env_lock();
7822        let _env = EnvGuard::without_deepseek_runtime_overrides();
7823
7824        for (alias, expected) in [
7825            (
7826                "trinity-large-thinking",
7827                OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL,
7828            ),
7829            ("qwen3.6-flash", OPENROUTER_QWEN_3_6_FLASH_MODEL),
7830            ("qwen3.6-35b-a3b", OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
7831            ("qwen3.6-max-preview", OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
7832            ("qwen3.6-plus", OPENROUTER_QWEN_3_6_PLUS_MODEL),
7833            ("mimo-v2.5-pro", OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
7834            ("kimi-k2.7-code", OPENROUTER_KIMI_K2_7_CODE_MODEL),
7835            ("kimi", OPENROUTER_KIMI_K2_7_CODE_MODEL),
7836            ("kimi-k2.6", OPENROUTER_KIMI_K2_6_MODEL),
7837            ("minimax-m3", OPENROUTER_MINIMAX_M3_MODEL),
7838            ("minimax-2.7", OPENROUTER_MINIMAX_2_7_MODEL),
7839            ("gemma-4-31b-it", OPENROUTER_GEMMA_4_31B_MODEL),
7840            ("glm-5.1", OPENROUTER_GLM_5_1_MODEL),
7841            ("glm-5.2", OPENROUTER_GLM_5_2_MODEL),
7842        ] {
7843            let cli = CliRuntimeOverrides {
7844                provider: Some(ProviderKind::Openrouter),
7845                model: Some(alias.to_string()),
7846                ..CliRuntimeOverrides::default()
7847            };
7848
7849            let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7850
7851            assert_eq!(resolved.provider, ProviderKind::Openrouter);
7852            assert_eq!(resolved.model, expected);
7853        }
7854    }
7855
7856    #[test]
7857    fn novita_provider_normalizes_flash_aliases() {
7858        let _lock = env_lock();
7859        let _env = EnvGuard::without_deepseek_runtime_overrides();
7860        let cli = CliRuntimeOverrides {
7861            provider: Some(ProviderKind::Novita),
7862            model: Some("deepseek-v4-flash".to_string()),
7863            ..CliRuntimeOverrides::default()
7864        };
7865
7866        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7867
7868        assert_eq!(resolved.provider, ProviderKind::Novita);
7869        assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL);
7870    }
7871
7872    #[test]
7873    fn siliconflow_provider_normalizes_flash_aliases() {
7874        let _lock = env_lock();
7875        let _env = EnvGuard::without_deepseek_runtime_overrides();
7876        let cli = CliRuntimeOverrides {
7877            provider: Some(ProviderKind::Siliconflow),
7878            model: Some("deepseek-v4-flash".to_string()),
7879            ..CliRuntimeOverrides::default()
7880        };
7881
7882        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7883
7884        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7885        assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL);
7886    }
7887
7888    #[test]
7889    fn siliconflow_provider_normalizes_reasoning_aliases_to_pro() {
7890        let _lock = env_lock();
7891        let _env = EnvGuard::without_deepseek_runtime_overrides();
7892
7893        for alias in ["deepseek-reasoner", "deepseek-r1"] {
7894            let cli = CliRuntimeOverrides {
7895                provider: Some(ProviderKind::Siliconflow),
7896                model: Some(alias.to_string()),
7897                ..CliRuntimeOverrides::default()
7898            };
7899
7900            let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7901
7902            assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7903            assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
7904        }
7905    }
7906
7907    #[test]
7908    fn siliconflow_provider_preserves_deepseek_v3_2_alias() {
7909        let _lock = env_lock();
7910        let _env = EnvGuard::without_deepseek_runtime_overrides();
7911        let cli = CliRuntimeOverrides {
7912            provider: Some(ProviderKind::Siliconflow),
7913            model: Some("deepseek-v3.2".to_string()),
7914            ..CliRuntimeOverrides::default()
7915        };
7916
7917        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7918
7919        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7920        assert_eq!(resolved.model, "deepseek-v3.2");
7921    }
7922
7923    #[test]
7924    fn sglang_provider_normalizes_flash_aliases() {
7925        let _lock = env_lock();
7926        let _env = EnvGuard::without_deepseek_runtime_overrides();
7927        let cli = CliRuntimeOverrides {
7928            provider: Some(ProviderKind::Sglang),
7929            model: Some("deepseek-v4-flash".to_string()),
7930            ..CliRuntimeOverrides::default()
7931        };
7932
7933        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7934
7935        assert_eq!(resolved.provider, ProviderKind::Sglang);
7936        assert_eq!(resolved.model, DEFAULT_SGLANG_FLASH_MODEL);
7937    }
7938
7939    #[test]
7940    fn vllm_provider_normalizes_flash_aliases() {
7941        let _lock = env_lock();
7942        let _env = EnvGuard::without_deepseek_runtime_overrides();
7943        let cli = CliRuntimeOverrides {
7944            provider: Some(ProviderKind::Vllm),
7945            model: Some("deepseek-v4-flash".to_string()),
7946            ..CliRuntimeOverrides::default()
7947        };
7948
7949        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7950
7951        assert_eq!(resolved.provider, ProviderKind::Vllm);
7952        assert_eq!(resolved.model, DEFAULT_VLLM_FLASH_MODEL);
7953    }
7954
7955    #[test]
7956    fn openrouter_provider_specific_config_overrides_env() {
7957        let _lock = env_lock();
7958        let _env = EnvGuard::without_deepseek_runtime_overrides();
7959        let mut config = ConfigToml {
7960            provider: ProviderKind::Openrouter,
7961            ..ConfigToml::default()
7962        };
7963        config.providers.openrouter.api_key = Some("file-key".to_string());
7964        config.providers.openrouter.base_url = Some("https://or-mirror.example/v1".to_string());
7965
7966        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7967
7968        assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
7969        assert_eq!(resolved.base_url, "https://or-mirror.example/v1");
7970    }
7971
7972    #[test]
7973    fn openrouter_custom_base_url_preserves_provider_model() {
7974        let _lock = env_lock();
7975        let _env = EnvGuard::without_deepseek_runtime_overrides();
7976        let mut config = ConfigToml {
7977            provider: ProviderKind::Openrouter,
7978            ..ConfigToml::default()
7979        };
7980        config.providers.openrouter.base_url = Some("https://gateway.example.com/v1".to_string());
7981        config.providers.openrouter.model = Some("DeepSeek-V4-Pro".to_string());
7982
7983        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7984
7985        assert_eq!(resolved.provider, ProviderKind::Openrouter);
7986        assert_eq!(resolved.base_url, "https://gateway.example.com/v1");
7987        assert_eq!(resolved.model, "DeepSeek-V4-Pro");
7988    }
7989
7990    #[test]
7991    fn fireworks_custom_base_url_preserves_provider_model() {
7992        let _lock = env_lock();
7993        let _env = EnvGuard::without_deepseek_runtime_overrides();
7994        let mut config = ConfigToml {
7995            provider: ProviderKind::Fireworks,
7996            ..ConfigToml::default()
7997        };
7998        config.providers.fireworks.base_url = Some("https://my-gateway.example/v1".to_string());
7999        config.providers.fireworks.model = Some("DeepSeek-V4-Pro".to_string());
8000
8001        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8002
8003        assert_eq!(resolved.provider, ProviderKind::Fireworks);
8004        assert_eq!(resolved.base_url, "https://my-gateway.example/v1");
8005        // Custom base URL skips provider-specific model prefixing.
8006        assert_eq!(resolved.model, "DeepSeek-V4-Pro");
8007    }
8008
8009    #[test]
8010    fn siliconflow_custom_base_url_preserves_provider_model() {
8011        let _lock = env_lock();
8012        let _env = EnvGuard::without_deepseek_runtime_overrides();
8013        let mut config = ConfigToml {
8014            provider: ProviderKind::Siliconflow,
8015            ..ConfigToml::default()
8016        };
8017        config.providers.siliconflow.base_url = Some("https://my-gateway.example/v1".to_string());
8018        config.providers.siliconflow.model = Some("DeepSeek-V4-Pro".to_string());
8019
8020        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8021
8022        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
8023        assert_eq!(resolved.base_url, "https://my-gateway.example/v1");
8024        assert_eq!(resolved.model, "DeepSeek-V4-Pro");
8025    }
8026
8027    #[test]
8028    fn config_file_resolves_above_env_and_keyring() {
8029        use codewhale_secrets::KeyringStore;
8030        let _lock = env_lock();
8031        let _env = EnvGuard::without_deepseek_runtime_overrides();
8032        // Safety: env mutation guarded by env_lock().
8033        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
8034
8035        let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
8036        store.set("deepseek", "ring-key").unwrap();
8037        let secrets = Secrets::new(store);
8038
8039        let mut config = ConfigToml::default();
8040        config.providers.deepseek.api_key = Some("file-key".to_string());
8041
8042        let resolved =
8043            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
8044        assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
8045        assert_eq!(
8046            resolved.api_key_source,
8047            Some(RuntimeApiKeySource::ConfigFile)
8048        );
8049
8050        // Safety: env mutation guarded by env_lock().
8051        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
8052    }
8053
8054    #[test]
8055    fn env_resolves_when_config_file_and_keyring_empty() {
8056        let _lock = env_lock();
8057        let _env = EnvGuard::without_deepseek_runtime_overrides();
8058        // Safety: env mutation guarded by env_lock().
8059        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
8060
8061        let secrets = Secrets::new(std::sync::Arc::new(
8062            codewhale_secrets::InMemoryKeyringStore::new(),
8063        ));
8064        let config = ConfigToml::default();
8065
8066        let resolved =
8067            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
8068        assert_eq!(resolved.api_key.as_deref(), Some("env-key"));
8069        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
8070
8071        // Safety: env mutation guarded by env_lock().
8072        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
8073    }
8074
8075    #[test]
8076    fn config_file_resolves_when_keyring_and_env_empty() {
8077        let _lock = env_lock();
8078        let _env = EnvGuard::without_deepseek_runtime_overrides();
8079
8080        let secrets = Secrets::new(std::sync::Arc::new(
8081            codewhale_secrets::InMemoryKeyringStore::new(),
8082        ));
8083        let mut config = ConfigToml::default();
8084        config.providers.deepseek.api_key = Some("file-key".to_string());
8085
8086        let resolved =
8087            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
8088        assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
8089        assert_eq!(
8090            resolved.api_key_source,
8091            Some(RuntimeApiKeySource::ConfigFile)
8092        );
8093    }
8094
8095    #[test]
8096    fn keyring_resolves_when_config_file_empty_even_if_env_is_set() {
8097        use codewhale_secrets::KeyringStore;
8098        let _lock = env_lock();
8099        let _env = EnvGuard::without_deepseek_runtime_overrides();
8100        // Safety: env mutation guarded by env_lock().
8101        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key") };
8102
8103        let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
8104        store.set("deepseek", "ring-key").unwrap();
8105        let secrets = Secrets::new(store);
8106
8107        let resolved = ConfigToml::default()
8108            .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
8109        assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
8110        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
8111
8112        // Safety: env mutation guarded by env_lock().
8113        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
8114    }
8115
8116    #[test]
8117    fn cli_flag_still_overrides_keyring() {
8118        use codewhale_secrets::KeyringStore;
8119        let _lock = env_lock();
8120        let _env = EnvGuard::without_deepseek_runtime_overrides();
8121
8122        let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
8123        store.set("deepseek", "ring-key").unwrap();
8124        let secrets = Secrets::new(store);
8125
8126        let cli = CliRuntimeOverrides {
8127            api_key: Some("cli-key".to_string()),
8128            ..CliRuntimeOverrides::default()
8129        };
8130        let resolved = ConfigToml::default().resolve_runtime_options_with_secrets(&cli, &secrets);
8131        assert_eq!(resolved.api_key.as_deref(), Some("cli-key"));
8132        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli));
8133    }
8134
8135    #[test]
8136    fn provider_chain_initial_current_is_active() {
8137        let chain = ProviderChain::new(
8138            ProviderKind::NvidiaNim,
8139            &[ProviderKind::Deepseek, ProviderKind::Openrouter],
8140        );
8141
8142        assert_eq!(chain.current(), ProviderKind::NvidiaNim);
8143        assert_eq!(chain.position(), 0);
8144        assert_eq!(
8145            chain.providers(),
8146            &[
8147                ProviderKind::NvidiaNim,
8148                ProviderKind::Deepseek,
8149                ProviderKind::Openrouter,
8150            ]
8151        );
8152        assert!(!chain.is_fallback_active());
8153    }
8154
8155    #[test]
8156    fn provider_chain_advance_switches_to_fallback() {
8157        let mut chain = ProviderChain::new(
8158            ProviderKind::NvidiaNim,
8159            &[ProviderKind::Deepseek, ProviderKind::Openrouter],
8160        );
8161
8162        assert!(chain.has_next());
8163        assert_eq!(chain.advance(), Some(ProviderKind::Deepseek));
8164        assert_eq!(chain.current(), ProviderKind::Deepseek);
8165        assert!(chain.is_fallback_active());
8166    }
8167
8168    #[test]
8169    fn provider_chain_exhausts_returns_none() {
8170        let mut chain = ProviderChain::new(ProviderKind::Deepseek, &[ProviderKind::Openrouter]);
8171
8172        assert_eq!(chain.advance(), Some(ProviderKind::Openrouter));
8173        assert!(!chain.has_next());
8174        assert_eq!(chain.advance(), None);
8175    }
8176
8177    #[test]
8178    fn provider_chain_skips_duplicates() {
8179        let chain = ProviderChain::new(
8180            ProviderKind::Deepseek,
8181            &[
8182                ProviderKind::Deepseek,
8183                ProviderKind::NvidiaNim,
8184                ProviderKind::Deepseek,
8185            ],
8186        );
8187
8188        assert_eq!(
8189            chain.providers(),
8190            &[ProviderKind::Deepseek, ProviderKind::NvidiaNim]
8191        );
8192    }
8193
8194    #[test]
8195    fn provider_chain_remaining_counts_current_and_untried_entries() {
8196        let mut chain = ProviderChain::new(
8197            ProviderKind::Deepseek,
8198            &[ProviderKind::NvidiaNim, ProviderKind::Openrouter],
8199        );
8200
8201        assert_eq!(chain.remaining(), 3);
8202        assert_eq!(chain.advance(), Some(ProviderKind::NvidiaNim));
8203        assert_eq!(chain.remaining(), 2);
8204    }
8205
8206    #[test]
8207    fn config_toml_parses_fallback_providers() {
8208        let config: ConfigToml = toml::from_str(
8209            r#"
8210provider = "nvidia-nim"
8211fallback_providers = ["deepseek", "openrouter"]
8212"#,
8213        )
8214        .expect("fallback providers config");
8215
8216        assert_eq!(config.provider, ProviderKind::NvidiaNim);
8217        assert_eq!(
8218            config.fallback_providers,
8219            [ProviderKind::Deepseek, ProviderKind::Openrouter]
8220        );
8221    }
8222
8223    #[test]
8224    fn empty_fallback_providers_do_not_serialize() {
8225        let serialized = toml::to_string_pretty(&ConfigToml::default()).expect("config serializes");
8226
8227        assert!(!serialized.contains("fallback_providers"));
8228    }
8229
8230    #[test]
8231    fn fleet_exec_config_default_matches_subagent_depth() {
8232        // Fleet workers and standalone sub-agents share one recursion axis:
8233        // the fleet default equals DEFAULT_SPAWN_DEPTH (3) and affords >=3
8234        // nested delegation levels out of the box.
8235        assert_eq!(
8236            FleetExecConfig::default().max_spawn_depth,
8237            DEFAULT_SPAWN_DEPTH
8238        );
8239        assert_eq!(FleetExecConfig::default().max_spawn_depth, 3);
8240        const { assert!(DEFAULT_SPAWN_DEPTH <= MAX_SPAWN_DEPTH_CEILING) };
8241    }
8242
8243    #[test]
8244    fn fleet_exec_config_parses_max_spawn_depth() {
8245        let config: ConfigToml = toml::from_str(
8246            r#"
8247[fleet.exec]
8248max_spawn_depth = 2
8249"#,
8250        )
8251        .expect("fleet exec config should parse");
8252
8253        assert_eq!(config.fleet.expect("fleet config").exec.max_spawn_depth, 2);
8254    }
8255
8256    #[test]
8257    fn fallback_providers_do_not_change_runtime_resolution() {
8258        let _lock = env_lock();
8259        let _env = EnvGuard::without_deepseek_runtime_overrides();
8260        let config = ConfigToml {
8261            provider: ProviderKind::NvidiaNim,
8262            fallback_providers: vec![ProviderKind::Deepseek],
8263            ..ConfigToml::default()
8264        };
8265
8266        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8267
8268        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
8269    }
8270
8271    #[test]
8272    fn harness_posture_default_is_standard() {
8273        let posture = HarnessPosture::default();
8274
8275        assert_eq!(
8276            posture,
8277            HarnessPosture {
8278                kind: HarnessPostureKind::Standard,
8279                max_subagents: 0,
8280                prefer_codebase_search: false,
8281                compaction_strategy: HarnessCompactionStrategy::Default,
8282                tool_surface: HarnessToolSurface::Full,
8283                safety_posture: HarnessSafetyPosture::Standard,
8284            }
8285        );
8286    }
8287
8288    #[test]
8289    fn harness_posture_factories_are_typed() {
8290        assert_eq!(
8291            HarnessPosture::cache_heavy(),
8292            HarnessPosture {
8293                kind: HarnessPostureKind::CacheHeavy,
8294                max_subagents: 10,
8295                prefer_codebase_search: false,
8296                compaction_strategy: HarnessCompactionStrategy::PrefixCache,
8297                tool_surface: HarnessToolSurface::Full,
8298                safety_posture: HarnessSafetyPosture::Standard,
8299            }
8300        );
8301        assert_eq!(
8302            HarnessPosture::lean(),
8303            HarnessPosture {
8304                kind: HarnessPostureKind::Lean,
8305                max_subagents: 20,
8306                prefer_codebase_search: true,
8307                compaction_strategy: HarnessCompactionStrategy::Aggressive,
8308                tool_surface: HarnessToolSurface::Full,
8309                safety_posture: HarnessSafetyPosture::Standard,
8310            }
8311        );
8312    }
8313
8314    #[test]
8315    fn harness_profile_serde_round_trips_as_a_whole_struct() {
8316        let profile = HarnessProfile {
8317            provider_route: "deepseek".to_string(),
8318            model_pattern: "deepseek-v4.*".to_string(),
8319            posture: HarnessPosture::cache_heavy(),
8320        };
8321
8322        let json = serde_json::to_string(&profile).expect("serialize profile");
8323        let round_tripped: HarnessProfile =
8324            serde_json::from_str(&json).expect("deserialize profile");
8325
8326        assert_eq!(round_tripped, profile);
8327    }
8328
8329    #[test]
8330    fn config_toml_accepts_harness_profiles() {
8331        let config: ConfigToml = toml::from_str(
8332            r#"
8333provider = "deepseek"
8334model = "deepseek-v4-pro"
8335
8336[[harness_profiles]]
8337provider_route = "deepseek"
8338model_pattern = "deepseek-v4.*"
8339
8340[harness_profiles.posture]
8341kind = "cache-heavy"
8342max_subagents = 10
8343compaction_strategy = "prefix-cache"
8344tool_surface = "read-only"
8345safety_posture = "strict"
8346"#,
8347        )
8348        .expect("parse harness profiles");
8349
8350        assert_eq!(
8351            config.harness_profiles,
8352            vec![HarnessProfile {
8353                provider_route: "deepseek".to_string(),
8354                model_pattern: "deepseek-v4.*".to_string(),
8355                posture: HarnessPosture {
8356                    kind: HarnessPostureKind::CacheHeavy,
8357                    max_subagents: 10,
8358                    prefer_codebase_search: false,
8359                    compaction_strategy: HarnessCompactionStrategy::PrefixCache,
8360                    tool_surface: HarnessToolSurface::ReadOnly,
8361                    safety_posture: HarnessSafetyPosture::Strict,
8362                },
8363            }]
8364        );
8365    }
8366
8367    #[test]
8368    fn harness_profile_matches_provider_alias_and_model_wildcard() {
8369        let profile = HarnessProfile {
8370            provider_route: "xiaomi-mimo".to_string(),
8371            model_pattern: "mimo-v2.?-pro".to_string(),
8372            posture: HarnessPosture::cache_heavy(),
8373        };
8374
8375        assert!(profile.matches_route("mimo", "mimo-v2.5-pro"));
8376        assert!(!profile.matches_route("mimo", "mimo-v2.50-pro"));
8377        assert!(!profile.matches_route("deepseek", "mimo-v2.5-pro"));
8378    }
8379
8380    #[test]
8381    fn resolve_harness_profile_returns_first_matching_profile() {
8382        let config = ConfigToml {
8383            harness_profiles: vec![
8384                HarnessProfile {
8385                    provider_route: "deepseek".to_string(),
8386                    model_pattern: "deepseek-v4-flash".to_string(),
8387                    posture: HarnessPosture::lean(),
8388                },
8389                HarnessProfile {
8390                    provider_route: "deepseek".to_string(),
8391                    model_pattern: "deepseek-v4-*".to_string(),
8392                    posture: HarnessPosture::cache_heavy(),
8393                },
8394            ],
8395            ..ConfigToml::default()
8396        };
8397
8398        let flash = config
8399            .resolve_harness_profile("deepseek-cn", "deepseek-v4-flash")
8400            .expect("exact profile should match first");
8401        assert_eq!(flash.posture.kind, HarnessPostureKind::Lean);
8402
8403        let pro = config
8404            .resolve_harness_profile("deepseek", "deepseek-v4-pro")
8405            .expect("wildcard profile should match pro model");
8406        assert_eq!(pro.posture.kind, HarnessPostureKind::CacheHeavy);
8407    }
8408
8409    #[test]
8410    fn resolve_harness_profile_uses_built_in_seed_when_config_has_no_match() {
8411        let config = ConfigToml::default();
8412
8413        let xiaomi = config
8414            .resolve_harness_profile("xiaomi", "mimo-v2.5-pro")
8415            .expect("direct Xiaomi MiMo seed should resolve");
8416        assert_eq!(xiaomi.provider_route, "xiaomi-mimo");
8417        assert_eq!(xiaomi.posture.kind, HarnessPostureKind::CacheHeavy);
8418
8419        let arcee = config
8420            .resolve_harness_profile("arcee", "trinity-large-thinking")
8421            .expect("direct Arcee seed should resolve");
8422        assert_eq!(arcee.posture.kind, HarnessPostureKind::CacheHeavy);
8423
8424        let local = config
8425            .resolve_harness_profile("vllm", "Qwen/Qwen3.6-Coder")
8426            .expect("local seed should resolve");
8427        assert_eq!(local.posture.kind, HarnessPostureKind::Lean);
8428        assert!(local.posture.prefer_codebase_search);
8429    }
8430
8431    #[test]
8432    fn configured_harness_profile_overrides_built_in_seed() {
8433        let config = ConfigToml {
8434            harness_profiles: vec![HarnessProfile {
8435                provider_route: "xiaomi-mimo".to_string(),
8436                model_pattern: "mimo-v2.5-pro".to_string(),
8437                posture: HarnessPosture {
8438                    kind: HarnessPostureKind::Custom,
8439                    max_subagents: 3,
8440                    prefer_codebase_search: true,
8441                    compaction_strategy: HarnessCompactionStrategy::Default,
8442                    tool_surface: HarnessToolSurface::Auto,
8443                    safety_posture: HarnessSafetyPosture::Strict,
8444                },
8445            }],
8446            ..ConfigToml::default()
8447        };
8448
8449        let profile = config
8450            .resolve_harness_profile("xiaomi-mimo", "mimo-v2.5-pro")
8451            .expect("configured profile should match first");
8452
8453        assert_eq!(profile.posture.kind, HarnessPostureKind::Custom);
8454        assert_eq!(profile.posture.max_subagents, 3);
8455        assert_eq!(profile.posture.tool_surface, HarnessToolSurface::Auto);
8456        assert_eq!(profile.posture.safety_posture, HarnessSafetyPosture::Strict);
8457    }
8458
8459    #[test]
8460    fn resolve_harness_profile_returns_none_when_route_or_model_misses() {
8461        let config = ConfigToml {
8462            harness_profiles: vec![HarnessProfile {
8463                provider_route: "huggingface".to_string(),
8464                model_pattern: "deepseek-ai/*".to_string(),
8465                posture: HarnessPosture::lean(),
8466            }],
8467            ..ConfigToml::default()
8468        };
8469
8470        assert!(
8471            config
8472                .resolve_harness_profile("openrouter", "deepseek-ai/DeepSeek-V4-Pro")
8473                .is_none()
8474        );
8475        assert!(
8476            config
8477                .resolve_harness_profile("deepseek", "Qwen/Qwen3.6-Coder")
8478                .is_none()
8479        );
8480        assert!(
8481            config
8482                .resolve_harness_profile("openai", "mimo-v2.5-pro")
8483                .is_none()
8484        );
8485    }
8486
8487    #[test]
8488    fn resolving_harness_profile_does_not_change_runtime_options() {
8489        let _lock = env_lock();
8490        let _env = EnvGuard::without_deepseek_runtime_overrides();
8491        let config = ConfigToml {
8492            provider: ProviderKind::Deepseek,
8493            model: Some("deepseek-v4-pro".to_string()),
8494            harness_profiles: vec![HarnessProfile {
8495                provider_route: "deepseek".to_string(),
8496                model_pattern: "deepseek-v4-*".to_string(),
8497                posture: HarnessPosture::lean(),
8498            }],
8499            ..ConfigToml::default()
8500        };
8501
8502        let profile = config
8503            .resolve_harness_profile("deepseek", "deepseek-v4-pro")
8504            .expect("profile should resolve for display/future runtime");
8505        assert_eq!(profile.posture.kind, HarnessPostureKind::Lean);
8506
8507        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8508        assert_eq!(resolved.provider, ProviderKind::Deepseek);
8509        assert_eq!(resolved.model, "deepseek-v4-pro");
8510    }
8511
8512    #[test]
8513    fn harness_posture_kind_rejects_unknown_values() {
8514        let err = toml::from_str::<ConfigToml>(
8515            r#"
8516[[harness_profiles]]
8517provider_route = "deepseek"
8518model_pattern = "deepseek-v4.*"
8519
8520[harness_profiles.posture]
8521kind = "cahce-heavy"
8522"#,
8523        )
8524        .expect_err("misspelled kind should not deserialize as custom");
8525
8526        assert!(err.to_string().contains("cahce-heavy"));
8527    }
8528
8529    #[test]
8530    fn harness_posture_rejects_unknown_policy_keys() {
8531        let err = toml::from_str::<ConfigToml>(
8532            r#"
8533[[harness_profiles]]
8534provider_route = "deepseek"
8535model_pattern = "deepseek-v4.*"
8536
8537[harness_profiles.posture]
8538kind = "custom"
8539unknown_policy = "surprise"
8540"#,
8541        )
8542        .expect_err("unknown posture keys should not be ignored");
8543
8544        assert!(err.to_string().contains("unknown_policy"));
8545    }
8546
8547    #[test]
8548    fn test_verbosity_resolution() {
8549        let _lock = env_lock();
8550        // Test TOML parsing
8551        let toml_str = r#"
8552            verbosity = "concise"
8553        "#;
8554        let config: ConfigToml = toml::from_str(toml_str).unwrap();
8555        assert_eq!(config.verbosity, Some("concise".to_string()));
8556
8557        // Test Env overrides
8558        let _env = EnvGuard::without_deepseek_runtime_overrides();
8559        unsafe {
8560            std::env::set_var("CODEWHALE_VERBOSITY", "normal");
8561        }
8562        let env_overrides = EnvRuntimeOverrides::load();
8563        assert_eq!(env_overrides.verbosity, Some("normal".to_string()));
8564        unsafe {
8565            std::env::remove_var("CODEWHALE_VERBOSITY");
8566        }
8567
8568        // Test fallback to DEEPSEEK_VERBOSITY
8569        unsafe {
8570            std::env::set_var("DEEPSEEK_VERBOSITY", "concise");
8571        }
8572        let env_overrides = EnvRuntimeOverrides::load();
8573        assert_eq!(env_overrides.verbosity, Some("concise".to_string()));
8574        unsafe {
8575            std::env::remove_var("DEEPSEEK_VERBOSITY");
8576        }
8577    }
8578}