Skip to main content

codewhale_config/
lib.rs

1pub mod auth_source;
2pub mod catalog;
3mod harness;
4pub mod model_reference;
5pub mod models_dev;
6pub mod pricing;
7pub mod provider;
8mod provider_defaults;
9mod provider_kind;
10pub mod route;
11pub use harness::{
12    HarnessCompactionStrategy, HarnessPosture, HarnessPostureKind, HarnessProfile,
13    HarnessSafetyPosture, HarnessToolSurface, built_in_harness_profiles,
14};
15pub use model_reference::{Modality, ModelReferenceCard, ModelReferenceDatabase};
16pub(crate) use provider_defaults::*;
17pub use provider_kind::ProviderKind;
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::ffi::{OsStr, OsString};
21use std::fmt;
22use std::fs;
23#[cfg(unix)]
24use std::io::Read;
25use std::io::Write;
26use std::path::{Component, Path, PathBuf};
27use std::sync::OnceLock;
28
29use anyhow::{Context, Result, bail};
30pub use auth_source::{AuthSourceKind, ProviderAuthSourceToml};
31pub use codewhale_execpolicy::ToolAskRule;
32use codewhale_execpolicy::{ExecPolicyEngine, Ruleset};
33use codewhale_secrets::SecretSource;
34pub use codewhale_secrets::Secrets;
35use serde::{Deserialize, Serialize};
36
37#[cfg(unix)]
38use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
39
40pub const CONFIG_FILE_NAME: &str = "config.toml";
41pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
42
43#[derive(Debug, Clone, Serialize, Deserialize, Default)]
44pub struct ProviderConfigToml {
45    pub api_key: Option<String>,
46    pub base_url: Option<String>,
47    pub model: Option<String>,
48    #[serde(
49        default,
50        alias = "contextWindow",
51        alias = "context_window_tokens",
52        alias = "contextWindowTokens",
53        alias = "context_length",
54        alias = "contextLength"
55    )]
56    pub context_window: Option<u32>,
57    pub mode: Option<String>,
58    pub auth_mode: Option<String>,
59    pub insecure_skip_tls_verify: Option<bool>,
60    #[serde(default)]
61    pub http_headers: BTreeMap<String, String>,
62    pub path_suffix: Option<String>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub auth: Option<ProviderAuthSourceToml>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, Default)]
68pub struct ProvidersToml {
69    #[serde(default)]
70    pub deepseek: ProviderConfigToml,
71    #[serde(
72        default,
73        alias = "deepseek-anthropic",
74        alias = "deepseekAnthropic",
75        alias = "deepseek-claude",
76        alias = "deepseek_claude"
77    )]
78    pub deepseek_anthropic: ProviderConfigToml,
79    #[serde(default)]
80    pub nvidia_nim: ProviderConfigToml,
81    #[serde(default)]
82    pub openai: ProviderConfigToml,
83    #[serde(default)]
84    pub atlascloud: ProviderConfigToml,
85    #[serde(default)]
86    pub wanjie_ark: ProviderConfigToml,
87    #[serde(default)]
88    pub volcengine: ProviderConfigToml,
89    #[serde(default)]
90    pub openrouter: ProviderConfigToml,
91    #[serde(default, alias = "xiaomi", alias = "mimo", alias = "xiaomimimo")]
92    pub xiaomi_mimo: ProviderConfigToml,
93    #[serde(default)]
94    pub novita: ProviderConfigToml,
95    #[serde(default)]
96    pub fireworks: ProviderConfigToml,
97    #[serde(default)]
98    pub siliconflow: ProviderConfigToml,
99    #[serde(default, alias = "siliconflow-CN", alias = "siliconflow-cn")]
100    pub siliconflow_cn: ProviderConfigToml,
101    #[serde(default)]
102    pub arcee: ProviderConfigToml,
103    #[serde(default)]
104    pub moonshot: ProviderConfigToml,
105    #[serde(default)]
106    pub sglang: ProviderConfigToml,
107    #[serde(default)]
108    pub vllm: ProviderConfigToml,
109    #[serde(default)]
110    pub ollama: ProviderConfigToml,
111    #[serde(default)]
112    pub huggingface: ProviderConfigToml,
113    #[serde(default)]
114    pub together: ProviderConfigToml,
115    #[serde(
116        default,
117        alias = "baidu-qianfan",
118        alias = "baidu_qianfan",
119        alias = "baidu"
120    )]
121    pub qianfan: ProviderConfigToml,
122    #[serde(
123        default,
124        alias = "openai-codex",
125        alias = "openai_codex",
126        alias = "codex",
127        alias = "chatgpt",
128        alias = "chatgpt-codex"
129    )]
130    pub openai_codex: ProviderConfigToml,
131    #[serde(default)]
132    pub anthropic: ProviderConfigToml,
133    #[serde(
134        default,
135        alias = "z-ai",
136        alias = "z_ai",
137        alias = "z.ai",
138        alias = "zhipu",
139        alias = "zhipuai",
140        alias = "bigmodel",
141        alias = "big-model"
142    )]
143    pub zai: ProviderConfigToml,
144    #[serde(
145        default,
146        alias = "step-fun",
147        alias = "step_fun",
148        alias = "stepfun",
149        alias = "stepflash",
150        alias = "step-flash",
151        alias = "step_flash"
152    )]
153    pub stepfun: ProviderConfigToml,
154    #[serde(default, alias = "mini-max", alias = "mini_max", alias = "minimax")]
155    pub minimax: ProviderConfigToml,
156    #[serde(default, alias = "deep-infra", alias = "deep_infra")]
157    pub deepinfra: ProviderConfigToml,
158    /// Catch-all table for the dynamic OpenAI-compatible custom provider
159    /// identity (#1519). Arbitrary `[providers.<name>]` tables are handled by
160    /// the tui-side flatten map; this named slot keeps the canonical
161    /// `ProviderKind::Custom` lookups total without leaking into another
162    /// provider's config.
163    #[serde(default)]
164    pub custom: ProviderConfigToml,
165}
166
167/// Sibling `permissions.toml` schema.
168///
169/// This slice is intentionally ask-only: each rule is a typed condition that
170/// means "ask before this tool invocation." Typed allow/deny records and UI
171/// actions are expected to land in follow-up PRs.
172#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
173#[serde(deny_unknown_fields)]
174pub struct PermissionsToml {
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub rules: Vec<ToolAskRule>,
177}
178
179impl PermissionsToml {
180    #[must_use]
181    pub fn is_empty(&self) -> bool {
182        self.rules.is_empty()
183    }
184
185    #[must_use]
186    pub fn ruleset(&self) -> Ruleset {
187        Ruleset::user(Vec::new(), Vec::new()).with_ask_rules(self.rules.clone())
188    }
189}
190
191impl ProvidersToml {
192    #[must_use]
193    pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
194        match provider {
195            ProviderKind::Deepseek => &self.deepseek,
196            ProviderKind::DeepseekAnthropic => &self.deepseek_anthropic,
197            ProviderKind::NvidiaNim => &self.nvidia_nim,
198            ProviderKind::Openai => &self.openai,
199            ProviderKind::Atlascloud => &self.atlascloud,
200            ProviderKind::WanjieArk => &self.wanjie_ark,
201            ProviderKind::Volcengine => &self.volcengine,
202            ProviderKind::Openrouter => &self.openrouter,
203            ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
204            ProviderKind::Novita => &self.novita,
205            ProviderKind::Fireworks => &self.fireworks,
206            ProviderKind::Siliconflow => &self.siliconflow,
207            ProviderKind::SiliconflowCN => &self.siliconflow_cn,
208            ProviderKind::Arcee => &self.arcee,
209            ProviderKind::Moonshot => &self.moonshot,
210            ProviderKind::Sglang => &self.sglang,
211            ProviderKind::Vllm => &self.vllm,
212            ProviderKind::Ollama => &self.ollama,
213            ProviderKind::Huggingface => &self.huggingface,
214            ProviderKind::Together => &self.together,
215            ProviderKind::Qianfan => &self.qianfan,
216            ProviderKind::OpenaiCodex => &self.openai_codex,
217            ProviderKind::Anthropic => &self.anthropic,
218            ProviderKind::Zai => &self.zai,
219            ProviderKind::Stepfun => &self.stepfun,
220            ProviderKind::Minimax => &self.minimax,
221            ProviderKind::Deepinfra => &self.deepinfra,
222            ProviderKind::Custom => &self.custom,
223        }
224    }
225
226    pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
227        match provider {
228            ProviderKind::Deepseek => &mut self.deepseek,
229            ProviderKind::DeepseekAnthropic => &mut self.deepseek_anthropic,
230            ProviderKind::NvidiaNim => &mut self.nvidia_nim,
231            ProviderKind::Openai => &mut self.openai,
232            ProviderKind::Atlascloud => &mut self.atlascloud,
233            ProviderKind::WanjieArk => &mut self.wanjie_ark,
234            ProviderKind::Volcengine => &mut self.volcengine,
235            ProviderKind::Openrouter => &mut self.openrouter,
236            ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
237            ProviderKind::Novita => &mut self.novita,
238            ProviderKind::Fireworks => &mut self.fireworks,
239            ProviderKind::Siliconflow => &mut self.siliconflow,
240            ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
241            ProviderKind::Arcee => &mut self.arcee,
242            ProviderKind::Moonshot => &mut self.moonshot,
243            ProviderKind::Sglang => &mut self.sglang,
244            ProviderKind::Vllm => &mut self.vllm,
245            ProviderKind::Ollama => &mut self.ollama,
246            ProviderKind::Huggingface => &mut self.huggingface,
247            ProviderKind::Together => &mut self.together,
248            ProviderKind::Qianfan => &mut self.qianfan,
249            ProviderKind::OpenaiCodex => &mut self.openai_codex,
250            ProviderKind::Anthropic => &mut self.anthropic,
251            ProviderKind::Zai => &mut self.zai,
252            ProviderKind::Stepfun => &mut self.stepfun,
253            ProviderKind::Minimax => &mut self.minimax,
254            ProviderKind::Deepinfra => &mut self.deepinfra,
255            ProviderKind::Custom => &mut self.custom,
256        }
257    }
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, Default)]
261pub struct ConfigToml {
262    /// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek`
263    /// and `codewhale-tui` can share a single config file.
264    pub api_key: Option<String>,
265    /// TUI-compatible DeepSeek base URL.
266    pub base_url: Option<String>,
267    /// Optional extra HTTP headers forwarded to model API requests.
268    #[serde(default)]
269    pub http_headers: BTreeMap<String, String>,
270    /// TUI-compatible default DeepSeek model.
271    pub default_text_model: Option<String>,
272    #[serde(default)]
273    pub provider: ProviderKind,
274    pub model: Option<String>,
275    pub auth_mode: Option<String>,
276    pub output_mode: Option<String>,
277    pub verbosity: Option<String>,
278    pub log_level: Option<String>,
279    pub telemetry: Option<bool>,
280    pub approval_policy: Option<String>,
281    pub sandbox_mode: Option<String>,
282    /// Native tool catalog controls shared with `codewhale-tui`.
283    #[serde(default)]
284    pub tools: Option<ToolsToml>,
285    #[serde(default)]
286    pub providers: ProvidersToml,
287    /// Provider fallback chain (#2574). TUI runtime code may advance through
288    /// these providers after recoverable provider errors; config resolution
289    /// itself still reports the selected primary provider.
290    #[serde(default, skip_serializing_if = "Vec::is_empty")]
291    pub fallback_providers: Vec<ProviderKind>,
292    /// Per-domain network policy (#135). When absent, network tools fall back
293    /// to a permissive default that mirrors pre-v0.7.0 behavior.
294    #[serde(default)]
295    pub network: Option<NetworkPolicyToml>,
296    /// Community skill installer settings (#140). Mirrors
297    /// [`SkillsToml`] from the TUI side; the dispatcher consults
298    /// `registry_url` when running `deepseek skill install`.
299    #[serde(default)]
300    pub skills: Option<SkillsToml>,
301    /// Workspace side-git snapshots (#137). The live TUI defaults this to
302    /// enabled with 7-day retention when absent.
303    #[serde(default)]
304    pub snapshots: Option<SnapshotsToml>,
305    /// Post-edit LSP diagnostics injection (#136). When absent, the engine
306    /// applies the defaults documented in [`LspConfigToml`].
307    #[serde(default)]
308    pub lsp: Option<LspConfigToml>,
309    /// Per-model harness profiles (#2693). Runtime wiring lands in follow-up
310    /// v0.9 slices; this is the durable config data model.
311    #[serde(default)]
312    pub harness_profiles: Vec<HarnessProfile>,
313    /// Optional 1-8 hotbar slot bindings (#2064). When absent, the TUI falls
314    /// back to the built-in default slots.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub hotbar: Option<Vec<HotbarBindingToml>>,
317    /// App-server hook sink configuration. Kept separate from the TUI
318    /// lifecycle `[hooks]` table so config rewrites preserve existing hooks.
319    #[serde(default)]
320    pub hook_sinks: Option<HookSinksToml>,
321    /// Agent Fleet trust and security policy (#3165). When absent, fleet
322    /// workers inherit conservative Sandbox defaults.
323    #[serde(default)]
324    pub fleet: Option<FleetConfigToml>,
325    #[serde(flatten)]
326    pub extras: BTreeMap<String, toml::Value>,
327}
328
329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330enum ProviderConfigField {
331    ApiKey,
332    BaseUrl,
333    Model,
334    ContextWindow,
335    Mode,
336    AuthMode,
337    InsecureSkipTlsVerify,
338    HttpHeaders,
339    PathSuffix,
340}
341
342impl ProviderConfigField {
343    fn parse(key: &str) -> Option<Self> {
344        Some(match key {
345            "api_key" => Self::ApiKey,
346            "base_url" => Self::BaseUrl,
347            "model" => Self::Model,
348            "context_window" | "context_window_tokens" => Self::ContextWindow,
349            "mode" => Self::Mode,
350            "auth_mode" => Self::AuthMode,
351            "insecure_skip_tls_verify" => Self::InsecureSkipTlsVerify,
352            "http_headers" => Self::HttpHeaders,
353            "path_suffix" => Self::PathSuffix,
354            _ => return None,
355        })
356    }
357
358    fn key(self) -> &'static str {
359        match self {
360            Self::ApiKey => "api_key",
361            Self::BaseUrl => "base_url",
362            Self::Model => "model",
363            Self::ContextWindow => "context_window",
364            Self::Mode => "mode",
365            Self::AuthMode => "auth_mode",
366            Self::InsecureSkipTlsVerify => "insecure_skip_tls_verify",
367            Self::HttpHeaders => "http_headers",
368            Self::PathSuffix => "path_suffix",
369        }
370    }
371}
372
373fn parse_provider_config_key(key: &str) -> Option<(ProviderKind, ProviderConfigField)> {
374    let suffix = key.strip_prefix("providers.")?;
375    let (provider_key, field_key) = suffix.split_once('.')?;
376    let field = ProviderConfigField::parse(field_key)?;
377    let provider = ProviderKind::ALL
378        .iter()
379        .copied()
380        .find(|kind| kind.provider().provider_config_key() == provider_key)?;
381    Some((provider, field))
382}
383
384fn provider_config_key(provider: ProviderKind, field: ProviderConfigField) -> String {
385    format!(
386        "providers.{}.{}",
387        provider.provider().provider_config_key(),
388        field.key()
389    )
390}
391
392fn get_provider_config_value(
393    config: &ProviderConfigToml,
394    field: ProviderConfigField,
395) -> Option<String> {
396    match field {
397        ProviderConfigField::ApiKey => config.api_key.clone(),
398        ProviderConfigField::BaseUrl => config.base_url.clone(),
399        ProviderConfigField::Model => config.model.clone(),
400        ProviderConfigField::ContextWindow => config.context_window.map(|value| value.to_string()),
401        ProviderConfigField::Mode => config.mode.clone(),
402        ProviderConfigField::AuthMode => config.auth_mode.clone(),
403        ProviderConfigField::InsecureSkipTlsVerify => config
404            .insecure_skip_tls_verify
405            .map(|value| value.to_string()),
406        ProviderConfigField::HttpHeaders => serialize_http_headers(&config.http_headers),
407        ProviderConfigField::PathSuffix => config.path_suffix.clone(),
408    }
409}
410
411fn get_provider_config_display_value(
412    config: &ProviderConfigToml,
413    field: ProviderConfigField,
414) -> Option<String> {
415    match field {
416        ProviderConfigField::ApiKey => config.api_key.as_deref().map(redact_secret),
417        ProviderConfigField::HttpHeaders => {
418            serialize_http_headers_for_display(&config.http_headers)
419        }
420        _ => get_provider_config_value(config, field),
421    }
422}
423
424fn parse_context_window(value: &str) -> Result<u32> {
425    let parsed = value.trim().parse::<u32>().with_context(|| {
426        format!("invalid context_window '{value}': expected a positive token count")
427    })?;
428    if parsed == 0 {
429        bail!("context_window must be greater than 0");
430    }
431    Ok(parsed)
432}
433
434fn set_provider_config_value(
435    config: &mut ConfigToml,
436    provider: ProviderKind,
437    field: ProviderConfigField,
438    value: &str,
439) -> Result<()> {
440    match field {
441        ProviderConfigField::ApiKey => {
442            let value = value.to_string();
443            config.providers.for_provider_mut(provider).api_key = Some(value.clone());
444            if provider == ProviderKind::Deepseek {
445                config.api_key = Some(value);
446            }
447        }
448        ProviderConfigField::BaseUrl => {
449            let value = value.to_string();
450            config.providers.for_provider_mut(provider).base_url = Some(value.clone());
451            if provider == ProviderKind::Deepseek {
452                config.base_url = Some(value);
453            }
454        }
455        ProviderConfigField::Model => {
456            let value = value.to_string();
457            config.providers.for_provider_mut(provider).model = Some(value.clone());
458            if provider == ProviderKind::Deepseek {
459                config.default_text_model = Some(value);
460            }
461        }
462        ProviderConfigField::ContextWindow => {
463            config.providers.for_provider_mut(provider).context_window =
464                Some(parse_context_window(value)?);
465        }
466        ProviderConfigField::Mode => {
467            config.providers.for_provider_mut(provider).mode = Some(value.to_string());
468        }
469        ProviderConfigField::AuthMode => {
470            config.providers.for_provider_mut(provider).auth_mode = Some(value.to_string());
471        }
472        ProviderConfigField::InsecureSkipTlsVerify => {
473            config
474                .providers
475                .for_provider_mut(provider)
476                .insecure_skip_tls_verify = Some(parse_bool(value)?);
477        }
478        ProviderConfigField::HttpHeaders => {
479            let headers = parse_http_headers(value)?;
480            config.providers.for_provider_mut(provider).http_headers = headers.clone();
481            if provider == ProviderKind::Deepseek {
482                config.http_headers = headers;
483            }
484        }
485        ProviderConfigField::PathSuffix => {
486            config.providers.for_provider_mut(provider).path_suffix = Some(value.to_string());
487        }
488    }
489    Ok(())
490}
491
492fn unset_provider_config_value(
493    config: &mut ConfigToml,
494    provider: ProviderKind,
495    field: ProviderConfigField,
496) {
497    match field {
498        ProviderConfigField::ApiKey => {
499            config.providers.for_provider_mut(provider).api_key = None;
500            if provider == ProviderKind::Deepseek {
501                config.api_key = None;
502            }
503        }
504        ProviderConfigField::BaseUrl => {
505            config.providers.for_provider_mut(provider).base_url = None;
506            if provider == ProviderKind::Deepseek {
507                config.base_url = None;
508            }
509        }
510        ProviderConfigField::Model => {
511            config.providers.for_provider_mut(provider).model = None;
512            if provider == ProviderKind::Deepseek {
513                config.default_text_model = None;
514            }
515        }
516        ProviderConfigField::ContextWindow => {
517            config.providers.for_provider_mut(provider).context_window = None;
518        }
519        ProviderConfigField::Mode => {
520            config.providers.for_provider_mut(provider).mode = None;
521        }
522        ProviderConfigField::AuthMode => {
523            config.providers.for_provider_mut(provider).auth_mode = None;
524        }
525        ProviderConfigField::InsecureSkipTlsVerify => {
526            config
527                .providers
528                .for_provider_mut(provider)
529                .insecure_skip_tls_verify = None;
530        }
531        ProviderConfigField::HttpHeaders => {
532            config
533                .providers
534                .for_provider_mut(provider)
535                .http_headers
536                .clear();
537            if provider == ProviderKind::Deepseek {
538                config.http_headers.clear();
539            }
540        }
541        ProviderConfigField::PathSuffix => {
542            config.providers.for_provider_mut(provider).path_suffix = None;
543        }
544    }
545}
546
547fn insert_provider_config_values(
548    out: &mut BTreeMap<String, String>,
549    provider: ProviderKind,
550    config: &ProviderConfigToml,
551) {
552    if let Some(v) = config.api_key.as_ref() {
553        out.insert(
554            provider_config_key(provider, ProviderConfigField::ApiKey),
555            redact_secret(v),
556        );
557    }
558    if let Some(v) = config.base_url.as_ref() {
559        out.insert(
560            provider_config_key(provider, ProviderConfigField::BaseUrl),
561            v.clone(),
562        );
563    }
564    if let Some(v) = config.model.as_ref() {
565        out.insert(
566            provider_config_key(provider, ProviderConfigField::Model),
567            v.clone(),
568        );
569    }
570    if let Some(v) = config.context_window {
571        out.insert(
572            provider_config_key(provider, ProviderConfigField::ContextWindow),
573            v.to_string(),
574        );
575    }
576    if let Some(v) = config.mode.as_ref() {
577        out.insert(
578            provider_config_key(provider, ProviderConfigField::Mode),
579            v.clone(),
580        );
581    }
582    if let Some(v) = config.auth_mode.as_ref() {
583        out.insert(
584            provider_config_key(provider, ProviderConfigField::AuthMode),
585            v.clone(),
586        );
587    }
588    if let Some(v) = config.insecure_skip_tls_verify {
589        out.insert(
590            provider_config_key(provider, ProviderConfigField::InsecureSkipTlsVerify),
591            v.to_string(),
592        );
593    }
594    if let Some(v) = serialize_http_headers_for_display(&config.http_headers) {
595        out.insert(
596            provider_config_key(provider, ProviderConfigField::HttpHeaders),
597            v,
598        );
599    }
600    if let Some(v) = config.path_suffix.as_ref() {
601        out.insert(
602            provider_config_key(provider, ProviderConfigField::PathSuffix),
603            v.clone(),
604        );
605    }
606}
607
608impl ConfigToml {
609    /// Resolve the first configured harness profile for a provider/model route.
610    ///
611    /// This helper is deliberately dormant for v0.9: callers may display or
612    /// test the resolved profile, but runtime provider/model routing and prompt
613    /// shaping remain unchanged until a later, explicit integration slice.
614    #[must_use]
615    pub fn resolve_harness_profile(
616        &self,
617        provider_route: &str,
618        model: &str,
619    ) -> Option<&HarnessProfile> {
620        self.harness_profiles
621            .iter()
622            .chain(built_in_harness_profiles().iter())
623            .find(|profile| profile.matches_route(provider_route, model))
624    }
625
626    /// Resolve durable hotbar config into normalized 1-8 slot bindings.
627    ///
628    /// `known_action_ids` is supplied by the TUI action registry in later
629    /// slices. Unknown actions are preserved so the UI can render a disabled
630    /// `?` cell instead of silently deleting user config.
631    #[must_use]
632    pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
633        resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
634    }
635}
636
637/// Ordered primary-plus-fallback provider list for future provider routing.
638///
639/// The helper is intentionally dormant: constructing or parsing a chain does
640/// not change [`ConfigToml::resolve_runtime_options`].
641#[derive(Debug, Clone, PartialEq, Eq)]
642pub struct ProviderChain {
643    providers: Vec<ProviderKind>,
644    position: usize,
645}
646
647pub const HOTBAR_SLOT_COUNT: u8 = 8;
648
649pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
650    "voice.toggle",
651    "session.compact",
652    "mode.plan",
653    "mode.agent",
654    "mode.yolo",
655    "palette.open",
656    "sidebar.toggle",
657    "trust.toggle",
658];
659
660/// On-disk schema for one `[[hotbar]]` table.
661#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
662#[serde(deny_unknown_fields)]
663pub struct HotbarBindingToml {
664    pub slot: u8,
665    pub action: String,
666    #[serde(default)]
667    pub label: Option<String>,
668}
669
670/// Validated hotbar binding used by future render/dispatch layers.
671#[derive(Debug, Clone, PartialEq, Eq)]
672pub struct HotbarBinding {
673    pub slot: u8,
674    pub action: String,
675    pub label: Option<String>,
676}
677
678/// Non-fatal hotbar config issue. Invalid slots are skipped; duplicate slots
679/// use the last binding; unknown actions are kept for UI feedback.
680#[derive(Debug, Clone, PartialEq, Eq)]
681pub enum HotbarConfigWarning {
682    SlotOutOfRange {
683        slot: u8,
684        action: String,
685    },
686    DuplicateSlot {
687        slot: u8,
688        previous_action: String,
689        replacement_action: String,
690    },
691    UnknownAction {
692        slot: u8,
693        action: String,
694    },
695}
696
697impl fmt::Display for HotbarConfigWarning {
698    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
699        match self {
700            Self::SlotOutOfRange { slot, action } => write!(
701                f,
702                "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
703            ),
704            Self::DuplicateSlot {
705                slot,
706                previous_action,
707                replacement_action,
708            } => write!(
709                f,
710                "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
711            ),
712            Self::UnknownAction { slot, action } => write!(
713                f,
714                "hotbar slot {slot} references unknown action '{action}'; keeping binding"
715            ),
716        }
717    }
718}
719
720#[derive(Debug, Clone, PartialEq, Eq)]
721pub struct HotbarConfigResolution {
722    pub bindings: Vec<HotbarBinding>,
723    pub warnings: Vec<HotbarConfigWarning>,
724}
725
726#[must_use]
727pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
728    DEFAULT_HOTBAR_ACTIONS
729        .iter()
730        .enumerate()
731        .map(|(idx, action)| HotbarBinding {
732            slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
733            action: (*action).to_string(),
734            label: None,
735        })
736        .collect()
737}
738
739#[must_use]
740pub fn resolve_hotbar_bindings(
741    configured: Option<&[HotbarBindingToml]>,
742    known_action_ids: &[&str],
743) -> HotbarConfigResolution {
744    let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
745    let mut warnings = Vec::new();
746
747    let source = match configured {
748        Some(bindings) => bindings
749            .iter()
750            .map(|binding| HotbarBinding {
751                slot: binding.slot,
752                action: binding.action.clone(),
753                label: binding.label.clone(),
754            })
755            .collect::<Vec<_>>(),
756        None => default_hotbar_bindings(),
757    };
758
759    let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
760    for binding in source {
761        if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
762            warnings.push(HotbarConfigWarning::SlotOutOfRange {
763                slot: binding.slot,
764                action: binding.action,
765            });
766            continue;
767        }
768        if !known.is_empty() && !known.contains(binding.action.as_str()) {
769            warnings.push(HotbarConfigWarning::UnknownAction {
770                slot: binding.slot,
771                action: binding.action.clone(),
772            });
773        }
774        if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
775            warnings.push(HotbarConfigWarning::DuplicateSlot {
776                slot: binding.slot,
777                previous_action: previous.action,
778                replacement_action: binding.action,
779            });
780        }
781    }
782
783    HotbarConfigResolution {
784        bindings: by_slot.into_values().collect(),
785        warnings,
786    }
787}
788
789impl ProviderChain {
790    #[must_use]
791    pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
792        let mut providers = vec![active];
793        for fallback in fallbacks {
794            if *fallback != active && !providers.contains(fallback) {
795                providers.push(*fallback);
796            }
797        }
798        Self {
799            providers,
800            position: 0,
801        }
802    }
803
804    #[must_use]
805    pub fn providers(&self) -> &[ProviderKind] {
806        &self.providers
807    }
808
809    #[must_use]
810    pub fn position(&self) -> usize {
811        self.position
812    }
813
814    #[must_use]
815    pub fn current(&self) -> ProviderKind {
816        self.providers
817            .get(self.position)
818            .copied()
819            .unwrap_or(self.providers[0])
820    }
821
822    #[must_use]
823    pub fn has_next(&self) -> bool {
824        self.position + 1 < self.providers.len()
825    }
826
827    pub fn advance(&mut self) -> Option<ProviderKind> {
828        if !self.has_next() {
829            return None;
830        }
831        self.position += 1;
832        Some(self.current())
833    }
834
835    pub fn reset(&mut self) {
836        self.position = 0;
837    }
838
839    #[must_use]
840    pub fn is_fallback_active(&self) -> bool {
841        self.position > 0
842    }
843
844    /// Count the current provider plus untried chain entries.
845    #[must_use]
846    pub fn remaining(&self) -> usize {
847        self.providers.len() - self.position
848    }
849}
850
851/// On-disk schema for the `[hook_sinks]` table.
852#[derive(Debug, Clone, Serialize, Deserialize, Default)]
853pub struct HookSinksToml {
854    /// Unix domain socket path used by the app-server event sink.
855    ///
856    /// When unset, no Unix socket sink is registered. There is deliberately no
857    /// shared `/tmp` default because socket ownership should be explicit.
858    #[serde(default)]
859    pub unix_socket_path: Option<PathBuf>,
860}
861
862/// On-disk schema for the `[skills]` table (#140). See `config.example.toml`
863/// for documentation.
864#[derive(Debug, Clone, Serialize, Deserialize, Default)]
865pub struct SkillsToml {
866    /// Curated registry index URL. When unset, the TUI falls back to the
867    /// bundled default (community-curated GitHub raw).
868    #[serde(default)]
869    pub registry_url: Option<String>,
870    /// Per-skill maximum *uncompressed* size in bytes. When unset, the TUI
871    /// uses 5 MiB.
872    #[serde(default)]
873    pub max_install_size_bytes: Option<u64>,
874}
875
876/// On-disk schema for the `[tools]` table (#2076).
877#[derive(Debug, Clone, Serialize, Deserialize, Default)]
878pub struct ToolsToml {
879    /// Native tool names to keep loaded outside the default core catalog.
880    #[serde(default)]
881    pub always_load: Vec<String>,
882}
883
884/// On-disk schema for the `[snapshots]` table (#137). See
885/// `config.example.toml` for documentation.
886#[derive(Debug, Clone, Serialize, Deserialize)]
887pub struct SnapshotsToml {
888    #[serde(default = "default_snapshots_enabled")]
889    pub enabled: bool,
890    #[serde(default = "default_snapshot_max_age_days")]
891    pub max_age_days: u64,
892}
893
894fn default_snapshots_enabled() -> bool {
895    true
896}
897
898fn default_snapshot_max_age_days() -> u64 {
899    7
900}
901
902impl Default for SnapshotsToml {
903    fn default() -> Self {
904        Self {
905            enabled: default_snapshots_enabled(),
906            max_age_days: default_snapshot_max_age_days(),
907        }
908    }
909}
910
911/// On-disk schema for the `[fleet]` table (#3165). See `config.example.toml`
912/// and `docs/FLEET.md` for documentation.
913#[derive(Debug, Clone, Serialize, Deserialize)]
914pub struct FleetConfigToml {
915    /// Default trust level for fleet workers. One of `"sandbox"`, `"local"`,
916    /// `"remote-verified"`, or `"operator"`. Defaults to `"sandbox"`.
917    #[serde(default = "default_fleet_trust_level_str")]
918    pub default_trust_level: String,
919    /// Require identity verification for remote (SSH) workers before
920    /// granting them `remote-verified` trust. Defaults to true.
921    #[serde(default = "default_fleet_require_identity")]
922    pub require_identity_verification: bool,
923    /// Maximum trust level any worker may have (`"sandbox"`, `"local"`,
924    /// `"remote-verified"`, or `"operator"`). Defaults to `"operator"`.
925    #[serde(default = "default_fleet_max_trust_level_str")]
926    pub max_trust_level: String,
927    /// User-defined and built-in role presets.
928    ///
929    /// Each role defines default tool profiles, capabilities, budgets, and
930    /// trust settings that task specs can reference by name. Built-in roles
931    /// (`smoke-runner`, `reviewer`, `builder`, `read-only`) are always
932    /// available; user-defined roles in config override or extend them.
933    #[serde(default)]
934    pub roles: BTreeMap<String, FleetRolePreset>,
935    /// Fleet profile vocabulary (#3167). Profiles group role semantics,
936    /// loadout hints, permission defaults, and delegation bounds. They are
937    /// config-only in this slice; executor/model routing wiring lands later.
938    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
939    pub profiles: BTreeMap<String, FleetProfile>,
940    /// Headless worker execution hardening (#3027).
941    #[serde(default)]
942    pub exec: FleetExecConfig,
943}
944
945/// Canonical recursion-depth policy for the headless worker runtime.
946///
947/// Single source of truth shared by BOTH standalone sub-agents and fleet
948/// workers so the two cannot drift into "two moving targets":
949/// - [`DEFAULT_SPAWN_DEPTH`] is the default recursion budget (the sub-agent
950///   runtime's `DEFAULT_MAX_SPAWN_DEPTH` is defined as this value).
951/// - [`MAX_SPAWN_DEPTH_CEILING`] is the opt-in safety cap; every configured
952///   value (fleet `max_spawn_depth`, the `agent` tool's `max_depth`) clamps to it.
953///
954/// A worker runs at `spawn_depth = 0` and may spawn while
955/// `spawn_depth + 1 <= max_spawn_depth`, so a depth of N affords N nested
956/// delegation levels below the root worker. The default of 3 affords at least
957/// three recursion levels out of the box; the root worker still runs at
958/// depth 0 even when the budget is 0.
959pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
960
961/// Hard ceiling on recursion depth for any worker/sub-agent. The default stays
962/// conservative at [`DEFAULT_SPAWN_DEPTH`], while explicit config can opt into
963/// deeper trees for direct-API providers that can tolerate the fanout.
964/// Raising this single constant lifts the limit everywhere (the fleet clamp
965/// and `agent` validation both read it).
966pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
967
968/// Headless worker execution constraints (#3027).
969///
970/// These limits apply to all fleet workers and sub-agents spawned through
971/// the headless worker runtime. Task specs can tighten but not loosen them.
972#[derive(Debug, Clone, Serialize, Deserialize)]
973pub struct FleetExecConfig {
974    /// Tools that are always allowed regardless of role or task spec.
975    #[serde(default, skip_serializing_if = "Vec::is_empty")]
976    pub allowed_tools: Vec<String>,
977    /// Tools that are always disallowed, overriding role and task spec.
978    #[serde(default, skip_serializing_if = "Vec::is_empty")]
979    pub disallowed_tools: Vec<String>,
980    /// Hard ceiling on sub-agent steps (tool calls + model turns).
981    /// Workers that exceed this are terminated. Default: unbounded (u32::MAX).
982    #[serde(default = "default_fleet_max_turns")]
983    pub max_turns: u32,
984    /// Recursive child-agent budget for headless fleet workers.
985    /// Defaults to [`DEFAULT_SPAWN_DEPTH`] (3) so a fleet worker has the SAME
986    /// recursion budget as a standalone sub-agent — fleet and sub-agents are one
987    /// substrate, not two. Set 0 to block child `agent` calls (the root worker
988    /// still runs); the value is clamped to [`MAX_SPAWN_DEPTH_CEILING`].
989    #[serde(default = "default_fleet_max_spawn_depth")]
990    pub max_spawn_depth: u32,
991    /// Extra system prompt text appended to every headless worker.
992    /// Useful for injecting org-wide policy or behavior constraints.
993    #[serde(default, skip_serializing_if = "String::is_empty")]
994    pub append_system_prompt: String,
995    /// Output format for fleet worker results.
996    /// `"text"` (default) or `"stream-json"` for newline-delimited JSON events.
997    #[serde(default = "default_fleet_output_format")]
998    pub output_format: String,
999}
1000
1001fn default_fleet_max_turns() -> u32 {
1002    u32::MAX
1003}
1004
1005fn default_fleet_max_spawn_depth() -> u32 {
1006    DEFAULT_SPAWN_DEPTH
1007}
1008
1009fn default_fleet_output_format() -> String {
1010    "text".to_string()
1011}
1012
1013impl Default for FleetExecConfig {
1014    fn default() -> Self {
1015        Self {
1016            allowed_tools: Vec::new(),
1017            disallowed_tools: Vec::new(),
1018            max_turns: default_fleet_max_turns(),
1019            max_spawn_depth: default_fleet_max_spawn_depth(),
1020            append_system_prompt: String::new(),
1021            output_format: default_fleet_output_format(),
1022        }
1023    }
1024}
1025
1026/// Fleet org-chart profile.
1027///
1028/// A profile is an additive config record for future fleet scheduling policy.
1029/// Loading one must not grant runtime permissions by itself: shell and trust
1030/// escalation default off, and approvals default on.
1031#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1032pub struct FleetProfile {
1033    /// Org-chart slot this profile describes.
1034    #[serde(default)]
1035    pub slot: FleetSlot,
1036    /// Semantic role name and optional instruction overlay.
1037    #[serde(default)]
1038    pub role: FleetRole,
1039    /// Model class / route-role hint. This is data only in this slice.
1040    #[serde(default)]
1041    pub loadout: FleetLoadout,
1042    /// Optional explicit model id for this profile on the active/resolved route.
1043    ///
1044    /// This is not an auth or endpoint selector. Provider-scoped routing still
1045    /// validates the executable provider/model/wire-model decision.
1046    #[serde(default, skip_serializing_if = "Option::is_none")]
1047    pub model: Option<String>,
1048    /// Permission defaults requested by the profile.
1049    #[serde(default)]
1050    pub permissions: FleetProfilePermissions,
1051    /// Delegation hints for future manager policy.
1052    #[serde(default)]
1053    pub delegation: FleetDelegationHints,
1054}
1055
1056/// Semantic role declaration for a fleet profile.
1057///
1058/// TOML may use either `role = "reviewer"` or a role table with `name` and
1059/// `instructions`.
1060#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
1061pub struct FleetRole {
1062    /// Stable role name, e.g. `scout`, `implementer`, or `verifier`.
1063    pub name: String,
1064    /// Optional short description for config UIs and docs.
1065    #[serde(default, skip_serializing_if = "Option::is_none")]
1066    pub description: Option<String>,
1067    /// Optional instruction overlay to apply when the role is later consumed.
1068    #[serde(default, skip_serializing_if = "Option::is_none")]
1069    pub instructions: Option<String>,
1070}
1071
1072impl Default for FleetRole {
1073    fn default() -> Self {
1074        Self {
1075            name: "general".to_string(),
1076            description: None,
1077            instructions: None,
1078        }
1079    }
1080}
1081
1082impl<'de> Deserialize<'de> for FleetRole {
1083    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1084    where
1085        D: serde::Deserializer<'de>,
1086    {
1087        #[derive(Deserialize)]
1088        #[serde(untagged)]
1089        enum FleetRoleWire {
1090            Name(String),
1091            Full {
1092                #[serde(default)]
1093                name: Option<String>,
1094                #[serde(default)]
1095                description: Option<String>,
1096                #[serde(default)]
1097                instructions: Option<String>,
1098            },
1099        }
1100
1101        match FleetRoleWire::deserialize(deserializer)? {
1102            FleetRoleWire::Name(name) => Ok(Self {
1103                name,
1104                ..Self::default()
1105            }),
1106            FleetRoleWire::Full {
1107                name,
1108                description,
1109                instructions,
1110            } => Ok(Self {
1111                name: name.unwrap_or_else(|| Self::default().name),
1112                description,
1113                instructions,
1114            }),
1115        }
1116    }
1117}
1118
1119/// Org-chart slot for grouping fleet profiles.
1120#[derive(Debug, Clone, PartialEq, Eq, Default)]
1121pub enum FleetSlot {
1122    Manager,
1123    Scout,
1124    Implementer,
1125    Reviewer,
1126    Verifier,
1127    ToolHeavy,
1128    Operator,
1129    Summarizer,
1130    #[default]
1131    General,
1132    Custom(String),
1133}
1134
1135impl FleetSlot {
1136    #[must_use]
1137    pub fn as_str(&self) -> &str {
1138        match self {
1139            Self::Manager => "manager",
1140            Self::Scout => "scout",
1141            Self::Implementer => "implementer",
1142            Self::Reviewer => "reviewer",
1143            Self::Verifier => "verifier",
1144            Self::ToolHeavy => "tool-heavy",
1145            Self::Operator => "operator",
1146            Self::Summarizer => "summarizer",
1147            Self::General => "general",
1148            Self::Custom(value) => value.as_str(),
1149        }
1150    }
1151
1152    #[must_use]
1153    pub fn from_name(value: &str) -> Self {
1154        match value.trim() {
1155            "manager" | "coordinator" => Self::Manager,
1156            "scout" | "research" | "research-worker" => Self::Scout,
1157            "implementer" | "builder" => Self::Implementer,
1158            "reviewer" => Self::Reviewer,
1159            "verifier" | "tester" => Self::Verifier,
1160            "tool-heavy" | "tool_heavy" => Self::ToolHeavy,
1161            "operator" | "incident" | "incident-worker" => Self::Operator,
1162            "summarizer" | "reducer" => Self::Summarizer,
1163            "general" | "" => Self::General,
1164            other => Self::Custom(other.to_string()),
1165        }
1166    }
1167}
1168
1169impl Serialize for FleetSlot {
1170    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1171    where
1172        S: serde::Serializer,
1173    {
1174        serializer.serialize_str(self.as_str())
1175    }
1176}
1177
1178impl<'de> Deserialize<'de> for FleetSlot {
1179    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1180    where
1181        D: serde::Deserializer<'de>,
1182    {
1183        let value = String::deserialize(deserializer)?;
1184        Ok(Self::from_name(&value))
1185    }
1186}
1187
1188/// Model class or route-role hint for a profile.
1189#[derive(Debug, Clone, PartialEq, Eq, Default)]
1190pub enum FleetLoadout {
1191    #[default]
1192    Inherit,
1193    Strong,
1194    Fast,
1195    Balanced,
1196    DeepReasoning,
1197    Code,
1198    Review,
1199    ToolHeavy,
1200    Custom(String),
1201}
1202
1203impl FleetLoadout {
1204    #[must_use]
1205    pub fn as_str(&self) -> &str {
1206        match self {
1207            Self::Inherit => "inherit",
1208            Self::Strong => "strong",
1209            Self::Fast => "fast",
1210            Self::Balanced => "balanced",
1211            Self::DeepReasoning => "deep-reasoning",
1212            Self::Code => "code",
1213            Self::Review => "review",
1214            Self::ToolHeavy => "tool-heavy",
1215            Self::Custom(value) => value.as_str(),
1216        }
1217    }
1218
1219    #[must_use]
1220    pub fn from_name(value: &str) -> Self {
1221        match value.trim() {
1222            "inherit" | "default" | "auto" | "" => Self::Inherit,
1223            "strong" => Self::Strong,
1224            "fast" => Self::Fast,
1225            "balanced" => Self::Balanced,
1226            "deep-reasoning" | "deep_reasoning" | "reasoning" => Self::DeepReasoning,
1227            "code" | "coding" => Self::Code,
1228            "review" | "reviewer" => Self::Review,
1229            "tool-heavy" | "tool_heavy" => Self::ToolHeavy,
1230            other => Self::Custom(other.to_string()),
1231        }
1232    }
1233}
1234
1235impl Serialize for FleetLoadout {
1236    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1237    where
1238        S: serde::Serializer,
1239    {
1240        serializer.serialize_str(self.as_str())
1241    }
1242}
1243
1244impl<'de> Deserialize<'de> for FleetLoadout {
1245    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1246    where
1247        D: serde::Deserializer<'de>,
1248    {
1249        let value = String::deserialize(deserializer)?;
1250        Ok(Self::from_name(&value))
1251    }
1252}
1253
1254/// Safe permission defaults attached to a fleet profile.
1255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1256pub struct FleetProfilePermissions {
1257    /// Permit shell-capable tools for this profile when later consumed.
1258    #[serde(default)]
1259    pub allow_shell: bool,
1260    /// Permit trusted/elevated execution for this profile when later consumed.
1261    #[serde(default)]
1262    pub trust: bool,
1263    /// Require approval by default. This intentionally defaults on.
1264    #[serde(default = "default_fleet_profile_approval_required")]
1265    pub approval_required: bool,
1266}
1267
1268fn default_fleet_profile_approval_required() -> bool {
1269    true
1270}
1271
1272impl Default for FleetProfilePermissions {
1273    fn default() -> Self {
1274        Self {
1275            allow_shell: false,
1276            trust: false,
1277            approval_required: true,
1278        }
1279    }
1280}
1281
1282/// Delegation hints for future fleet manager scheduling.
1283#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1284pub struct FleetDelegationHints {
1285    /// Optional profile-level child spawn depth. `None` means inherit existing
1286    /// fleet/sub-agent config.
1287    #[serde(default, skip_serializing_if = "Option::is_none")]
1288    pub max_spawn_depth: Option<u32>,
1289    /// Optional profile-level worker concurrency hint.
1290    #[serde(
1291        default,
1292        alias = "concurrency",
1293        skip_serializing_if = "Option::is_none"
1294    )]
1295    pub max_concurrency: Option<usize>,
1296}
1297
1298/// A named role preset that bundles common worker settings.
1299///
1300/// Task specs reference a role name (e.g. `"role": "reviewer"`), and the
1301/// fleet manager fills in any missing fields from the preset. User-defined
1302/// roles in `[fleet.roles]` override built-in defaults with the same name.
1303///
1304/// Token budgets and tool-call limits are task-level decisions — they don't
1305/// belong on role presets. Use `timeout_seconds` as the safety bound.
1306#[derive(Debug, Clone, Serialize, Deserialize)]
1307pub struct FleetRolePreset {
1308    /// Short description of what this role is for.
1309    #[serde(skip_serializing_if = "Option::is_none")]
1310    pub description: Option<String>,
1311    /// Default tool profile (`"read-only"`, `"read-write"`, or `"custom"`).
1312    #[serde(skip_serializing_if = "Option::is_none")]
1313    pub tool_profile: Option<String>,
1314    /// Default set of tool names available to this role.
1315    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1316    pub tools: Vec<String>,
1317    /// Default capability tags (e.g. `"rust"`, `"git"`, `"gh"`).
1318    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1319    pub capabilities: Vec<String>,
1320    /// Default timeout in seconds for tasks using this role.
1321    #[serde(skip_serializing_if = "Option::is_none")]
1322    pub timeout_seconds: Option<u64>,
1323    /// Default trust level override for this role.
1324    #[serde(skip_serializing_if = "Option::is_none")]
1325    pub trust_level: Option<String>,
1326}
1327
1328fn default_fleet_trust_level_str() -> String {
1329    "sandbox".to_string()
1330}
1331
1332fn default_fleet_require_identity() -> bool {
1333    true
1334}
1335
1336fn default_fleet_max_trust_level_str() -> String {
1337    "operator".to_string()
1338}
1339
1340impl Default for FleetConfigToml {
1341    fn default() -> Self {
1342        Self {
1343            default_trust_level: default_fleet_trust_level_str(),
1344            require_identity_verification: default_fleet_require_identity(),
1345            max_trust_level: default_fleet_max_trust_level_str(),
1346            roles: BTreeMap::new(),
1347            profiles: BTreeMap::new(),
1348            exec: FleetExecConfig::default(),
1349        }
1350    }
1351}
1352
1353impl FleetConfigToml {
1354    /// Resolve a role preset by name. Checks user-defined roles first,
1355    /// then falls back to built-in role defaults.
1356    #[must_use]
1357    pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
1358        self.roles
1359            .get(name)
1360            .cloned()
1361            .or_else(|| built_in_role_presets().get(name).cloned())
1362    }
1363}
1364
1365/// Built-in role presets that are always available without config.
1366#[must_use]
1367pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
1368    [
1369        (
1370            "smoke-runner".to_string(),
1371            FleetRolePreset {
1372                description: Some("Lightweight read-only smoke check worker".to_string()),
1373                tool_profile: Some("read-only".to_string()),
1374                tools: vec![],
1375                capabilities: vec![],
1376                timeout_seconds: Some(300),
1377                trust_level: Some("local".to_string()),
1378            },
1379        ),
1380        (
1381            "reviewer".to_string(),
1382            FleetRolePreset {
1383                description: Some("Read-only code and documentation review".to_string()),
1384                tool_profile: Some("read-only".to_string()),
1385                tools: vec![],
1386                capabilities: vec![],
1387                timeout_seconds: Some(600),
1388                trust_level: None,
1389            },
1390        ),
1391        (
1392            "builder".to_string(),
1393            FleetRolePreset {
1394                description: Some(
1395                    "Read-write builder with compilation and test access".to_string(),
1396                ),
1397                tool_profile: Some("read-write".to_string()),
1398                tools: vec![],
1399                capabilities: vec![],
1400                timeout_seconds: Some(1800),
1401                trust_level: Some("local".to_string()),
1402            },
1403        ),
1404        (
1405            "read-only".to_string(),
1406            FleetRolePreset {
1407                description: Some(
1408                    "Minimal read-only observer with no writes or secrets".to_string(),
1409                ),
1410                tool_profile: Some("read-only".to_string()),
1411                tools: vec![],
1412                capabilities: vec![],
1413                timeout_seconds: Some(300),
1414                trust_level: Some("sandbox".to_string()),
1415            },
1416        ),
1417    ]
1418    .into()
1419}
1420
1421/// On-disk schema for the `[network]` table (#135). See `config.example.toml`
1422/// for documentation.
1423#[derive(Debug, Clone, Serialize, Deserialize)]
1424pub struct NetworkPolicyToml {
1425    /// Decision for hosts that are not in `allow` or `deny`. One of
1426    /// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`.
1427    #[serde(default = "default_network_decision")]
1428    pub default: String,
1429    /// Hosts that are always allowed. Subdomain rules: a leading dot
1430    /// (`.example.com`) matches subdomains but not the apex.
1431    #[serde(default)]
1432    pub allow: Vec<String>,
1433    /// Hosts that are always denied. Deny entries win over allow entries.
1434    #[serde(default)]
1435    pub deny: Vec<String>,
1436    /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an
1437    /// explicitly trusted proxy setup. Literal IP URLs remain blocked.
1438    #[serde(default)]
1439    pub proxy: Vec<String>,
1440    /// Whether to record one audit-log line per outbound network call.
1441    #[serde(default = "default_network_audit")]
1442    pub audit: bool,
1443}
1444
1445fn default_network_decision() -> String {
1446    "prompt".to_string()
1447}
1448
1449fn default_network_audit() -> bool {
1450    true
1451}
1452
1453impl Default for NetworkPolicyToml {
1454    fn default() -> Self {
1455        Self {
1456            default: default_network_decision(),
1457            allow: Vec::new(),
1458            deny: Vec::new(),
1459            proxy: Vec::new(),
1460            audit: default_network_audit(),
1461        }
1462    }
1463}
1464
1465/// On-disk schema for the `[lsp]` table (#136). See `config.example.toml`
1466/// for documentation. All fields are optional so the TUI runtime can fall
1467/// back to its own defaults when keys are absent.
1468#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1469pub struct LspConfigToml {
1470    /// Master switch.
1471    pub enabled: Option<bool>,
1472    /// Maximum time to wait for diagnostics after an edit, in milliseconds.
1473    pub poll_after_edit_ms: Option<u64>,
1474    /// Cap on diagnostics surfaced per file.
1475    pub max_diagnostics_per_file: Option<usize>,
1476    /// When `true`, warnings (severity 2) are surfaced in addition to errors.
1477    pub include_warnings: Option<bool>,
1478    /// Optional override for the `language -> [cmd, ...args]` table.
1479    pub servers: Option<BTreeMap<String, Vec<String>>>,
1480}
1481
1482impl ConfigToml {
1483    /// Merge safe project-level overrides from `$WORKSPACE/.codewhale/config.toml`
1484    /// or legacy `$WORKSPACE/.deepseek/config.toml`.
1485    ///
1486    /// Repo-local config is untrusted input. This helper intentionally ignores
1487    /// credentials, endpoints, provider selection, auth/session values, telemetry,
1488    /// network policy, skill registry, LSP command tables, and unknown extras.
1489    /// Approval and sandbox values may only tighten the existing user/global
1490    /// posture.
1491    pub fn merge_project_overrides(&mut self, project: ConfigToml) {
1492        if project.default_text_model.is_some() {
1493            self.default_text_model = project.default_text_model;
1494        }
1495        if project.model.is_some() {
1496            self.model = project.model;
1497        }
1498        if project.output_mode.is_some() {
1499            self.output_mode = project.output_mode;
1500        }
1501        if project.verbosity.is_some() {
1502            self.verbosity = project.verbosity;
1503        }
1504        if project.log_level.is_some() {
1505            self.log_level = project.log_level;
1506        }
1507        if let Some(policy) = project.approval_policy
1508            && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
1509        {
1510            self.approval_policy = Some(policy);
1511        }
1512        if let Some(mode) = project.sandbox_mode
1513            && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
1514        {
1515            self.sandbox_mode = Some(mode);
1516        }
1517        if project.tools.is_some() {
1518            self.tools = project.tools;
1519        }
1520        for provider in ProviderKind::ALL {
1521            merge_project_provider_config(
1522                self.providers.for_provider_mut(provider),
1523                project.providers.for_provider(provider),
1524            );
1525        }
1526    }
1527
1528    #[must_use]
1529    pub fn get_value(&self, key: &str) -> Option<String> {
1530        if let Some((provider, field)) = parse_provider_config_key(key) {
1531            return get_provider_config_value(self.providers.for_provider(provider), field);
1532        }
1533
1534        match key {
1535            "provider" => Some(self.provider.as_str().to_string()),
1536            "api_key" => self.api_key.clone(),
1537            "base_url" => self.base_url.clone(),
1538            "http_headers" => serialize_http_headers(&self.http_headers),
1539            "default_text_model" => self.default_text_model.clone(),
1540            "model" => self.model.clone(),
1541            "auth.mode" => self.auth_mode.clone(),
1542            "output_mode" => self.output_mode.clone(),
1543            "verbosity" => self.verbosity.clone(),
1544            "log_level" => self.log_level.clone(),
1545            "telemetry" => self.telemetry.map(|v| v.to_string()),
1546            "approval_policy" => self.approval_policy.clone(),
1547            "sandbox_mode" => self.sandbox_mode.clone(),
1548            "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
1549            "hook_sinks.unix_socket_path" => self
1550                .hook_sinks
1551                .as_ref()
1552                .and_then(|sinks| sinks.unix_socket_path.as_ref())
1553                .map(|path| path.display().to_string()),
1554            _ => self.extras.get(key).map(toml::Value::to_string),
1555        }
1556    }
1557
1558    #[must_use]
1559    pub fn get_display_value(&self, key: &str) -> Option<String> {
1560        if let Some((provider, field)) = parse_provider_config_key(key) {
1561            return get_provider_config_display_value(self.providers.for_provider(provider), field);
1562        }
1563
1564        if key == "http_headers" {
1565            return serialize_http_headers_for_display(&self.http_headers);
1566        }
1567
1568        if let Some(value) = self.extras.get(key) {
1569            return Some(redact_toml_value_for_display(key, value));
1570        }
1571
1572        self.get_value(key).map(|value| {
1573            if is_sensitive_config_key(key) {
1574                redact_secret(&value)
1575            } else {
1576                value
1577            }
1578        })
1579    }
1580
1581    pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
1582        if let Some((provider, field)) = parse_provider_config_key(key) {
1583            return set_provider_config_value(self, provider, field, value);
1584        }
1585
1586        match key {
1587            "provider" => {
1588                self.provider = ProviderKind::parse(value).with_context(|| {
1589                    format!(
1590                        "unknown provider '{value}': expected {}",
1591                        ProviderKind::names_hint()
1592                    )
1593                })?;
1594            }
1595            "api_key" => self.api_key = Some(value.to_string()),
1596            "base_url" => self.base_url = Some(value.to_string()),
1597            "http_headers" => self.http_headers = parse_http_headers(value)?,
1598            "default_text_model" => self.default_text_model = Some(value.to_string()),
1599            "model" => self.model = Some(value.to_string()),
1600            "auth.mode" => self.auth_mode = Some(value.to_string()),
1601            "output_mode" => self.output_mode = Some(value.to_string()),
1602            "verbosity" => self.verbosity = Some(value.to_string()),
1603            "log_level" => self.log_level = Some(value.to_string()),
1604            "telemetry" => {
1605                self.telemetry = Some(parse_bool(value)?);
1606            }
1607            "approval_policy" => self.approval_policy = Some(value.to_string()),
1608            "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
1609            "hook_sinks.unix_socket_path" => {
1610                self.hook_sinks
1611                    .get_or_insert_with(HookSinksToml::default)
1612                    .unix_socket_path = Some(PathBuf::from(value));
1613            }
1614            _ => {
1615                self.extras
1616                    .insert(key.to_string(), toml::Value::String(value.to_string()));
1617            }
1618        }
1619        Ok(())
1620    }
1621
1622    pub fn unset_value(&mut self, key: &str) -> Result<()> {
1623        if let Some((provider, field)) = parse_provider_config_key(key) {
1624            unset_provider_config_value(self, provider, field);
1625            return Ok(());
1626        }
1627
1628        match key {
1629            "provider" => self.provider = ProviderKind::Deepseek,
1630            "api_key" => self.api_key = None,
1631            "base_url" => self.base_url = None,
1632            "http_headers" => self.http_headers.clear(),
1633            "default_text_model" => self.default_text_model = None,
1634            "model" => self.model = None,
1635            "auth.mode" => self.auth_mode = None,
1636            "output_mode" => self.output_mode = None,
1637            "verbosity" => self.verbosity = None,
1638            "log_level" => self.log_level = None,
1639            "telemetry" => self.telemetry = None,
1640            "approval_policy" => self.approval_policy = None,
1641            "sandbox_mode" => self.sandbox_mode = None,
1642            "hook_sinks.unix_socket_path" => {
1643                if let Some(sinks) = self.hook_sinks.as_mut() {
1644                    sinks.unix_socket_path = None;
1645                }
1646            }
1647            _ => {
1648                self.extras.remove(key);
1649            }
1650        }
1651        Ok(())
1652    }
1653
1654    #[must_use]
1655    pub fn list_values(&self) -> BTreeMap<String, String> {
1656        let mut out = BTreeMap::new();
1657        out.insert("provider".to_string(), self.provider.as_str().to_string());
1658
1659        if let Some(v) = self.api_key.as_ref() {
1660            out.insert("api_key".to_string(), redact_secret(v));
1661        }
1662        if let Some(v) = self.base_url.as_ref() {
1663            out.insert("base_url".to_string(), v.clone());
1664        }
1665        if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
1666            out.insert("http_headers".to_string(), v);
1667        }
1668        if let Some(v) = self.default_text_model.as_ref() {
1669            out.insert("default_text_model".to_string(), v.clone());
1670        }
1671        if let Some(v) = self.model.as_ref() {
1672            out.insert("model".to_string(), v.clone());
1673        }
1674        if let Some(v) = self.auth_mode.as_ref() {
1675            out.insert("auth.mode".to_string(), v.clone());
1676        }
1677        if let Some(v) = self.output_mode.as_ref() {
1678            out.insert("output_mode".to_string(), v.clone());
1679        }
1680        if let Some(v) = self.verbosity.as_ref() {
1681            out.insert("verbosity".to_string(), v.clone());
1682        }
1683        if let Some(v) = self.log_level.as_ref() {
1684            out.insert("log_level".to_string(), v.clone());
1685        }
1686        if let Some(v) = self.telemetry {
1687            out.insert("telemetry".to_string(), v.to_string());
1688        }
1689        if let Some(v) = self.approval_policy.as_ref() {
1690            out.insert("approval_policy".to_string(), v.clone());
1691        }
1692        if let Some(v) = self.sandbox_mode.as_ref() {
1693            out.insert("sandbox_mode".to_string(), v.clone());
1694        }
1695        if let Some(v) = self
1696            .hook_sinks
1697            .as_ref()
1698            .and_then(|sinks| sinks.unix_socket_path.as_ref())
1699        {
1700            out.insert(
1701                "hook_sinks.unix_socket_path".to_string(),
1702                v.display().to_string(),
1703            );
1704        }
1705
1706        for provider in ProviderKind::ALL {
1707            insert_provider_config_values(
1708                &mut out,
1709                provider,
1710                self.providers.for_provider(provider),
1711            );
1712        }
1713
1714        for (k, v) in &self.extras {
1715            out.insert(k.clone(), redact_toml_value_for_display(k, v));
1716        }
1717        out
1718    }
1719
1720    /// Resolve runtime options without touching platform credential stores.
1721    ///
1722    /// This method keeps library callers prompt-free: CLI flag → config file
1723    /// → environment. Call `resolve_runtime_options_with_secrets` when a
1724    /// user-facing dispatcher should recover credentials from the configured
1725    /// secret store.
1726    #[must_use]
1727    pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
1728        let no_keyring = Secrets::new(std::sync::Arc::new(
1729            codewhale_secrets::InMemoryKeyringStore::new(),
1730        ));
1731        self.resolve_runtime_options_with_secrets(cli, &no_keyring)
1732    }
1733
1734    /// Resolve runtime options using an explicit secrets façade.
1735    ///
1736    /// API-key precedence is **CLI flag → config-file → secret store → environment**.
1737    #[must_use]
1738    pub fn resolve_runtime_options_with_secrets(
1739        &self,
1740        cli: &CliRuntimeOverrides,
1741        secrets: &Secrets,
1742    ) -> ResolvedRuntimeOptions {
1743        let env = EnvRuntimeOverrides::load();
1744        let (provider, provider_source) = if let Some(provider) = cli.provider {
1745            (provider, ProviderSource::Cli)
1746        } else if let Some(provider) = env.provider {
1747            (
1748                provider,
1749                ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
1750            )
1751        } else {
1752            (self.provider, ProviderSource::Config)
1753        };
1754
1755        let mut provider_cfg = self.providers.for_provider(provider).clone();
1756        if provider == ProviderKind::SiliconflowCN {
1757            let fb = &self.providers.siliconflow;
1758            if provider_cfg.api_key.is_none() {
1759                provider_cfg.api_key = fb.api_key.clone();
1760            }
1761            if provider_cfg.base_url.is_none() {
1762                provider_cfg.base_url = fb.base_url.clone();
1763            }
1764            if provider_cfg.model.is_none() {
1765                provider_cfg.model = fb.model.clone();
1766            }
1767        }
1768        let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
1769            .then(|| self.api_key.clone())
1770            .flatten();
1771        let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
1772            .then(|| self.base_url.clone())
1773            .flatten();
1774        let root_deepseek_model = (provider == ProviderKind::Deepseek)
1775            .then(|| self.default_text_model.clone())
1776            .flatten();
1777        let auth_mode = cli
1778            .auth_mode
1779            .clone()
1780            .or_else(|| env.auth_mode.clone())
1781            .or_else(|| provider_cfg.auth_mode.clone())
1782            .or_else(|| self.auth_mode.clone());
1783        let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
1784        let configured_base_url = cli
1785            .base_url
1786            .clone()
1787            .or_else(|| env.base_url_for(provider))
1788            .or_else(|| provider_cfg.base_url.clone())
1789            .or(root_deepseek_base_url);
1790        let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
1791            env.xiaomi_mimo_mode
1792                .clone()
1793                .or_else(|| provider_cfg.mode.clone())
1794        } else {
1795            None
1796        };
1797        let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
1798            xiaomi_mimo_env_api_key_for_runtime(
1799                xiaomi_mimo_mode.as_deref(),
1800                configured_base_url.as_deref(),
1801            )
1802        } else {
1803            None
1804        };
1805        let explicit_api_key_for_endpoint = cli
1806            .api_key
1807            .as_deref()
1808            .or(from_file.as_deref())
1809            .or(xiaomi_mimo_env_api_key.as_deref());
1810        let base_url = if provider == ProviderKind::XiaomiMimo {
1811            resolve_xiaomi_mimo_base_url(
1812                configured_base_url,
1813                explicit_api_key_for_endpoint,
1814                xiaomi_mimo_mode.as_deref(),
1815            )
1816        } else {
1817            configured_base_url.unwrap_or_else(|| match provider {
1818                ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
1819                ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string(),
1820                ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
1821                ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
1822                ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
1823                ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
1824                ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
1825                ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
1826                ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
1827                ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
1828                ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
1829                ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
1830                ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
1831                ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
1832                ProviderKind::Moonshot => {
1833                    if auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) {
1834                        DEFAULT_KIMI_CODE_BASE_URL.to_string()
1835                    } else {
1836                        DEFAULT_MOONSHOT_BASE_URL.to_string()
1837                    }
1838                }
1839                ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
1840                ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
1841                ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
1842                ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
1843                ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
1844                ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL.to_string(),
1845                ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
1846                ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
1847                ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
1848                ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
1849                ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
1850                ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
1851                // The custom provider has no built-in endpoint; fall back to its
1852                // descriptor placeholder so the lookup is total. Real custom
1853                // routes always supply a configured base_url before this point.
1854                ProviderKind::Custom => provider.provider().default_base_url().to_string(),
1855            })
1856        };
1857        // CLI flag wins outright. Otherwise: config-file → injected secrets/env.
1858        // This makes `deepseek auth set` a reliable fix even when the user's
1859        // shell still exports an old key. When the file is empty, the injected
1860        // secrets façade recovers configured secret-store credentials before
1861        // falling back to ambient env.
1862        let uses_kimi_oauth = provider == ProviderKind::Moonshot
1863            && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth);
1864        let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() {
1865            (Some(value), Some(RuntimeApiKeySource::Cli))
1866        } else if uses_kimi_oauth {
1867            (None, None)
1868        } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) {
1869            (Some(value), Some(RuntimeApiKeySource::ConfigFile))
1870        } else if let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty()) {
1871            (Some(value), Some(RuntimeApiKeySource::Env))
1872        } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
1873            match env_api_key_for_provider(provider) {
1874                Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
1875                None => (None, None),
1876            }
1877        } else {
1878            match secrets.resolve_with_source(provider.as_str()) {
1879                Some((value, source)) => {
1880                    let source = match source {
1881                        SecretSource::Keyring => RuntimeApiKeySource::Keyring,
1882                        SecretSource::Env => RuntimeApiKeySource::Env,
1883                    };
1884                    (Some(value), Some(source))
1885                }
1886                None => match env_api_key_for_provider(provider) {
1887                    Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
1888                    None => (None, None),
1889                },
1890            }
1891        };
1892
1893        let env_provider_model = env.model_for(provider, &base_url);
1894        let explicit_model = cli.model.is_some()
1895            || env.model.is_some()
1896            || env_provider_model.is_some()
1897            || provider_cfg.model.is_some()
1898            || root_deepseek_model.is_some()
1899            || self.model.is_some();
1900        let model = cli
1901            .model
1902            .clone()
1903            .or_else(|| env.model.clone())
1904            .or(env_provider_model)
1905            .or_else(|| provider_cfg.model.clone())
1906            .or(root_deepseek_model)
1907            .or_else(|| self.model.clone())
1908            .unwrap_or_else(|| {
1909                if provider == ProviderKind::Moonshot
1910                    && (auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth)
1911                        || moonshot_base_url_uses_kimi_code(&base_url))
1912                {
1913                    DEFAULT_KIMI_CODE_MODEL.to_string()
1914                } else {
1915                    default_model_for_provider(provider).to_string()
1916                }
1917            });
1918        let model =
1919            if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
1920                model.trim().to_string()
1921            } else {
1922                normalize_model_for_provider(provider, &model)
1923            };
1924
1925        let mut http_headers = self.http_headers.clone();
1926        http_headers.extend(provider_cfg.http_headers.clone());
1927        if let Some(env_headers) = env.http_headers {
1928            http_headers.extend(env_headers);
1929        }
1930        http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
1931
1932        let output_mode = cli
1933            .output_mode
1934            .clone()
1935            .or_else(|| env.output_mode.clone())
1936            .or_else(|| self.output_mode.clone());
1937        let log_level = cli
1938            .log_level
1939            .clone()
1940            .or_else(|| env.log_level.clone())
1941            .or_else(|| self.log_level.clone());
1942        let telemetry = cli
1943            .telemetry
1944            .or(env.telemetry)
1945            .or(self.telemetry)
1946            .unwrap_or(false);
1947        let approval_policy = cli
1948            .approval_policy
1949            .clone()
1950            .or_else(|| env.approval_policy.clone())
1951            .or_else(|| self.approval_policy.clone());
1952        let sandbox_mode = cli
1953            .sandbox_mode
1954            .clone()
1955            .or_else(|| env.sandbox_mode.clone())
1956            .or_else(|| self.sandbox_mode.clone());
1957        let yolo = cli.yolo.or(env.yolo);
1958        let verbosity = cli
1959            .verbosity
1960            .clone()
1961            .or_else(|| env.verbosity.clone())
1962            .or_else(|| self.verbosity.clone());
1963
1964        ResolvedRuntimeOptions {
1965            provider,
1966            provider_source,
1967            model,
1968            api_key,
1969            api_key_source,
1970            base_url,
1971            auth_mode,
1972            insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
1973            output_mode,
1974            log_level,
1975            telemetry,
1976            approval_policy,
1977            sandbox_mode,
1978            yolo,
1979            verbosity,
1980            http_headers,
1981        }
1982    }
1983}
1984
1985fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
1986    if source.model.is_some() {
1987        target.model = source.model.clone();
1988    }
1989}
1990
1991#[must_use]
1992pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
1993    let Some(project_rank) = approval_policy_rank(project) else {
1994        return false;
1995    };
1996    match current.and_then(approval_policy_rank) {
1997        Some(current_rank) => project_rank >= current_rank,
1998        None => project_rank >= 2,
1999    }
2000}
2001
2002#[must_use]
2003pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
2004    let normalized_project = project.trim().to_ascii_lowercase();
2005    if normalized_project == "external-sandbox" {
2006        return current
2007            .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
2008            .unwrap_or(false);
2009    }
2010
2011    let Some(project_rank) = sandbox_mode_rank(project) else {
2012        return false;
2013    };
2014    match current.and_then(sandbox_mode_rank) {
2015        Some(current_rank) => project_rank >= current_rank,
2016        None => project_rank >= 2,
2017    }
2018}
2019
2020fn approval_policy_rank(value: &str) -> Option<u8> {
2021    match value.trim().to_ascii_lowercase().as_str() {
2022        "auto" => Some(0),
2023        "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
2024        "never" | "deny" | "denied" => Some(2),
2025        _ => None,
2026    }
2027}
2028
2029fn sandbox_mode_rank(value: &str) -> Option<u8> {
2030    match value.trim().to_ascii_lowercase().as_str() {
2031        "danger-full-access" => Some(0),
2032        "external-sandbox" => Some(0),
2033        "workspace-write" => Some(1),
2034        "read-only" => Some(2),
2035        _ => None,
2036    }
2037}
2038
2039/// Load a project-level config from the workspace.
2040///
2041/// Checks `$WORKSPACE/.codewhale/config.toml` first, falling back to
2042/// `$WORKSPACE/.deepseek/config.toml` for backward compatibility.
2043/// Returns `None` if neither file exists or can't be parsed.
2044pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
2045    for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
2046        let path = workspace.join(dir).join(CONFIG_FILE_NAME);
2047        if !project_config_candidate_exists(&path) {
2048            continue;
2049        }
2050        let raw = match read_checked_config_file(&path) {
2051            Ok(raw) => raw,
2052            Err(e) => {
2053                tracing::warn!("Failed to read project config {}: {e:#}", path.display());
2054                return None;
2055            }
2056        };
2057        match toml::from_str(&raw) {
2058            Ok(config) => return Some(config),
2059            Err(e) => {
2060                tracing::warn!("Failed to parse project config {}: {e}", path.display());
2061                return None;
2062            }
2063        }
2064    }
2065    None
2066}
2067
2068fn project_config_candidate_exists(path: &Path) -> bool {
2069    fs::symlink_metadata(path).is_ok_and(|metadata| {
2070        let file_type = metadata.file_type();
2071        file_type.is_file() || file_type.is_symlink()
2072    })
2073}
2074
2075fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
2076    if matches!(provider, ProviderKind::XiaomiMimo)
2077        && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
2078    {
2079        return canonical.to_string();
2080    }
2081    if matches!(provider, ProviderKind::Minimax)
2082        && let Some(canonical) = canonical_minimax_model_id(model)
2083    {
2084        return canonical.to_string();
2085    }
2086    if matches!(provider, ProviderKind::Zai)
2087        && let Some(canonical) = canonical_zai_model_id(model)
2088    {
2089        return canonical.to_string();
2090    }
2091
2092    if matches!(
2093        provider,
2094        ProviderKind::Atlascloud
2095            | ProviderKind::WanjieArk
2096            | ProviderKind::Volcengine
2097            | ProviderKind::XiaomiMimo
2098            | ProviderKind::Zai
2099            | ProviderKind::Stepfun
2100            | ProviderKind::Minimax
2101            | ProviderKind::Qianfan
2102            | ProviderKind::Ollama
2103    ) {
2104        return model.to_string();
2105    }
2106
2107    let normalized = model.trim().to_ascii_lowercase();
2108    if provider == ProviderKind::Openrouter
2109        && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
2110    {
2111        return canonical.to_string();
2112    }
2113    match (provider, normalized.as_str()) {
2114        (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
2115            DEFAULT_NVIDIA_NIM_MODEL.to_string()
2116        }
2117        (
2118            ProviderKind::NvidiaNim,
2119            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2120            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2121        ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
2122        (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
2123            DEFAULT_OPENROUTER_MODEL.to_string()
2124        }
2125        (
2126            ProviderKind::Openrouter,
2127            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2128            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2129        ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
2130        (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
2131            DEFAULT_NOVITA_MODEL.to_string()
2132        }
2133        (
2134            ProviderKind::Novita,
2135            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2136            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2137        ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
2138        (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
2139            DEFAULT_FIREWORKS_MODEL.to_string()
2140        }
2141        (
2142            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2143            "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
2144        ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
2145        (
2146            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2147            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
2148        ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
2149        (
2150            ProviderKind::Arcee,
2151            "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
2152        ) => DEFAULT_ARCEE_MODEL.to_string(),
2153        (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
2154            ARCEE_TRINITY_MINI_MODEL.to_string()
2155        }
2156        (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
2157            ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
2158        }
2159        (
2160            ProviderKind::Moonshot,
2161            "kimi"
2162            | "kimi-k2"
2163            | "kimi-k2.7"
2164            | "kimi-k2-7"
2165            | "kimi-k2.7-code"
2166            | "kimi-k2-7-code"
2167            | "kimi-code"
2168            | "moonshot-kimi-k2.7-code",
2169        ) => DEFAULT_MOONSHOT_MODEL.to_string(),
2170        (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
2171            MOONSHOT_KIMI_K2_6_MODEL.to_string()
2172        }
2173        (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
2174            DEFAULT_SGLANG_MODEL.to_string()
2175        }
2176        (
2177            ProviderKind::Sglang,
2178            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2179            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2180        ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
2181        (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
2182            DEFAULT_VLLM_MODEL.to_string()
2183        }
2184        (
2185            ProviderKind::Vllm,
2186            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2187            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2188        ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
2189        (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
2190            DEFAULT_HUGGINGFACE_MODEL.to_string()
2191        }
2192        (
2193            ProviderKind::Huggingface,
2194            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2195            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2196        ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
2197        (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
2198            DEFAULT_TOGETHER_MODEL.to_string()
2199        }
2200        (
2201            ProviderKind::Together,
2202            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2203            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2204        ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
2205        (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
2206            DEFAULT_DEEPINFRA_MODEL.to_string()
2207        }
2208        (
2209            ProviderKind::Deepinfra,
2210            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2211            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2212        ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
2213        _ => model.to_string(),
2214    }
2215}
2216
2217fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
2218    let normalized = model.trim().to_ascii_lowercase();
2219    let normalized = normalized.replace(['_', ' '], "-");
2220    match normalized.as_str() {
2221        "mimo"
2222        | DEFAULT_XIAOMI_MIMO_MODEL
2223        | "mimo-v2-5-pro"
2224        | "xiaomi-mimo-v2.5-pro"
2225        | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
2226        XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL
2227        | "mimo-v2-5-pro-ultraspeed"
2228        | "xiaomi-mimo-v2.5-pro-ultraspeed"
2229        | "xiaomi-mimo-v2-5-pro-ultraspeed"
2230        | "ultraspeed"
2231        | "pro-ultraspeed" => Some(XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL),
2232        "omni"
2233        | "mimo-omni"
2234        | "v2.5-omni"
2235        | "v25-omni"
2236        | "mimo-v2.5"
2237        | "mimo-v25"
2238        | "mimo-v2-5"
2239        | "mimo-v2.5-omni"
2240        | "mimo-v25-omni"
2241        | "mimo-v2-5-omni"
2242        | "xiaomi-mimo-v2.5"
2243        | "xiaomi-mimo-v2-5"
2244        | "xiaomi-mimo-v2.5-omni"
2245        | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
2246        "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
2247            Some(XIAOMI_MIMO_ASR_MODEL)
2248        }
2249        "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
2250            Some(XIAOMI_MIMO_TTS_MODEL)
2251        }
2252        "mimo-tts-voicedesign"
2253        | "mimo-voice-design"
2254        | "mimo-v25-tts-voicedesign"
2255        | "mimo-v2.5-tts-voicedesign"
2256        | "voicedesign"
2257        | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
2258        "mimo-tts-voiceclone"
2259        | "mimo-voice-clone"
2260        | "mimo-v25-tts-voiceclone"
2261        | "mimo-v2.5-tts-voiceclone"
2262        | "voiceclone"
2263        | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
2264        "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
2265        _ => None,
2266    }
2267}
2268
2269fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
2270    let normalized = model.trim().to_ascii_lowercase();
2271    let normalized = normalized.replace(['_', ' '], "-");
2272    match normalized.as_str() {
2273        "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
2274            Some(DEFAULT_MINIMAX_MODEL)
2275        }
2276        "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
2277            Some(MINIMAX_M2_7_MODEL)
2278        }
2279        "minimax-m2.7-highspeed"
2280        | "minimax-m2-7-highspeed"
2281        | "minimax-m-2.7-highspeed"
2282        | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
2283        "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
2284            Some(MINIMAX_M2_5_MODEL)
2285        }
2286        "minimax-m2.5-highspeed"
2287        | "minimax-m2-5-highspeed"
2288        | "minimax-m-2.5-highspeed"
2289        | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
2290        "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
2291            Some(MINIMAX_M2_1_MODEL)
2292        }
2293        "minimax-m2.1-highspeed"
2294        | "minimax-m2-1-highspeed"
2295        | "minimax-m-2.1-highspeed"
2296        | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
2297        "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
2298        _ => None,
2299    }
2300}
2301
2302fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
2303    let normalized = model.trim().to_ascii_lowercase();
2304    let normalized = normalized.replace(['_', ' '], "-");
2305    match normalized.as_str() {
2306        "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
2307        "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(DEFAULT_ZAI_MODEL),
2308        "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
2309        _ => None,
2310    }
2311}
2312
2313fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
2314    let normalized = model.trim().to_ascii_lowercase();
2315    let normalized = normalized.replace(['_', ' '], "-");
2316    match normalized.as_str() {
2317        OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
2318        | "trinity"
2319        | "trinity-large-thinking"
2320        | "arcee-trinity"
2321        | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
2322        OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
2323            Some(OPENROUTER_GEMMA_4_31B_MODEL)
2324        }
2325        OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
2326            Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
2327        }
2328        OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
2329            Some(OPENROUTER_GLM_5_1_MODEL)
2330        }
2331        OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
2332            Some(OPENROUTER_GLM_5_2_MODEL)
2333        }
2334        OPENROUTER_KIMI_K2_7_CODE_MODEL
2335        | "kimi"
2336        | "kimi-k2"
2337        | "kimi-k2.7"
2338        | "kimi-k2-7"
2339        | "kimi-k2.7-code"
2340        | "kimi-k2-7-code"
2341        | "kimi-code"
2342        | "moonshot-kimi-k2.7-code"
2343        | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
2344        OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
2345            Some(OPENROUTER_KIMI_K2_6_MODEL)
2346        }
2347        OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
2348            Some(OPENROUTER_MINIMAX_M3_MODEL)
2349        }
2350        OPENROUTER_MINIMAX_M2_7_MODEL
2351        | "minimax-2.7"
2352        | "minimax-2-7"
2353        | "minimax-m2.7"
2354        | "minimax-m2-7"
2355        | "minimax-m-2.7"
2356        | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_M2_7_MODEL),
2357        OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
2358        | "nemotron-3-nano-omni"
2359        | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
2360        OPENROUTER_QWEN_3_6_35B_A3B_MODEL
2361        | "qwen3.6-35b-a3b"
2362        | "qwen-3.6-35b-a3b"
2363        | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
2364        OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
2365            Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
2366        }
2367        OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
2368        | "qwen3.6-max-preview"
2369        | "qwen-3.6-max-preview"
2370        | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
2371        OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
2372            Some(OPENROUTER_QWEN_3_6_27B_MODEL)
2373        }
2374        OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
2375            Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
2376        }
2377        OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
2378            Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
2379        }
2380        OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
2381            Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
2382        }
2383        OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
2384        | "mimo-v2.5-pro"
2385        | "mimo-v2-5-pro"
2386        | "xiaomi-mimo-v2.5-pro"
2387        | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
2388        OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
2389        | "mimo-v2.5"
2390        | "mimo-v2-5"
2391        | "xiaomi-mimo-v2.5"
2392        | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
2393        _ => None,
2394    }
2395}
2396
2397fn default_model_for_provider(provider: ProviderKind) -> &'static str {
2398    match provider {
2399        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
2400        ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
2401        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
2402        ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
2403        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
2404        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
2405        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
2406        ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
2407        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
2408        ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
2409        ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
2410        ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
2411        ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
2412        ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
2413        ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
2414        ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
2415        ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
2416        ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
2417        ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
2418        ProviderKind::Qianfan => DEFAULT_QIANFAN_MODEL,
2419        ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
2420        ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
2421        ProviderKind::Zai => DEFAULT_ZAI_MODEL,
2422        ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
2423        ProviderKind::Minimax => DEFAULT_MINIMAX_MODEL,
2424        ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
2425        // No built-in default model; the registry placeholder keeps this total.
2426        ProviderKind::Custom => provider.provider().default_model(),
2427    }
2428}
2429
2430fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
2431    match provider {
2432        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
2433        ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
2434        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
2435        ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
2436        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
2437        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
2438        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
2439        ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
2440        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
2441        ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
2442        ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
2443        ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
2444        ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
2445        ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
2446        ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
2447        ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
2448        ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
2449        ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
2450        ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
2451        ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
2452        ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL,
2453        ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
2454        ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
2455        ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
2456        ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
2457        ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
2458        ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
2459        // No built-in default base URL; the registry placeholder keeps this total.
2460        ProviderKind::Custom => provider.provider().default_base_url(),
2461    }
2462}
2463
2464fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
2465    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
2466    normalized == DEFAULT_KIMI_CODE_BASE_URL
2467        || normalized == "https://api.kimi.com/coding"
2468        || normalized.starts_with("https://api.kimi.com/coding/")
2469}
2470
2471fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
2472    let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
2473    if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
2474        return None;
2475    }
2476    Some(match normalized.as_str() {
2477        "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
2478            DEFAULT_XIAOMI_MIMO_BASE_URL
2479        }
2480        "token-plan-cn"
2481        | "token-plan-china"
2482        | "token-plan-mainland"
2483        | "token-plan-mainland-china"
2484        | "cn"
2485        | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
2486        "token-plan-sgp"
2487        | "token-plan-sg"
2488        | "token-plan-singapore"
2489        | "sgp"
2490        | "sg"
2491        | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
2492        "token-plan-ams"
2493        | "token-plan-eu"
2494        | "token-plan-europe"
2495        | "token-plan-amsterdam"
2496        | "ams"
2497        | "eu"
2498        | "europe"
2499        | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
2500        _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
2501    })
2502}
2503
2504fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
2505    matches!(
2506        normalized_mode,
2507        "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
2508    )
2509}
2510
2511fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
2512    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
2513    normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
2514        || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
2515        || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
2516}
2517
2518fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
2519    candidates.iter().find_map(|name| {
2520        std::env::var(name)
2521            .ok()
2522            .filter(|value| !value.trim().is_empty())
2523    })
2524}
2525
2526fn xiaomi_mimo_env_api_key_for_runtime(
2527    mode: Option<&str>,
2528    base_url: Option<&str>,
2529) -> Option<String> {
2530    const TOKEN_PLAN_ENV_VARS: &[&str] =
2531        &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
2532    const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
2533
2534    let normalized_mode =
2535        mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
2536    let standard_selected = normalized_mode
2537        .as_deref()
2538        .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
2539        || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
2540    if standard_selected {
2541        return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
2542    }
2543
2544    let token_plan_selected = normalized_mode
2545        .as_deref()
2546        .and_then(xiaomi_mimo_base_url_for_mode)
2547        .is_some()
2548        || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
2549    if token_plan_selected {
2550        return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
2551    }
2552
2553    xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
2554}
2555
2556fn resolve_xiaomi_mimo_base_url(
2557    configured: Option<String>,
2558    api_key: Option<&str>,
2559    mode: Option<&str>,
2560) -> String {
2561    let normalized_mode =
2562        mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
2563    let uses_standard_mode = normalized_mode
2564        .as_deref()
2565        .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
2566    let mode_base_url = normalized_mode
2567        .as_deref()
2568        .and_then(xiaomi_mimo_base_url_for_mode);
2569    let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
2570    match configured {
2571        Some(base_url) if uses_standard_mode => base_url,
2572        Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
2573            mode_base_url
2574                .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
2575                .to_string()
2576        }
2577        Some(base_url) => base_url,
2578        None => {
2579            if let Some(base_url) = mode_base_url {
2580                base_url.to_string()
2581            } else if uses_standard_mode {
2582                XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
2583            } else if uses_token_plan || api_key.is_none() {
2584                DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
2585            } else {
2586                XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
2587            }
2588        }
2589    }
2590}
2591
2592fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
2593    api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
2594}
2595
2596fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
2597    matches!(
2598        base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
2599        "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
2600    )
2601}
2602
2603fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
2604    if provider.is_siliconflow() && siliconflow_base_url_is_official(base_url) {
2605        return false;
2606    }
2607    if provider == ProviderKind::XiaomiMimo
2608        && (xiaomi_mimo_base_url_uses_token_plan(base_url)
2609            || xiaomi_mimo_base_url_is_pay_as_you_go(base_url))
2610    {
2611        return false;
2612    }
2613    let actual = base_url.trim_end_matches('/');
2614    let default = default_base_url_for_provider(provider).trim_end_matches('/');
2615    actual != default
2616}
2617
2618fn siliconflow_base_url_is_official(base_url: &str) -> bool {
2619    matches!(
2620        base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
2621        "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
2622    )
2623}
2624
2625fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
2626    base_url_is_custom_for_provider(provider, base_url)
2627}
2628
2629fn should_skip_secret_store_for_provider(
2630    provider: ProviderKind,
2631    base_url: &str,
2632    auth_mode: Option<&str>,
2633) -> bool {
2634    if auth_mode_requires_api_key(auth_mode) {
2635        return false;
2636    }
2637    if auth_mode_disables_api_key(auth_mode) {
2638        return true;
2639    }
2640
2641    matches!(
2642        provider,
2643        ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama
2644    ) || base_url_uses_local_host(base_url)
2645}
2646
2647fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
2648    if provider == ProviderKind::Huggingface {
2649        return std::env::var("HUGGINGFACE_API_KEY")
2650            .ok()
2651            .filter(|value| !value.trim().is_empty())
2652            .or_else(|| {
2653                std::env::var("HF_TOKEN")
2654                    .ok()
2655                    .filter(|value| !value.trim().is_empty())
2656            });
2657    }
2658
2659    codewhale_secrets::env_for(provider.as_str())
2660}
2661
2662fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
2663    matches!(
2664        auth_mode
2665            .map(str::trim)
2666            .filter(|value| !value.is_empty())
2667            .map(|value| value.to_ascii_lowercase()),
2668        Some(value)
2669            if matches!(
2670                value.as_str(),
2671                "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
2672            )
2673    )
2674}
2675
2676fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
2677    matches!(
2678        auth_mode
2679            .map(str::trim)
2680            .filter(|value| !value.is_empty())
2681            .map(|value| value.to_ascii_lowercase()),
2682        Some(value)
2683            if matches!(
2684                value.as_str(),
2685                "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
2686            )
2687    )
2688}
2689
2690fn auth_mode_uses_kimi_oauth(auth_mode: &str) -> bool {
2691    matches!(
2692        auth_mode
2693            .trim()
2694            .to_ascii_lowercase()
2695            .replace('-', "_")
2696            .as_str(),
2697        "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
2698    )
2699}
2700
2701fn base_url_uses_local_host(base_url: &str) -> bool {
2702    let Some(host) = base_url_host(base_url) else {
2703        return false;
2704    };
2705    let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
2706    if matches!(host.as_str(), "localhost" | "0.0.0.0") {
2707        return true;
2708    }
2709    host.parse::<std::net::IpAddr>()
2710        .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
2711}
2712
2713fn base_url_host(base_url: &str) -> Option<&str> {
2714    let without_scheme = base_url
2715        .split_once("://")
2716        .map_or(base_url, |(_, rest)| rest);
2717    let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
2718    if let Some(rest) = authority.strip_prefix('[') {
2719        return rest.split_once(']').map(|(host, _)| host);
2720    }
2721    authority.split(':').next().filter(|host| !host.is_empty())
2722}
2723
2724#[derive(Debug, Clone, Default)]
2725pub struct CliRuntimeOverrides {
2726    pub provider: Option<ProviderKind>,
2727    pub model: Option<String>,
2728    pub api_key: Option<String>,
2729    pub base_url: Option<String>,
2730    pub auth_mode: Option<String>,
2731    pub output_mode: Option<String>,
2732    pub log_level: Option<String>,
2733    pub telemetry: Option<bool>,
2734    pub approval_policy: Option<String>,
2735    pub sandbox_mode: Option<String>,
2736    pub yolo: Option<bool>,
2737    pub verbosity: Option<String>,
2738}
2739
2740#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2741pub enum RuntimeApiKeySource {
2742    Cli,
2743    ConfigFile,
2744    Keyring,
2745    Env,
2746}
2747
2748impl RuntimeApiKeySource {
2749    #[must_use]
2750    pub fn as_env_value(self) -> &'static str {
2751        match self {
2752            Self::Cli => "cli",
2753            Self::ConfigFile => "config",
2754            Self::Keyring => "keyring",
2755            Self::Env => "env",
2756        }
2757    }
2758}
2759
2760#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2761pub enum ProviderSource {
2762    Cli,
2763    Env(&'static str),
2764    Config,
2765}
2766
2767#[derive(Debug, Clone)]
2768pub struct ResolvedRuntimeOptions {
2769    pub provider: ProviderKind,
2770    pub provider_source: ProviderSource,
2771    pub model: String,
2772    pub api_key: Option<String>,
2773    pub api_key_source: Option<RuntimeApiKeySource>,
2774    pub base_url: String,
2775    pub auth_mode: Option<String>,
2776    pub insecure_skip_tls_verify: bool,
2777    pub output_mode: Option<String>,
2778    pub log_level: Option<String>,
2779    pub telemetry: bool,
2780    pub approval_policy: Option<String>,
2781    pub sandbox_mode: Option<String>,
2782    pub yolo: Option<bool>,
2783    pub verbosity: Option<String>,
2784    pub http_headers: BTreeMap<String, String>,
2785}
2786
2787#[derive(Debug, Clone)]
2788pub struct ConfigStore {
2789    path: PathBuf,
2790    pub config: ConfigToml,
2791    permissions: PermissionsToml,
2792    /// Original file text, retained so [`save`](Self::save) can merge
2793    /// comments back after serialisation.
2794    original_raw: Option<String>,
2795}
2796
2797impl ConfigStore {
2798    pub fn load(path: Option<PathBuf>) -> Result<Self> {
2799        let path = resolve_config_path(path)?;
2800        let (config, original_raw) = if checked_path_exists(&path)? {
2801            let raw = read_checked_config_file(&path)?;
2802            let parsed: ConfigToml = toml::from_str(&raw)
2803                .with_context(|| format!("failed to parse config at {}", path.display()))?;
2804            (parsed, Some(raw))
2805        } else {
2806            (ConfigToml::default(), None)
2807        };
2808        let permissions = load_sibling_permissions(&path)?;
2809
2810        Ok(Self {
2811            path,
2812            config,
2813            permissions,
2814            original_raw,
2815        })
2816    }
2817
2818    pub fn save(&self) -> Result<()> {
2819        let path = normalize_config_file_path(self.path.clone())?;
2820        if let Some(parent) = path.parent() {
2821            fs::create_dir_all(parent).with_context(|| {
2822                format!("failed to create config directory {}", parent.display())
2823            })?;
2824        }
2825        let body = if let Some(ref original_raw) = self.original_raw {
2826            let serialized =
2827                toml::to_string_pretty(&self.config).context("failed to serialize config")?;
2828            merge_and_preserve_comments(&serialized, original_raw).unwrap_or_else(|e| {
2829                tracing::warn!("failed to merge config comments, saving without them: {e:#}");
2830                serialized
2831            })
2832        } else {
2833            toml::to_string_pretty(&self.config).context("failed to serialize config")?
2834        };
2835        if checked_path_exists(&path)? {
2836            let existing = read_checked_config_file(&path)?;
2837            if existing == body {
2838                return Ok(());
2839            }
2840            write_one_time_config_backup(&path)?;
2841        }
2842        #[cfg(unix)]
2843        {
2844            let mut file = fs::OpenOptions::new()
2845                .write(true)
2846                .create(true)
2847                .truncate(true)
2848                .mode(0o600)
2849                .open(&path)
2850                .with_context(|| format!("failed to write config at {}", path.display()))?;
2851            file.write_all(body.as_bytes())
2852                .with_context(|| format!("failed to write config at {}", path.display()))?;
2853            file.set_permissions(fs::Permissions::from_mode(0o600))
2854                .with_context(|| {
2855                    format!("failed to set config permissions at {}", path.display())
2856                })?;
2857        }
2858        #[cfg(not(unix))]
2859        {
2860            fs::write(&path, body)
2861                .with_context(|| format!("failed to write config at {}", path.display()))?;
2862        }
2863        Ok(())
2864    }
2865
2866    #[must_use]
2867    pub fn path(&self) -> &Path {
2868        &self.path
2869    }
2870
2871    #[must_use]
2872    pub fn permissions(&self) -> &PermissionsToml {
2873        &self.permissions
2874    }
2875
2876    #[must_use]
2877    pub fn permissions_path(&self) -> PathBuf {
2878        checked_permissions_path_for_config_path(&self.path)
2879            .expect("ConfigStore path is validated before construction")
2880    }
2881
2882    #[must_use]
2883    pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
2884        if self.permissions.is_empty() {
2885            ExecPolicyEngine::new(Vec::new(), Vec::new())
2886        } else {
2887            ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
2888        }
2889    }
2890
2891    /// Atomically append ask-only permission rules to the sibling
2892    /// `permissions.toml` file.
2893    ///
2894    /// Existing comments and formatting are preserved. Exact duplicate rules
2895    /// are ignored, and the in-memory permissions snapshot is refreshed after
2896    /// a successful write.
2897    pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
2898        if rules.is_empty() {
2899            return Ok(0);
2900        }
2901
2902        let path = checked_permissions_path_for_config_path(&self.path)?;
2903        let raw = if checked_path_exists(&path)? {
2904            read_checked_permissions_file(&path)?
2905        } else {
2906            String::new()
2907        };
2908        let mut permissions = if raw.trim().is_empty() {
2909            PermissionsToml::default()
2910        } else {
2911            toml::from_str(&raw)
2912                .with_context(|| format!("failed to parse permissions at {}", path.display()))?
2913        };
2914        let mut document = if raw.trim().is_empty() {
2915            toml_edit::DocumentMut::new()
2916        } else {
2917            raw.parse::<toml_edit::DocumentMut>()
2918                .with_context(|| format!("failed to edit permissions at {}", path.display()))?
2919        };
2920
2921        if !document.contains_key("rules") {
2922            document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
2923        }
2924        let rules_item = document
2925            .get_mut("rules")
2926            .expect("rules entry was inserted above");
2927
2928        let mut added = 0;
2929        for rule in rules {
2930            if permissions.rules.contains(rule) {
2931                continue;
2932            }
2933            append_ask_rule(rules_item, rule)?;
2934            permissions.rules.push(rule.clone());
2935            added += 1;
2936        }
2937        if added == 0 {
2938            self.permissions = permissions;
2939            return Ok(0);
2940        }
2941
2942        let body = document.to_string();
2943        let persisted: PermissionsToml = toml::from_str(&body).with_context(|| {
2944            format!(
2945                "generated invalid permissions document for {}",
2946                path.display()
2947            )
2948        })?;
2949        write_permissions_atomic(&path, body.as_bytes())?;
2950        self.permissions = persisted;
2951        Ok(added)
2952    }
2953}
2954
2955fn config_backup_file_name(path: &Path) -> OsString {
2956    let mut file_name = path
2957        .file_name()
2958        .map(OsString::from)
2959        .unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
2960    file_name.push(".bak");
2961    file_name
2962}
2963
2964fn config_sibling_path_unchecked(config_path: &Path, file_name: &OsStr) -> PathBuf {
2965    config_path
2966        .parent()
2967        .unwrap_or_else(|| Path::new("."))
2968        .join(file_name)
2969}
2970
2971fn checked_config_sibling_path(config_path: &Path, file_name: &OsStr) -> Result<PathBuf> {
2972    let config_path = normalize_config_file_path(config_path.to_path_buf())?;
2973    let parent = config_path
2974        .parent()
2975        .context("config path must include a parent directory")?;
2976    let path = parent.join(file_name);
2977    reject_path_symlink(&path)?;
2978    Ok(path)
2979}
2980
2981#[cfg(test)]
2982fn config_backup_path(path: &Path) -> PathBuf {
2983    config_sibling_path_unchecked(path, &config_backup_file_name(path))
2984}
2985
2986fn checked_config_backup_path(path: &Path) -> Result<PathBuf> {
2987    checked_config_sibling_path(path, &config_backup_file_name(path))
2988}
2989
2990fn write_one_time_config_backup(path: &Path) -> Result<()> {
2991    let backup = checked_config_backup_path(path)?;
2992    if backup.exists() {
2993        return Ok(());
2994    }
2995    fs::copy(path, &backup).with_context(|| {
2996        format!(
2997            "failed to create config backup {} from {}",
2998            backup.display(),
2999            path.display()
3000        )
3001    })?;
3002    #[cfg(unix)]
3003    {
3004        fs::set_permissions(&backup, fs::Permissions::from_mode(0o600)).with_context(|| {
3005            format!(
3006                "failed to set config backup permissions at {}",
3007                backup.display()
3008            )
3009        })?;
3010    }
3011    Ok(())
3012}
3013
3014/// Merge comments and formatting from an original TOML file into a
3015/// freshly serialized document so user annotations (comments, whitespace,
3016/// disabled keys) survive config rewrites.
3017///
3018/// `original_raw` is the raw text of the file before the change; the
3019/// function parses it internally with [`toml_edit`] so callers stay free
3020/// of that dependency.
3021pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
3022    let original = original_raw
3023        .parse::<toml_edit::DocumentMut>()
3024        .context("failed to parse original config for comment merge")?;
3025
3026    let mut new_doc = serialized
3027        .parse::<toml_edit::DocumentMut>()
3028        .context("failed to parse serialized config for comment merge")?;
3029
3030    // Reuse the original document’s trailing text (file-footer comments /
3031    // disabled keys) so they survive the rewrite.
3032    new_doc.set_trailing(original.trailing().clone());
3033
3034    // Copy the top-level table's decor (document-header comments, whitespace
3035    // before the first key) which `toml_edit` stores on the root `Table` itself.
3036    *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
3037
3038    merge_decor_table(new_doc.as_table_mut(), original.as_table());
3039
3040    Ok(new_doc.to_string())
3041}
3042
3043/// Recursively copy `decor` (prefix/suffix comments and whitespace) from
3044/// every key in `source` that also exists in `target`.
3045fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3046    // Collect keys first — the borrow checker won't let us hold
3047    // `get_key_value_mut` while iterating.
3048    let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
3049    for key in &keys {
3050        let Some((source_key, source_item)) = source.get_key_value(key) else {
3051            continue;
3052        };
3053        let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
3054            continue;
3055        };
3056
3057        // Copy the key-level decor (comments before the key itself)
3058        *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
3059
3060        copy_item_decor(target_item, source_item);
3061
3062        if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
3063            merge_decor_table(tt, st);
3064        }
3065
3066        if let (Some(ta), Some(sa)) = (
3067            target_item.as_array_of_tables_mut(),
3068            source_item.as_array_of_tables(),
3069        ) {
3070            for (i, source_table) in sa.iter().enumerate() {
3071                if let Some(target_table) = ta.get_mut(i) {
3072                    copy_item_decor_table(target_table, source_table);
3073                    merge_decor_table(target_table, source_table);
3074                }
3075            }
3076        }
3077    }
3078}
3079
3080/// Copy the decor (comments and surrounding whitespace) from `source` to `target`,
3081/// respecting the concrete item type since [`toml_edit::Item`] has no uniform
3082/// `decor` accessor.
3083fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
3084    match (target, source) {
3085        (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
3086            *tt.decor_mut() = st.decor().clone();
3087        }
3088        (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
3089            *tv.decor_mut() = sv.decor().clone();
3090        }
3091        _ => {}
3092    }
3093}
3094
3095fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3096    *target.decor_mut() = source.decor().clone();
3097}
3098
3099/// Process-wide default [`Secrets`] façade. The first caller wins; the
3100/// lock is exposed so test or CLI code can install an explicit
3101/// backend (e.g. an [`codewhale_secrets::InMemoryKeyringStore`]) before
3102/// any resolver runs.
3103pub fn default_secrets() -> &'static Secrets {
3104    static SECRETS: OnceLock<Secrets> = OnceLock::new();
3105    SECRETS.get_or_init(|| {
3106        // Tests should never poke real platform credential stores. Cargo sets the
3107        // `RUST_TEST_*` family of env vars (and `CARGO_PKG_NAME` is
3108        // always populated), but the `cfg(test)` flag is the canonical
3109        // signal here. See `install_test_secrets` for explicit installs.
3110        #[cfg(test)]
3111        {
3112            Secrets::new(std::sync::Arc::new(
3113                codewhale_secrets::InMemoryKeyringStore::new(),
3114            ))
3115        }
3116        #[cfg(not(test))]
3117        {
3118            Secrets::auto_detect()
3119        }
3120    })
3121}
3122
3123// ── CodeWhale state root (v0.8.44) ──────────────────────────────────
3124//
3125// v0.8.44 migrates product-owned app state from ~/.deepseek/ to
3126// ~/.codewhale/ while keeping ~/.deepseek/ as a compatibility fallback.
3127// New installs write to ~/.codewhale/. Existing installs with only
3128// ~/.deepseek/ continue working without data loss.
3129
3130/// Canonical CodeWhale app directory name under $HOME.
3131pub const CODEWHALE_APP_DIR: &str = ".codewhale";
3132
3133/// Legacy DeepSeek-branded app directory name (compatibility fallback).
3134pub const LEGACY_APP_DIR: &str = ".deepseek";
3135
3136/// Resolve the primary CodeWhale home directory.
3137///
3138/// `$CODEWHALE_HOME` takes precedence when set. Otherwise defaults to
3139/// `$HOME/.codewhale`. This is the write target for new product state.
3140pub fn codewhale_home() -> Result<PathBuf> {
3141    if let Some(path) = codewhale_home_env_override() {
3142        return Ok(path);
3143    }
3144    let home = effective_home_dir().context("failed to resolve home directory")?;
3145    Ok(home.join(CODEWHALE_APP_DIR))
3146}
3147
3148fn codewhale_home_env_override() -> Option<PathBuf> {
3149    let val = std::env::var("CODEWHALE_HOME").ok()?;
3150    let trimmed = val.trim();
3151    if trimmed.is_empty() {
3152        None
3153    } else {
3154        Some(PathBuf::from(trimmed))
3155    }
3156}
3157
3158/// Resolve the legacy DeepSeek home directory (`$HOME/.deepseek`).
3159///
3160/// Always returns the legacy path regardless of whether it exists.
3161pub fn legacy_deepseek_home() -> Result<PathBuf> {
3162    let home = effective_home_dir().context("failed to resolve home directory")?;
3163    Ok(home.join(LEGACY_APP_DIR))
3164}
3165
3166fn effective_home_dir() -> Option<PathBuf> {
3167    std::env::var_os("HOME")
3168        .filter(|value| !value.is_empty())
3169        .map(PathBuf::from)
3170        .or_else(dirs::home_dir)
3171}
3172
3173/// Reject state subdirs that could escape the state root via path injection.
3174///
3175/// `ensure_state_dir` / `resolve_state_dir` are public APIs taking an arbitrary
3176/// subdir string; every in-tree caller passes a hardcoded single component
3177/// (e.g. `"sessions"`, `"."`). This validates defensively so a future caller
3178/// can never traverse out of the state root via `..` components or an absolute
3179/// path. Nested relative paths such as `"a/b"` are permitted.
3180fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
3181    if subdir.is_empty() {
3182        bail!("state subdir must not be empty");
3183    }
3184    let path = std::path::Path::new(subdir);
3185    if path.is_absolute() {
3186        bail!("state subdir must not be an absolute path: {subdir}");
3187    }
3188    if path.components().any(|c| {
3189        matches!(
3190            c,
3191            std::path::Component::RootDir | std::path::Component::Prefix(_)
3192        )
3193    }) {
3194        bail!("state subdir must not contain a root or prefix: {subdir}");
3195    }
3196    if path
3197        .components()
3198        .any(|c| matches!(c, std::path::Component::ParentDir))
3199    {
3200        bail!("state subdir must not contain parent-dir (..) components: {subdir}");
3201    }
3202    Ok(())
3203}
3204
3205/// Resolve a state subdirectory, preferring the CodeWhale root if
3206/// it already exists, otherwise falling back to the legacy root.
3207///
3208/// This is the read-path resolver: it returns the primary path when
3209/// migration has occurred or on a fresh install, but keeps reading
3210/// from the legacy path for users who haven't migrated yet.
3211pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
3212    ensure_safe_state_subdir(subdir)?;
3213    let explicit_codewhale_home = codewhale_home_env_override().is_some();
3214    let primary = codewhale_home()?.join(subdir);
3215    if explicit_codewhale_home || primary.exists() {
3216        return Ok(primary);
3217    }
3218    let legacy = legacy_deepseek_home()?.join(subdir);
3219    if legacy.exists() {
3220        return Ok(legacy);
3221    }
3222    // Neither exists — return primary for first-write creation.
3223    Ok(primary)
3224}
3225
3226/// Ensure a state subdirectory exists under the primary CodeWhale root,
3227/// creating it if necessary. This is the write-path resolver.
3228///
3229/// On the first creation of a real subdirectory (not the root sentinel `"."`),
3230/// if a legacy `~/.deepseek/<subdir>` exists but the primary
3231/// `~/.codewhale/<subdir>` does not, the legacy directory is relocated into
3232/// the primary location so the user keeps their data and the legacy tree
3233/// stops growing (#3240). After migration, [`resolve_state_dir`] finds the
3234/// data in the primary location; the read resolver itself is unchanged.
3235pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
3236    ensure_safe_state_subdir(subdir)?;
3237    let explicit_codewhale_home = codewhale_home_env_override().is_some();
3238    let dir = codewhale_home()?.join(subdir);
3239    if !explicit_codewhale_home {
3240        migrate_legacy_state_dir(&dir, subdir)?;
3241    }
3242    std::fs::create_dir_all(&dir)
3243        .with_context(|| format!("failed to create {}/", dir.display()))?;
3244    Ok(dir)
3245}
3246
3247/// One-time relocation of a legacy `~/.deepseek/<subdir>` state directory into
3248/// the primary `~/.codewhale/<subdir>` location (#3240). No-op once the primary
3249/// exists, for the root sentinel `"."` (a whole-tree move is owned by the
3250/// config-file migration), or when no legacy directory is present.
3251fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<()> {
3252    if primary.exists() || subdir == "." || subdir.is_empty() {
3253        return Ok(());
3254    }
3255    let legacy = match legacy_deepseek_home() {
3256        Ok(home) => home.join(subdir),
3257        Err(_) => return Ok(()),
3258    };
3259    if !legacy.exists() {
3260        return Ok(());
3261    }
3262    // The primary's parent (the ~/.codewhale root) must exist for the rename.
3263    if let Some(parent) = primary.parent() {
3264        if let Err(err) = std::fs::create_dir_all(parent) {
3265            tracing::warn!(
3266                target: "config::migration",
3267                "Could not create {} for state migration ({}); writing to primary anyway",
3268                parent.display(),
3269                err
3270            );
3271        }
3272    }
3273    match std::fs::rename(&legacy, primary) {
3274        Ok(()) => {
3275            tracing::info!(
3276                target: "config::migration",
3277                "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
3278                legacy.display(),
3279                primary.display()
3280            );
3281        }
3282        Err(err) => {
3283            // Cross-device rename or permission issue: fall back to a
3284            // recursive copy so the user keeps their data. The legacy tree is
3285            // left in place; it stops growing because writes now target the
3286            // primary path.
3287            match copy_dir_recursive(&legacy, primary) {
3288                Ok(()) => {
3289                    tracing::info!(
3290                        target: "config::migration",
3291                        "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
3292                         The legacy .deepseek copy was left in place.",
3293                        legacy.display(),
3294                        primary.display()
3295                    );
3296                }
3297                Err(copy_err) => {
3298                    tracing::warn!(
3299                        target: "config::migration",
3300                        "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
3301                         New data is written to the primary path; the legacy tree remains untouched.",
3302                        legacy.display(),
3303                        primary.display()
3304                    );
3305                }
3306            }
3307        }
3308    }
3309    Ok(())
3310}
3311
3312/// Recursively copy a directory tree from `src` to `dst`, creating `dst`.
3313/// Symlinks and other non-file/non-dir entries are skipped (rare in state dirs).
3314fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
3315    std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
3316    for entry in
3317        std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
3318    {
3319        let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
3320        let path = entry.path();
3321        let target = dst.join(entry.file_name());
3322        let file_type = entry
3323            .file_type()
3324            .with_context(|| format!("failed to read file type for {}", path.display()))?;
3325        if file_type.is_dir() {
3326            copy_dir_recursive(&path, &target)?;
3327        } else if file_type.is_file() {
3328            std::fs::copy(&path, &target).with_context(|| {
3329                format!("failed to copy {} -> {}", path.display(), target.display())
3330            })?;
3331        }
3332    }
3333    Ok(())
3334}
3335
3336/// Resolve a project-local state subdirectory, preferring `.codewhale/`
3337/// when it exists, falling back to `.deepseek/` for legacy projects.
3338///
3339/// Returns `(true, path)` when the primary `.codewhale/` path is used,
3340/// `(false, path)` for the legacy fallback. The boolean helps callers
3341/// emit a deprecation notice on legacy paths.
3342pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> Result<(bool, PathBuf)> {
3343    ensure_safe_state_subdir(subdir)?;
3344    let workspace = normalize_project_workspace(workspace)?;
3345    let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3346    if primary.exists() {
3347        return Ok((true, primary));
3348    }
3349    let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
3350    Ok((false, legacy))
3351}
3352
3353/// Ensure a project-local state subdirectory exists under `.codewhale/`,
3354/// creating it if necessary. Returns the directory path.
3355pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
3356    ensure_safe_state_subdir(subdir)?;
3357    let workspace = normalize_project_workspace(workspace)?;
3358    let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3359    std::fs::create_dir_all(&dir)
3360        .with_context(|| format!("failed to create {}/", dir.display()))?;
3361    Ok(dir)
3362}
3363
3364pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
3365    if let Some(path) = explicit {
3366        return normalize_config_file_path(path);
3367    }
3368    if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
3369        if let Some(path) = config_path_from_env_value(&path)? {
3370            return Ok(path);
3371        }
3372        return default_config_path();
3373    }
3374    if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
3375        if let Some(path) = config_path_from_env_value(&path)? {
3376            return Ok(path);
3377        }
3378        return default_config_path();
3379    }
3380    default_config_path()
3381}
3382
3383fn config_path_from_env_value(path: &str) -> Result<Option<PathBuf>> {
3384    let trimmed = path.trim();
3385    if trimmed.is_empty() {
3386        Ok(None)
3387    } else {
3388        normalize_config_file_path(PathBuf::from(trimmed)).map(Some)
3389    }
3390}
3391
3392#[must_use]
3393pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
3394    config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
3395}
3396
3397fn checked_permissions_path_for_config_path(config_path: &Path) -> Result<PathBuf> {
3398    checked_config_sibling_path(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
3399}
3400
3401pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
3402    checked_permissions_path_for_config_path(&resolve_config_path(config_path)?)
3403}
3404
3405fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
3406    let permissions_path = checked_permissions_path_for_config_path(config_path)?;
3407    if !checked_path_exists(&permissions_path)? {
3408        return Ok(PermissionsToml::default());
3409    }
3410
3411    let raw = read_checked_permissions_file(&permissions_path)?;
3412    toml::from_str(&raw).with_context(|| {
3413        format!(
3414            "failed to parse permissions at {}",
3415            permissions_path.display()
3416        )
3417    })
3418}
3419
3420fn append_ask_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
3421    match item {
3422        toml_edit::Item::ArrayOfTables(rules) => {
3423            rules.push(ask_rule_table(rule));
3424            Ok(())
3425        }
3426        toml_edit::Item::Value(value) => {
3427            let Some(rules) = value.as_array_mut() else {
3428                bail!("`rules` in permissions.toml must be an array");
3429            };
3430            rules.push(toml_edit::Value::InlineTable(ask_rule_inline_table(rule)));
3431            Ok(())
3432        }
3433        _ => bail!("`rules` in permissions.toml must be an array"),
3434    }
3435}
3436
3437fn ask_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
3438    let mut table = toml_edit::Table::new();
3439    table["tool"] = toml_edit::value(rule.tool.clone());
3440    if let Some(command) = rule.command.as_deref() {
3441        table["command"] = toml_edit::value(command);
3442    }
3443    if let Some(path) = rule.path.as_deref() {
3444        table["path"] = toml_edit::value(path);
3445    }
3446    table
3447}
3448
3449fn ask_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
3450    let mut table = toml_edit::InlineTable::new();
3451    table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
3452    if let Some(command) = rule.command.as_deref() {
3453        table.insert("command", toml_edit::Value::from(command));
3454    }
3455    if let Some(path) = rule.path.as_deref() {
3456        table.insert("path", toml_edit::Value::from(path));
3457    }
3458    table
3459}
3460
3461fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
3462    let parent = path.parent().with_context(|| {
3463        format!(
3464            "permissions path has no parent directory: {}",
3465            path.display()
3466        )
3467    })?;
3468    fs::create_dir_all(parent).with_context(|| {
3469        format!(
3470            "failed to create permissions directory {}",
3471            parent.display()
3472        )
3473    })?;
3474
3475    let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
3476        format!(
3477            "failed to create temporary permissions file in {}",
3478            parent.display()
3479        )
3480    })?;
3481    #[cfg(unix)]
3482    temporary
3483        .as_file()
3484        .set_permissions(fs::Permissions::from_mode(0o600))
3485        .with_context(|| {
3486            format!(
3487                "failed to secure temporary permissions file for {}",
3488                path.display()
3489            )
3490        })?;
3491    temporary
3492        .write_all(body)
3493        .with_context(|| format!("failed to write permissions at {}", path.display()))?;
3494    temporary
3495        .as_file()
3496        .sync_all()
3497        .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
3498    temporary
3499        .persist(path)
3500        .map_err(|error| error.error)
3501        .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
3502    Ok(())
3503}
3504
3505pub fn default_config_path() -> Result<PathBuf> {
3506    // Prefer ~/.codewhale/config.toml when it exists (fresh install or
3507    // migrated), otherwise fall back to ~/.deepseek/config.toml.
3508    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
3509    if primary.exists() {
3510        return Ok(primary);
3511    }
3512    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
3513    if legacy.exists() {
3514        return Ok(legacy);
3515    }
3516    // Neither exists — return primary so first write creates it there.
3517    Ok(primary)
3518}
3519
3520#[derive(Debug, Clone, PartialEq, Eq)]
3521pub struct ConfigMigration {
3522    pub legacy_path: PathBuf,
3523    pub primary_path: PathBuf,
3524}
3525
3526impl ConfigMigration {
3527    pub fn user_notice(&self) -> String {
3528        format!(
3529            "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
3530            self.legacy_path.display(),
3531            self.primary_path.display()
3532        )
3533    }
3534}
3535
3536/// v0.8.44: one-time migration from `~/.deepseek/config.toml` to
3537/// `~/.codewhale/config.toml`. Called on first launch after the config
3538/// is loaded; copies the legacy file if the primary doesn't exist yet.
3539/// Never overwrites an existing primary config.
3540pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
3541    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
3542    if primary.exists() {
3543        return Ok(None);
3544    }
3545    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
3546    if !legacy.exists() {
3547        return Ok(None);
3548    }
3549    // Copy the config to the new home.
3550    if let Some(parent) = primary.parent() {
3551        std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
3552    }
3553    std::fs::copy(&legacy, &primary)
3554        .context("failed to migrate config from deepseek to codewhale home")?;
3555    tracing::info!(
3556        "Migrated config from {} to {}",
3557        legacy.display(),
3558        primary.display()
3559    );
3560    Ok(Some(ConfigMigration {
3561        legacy_path: legacy,
3562        primary_path: primary,
3563    }))
3564}
3565
3566fn parse_bool(raw: &str) -> Result<bool> {
3567    match raw.trim().to_ascii_lowercase().as_str() {
3568        "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
3569        "0" | "false" | "no" | "off" | "disabled" => Ok(false),
3570        _ => bail!("invalid boolean '{raw}'"),
3571    }
3572}
3573
3574fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
3575    let mut headers = BTreeMap::new();
3576    for pair in raw.trim().split(',') {
3577        let pair = pair.trim();
3578        if pair.is_empty() {
3579            continue;
3580        }
3581        let Some((name, value)) = pair.split_once('=') else {
3582            bail!("invalid header pair '{pair}', expected name=value");
3583        };
3584        let name = name.trim();
3585        let value = value.trim();
3586        if name.is_empty() {
3587            bail!("header name cannot be empty");
3588        }
3589        if value.is_empty() {
3590            continue;
3591        }
3592        headers.insert(name.to_string(), value.to_string());
3593    }
3594    Ok(headers)
3595}
3596
3597fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
3598    if headers.is_empty() {
3599        return None;
3600    }
3601    Some(
3602        headers
3603            .iter()
3604            .map(|(name, value)| format!("{name}={value}"))
3605            .collect::<Vec<_>>()
3606            .join(","),
3607    )
3608}
3609
3610fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
3611    if headers.is_empty() {
3612        return None;
3613    }
3614    Some(
3615        headers
3616            .iter()
3617            .map(|(name, value)| {
3618                let display_value = if is_sensitive_config_key(name) {
3619                    redact_secret(value)
3620                } else {
3621                    value.clone()
3622                };
3623                format!("{name}={display_value}")
3624            })
3625            .collect::<Vec<_>>()
3626            .join(","),
3627    )
3628}
3629
3630fn redact_secret(secret: &str) -> String {
3631    let chars: Vec<char> = secret.chars().collect();
3632    if chars.len() <= 16 {
3633        return "********".to_string();
3634    }
3635    let prefix: String = chars.iter().take(4).collect();
3636    let suffix: String = chars
3637        .iter()
3638        .rev()
3639        .take(4)
3640        .collect::<Vec<_>>()
3641        .into_iter()
3642        .rev()
3643        .collect();
3644    format!("{prefix}***{suffix}")
3645}
3646
3647#[must_use]
3648pub fn is_sensitive_config_key(key: &str) -> bool {
3649    let Some(segment) = key.rsplit('.').next() else {
3650        return false;
3651    };
3652    let normalized = segment
3653        .trim()
3654        .trim_matches('"')
3655        .replace('-', "_")
3656        .to_ascii_lowercase();
3657
3658    matches!(
3659        normalized.as_str(),
3660        "api_key"
3661            | "apikey"
3662            | "api_keys"
3663            | "authorization"
3664            | "bearer"
3665            | "client_secret"
3666            | "credential"
3667            | "credentials"
3668            | "id_token"
3669            | "password"
3670            | "passwords"
3671            | "passwd"
3672            | "proxy_authorization"
3673            | "refresh_token"
3674            | "secret"
3675            | "secrets"
3676            | "token"
3677            | "tokens"
3678    ) || normalized.ends_with("_api_key")
3679        || normalized.ends_with("_authorization")
3680        || normalized.ends_with("_password")
3681        || normalized.ends_with("_secret")
3682        || normalized.ends_with("_token")
3683}
3684
3685fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
3686    redact_toml_value_for_display_inner(key, false, value).to_string()
3687}
3688
3689fn redact_toml_value_for_display_inner(
3690    key: &str,
3691    sensitive_ancestor: bool,
3692    value: &toml::Value,
3693) -> toml::Value {
3694    let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
3695    match value {
3696        toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
3697        toml::Value::Array(values) => toml::Value::Array(
3698            values
3699                .iter()
3700                .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
3701                .collect(),
3702        ),
3703        toml::Value::Table(table) => {
3704            let mut redacted = toml::map::Map::new();
3705            for (child_key, child_value) in table {
3706                let path = if key.is_empty() {
3707                    child_key.clone()
3708                } else {
3709                    format!("{key}.{child_key}")
3710                };
3711                redacted.insert(
3712                    child_key.clone(),
3713                    redact_toml_value_for_display_inner(&path, sensitive, child_value),
3714                );
3715            }
3716            toml::Value::Table(redacted)
3717        }
3718        _ if sensitive => toml::Value::String("********".to_string()),
3719        _ => value.clone(),
3720    }
3721}
3722
3723fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
3724    if path.as_os_str().is_empty() {
3725        bail!("config path cannot be empty");
3726    }
3727    if path
3728        .components()
3729        .any(|component| matches!(component, Component::ParentDir))
3730    {
3731        bail!("config path cannot contain '..' components");
3732    }
3733    if path.file_name().is_none() {
3734        bail!("config path must include a file name");
3735    }
3736    let absolute = if path.is_absolute() {
3737        path
3738    } else {
3739        std::env::current_dir()
3740            .context("failed to resolve current directory for config path")?
3741            .join(path)
3742    };
3743    let file_name = absolute
3744        .file_name()
3745        .map(OsString::from)
3746        .context("config path must include a file name")?;
3747    let parent = absolute
3748        .parent()
3749        .context("config path must include a parent directory")?;
3750    let parent = match parent.canonicalize() {
3751        Ok(parent) => parent,
3752        Err(err) if err.kind() == std::io::ErrorKind::NotFound => parent.to_path_buf(),
3753        Err(err) => {
3754            return Err(err).with_context(|| {
3755                format!("failed to resolve config directory {}", parent.display())
3756            });
3757        }
3758    };
3759    let normalized = parent.join(file_name);
3760    reject_path_symlink(&normalized)?;
3761    Ok(normalized)
3762}
3763
3764fn normalize_project_workspace(workspace: &Path) -> Result<PathBuf> {
3765    if workspace.as_os_str().is_empty() {
3766        bail!("project workspace path cannot be empty");
3767    }
3768    if workspace
3769        .components()
3770        .any(|component| matches!(component, Component::ParentDir))
3771    {
3772        bail!("project workspace path cannot contain '..' components");
3773    }
3774    let absolute = if workspace.is_absolute() {
3775        workspace.to_path_buf()
3776    } else {
3777        std::env::current_dir()
3778            .context("failed to resolve current directory for project workspace")?
3779            .join(workspace)
3780    };
3781    match absolute.canonicalize() {
3782        Ok(path) => Ok(path),
3783        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
3784            Ok(normalize_path_components(&absolute))
3785        }
3786        Err(err) => Err(err).with_context(|| {
3787            format!(
3788                "failed to resolve project workspace {}",
3789                workspace.display()
3790            )
3791        }),
3792    }
3793}
3794
3795fn normalize_path_components(path: &Path) -> PathBuf {
3796    let mut normalized = PathBuf::new();
3797    for component in path.components() {
3798        match component {
3799            Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
3800            Component::CurDir => {}
3801            Component::ParentDir => {
3802                normalized.pop();
3803            }
3804            Component::Normal(part) => normalized.push(part),
3805        }
3806    }
3807    if normalized.as_os_str().is_empty() {
3808        PathBuf::from(".")
3809    } else {
3810        normalized
3811    }
3812}
3813
3814fn checked_path_exists(path: &Path) -> Result<bool> {
3815    let path = normalize_config_file_path(path.to_path_buf())?;
3816    path.try_exists()
3817        .with_context(|| format!("failed to inspect config path {}", path.display()))
3818}
3819
3820fn read_checked_config_file(path: &Path) -> Result<String> {
3821    read_checked_toml_file(path, "config")
3822}
3823
3824fn read_checked_permissions_file(path: &Path) -> Result<String> {
3825    read_checked_toml_file(path, "permissions")
3826}
3827
3828fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
3829    let path = normalize_config_file_path(path.to_path_buf())?;
3830    read_string_no_follow(&path)
3831        .with_context(|| format!("failed to read {label} at {}", path.display()))
3832}
3833
3834#[cfg(unix)]
3835fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
3836    let mut file = fs::OpenOptions::new()
3837        .read(true)
3838        .custom_flags(libc::O_NOFOLLOW)
3839        .open(path)?;
3840    let mut raw = String::new();
3841    file.read_to_string(&mut raw)?;
3842    Ok(raw)
3843}
3844
3845#[cfg(not(unix))]
3846fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
3847    fs::read_to_string(path)
3848}
3849
3850fn reject_path_symlink(path: &Path) -> Result<()> {
3851    let Ok(metadata) = fs::symlink_metadata(path) else {
3852        return Ok(());
3853    };
3854    if metadata.file_type().is_symlink() {
3855        bail!("config path must not be a symlink: {}", path.display());
3856    }
3857    Ok(())
3858}
3859
3860#[derive(Debug, Clone, Default)]
3861struct EnvRuntimeOverrides {
3862    provider: Option<ProviderKind>,
3863    provider_source: Option<&'static str>,
3864    model: Option<String>,
3865    volcengine_model: Option<String>,
3866    wanjie_ark_model: Option<String>,
3867    openrouter_model: Option<String>,
3868    moonshot_model: Option<String>,
3869    xiaomi_mimo_model: Option<String>,
3870    xiaomi_mimo_mode: Option<String>,
3871    novita_model: Option<String>,
3872    fireworks_model: Option<String>,
3873    arcee_model: Option<String>,
3874    output_mode: Option<String>,
3875    auth_mode: Option<String>,
3876    log_level: Option<String>,
3877    telemetry: Option<bool>,
3878    approval_policy: Option<String>,
3879    sandbox_mode: Option<String>,
3880    yolo: Option<bool>,
3881    verbosity: Option<String>,
3882    http_headers: Option<BTreeMap<String, String>>,
3883    deepseek_base_url: Option<String>,
3884    deepseek_anthropic_base_url: Option<String>,
3885    nvidia_base_url: Option<String>,
3886    openai_base_url: Option<String>,
3887    atlascloud_base_url: Option<String>,
3888    volcengine_base_url: Option<String>,
3889    wanjie_ark_base_url: Option<String>,
3890    openrouter_base_url: Option<String>,
3891    xiaomi_mimo_base_url: Option<String>,
3892    novita_base_url: Option<String>,
3893    fireworks_base_url: Option<String>,
3894    siliconflow_base_url: Option<String>,
3895    siliconflow_model: Option<String>,
3896    arcee_base_url: Option<String>,
3897    moonshot_base_url: Option<String>,
3898    sglang_base_url: Option<String>,
3899    vllm_base_url: Option<String>,
3900    ollama_base_url: Option<String>,
3901    huggingface_base_url: Option<String>,
3902    huggingface_model: Option<String>,
3903    together_base_url: Option<String>,
3904    together_model: Option<String>,
3905    qianfan_base_url: Option<String>,
3906    qianfan_model: Option<String>,
3907    openai_codex_base_url: Option<String>,
3908    openai_codex_model: Option<String>,
3909    anthropic_base_url: Option<String>,
3910    anthropic_model: Option<String>,
3911    zai_base_url: Option<String>,
3912    zai_model: Option<String>,
3913    stepfun_base_url: Option<String>,
3914    stepfun_model: Option<String>,
3915    minimax_base_url: Option<String>,
3916    minimax_model: Option<String>,
3917    deepinfra_base_url: Option<String>,
3918    deepinfra_model: Option<String>,
3919}
3920
3921impl EnvRuntimeOverrides {
3922    fn load() -> Self {
3923        let (provider, provider_source) = Self::load_provider();
3924        Self {
3925            provider,
3926            provider_source,
3927            model: std::env::var("CODEWHALE_MODEL")
3928                .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
3929                .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
3930                .ok()
3931                .filter(|v| !v.trim().is_empty()),
3932            volcengine_model: std::env::var("VOLCENGINE_MODEL")
3933                .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
3934                .ok()
3935                .filter(|v| !v.trim().is_empty()),
3936            wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
3937                .or_else(|_| std::env::var("WANJIE_MODEL"))
3938                .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
3939                .ok()
3940                .filter(|v| !v.trim().is_empty()),
3941            openrouter_model: std::env::var("OPENROUTER_MODEL")
3942                .ok()
3943                .filter(|v| !v.trim().is_empty()),
3944            moonshot_model: std::env::var("MOONSHOT_MODEL")
3945                .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
3946                .or_else(|_| std::env::var("KIMI_MODEL"))
3947                .ok()
3948                .filter(|v| !v.trim().is_empty()),
3949            xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
3950                .or_else(|_| std::env::var("MIMO_MODEL"))
3951                .ok()
3952                .filter(|v| !v.trim().is_empty()),
3953            xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
3954                .or_else(|_| std::env::var("MIMO_MODE"))
3955                .ok()
3956                .filter(|v| !v.trim().is_empty()),
3957            novita_model: std::env::var("NOVITA_MODEL")
3958                .ok()
3959                .filter(|v| !v.trim().is_empty()),
3960            fireworks_model: std::env::var("FIREWORKS_MODEL")
3961                .ok()
3962                .filter(|v| !v.trim().is_empty()),
3963            arcee_model: std::env::var("ARCEE_MODEL")
3964                .ok()
3965                .filter(|v| !v.trim().is_empty()),
3966            verbosity: std::env::var("CODEWHALE_VERBOSITY")
3967                .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
3968                .ok(),
3969            output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
3970            auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
3971            log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
3972            telemetry: std::env::var("DEEPSEEK_TELEMETRY")
3973                .ok()
3974                .and_then(|v| match parse_bool(&v) {
3975                    Ok(b) => Some(b),
3976                    Err(_) => {
3977                        tracing::warn!("Invalid DEEPSEEK_TELEMETRY value '{v}', expected true/false");
3978                        None
3979                    }
3980                }),
3981            approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
3982            sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
3983            yolo: std::env::var("DEEPSEEK_YOLO")
3984                .ok()
3985                .and_then(|v| match parse_bool(&v) {
3986                    Ok(b) => Some(b),
3987                    Err(_) => {
3988                        tracing::warn!("Invalid DEEPSEEK_YOLO value '{v}', expected true/false");
3989                        None
3990                    }
3991                }),
3992            http_headers: std::env::var("DEEPSEEK_HTTP_HEADERS")
3993                .ok()
3994                .and_then(|value| match parse_http_headers(&value) {
3995                    Ok(h) => Some(h),
3996                    Err(_) => {
3997                        tracing::warn!("Invalid DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
3998                        None
3999                    }
4000                })
4001                .filter(|headers| !headers.is_empty()),
4002            deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
4003                .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
4004                .ok()
4005                .filter(|v| !v.trim().is_empty()),
4006            deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
4007                .or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
4008                .ok()
4009                .filter(|v| !v.trim().is_empty()),
4010            nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
4011                .or_else(|_| std::env::var("NIM_BASE_URL"))
4012                .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
4013                .ok()
4014                .filter(|v| !v.trim().is_empty()),
4015            openai_base_url: std::env::var("OPENAI_BASE_URL")
4016                .ok()
4017                .filter(|v| !v.trim().is_empty()),
4018            atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
4019                .ok()
4020                .filter(|v| !v.trim().is_empty()),
4021            volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
4022                .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
4023                .or_else(|_| std::env::var("ARK_BASE_URL"))
4024                .ok()
4025                .filter(|v| !v.trim().is_empty()),
4026            wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
4027                .or_else(|_| std::env::var("WANJIE_BASE_URL"))
4028                .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
4029                .ok()
4030                .filter(|v| !v.trim().is_empty()),
4031            openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
4032                .ok()
4033                .filter(|v| !v.trim().is_empty()),
4034            xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
4035                .or_else(|_| std::env::var("MIMO_BASE_URL"))
4036                .ok()
4037                .filter(|v| !v.trim().is_empty()),
4038            novita_base_url: std::env::var("NOVITA_BASE_URL")
4039                .ok()
4040                .filter(|v| !v.trim().is_empty()),
4041            fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
4042                .ok()
4043                .filter(|v| !v.trim().is_empty()),
4044            siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
4045                .ok()
4046                .filter(|v| !v.trim().is_empty()),
4047            siliconflow_model: std::env::var("SILICONFLOW_MODEL")
4048                .ok()
4049                .filter(|v| !v.trim().is_empty()),
4050            arcee_base_url: std::env::var("ARCEE_BASE_URL")
4051                .ok()
4052                .filter(|v| !v.trim().is_empty()),
4053            moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
4054                .or_else(|_| std::env::var("KIMI_BASE_URL"))
4055                .ok()
4056                .filter(|v| !v.trim().is_empty()),
4057            sglang_base_url: std::env::var("SGLANG_BASE_URL")
4058                .ok()
4059                .filter(|v| !v.trim().is_empty()),
4060            vllm_base_url: std::env::var("VLLM_BASE_URL")
4061                .ok()
4062                .filter(|v| !v.trim().is_empty()),
4063            ollama_base_url: std::env::var("OLLAMA_BASE_URL")
4064                .ok()
4065                .filter(|v| !v.trim().is_empty()),
4066            huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
4067                .or_else(|_| std::env::var("HF_BASE_URL"))
4068                .ok()
4069                .filter(|v| !v.trim().is_empty()),
4070            huggingface_model: std::env::var("HUGGINGFACE_MODEL")
4071                .or_else(|_| std::env::var("HF_MODEL"))
4072                .ok()
4073                .filter(|v| !v.trim().is_empty()),
4074            together_base_url: std::env::var("TOGETHER_BASE_URL")
4075                .ok()
4076                .filter(|v| !v.trim().is_empty()),
4077            together_model: std::env::var("TOGETHER_MODEL")
4078                .ok()
4079                .filter(|v| !v.trim().is_empty()),
4080            qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
4081                .ok()
4082                .filter(|v| !v.trim().is_empty())
4083                .or_else(|| {
4084                    std::env::var("BAIDU_QIANFAN_BASE_URL")
4085                        .ok()
4086                        .filter(|v| !v.trim().is_empty())
4087                }),
4088            qianfan_model: std::env::var("QIANFAN_MODEL")
4089                .ok()
4090                .filter(|v| !v.trim().is_empty())
4091                .or_else(|| {
4092                    std::env::var("BAIDU_QIANFAN_MODEL")
4093                        .ok()
4094                        .filter(|v| !v.trim().is_empty())
4095                }),
4096            openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
4097                .or_else(|_| std::env::var("CODEX_BASE_URL"))
4098                .ok()
4099                .filter(|v| !v.trim().is_empty()),
4100            openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
4101                .or_else(|_| std::env::var("CODEX_MODEL"))
4102                .ok()
4103                .filter(|v| !v.trim().is_empty()),
4104            anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
4105                .ok()
4106                .filter(|v| !v.trim().is_empty()),
4107            anthropic_model: std::env::var("ANTHROPIC_MODEL")
4108                .ok()
4109                .filter(|v| !v.trim().is_empty()),
4110            zai_base_url: std::env::var("ZAI_BASE_URL")
4111                .or_else(|_| std::env::var("Z_AI_BASE_URL"))
4112                .or_else(|_| std::env::var("ZHIPU_BASE_URL"))
4113                .or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
4114                .or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
4115                .ok()
4116                .filter(|v| !v.trim().is_empty()),
4117            zai_model: std::env::var("ZAI_MODEL")
4118                .or_else(|_| std::env::var("Z_AI_MODEL"))
4119                .or_else(|_| std::env::var("ZHIPU_MODEL"))
4120                .or_else(|_| std::env::var("ZHIPUAI_MODEL"))
4121                .or_else(|_| std::env::var("BIGMODEL_MODEL"))
4122                .or_else(|_| std::env::var("GLM_MODEL"))
4123                .ok()
4124                .filter(|v| !v.trim().is_empty()),
4125            stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
4126                .or_else(|_| std::env::var("STEP_BASE_URL"))
4127                .ok()
4128                .filter(|v| !v.trim().is_empty()),
4129            stepfun_model: std::env::var("STEPFUN_MODEL")
4130                .or_else(|_| std::env::var("STEP_MODEL"))
4131                .ok()
4132                .filter(|v| !v.trim().is_empty()),
4133            minimax_base_url: std::env::var("MINIMAX_BASE_URL")
4134                .ok()
4135                .filter(|v| !v.trim().is_empty()),
4136            minimax_model: std::env::var("MINIMAX_MODEL")
4137                .ok()
4138                .filter(|v| !v.trim().is_empty()),
4139            deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
4140                .ok()
4141                .filter(|v| !v.trim().is_empty()),
4142            deepinfra_model: std::env::var("DEEPINFRA_MODEL")
4143                .ok()
4144                .filter(|v| !v.trim().is_empty()),
4145        }
4146    }
4147
4148    fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
4149        if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
4150            let parsed = ProviderKind::parse(&value);
4151            return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
4152        }
4153
4154        if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
4155            let parsed = ProviderKind::parse(&value);
4156            return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
4157        }
4158
4159        (None, None)
4160    }
4161
4162    fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
4163        // Defaults belong in the resolver's final fallback so config-file
4164        // values (`providers.<name>.base_url`) still win when env is unset.
4165        match provider {
4166            ProviderKind::Deepseek => self.deepseek_base_url.clone(),
4167            ProviderKind::DeepseekAnthropic => self.deepseek_anthropic_base_url.clone(),
4168            ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
4169            ProviderKind::Openai => self.openai_base_url.clone(),
4170            ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
4171            ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
4172            ProviderKind::Volcengine => self.volcengine_base_url.clone(),
4173            ProviderKind::Openrouter => self.openrouter_base_url.clone(),
4174            ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
4175            ProviderKind::Novita => self.novita_base_url.clone(),
4176            ProviderKind::Fireworks => self.fireworks_base_url.clone(),
4177            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4178                self.siliconflow_base_url.clone()
4179            }
4180            ProviderKind::Arcee => self.arcee_base_url.clone(),
4181            ProviderKind::Moonshot => self.moonshot_base_url.clone(),
4182            ProviderKind::Sglang => self.sglang_base_url.clone(),
4183            ProviderKind::Vllm => self.vllm_base_url.clone(),
4184            ProviderKind::Ollama => self.ollama_base_url.clone(),
4185            ProviderKind::Huggingface => self.huggingface_base_url.clone(),
4186            ProviderKind::Together => self.together_base_url.clone(),
4187            ProviderKind::Qianfan => self.qianfan_base_url.clone(),
4188            ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
4189            ProviderKind::Anthropic => self.anthropic_base_url.clone(),
4190            ProviderKind::Zai => self.zai_base_url.clone(),
4191            ProviderKind::Stepfun => self.stepfun_base_url.clone(),
4192            ProviderKind::Minimax => self.minimax_base_url.clone(),
4193            ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
4194            // No dedicated CODEWHALE_CUSTOM_BASE_URL env override: a custom
4195            // provider's base URL comes from its `[providers.<name>]` table.
4196            ProviderKind::Custom => None,
4197        }
4198    }
4199
4200    fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
4201        let model = match provider {
4202            ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
4203            ProviderKind::Volcengine => self.volcengine_model.clone(),
4204            ProviderKind::Openrouter => self.openrouter_model.clone(),
4205            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4206                self.siliconflow_model.clone()
4207            }
4208            ProviderKind::Arcee => self.arcee_model.clone(),
4209            ProviderKind::Moonshot => self.moonshot_model.clone(),
4210            ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
4211            ProviderKind::Novita => self.novita_model.clone(),
4212            ProviderKind::Fireworks => self.fireworks_model.clone(),
4213            ProviderKind::Huggingface => self.huggingface_model.clone(),
4214            ProviderKind::Together => self.together_model.clone(),
4215            ProviderKind::Qianfan => self.qianfan_model.clone(),
4216            ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
4217            ProviderKind::Anthropic => self.anthropic_model.clone(),
4218            ProviderKind::Zai => self.zai_model.clone(),
4219            ProviderKind::Stepfun => self.stepfun_model.clone(),
4220            ProviderKind::Minimax => self.minimax_model.clone(),
4221            ProviderKind::Deepinfra => self.deepinfra_model.clone(),
4222            _ => None,
4223        }?;
4224
4225        if provider_preserves_custom_base_url_model(provider, base_url) {
4226            Some(model.trim().to_string())
4227        } else {
4228            Some(normalize_model_for_provider(provider, &model))
4229        }
4230    }
4231}
4232
4233#[cfg(test)]
4234mod tests;