Skip to main content

aurum_core/
config.rs

1//! Configuration loading for Aurum (JOE-1935 provider profiles).
2//!
3//! # Precedence (highest wins)
4//!
5//! 1. CLI flags (via [`Config::apply_cli`] / [`ValidatedConfig::apply_cli`])
6//! 2. Environment variables for provider secrets and a few overrides
7//! 3. Config file
8//! 4. Built-in defaults
9//!
10//! # Schema (canonical)
11//!
12//! ```toml
13//! [stt]
14//! provider = "local"
15//! model = "base"
16//! language = "auto"
17//!
18//! [tts]
19//! provider = "local"
20//! model = "kitten-nano-int8"
21//! voice = "Luna"
22//! language = "en"
23//! speaking_rate = 1.0
24//!
25//! [providers.openrouter]
26//! # api_key from OPENROUTER_API_KEY preferred
27//! stt_mode = "auto"
28//!
29//! [providers.openai]
30//! # api_key from OPENAI_API_KEY
31//!
32//! [providers.elevenlabs]
33//! # api_key from ELEVENLABS_API_KEY
34//!
35//! [providers.xai]
36//! # api_key from XAI_API_KEY
37//! ```
38//!
39//! # Legacy compatibility
40//!
41//! `[default]` (STT) and `[openrouter]` continue to load with identical effective
42//! behaviour when the new sections are absent. When **both** old and new sections
43//! are present and disagree, load fails closed with [`UserError::InvalidConfig`].
44//! When only legacy is present, values are migrated into the canonical runtime
45//! fields during load (no silent behaviour change).
46//!
47//! A provider is **never** inferred merely because its API key is present.
48
49use crate::error::{Result, UserError};
50use crate::provider_platform::ProviderId;
51use crate::secret::SecretString;
52use directories::ProjectDirs;
53use serde::{Deserialize, Serialize};
54use std::fs;
55use std::path::{Path, PathBuf};
56
57/// Built-in defaults used when nothing else is set.
58pub const DEFAULT_PROVIDER: &str = "local";
59pub const DEFAULT_LOCAL_MODEL: &str = "base";
60pub const DEFAULT_OPENROUTER_MODEL: &str = "google/gemini-2.5-flash";
61pub const DEFAULT_LANGUAGE: &str = "auto";
62pub const DEFAULT_OUTPUT: &str = "txt";
63pub const DEFAULT_CLEANUP: &str = "raw";
64pub const DEFAULT_CLEANUP_PROVIDER: &str = "rules";
65pub const DEFAULT_TTS_PROVIDER: &str = "local";
66pub const DEFAULT_TTS_LANGUAGE: &str = "en";
67pub const DEFAULT_TTS_MAX_CHARS: usize = 5_000;
68pub const DEFAULT_TTS_TIMEOUT_MS: u64 = 120_000;
69pub const DEFAULT_TTS_SPEAKING_RATE: f32 = 1.0;
70pub const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
71
72/// On-disk configuration file schema.
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74pub struct ConfigFile {
75    /// Legacy STT section (`[default]`). Prefer `[stt]`.
76    #[serde(default)]
77    pub default: Option<DefaultSection>,
78    /// Legacy OpenRouter section. Prefer `[providers.openrouter]`.
79    #[serde(default)]
80    pub openrouter: Option<OpenRouterSection>,
81    /// Canonical STT direction section.
82    #[serde(default)]
83    pub stt: Option<SttSection>,
84    #[serde(default)]
85    pub cleanup: CleanupSection,
86    #[serde(default)]
87    pub tts: TtsSection,
88    /// Named provider credentials and vendor options.
89    #[serde(default)]
90    pub providers: ProvidersFileSection,
91}
92
93/// Canonical STT direction (`[stt]`).
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct SttSection {
96    #[serde(default = "default_provider")]
97    pub provider: String,
98    #[serde(default = "default_local_model")]
99    pub model: String,
100    #[serde(default = "default_language")]
101    pub language: String,
102}
103
104impl Default for SttSection {
105    fn default() -> Self {
106        Self {
107            provider: default_provider(),
108            model: default_local_model(),
109            language: default_language(),
110        }
111    }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct DefaultSection {
116    #[serde(default = "default_provider")]
117    pub provider: String,
118    #[serde(default = "default_local_model")]
119    pub model: String,
120    #[serde(default = "default_language")]
121    pub language: String,
122    #[serde(default = "default_output")]
123    pub output: String,
124}
125
126impl Default for DefaultSection {
127    fn default() -> Self {
128        Self {
129            provider: default_provider(),
130            model: default_local_model(),
131            language: default_language(),
132            output: default_output(),
133        }
134    }
135}
136
137/// Legacy `[openrouter]` section (also the shape of `[providers.openrouter]` extras).
138#[derive(Clone, Default, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct OpenRouterSection {
141    /// Prefer `OPENROUTER_API_KEY` env var over this field.
142    ///
143    /// Stored as [`SecretString`]: Debug/Display/Serialize never emit plaintext
144    /// (JOE-1914). Deserialize still loads the value from TOML.
145    pub api_key: Option<SecretString>,
146    /// Default remote model when provider is openrouter.
147    pub model: Option<String>,
148    /// Optional custom base URL (for testing / proxies).
149    pub base_url: Option<String>,
150    /// Allow credentialed non-OpenRouter HTTPS endpoints (JOE-1587). Default false.
151    #[serde(default)]
152    pub allow_custom_endpoint: bool,
153    /// STT path mode: `auto` | `chat` | `transcriptions` (JOE-1586).
154    #[serde(default = "default_stt_mode")]
155    pub stt_mode: String,
156    /// Use system HTTP(S)_PROXY (privacy implications). Default false.
157    #[serde(default)]
158    pub use_system_proxy: bool,
159}
160
161impl std::fmt::Debug for OpenRouterSection {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        f.debug_struct("OpenRouterSection")
164            .field("api_key", &self.api_key)
165            .field("model", &self.model)
166            .field("base_url", &self.base_url)
167            .field("allow_custom_endpoint", &self.allow_custom_endpoint)
168            .field("stt_mode", &self.stt_mode)
169            .field("use_system_proxy", &self.use_system_proxy)
170            .finish()
171    }
172}
173
174fn default_stt_mode() -> String {
175    "auto".into()
176}
177
178/// Shared credential + optional base URL for named remote providers.
179#[derive(Clone, Default, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181pub struct ProviderCredentialSection {
182    pub api_key: Option<SecretString>,
183    pub base_url: Option<String>,
184}
185
186impl std::fmt::Debug for ProviderCredentialSection {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        f.debug_struct("ProviderCredentialSection")
189            .field("api_key", &self.api_key)
190            .field("base_url", &self.base_url)
191            .finish()
192    }
193}
194
195/// `[providers.*]` file section — only known provider ids (deny unknown).
196#[derive(Debug, Clone, Default, Serialize, Deserialize)]
197#[serde(deny_unknown_fields)]
198pub struct ProvidersFileSection {
199    #[serde(default)]
200    pub openrouter: Option<OpenRouterSection>,
201    #[serde(default)]
202    pub openai: Option<ProviderCredentialSection>,
203    #[serde(default)]
204    pub elevenlabs: Option<ProviderCredentialSection>,
205    #[serde(default)]
206    pub xai: Option<ProviderCredentialSection>,
207}
208
209/// Runtime provider credential block (no ever-growing flat secrets list).
210#[derive(Clone, Default)]
211pub struct ProviderCredentialConfig {
212    pub api_key: Option<SecretString>,
213    pub base_url: Option<String>,
214}
215
216impl std::fmt::Debug for ProviderCredentialConfig {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        f.debug_struct("ProviderCredentialConfig")
219            .field("api_key", &self.api_key)
220            .field("base_url", &self.base_url)
221            .finish()
222    }
223}
224
225/// Typed named provider configs on the runtime [`Config`].
226#[derive(Clone, Default)]
227pub struct ProvidersConfig {
228    pub openai: ProviderCredentialConfig,
229    pub elevenlabs: ProviderCredentialConfig,
230    pub xai: ProviderCredentialConfig,
231}
232
233impl std::fmt::Debug for ProvidersConfig {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        f.debug_struct("ProvidersConfig")
236            .field("openai", &self.openai)
237            .field("elevenlabs", &self.elevenlabs)
238            .field("xai", &self.xai)
239            .finish()
240    }
241}
242
243/// Post-ASR cleanup defaults (`[cleanup]`).
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct CleanupSection {
246    /// `raw` | `clean` | `bullets` | `professional` | `summary`
247    #[serde(default = "default_cleanup")]
248    pub style: String,
249    /// `rules` (on-device) | `openrouter`
250    #[serde(default = "default_cleanup_provider")]
251    pub provider: String,
252    /// Optional model id when provider is openrouter.
253    pub openrouter_model: Option<String>,
254}
255
256impl Default for CleanupSection {
257    fn default() -> Self {
258        Self {
259            style: default_cleanup(),
260            provider: default_cleanup_provider(),
261            openrouter_model: None,
262        }
263    }
264}
265
266/// TTS direction defaults (`[tts]`).
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct TtsSection {
269    #[serde(default = "default_tts_provider")]
270    pub provider: String,
271    #[serde(default = "default_tts_model")]
272    pub model: String,
273    #[serde(default = "default_tts_voice")]
274    pub voice: String,
275    #[serde(default = "default_tts_language")]
276    pub language: String,
277    /// Playback / synthesis rate multiplier (1.0 = normal).
278    #[serde(default = "default_tts_speaking_rate")]
279    pub speaking_rate: f32,
280    #[serde(default = "default_tts_max_chars")]
281    pub max_chars: usize,
282    #[serde(default = "default_tts_timeout_ms")]
283    pub timeout_ms: u64,
284    /// Optional default local model-pack directory (JOE-1619). CLI `--pack-dir`
285    /// overrides this. Never shadows built-in catalogue cache identity.
286    #[serde(default)]
287    pub pack_dir: Option<String>,
288    /// Allow `local_unverified` packs when `pack_dir` / CLI pack is used.
289    #[serde(default)]
290    pub allow_unverified: bool,
291    /// Custom catalogue entries for supported adapters (JOE-1620).
292    #[serde(default)]
293    pub custom_models: Vec<CustomTtsModelConfig>,
294}
295
296/// Config file form of `[[tts.custom_models]]` (JOE-1620).
297#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
298pub struct CustomTtsModelConfig {
299    pub id: String,
300    pub adapter: String,
301    #[serde(default)]
302    pub pack_dir: Option<String>,
303    pub trust: String,
304    #[serde(default)]
305    pub license: Option<String>,
306    #[serde(default)]
307    pub notes: Option<String>,
308}
309
310impl Default for TtsSection {
311    fn default() -> Self {
312        Self {
313            provider: default_tts_provider(),
314            model: default_tts_model(),
315            voice: default_tts_voice(),
316            language: default_tts_language(),
317            speaking_rate: default_tts_speaking_rate(),
318            max_chars: default_tts_max_chars(),
319            timeout_ms: default_tts_timeout_ms(),
320            pack_dir: None,
321            allow_unverified: false,
322            custom_models: Vec::new(),
323        }
324    }
325}
326
327fn default_provider() -> String {
328    DEFAULT_PROVIDER.to_string()
329}
330fn default_local_model() -> String {
331    DEFAULT_LOCAL_MODEL.to_string()
332}
333fn default_language() -> String {
334    DEFAULT_LANGUAGE.to_string()
335}
336fn default_output() -> String {
337    DEFAULT_OUTPUT.to_string()
338}
339fn default_cleanup() -> String {
340    DEFAULT_CLEANUP.to_string()
341}
342fn default_cleanup_provider() -> String {
343    DEFAULT_CLEANUP_PROVIDER.to_string()
344}
345fn default_tts_provider() -> String {
346    DEFAULT_TTS_PROVIDER.to_string()
347}
348fn default_tts_model() -> String {
349    #[cfg(feature = "tts")]
350    {
351        crate::tts::DEFAULT_TTS_MODEL.to_string()
352    }
353    #[cfg(not(feature = "tts"))]
354    {
355        "kitten-nano-int8".to_string()
356    }
357}
358fn default_tts_voice() -> String {
359    #[cfg(feature = "tts")]
360    {
361        crate::tts::DEFAULT_TTS_VOICE.to_string()
362    }
363    #[cfg(not(feature = "tts"))]
364    {
365        "Luna".to_string()
366    }
367}
368fn default_tts_language() -> String {
369    DEFAULT_TTS_LANGUAGE.to_string()
370}
371fn default_tts_max_chars() -> usize {
372    DEFAULT_TTS_MAX_CHARS
373}
374fn default_tts_timeout_ms() -> u64 {
375    DEFAULT_TTS_TIMEOUT_MS
376}
377fn default_tts_speaking_rate() -> f32 {
378    DEFAULT_TTS_SPEAKING_RATE
379}
380
381/// Fully-resolved runtime configuration after merging all sources.
382///
383/// STT direction uses `provider` / `model` / `language`. TTS uses `tts_*`.
384/// OpenRouter options remain on the historical `openrouter_*` fields (mirrors of
385/// `[providers.openrouter]` / legacy `[openrouter]`). Other vendors live under
386/// [`Config::providers`] so the flat surface does not grow per vendor.
387#[derive(Clone)]
388pub struct Config {
389    /// STT provider id (`local`, `openrouter`, …). Never inferred from key presence.
390    pub provider: String,
391    pub model: Option<String>,
392    pub language: String,
393    pub output: String,
394    pub output_file: Option<PathBuf>,
395    pub timestamps: bool,
396    pub verbose: bool,
397    /// OpenRouter API key — redacted via [`SecretString`] (JOE-1779).
398    pub openrouter_api_key: Option<SecretString>,
399    pub openrouter_base_url: String,
400    pub openrouter_default_model: String,
401    /// Allow custom credentialed endpoints (JOE-1587).
402    pub openrouter_allow_custom_endpoint: bool,
403    /// `auto` | `chat` | `transcriptions` (JOE-1586).
404    pub openrouter_stt_mode: String,
405    pub openrouter_use_system_proxy: bool,
406    /// Named non-OpenRouter provider credentials (JOE-1935).
407    pub providers: ProvidersConfig,
408    /// Cleanup style name (`raw`, `clean`, …).
409    pub cleanup_style: String,
410    /// Cleanup backend name (`rules`, `openrouter`).
411    pub cleanup_provider: String,
412    /// Optional dedicated model for OpenRouter cleanup.
413    pub cleanup_openrouter_model: Option<String>,
414    /// TTS provider name (default `local`).
415    pub tts_provider: String,
416    pub tts_model: String,
417    pub tts_voice: String,
418    pub tts_language: String,
419    pub tts_speaking_rate: f32,
420    pub tts_max_chars: usize,
421    pub tts_timeout_ms: u64,
422    /// Optional default pack directory for local override (JOE-1619).
423    pub tts_pack_dir: Option<PathBuf>,
424    pub tts_allow_unverified: bool,
425    /// Validated custom TTS catalogue entries (empty when packs not present yet).
426    pub tts_custom_models: Vec<CustomTtsModelConfig>,
427    /// When true, remote STT/TTS providers are rejected at validation (JOE-1935).
428    pub local_only: bool,
429    pub config_path: Option<PathBuf>,
430    pub cache_dir: PathBuf,
431}
432
433impl std::fmt::Debug for Config {
434    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435        f.debug_struct("Config")
436            .field("provider", &self.provider)
437            .field("model", &self.model)
438            .field("language", &self.language)
439            .field("output", &self.output)
440            .field("output_file", &self.output_file)
441            .field("timestamps", &self.timestamps)
442            .field("verbose", &self.verbose)
443            .field("openrouter_api_key", &self.openrouter_api_key)
444            .field("openrouter_base_url", &self.openrouter_base_url)
445            .field("openrouter_default_model", &self.openrouter_default_model)
446            .field(
447                "openrouter_allow_custom_endpoint",
448                &self.openrouter_allow_custom_endpoint,
449            )
450            .field("openrouter_stt_mode", &self.openrouter_stt_mode)
451            .field(
452                "openrouter_use_system_proxy",
453                &self.openrouter_use_system_proxy,
454            )
455            .field("providers", &self.providers)
456            .field("cleanup_style", &self.cleanup_style)
457            .field("cleanup_provider", &self.cleanup_provider)
458            .field("cleanup_openrouter_model", &self.cleanup_openrouter_model)
459            .field("tts_provider", &self.tts_provider)
460            .field("tts_model", &self.tts_model)
461            .field("tts_voice", &self.tts_voice)
462            .field("tts_language", &self.tts_language)
463            .field("tts_speaking_rate", &self.tts_speaking_rate)
464            .field("tts_max_chars", &self.tts_max_chars)
465            .field("tts_timeout_ms", &self.tts_timeout_ms)
466            .field("tts_pack_dir", &self.tts_pack_dir)
467            .field("tts_allow_unverified", &self.tts_allow_unverified)
468            .field("tts_custom_models", &self.tts_custom_models)
469            .field("local_only", &self.local_only)
470            .field("config_path", &self.config_path)
471            .field("cache_dir", &self.cache_dir)
472            .finish()
473    }
474}
475
476/// Source of an effective config value.
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
478#[serde(rename_all = "snake_case")]
479pub enum ConfigValueSource {
480    Default,
481    File,
482    Environment,
483    Cli,
484}
485
486/// Attribution for key fields (diagnostics only).
487#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct ConfigSourceMap {
489    pub provider: ConfigValueSource,
490    pub openrouter_api_key: ConfigValueSource,
491    pub openrouter_base_url: ConfigValueSource,
492    pub openai_api_key: ConfigValueSource,
493    pub elevenlabs_api_key: ConfigValueSource,
494    pub xai_api_key: ConfigValueSource,
495    pub tts_model: ConfigValueSource,
496}
497
498impl ConfigSourceMap {
499    fn default_attribution(cfg: &Config) -> Self {
500        let key_src =
501            env_or_file_or_default("OPENROUTER_API_KEY", cfg.openrouter_api_key.is_some());
502        let base_src = if env_nonempty("OPENROUTER_BASE_URL") {
503            ConfigValueSource::Environment
504        } else {
505            ConfigValueSource::File
506        };
507        let tts_src = if env_nonempty("AURUM_TTS_MODEL") {
508            ConfigValueSource::Environment
509        } else {
510            ConfigValueSource::File
511        };
512        Self {
513            provider: if cfg.config_path.is_some() {
514                ConfigValueSource::File
515            } else {
516                ConfigValueSource::Default
517            },
518            openrouter_api_key: key_src,
519            openrouter_base_url: base_src,
520            openai_api_key: env_or_file_or_default(
521                "OPENAI_API_KEY",
522                cfg.providers.openai.api_key.is_some(),
523            ),
524            elevenlabs_api_key: env_or_file_or_default(
525                "ELEVENLABS_API_KEY",
526                cfg.providers.elevenlabs.api_key.is_some(),
527            ),
528            xai_api_key: env_or_file_or_default("XAI_API_KEY", cfg.providers.xai.api_key.is_some()),
529            tts_model: tts_src,
530        }
531    }
532}
533
534fn env_nonempty(name: &str) -> bool {
535    std::env::var(name).ok().filter(|s| !s.is_empty()).is_some()
536}
537
538fn env_or_file_or_default(env_name: &str, file_present: bool) -> ConfigValueSource {
539    if env_nonempty(env_name) {
540        ConfigValueSource::Environment
541    } else if file_present {
542        ConfigValueSource::File
543    } else {
544        ConfigValueSource::Default
545    }
546}
547
548/// Redacted presence metadata for one provider credential.
549#[derive(Debug, Clone, Serialize, Deserialize)]
550pub struct ProviderSecretDiagnostic {
551    /// `Some("***")` when a key is present; `None` when absent. Never plaintext.
552    pub api_key: Option<String>,
553    pub base_url: Option<String>,
554    pub api_key_source: ConfigValueSource,
555}
556
557/// Redacted JSON-serializable effective config.
558#[derive(Debug, Clone, Serialize, Deserialize)]
559pub struct EffectiveConfigDiagnostic {
560    pub provider: String,
561    pub model: Option<String>,
562    pub language: String,
563    pub output: String,
564    pub timestamps: bool,
565    pub openrouter_api_key: Option<String>,
566    pub openrouter_base_url: String,
567    pub openrouter_default_model: String,
568    pub openrouter_stt_mode: String,
569    pub openrouter_allow_custom_endpoint: bool,
570    /// Redacted provider secret presence (JOE-1935).
571    pub providers: ProvidersDiagnostic,
572    pub cleanup_style: String,
573    pub cleanup_provider: String,
574    pub tts_provider: String,
575    pub tts_model: String,
576    pub tts_voice: String,
577    pub tts_language: String,
578    pub tts_speaking_rate: f32,
579    pub tts_max_chars: usize,
580    pub tts_timeout_ms: u64,
581    pub tts_pack_dir: Option<String>,
582    pub tts_allow_unverified: bool,
583    pub tts_custom_model_ids: Vec<String>,
584    pub local_only: bool,
585    pub config_path: Option<String>,
586    pub cache_dir: String,
587    pub sources: ConfigSourceMap,
588}
589
590/// Redacted view of named provider credentials.
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct ProvidersDiagnostic {
593    pub openrouter: ProviderSecretDiagnostic,
594    pub openai: ProviderSecretDiagnostic,
595    pub elevenlabs: ProviderSecretDiagnostic,
596    pub xai: ProviderSecretDiagnostic,
597}
598
599impl Config {
600    /// Resolve the platform-appropriate config file path.
601    pub fn default_config_path() -> Option<PathBuf> {
602        ProjectDirs::from("", "", "aurum").map(|d| d.config_dir().join("config.toml"))
603    }
604
605    /// Resolve the platform-appropriate cache directory (models live under `models/`).
606    pub fn default_cache_dir() -> Result<PathBuf> {
607        if let Some(dirs) = ProjectDirs::from("", "", "aurum") {
608            return Ok(dirs.cache_dir().to_path_buf());
609        }
610        let home = dirs_home()?;
611        Ok(home.join(".cache").join("aurum"))
612    }
613
614    /// Load config file from the default location (if present) and merge with env vars.
615    pub fn load() -> Result<Self> {
616        let path = Self::default_config_path();
617        let file = match &path {
618            Some(p) if p.exists() => Some(load_config_file(p)?),
619            _ => None,
620        };
621        let cfg = Self::from_parts(file, path)?;
622        cfg.validate_tts_custom_models()?;
623        Ok(cfg)
624    }
625
626    /// Load from an explicit config file path (used in tests / CLI `--config`).
627    pub fn load_from(path: &Path) -> Result<Self> {
628        let file = if path.exists() {
629            Some(load_config_file(path)?)
630        } else {
631            None
632        };
633        let cfg = Self::from_parts(file, Some(path.to_path_buf()))?;
634        cfg.validate_tts_custom_models()?;
635        Ok(cfg)
636    }
637
638    /// Load from an explicit path; **error** if the file is missing (JOE-1608).
639    pub fn load_from_required(path: &Path) -> Result<Self> {
640        if !path.exists() {
641            return Err(UserError::InvalidConfig {
642                reason: format!(
643                    "config file not found: {}\n  Hint: create it or omit --config to use defaults",
644                    path.display()
645                ),
646            }
647            .into());
648        }
649        let file = load_config_file(path)?;
650        let cfg = Self::from_parts(Some(file), Some(path.to_path_buf()))?;
651        cfg.validate()?;
652        Ok(cfg)
653    }
654
655    /// Expose the OpenRouter API key as a plain string for provider construction.
656    pub fn openrouter_api_key_exposed(&self) -> Option<String> {
657        self.openrouter_api_key
658            .as_ref()
659            .map(|s| s.expose().to_string())
660    }
661
662    /// Provider-scoped secret for build-context construction (JOE-1935).
663    pub fn provider_secret(&self, id: &ProviderId) -> Option<SecretString> {
664        match id.as_str() {
665            "openrouter" => self.openrouter_api_key.clone(),
666            "openai" => self.providers.openai.api_key.clone(),
667            "elevenlabs" => self.providers.elevenlabs.api_key.clone(),
668            "xai" => self.providers.xai.api_key.clone(),
669            _ => None,
670        }
671    }
672
673    /// Validate provider/model/style/limit cross-fields (JOE-1608 / JOE-1935).
674    pub fn validate(&self) -> Result<()> {
675        validate_stt_provider(&self.provider)?;
676        validate_tts_provider(&self.tts_provider)?;
677        let _ = crate::output::OutputFormat::parse(&self.output)?;
678        let _ = crate::cleanup::CleanupStyle::parse(&self.cleanup_style)?;
679        let _ = crate::cleanup::CleanupProviderKind::parse(&self.cleanup_provider)?;
680        let _ = crate::providers::OpenRouterSttMode::parse(&self.openrouter_stt_mode)?;
681
682        if self.tts_max_chars == 0 {
683            return Err(UserError::InvalidConfig {
684                reason: "tts.max_chars must be >= 1".into(),
685            }
686            .into());
687        }
688        if self.tts_timeout_ms == 0 {
689            return Err(UserError::InvalidConfig {
690                reason: "tts.timeout_ms must be >= 1".into(),
691            }
692            .into());
693        }
694        if self.tts_max_chars > 500_000 {
695            return Err(UserError::InvalidConfig {
696                reason: format!(
697                    "tts.max_chars {} exceeds safe ceiling 500000",
698                    self.tts_max_chars
699                ),
700            }
701            .into());
702        }
703        if !self.tts_speaking_rate.is_finite()
704            || self.tts_speaking_rate <= 0.0
705            || self.tts_speaking_rate > 4.0
706        {
707            return Err(UserError::InvalidConfig {
708                reason: format!(
709                    "tts.speaking_rate must be finite and in (0, 4] (got {})",
710                    self.tts_speaking_rate
711                ),
712            }
713            .into());
714        }
715        if !self.openrouter_base_url.starts_with("https://")
716            && !self.openrouter_base_url.starts_with("http://localhost")
717            && !self.openrouter_base_url.contains("127.0.0.1")
718            && self.openrouter_base_url.starts_with("http://")
719        {
720            return Err(UserError::InvalidConfig {
721                reason: format!(
722                    "openrouter base_url must use https (got {})",
723                    self.openrouter_base_url
724                ),
725            }
726            .into());
727        }
728
729        if self.local_only {
730            if is_remote_provider(&self.provider) {
731                return Err(UserError::InvalidConfig {
732                    reason: format!(
733                        "local_only=true rejects remote STT provider '{}'\n  \
734                         Hint: set [stt] provider = \"local\" (or [default] provider) \
735                         or unset local_only",
736                        self.provider
737                    ),
738                }
739                .into());
740            }
741            if is_remote_provider(&self.tts_provider) {
742                return Err(UserError::InvalidConfig {
743                    reason: format!(
744                        "local_only=true rejects remote TTS provider '{}'\n  \
745                         Hint: set [tts] provider = \"local\" or unset local_only",
746                        self.tts_provider
747                    ),
748                }
749                .into());
750            }
751        }
752
753        self.validate_tts_custom_models()?;
754        Ok(())
755    }
756
757    /// Validate `[[tts.custom_models]]` uniqueness and reserved namespaces.
758    pub fn validate_tts_custom_models(&self) -> Result<()> {
759        #[cfg(feature = "tts")]
760        {
761            use crate::tts::{validate_custom_models, CustomTtsModelEntry, MAX_CUSTOM_MODELS};
762            if self.tts_custom_models.len() > MAX_CUSTOM_MODELS {
763                return Err(UserError::InvalidConfig {
764                    reason: format!(
765                        "too many [[tts.custom_models]] entries ({} > {MAX_CUSTOM_MODELS})",
766                        self.tts_custom_models.len()
767                    ),
768                }
769                .into());
770            }
771            let mut ids = std::collections::HashSet::new();
772            let mut present = Vec::new();
773            for e in &self.tts_custom_models {
774                let id = e.id.trim();
775                if id.is_empty() {
776                    return Err(UserError::InvalidConfig {
777                        reason: "custom TTS model id must be non-empty".into(),
778                    }
779                    .into());
780                }
781                if !ids.insert(id.to_string()) {
782                    return Err(UserError::InvalidConfig {
783                        reason: format!("duplicate custom TTS model id '{id}'"),
784                    }
785                    .into());
786                }
787                if id == crate::tts::DEFAULT_TTS_MODEL
788                    || crate::tts::lookup_model(id)
789                        .map(|m| m.shipped)
790                        .unwrap_or(false)
791                {
792                    return Err(UserError::InvalidConfig {
793                        reason: format!(
794                            "custom model id '{id}' collides with built-in catalogue entry"
795                        ),
796                    }
797                    .into());
798                }
799                let _ = crate::tts::lookup_adapter(&e.adapter)?;
800                let trust = crate::tts::TrustMode::parse(&e.trust)?;
801                if matches!(trust, crate::tts::TrustMode::Builtin) {
802                    return Err(UserError::InvalidConfig {
803                        reason: "custom models cannot use trust=builtin".into(),
804                    }
805                    .into());
806                }
807                if let Some(dir) = e.pack_dir.as_ref().map(PathBuf::from) {
808                    if dir.exists() {
809                        present.push(CustomTtsModelEntry {
810                            id: e.id.clone(),
811                            adapter: e.adapter.clone(),
812                            pack_dir: e.pack_dir.clone(),
813                            trust: e.trust.clone(),
814                            license: e.license.clone(),
815                            notes: e.notes.clone(),
816                        });
817                    }
818                } else {
819                    return Err(UserError::InvalidConfig {
820                        reason: format!(
821                            "custom model '{id}' requires pack_dir (remote custom packs \
822                             are not enabled in v0.0.3)"
823                        ),
824                    }
825                    .into());
826                }
827            }
828            if !present.is_empty() {
829                let _ = validate_custom_models(&present)?;
830            }
831        }
832        Ok(())
833    }
834
835    /// Redacted diagnostic view for `--print-effective-config` (JOE-1608 / JOE-1935).
836    pub fn effective_diagnostic(&self) -> EffectiveConfigDiagnostic {
837        let sources = ConfigSourceMap::default_attribution(self);
838        EffectiveConfigDiagnostic {
839            provider: self.provider.clone(),
840            model: self.model.clone(),
841            language: self.language.clone(),
842            output: self.output.clone(),
843            timestamps: self.timestamps,
844            openrouter_api_key: self.openrouter_api_key.as_ref().map(|_| "***".into()),
845            openrouter_base_url: self.openrouter_base_url.clone(),
846            openrouter_default_model: self.openrouter_default_model.clone(),
847            openrouter_stt_mode: self.openrouter_stt_mode.clone(),
848            openrouter_allow_custom_endpoint: self.openrouter_allow_custom_endpoint,
849            providers: ProvidersDiagnostic {
850                openrouter: ProviderSecretDiagnostic {
851                    api_key: self.openrouter_api_key.as_ref().map(|_| "***".into()),
852                    base_url: Some(self.openrouter_base_url.clone()),
853                    api_key_source: sources.openrouter_api_key,
854                },
855                openai: ProviderSecretDiagnostic {
856                    api_key: self.providers.openai.api_key.as_ref().map(|_| "***".into()),
857                    base_url: self.providers.openai.base_url.clone(),
858                    api_key_source: sources.openai_api_key,
859                },
860                elevenlabs: ProviderSecretDiagnostic {
861                    api_key: self
862                        .providers
863                        .elevenlabs
864                        .api_key
865                        .as_ref()
866                        .map(|_| "***".into()),
867                    base_url: self.providers.elevenlabs.base_url.clone(),
868                    api_key_source: sources.elevenlabs_api_key,
869                },
870                xai: ProviderSecretDiagnostic {
871                    api_key: self.providers.xai.api_key.as_ref().map(|_| "***".into()),
872                    base_url: self.providers.xai.base_url.clone(),
873                    api_key_source: sources.xai_api_key,
874                },
875            },
876            cleanup_style: self.cleanup_style.clone(),
877            cleanup_provider: self.cleanup_provider.clone(),
878            tts_provider: self.tts_provider.clone(),
879            tts_model: self.tts_model.clone(),
880            tts_voice: self.tts_voice.clone(),
881            tts_language: self.tts_language.clone(),
882            tts_speaking_rate: self.tts_speaking_rate,
883            tts_max_chars: self.tts_max_chars,
884            tts_timeout_ms: self.tts_timeout_ms,
885            tts_pack_dir: self.tts_pack_dir.as_ref().map(|p| p.display().to_string()),
886            tts_allow_unverified: self.tts_allow_unverified,
887            tts_custom_model_ids: self
888                .tts_custom_models
889                .iter()
890                .map(|m| m.id.clone())
891                .collect(),
892            local_only: self.local_only,
893            config_path: self.config_path.as_ref().map(|p| p.display().to_string()),
894            cache_dir: self.cache_dir.display().to_string(),
895            sources,
896        }
897    }
898
899    fn from_parts(file: Option<ConfigFile>, config_path: Option<PathBuf>) -> Result<Self> {
900        let file = file.unwrap_or_default();
901
902        let (provider, model, language, output) = merge_stt_and_default(&file)?;
903        let openrouter = merge_openrouter(&file)?;
904
905        let openrouter_api_key = std::env::var("OPENROUTER_API_KEY")
906            .ok()
907            .filter(|s| !s.is_empty())
908            .map(SecretString::new)
909            .or(openrouter.api_key);
910
911        let openrouter_base_url = std::env::var("OPENROUTER_BASE_URL")
912            .ok()
913            .filter(|s| !s.is_empty())
914            .or(openrouter.base_url)
915            .unwrap_or_else(|| DEFAULT_OPENROUTER_BASE_URL.to_string());
916
917        let openrouter_default_model = openrouter
918            .model
919            .unwrap_or_else(|| DEFAULT_OPENROUTER_MODEL.to_string());
920
921        let openai = merge_provider_cred(file.providers.openai.as_ref(), "OPENAI_API_KEY");
922        let elevenlabs =
923            merge_provider_cred(file.providers.elevenlabs.as_ref(), "ELEVENLABS_API_KEY");
924        let xai = merge_provider_cred(file.providers.xai.as_ref(), "XAI_API_KEY");
925
926        let cache_dir =
927            Self::default_cache_dir().unwrap_or_else(|_| std::env::temp_dir().join("aurum-cache"));
928
929        let tts_model = std::env::var("AURUM_TTS_MODEL")
930            .ok()
931            .filter(|s| !s.is_empty())
932            .unwrap_or_else(|| file.tts.model.clone());
933        let tts_voice = std::env::var("AURUM_TTS_VOICE")
934            .ok()
935            .filter(|s| !s.is_empty())
936            .unwrap_or_else(|| file.tts.voice.clone());
937        let tts_language = std::env::var("AURUM_TTS_LANGUAGE")
938            .ok()
939            .filter(|s| !s.is_empty())
940            .unwrap_or_else(|| file.tts.language.clone());
941
942        Ok(Self {
943            provider,
944            model: Some(model),
945            language,
946            output,
947            output_file: None,
948            timestamps: false,
949            verbose: false,
950            openrouter_api_key,
951            openrouter_base_url,
952            openrouter_default_model,
953            openrouter_allow_custom_endpoint: openrouter.allow_custom_endpoint,
954            openrouter_stt_mode: if openrouter.stt_mode.trim().is_empty() {
955                default_stt_mode()
956            } else {
957                openrouter.stt_mode
958            },
959            openrouter_use_system_proxy: openrouter.use_system_proxy,
960            providers: ProvidersConfig {
961                openai,
962                elevenlabs,
963                xai,
964            },
965            cleanup_style: file.cleanup.style,
966            cleanup_provider: file.cleanup.provider,
967            cleanup_openrouter_model: file.cleanup.openrouter_model,
968            tts_provider: file.tts.provider,
969            tts_model,
970            tts_voice,
971            tts_language,
972            tts_speaking_rate: file.tts.speaking_rate,
973            tts_max_chars: file.tts.max_chars.max(1),
974            tts_timeout_ms: if file.tts.timeout_ms == 0 {
975                DEFAULT_TTS_TIMEOUT_MS
976            } else {
977                file.tts.timeout_ms
978            },
979            tts_pack_dir: file.tts.pack_dir.map(PathBuf::from),
980            tts_allow_unverified: file.tts.allow_unverified,
981            tts_custom_models: file.tts.custom_models,
982            local_only: false,
983            config_path,
984            cache_dir,
985        })
986    }
987
988    /// Apply CLI overrides on top of the loaded config.
989    #[allow(clippy::too_many_arguments)]
990    pub fn apply_cli(
991        &mut self,
992        provider: Option<&str>,
993        model: Option<&str>,
994        language: Option<&str>,
995        output: Option<&str>,
996        output_file: Option<&Path>,
997        timestamps: bool,
998        verbose: bool,
999        cleanup: Option<&str>,
1000        cleanup_provider: Option<&str>,
1001        cleanup_model: Option<&str>,
1002    ) {
1003        if let Some(p) = provider {
1004            self.provider = p.to_string();
1005        }
1006        if let Some(m) = model {
1007            self.model = Some(m.to_string());
1008        }
1009        if let Some(l) = language {
1010            self.language = l.to_string();
1011        }
1012        if let Some(o) = output {
1013            self.output = o.to_string();
1014        }
1015        if let Some(path) = output_file {
1016            self.output_file = Some(path.to_path_buf());
1017        }
1018        if timestamps {
1019            self.timestamps = true;
1020        }
1021        if verbose {
1022            self.verbose = true;
1023        }
1024        if let Some(c) = cleanup {
1025            self.cleanup_style = c.to_string();
1026        }
1027        if let Some(p) = cleanup_provider {
1028            self.cleanup_provider = p.to_string();
1029        }
1030        if let Some(m) = cleanup_model {
1031            self.cleanup_openrouter_model = Some(m.to_string());
1032        }
1033    }
1034
1035    /// Resolve the effective model for the active provider.
1036    pub fn resolve_model(&self, model_explicitly_set: bool) -> Result<String> {
1037        if model_explicitly_set {
1038            let m = self
1039                .model
1040                .clone()
1041                .unwrap_or_else(|| self.default_model_for_provider());
1042            if self.provider == "openrouter"
1043                && !m.contains('/')
1044                && (crate::model::lookup_model(&m).is_ok() || m == DEFAULT_LOCAL_MODEL)
1045            {
1046                return Err(UserError::Other {
1047                    message: format!(
1048                        "model '{m}' looks like a local whisper model, not an OpenRouter id.\n \
1049 Hint: use e.g. google/gemini-2.5-flash-lite or openai/gpt-audio-mini, \
1050 or omit --model to use the OpenRouter default."
1051                    ),
1052                }
1053                .into());
1054            }
1055            return Ok(m);
1056        }
1057        match self.provider.as_str() {
1058            "openrouter" => {
1059                let m = self
1060                    .model
1061                    .clone()
1062                    .unwrap_or_else(|| self.openrouter_default_model.clone());
1063                if m.contains('/') {
1064                    Ok(m)
1065                } else if m == DEFAULT_LOCAL_MODEL || crate::model::lookup_model(&m).is_ok() {
1066                    Ok(self.openrouter_default_model.clone())
1067                } else {
1068                    Ok(m)
1069                }
1070            }
1071            _ => Ok(self
1072                .model
1073                .clone()
1074                .unwrap_or_else(|| DEFAULT_LOCAL_MODEL.to_string())),
1075        }
1076    }
1077
1078    fn default_model_for_provider(&self) -> String {
1079        match self.provider.as_str() {
1080            "openrouter" => self.openrouter_default_model.clone(),
1081            _ => DEFAULT_LOCAL_MODEL.to_string(),
1082        }
1083    }
1084}
1085
1086// --- Merge / migration helpers (JOE-1935) ------------------------------------
1087
1088struct MergedOpenRouter {
1089    api_key: Option<SecretString>,
1090    model: Option<String>,
1091    base_url: Option<String>,
1092    allow_custom_endpoint: bool,
1093    stt_mode: String,
1094    use_system_proxy: bool,
1095}
1096
1097/// Merge `[stt]` with legacy `[default]`.
1098fn merge_stt_and_default(file: &ConfigFile) -> Result<(String, String, String, String)> {
1099    let legacy = file.default.as_ref();
1100    let stt = file.stt.as_ref();
1101
1102    match (legacy, stt) {
1103        (Some(d), Some(s)) => {
1104            if d.provider != s.provider || d.model != s.model || d.language != s.language {
1105                return Err(UserError::InvalidConfig {
1106                    reason: format!(
1107                        "conflicting [default] and [stt] sections\n  \
1108                         [default]: provider={:?} model={:?} language={:?}\n  \
1109                         [stt]:     provider={:?} model={:?} language={:?}\n  \
1110                         Hint: keep only [stt] (preferred) or make both agree; \
1111                         Aurum does not silently pick one",
1112                        d.provider, d.model, d.language, s.provider, s.model, s.language
1113                    ),
1114                }
1115                .into());
1116            }
1117            Ok((
1118                s.provider.clone(),
1119                s.model.clone(),
1120                s.language.clone(),
1121                d.output.clone(),
1122            ))
1123        }
1124        (Some(d), None) => Ok((
1125            d.provider.clone(),
1126            d.model.clone(),
1127            d.language.clone(),
1128            d.output.clone(),
1129        )),
1130        (None, Some(s)) => Ok((
1131            s.provider.clone(),
1132            s.model.clone(),
1133            s.language.clone(),
1134            default_output(),
1135        )),
1136        (None, None) => Ok((
1137            default_provider(),
1138            default_local_model(),
1139            default_language(),
1140            default_output(),
1141        )),
1142    }
1143}
1144
1145/// Merge legacy `[openrouter]` with `[providers.openrouter]`.
1146fn merge_openrouter(file: &ConfigFile) -> Result<MergedOpenRouter> {
1147    let legacy = file.openrouter.as_ref();
1148    let neu = file.providers.openrouter.as_ref();
1149
1150    match (legacy, neu) {
1151        (Some(a), Some(b)) => {
1152            if openrouter_sections_disagree(a, b) {
1153                return Err(UserError::InvalidConfig {
1154                    reason: "conflicting [openrouter] and [providers.openrouter] sections\n  \
1155                             Hint: keep only [providers.openrouter] (preferred) or make both \
1156                             agree; Aurum does not silently merge disagreements"
1157                        .into(),
1158                }
1159                .into());
1160            }
1161            Ok(merged_from_section(b))
1162        }
1163        (Some(a), None) => Ok(merged_from_section(a)),
1164        (None, Some(b)) => Ok(merged_from_section(b)),
1165        (None, None) => Ok(MergedOpenRouter {
1166            api_key: None,
1167            model: None,
1168            base_url: None,
1169            allow_custom_endpoint: false,
1170            stt_mode: default_stt_mode(),
1171            use_system_proxy: false,
1172        }),
1173    }
1174}
1175
1176fn merged_from_section(s: &OpenRouterSection) -> MergedOpenRouter {
1177    MergedOpenRouter {
1178        api_key: s.api_key.clone(),
1179        model: s.model.clone(),
1180        base_url: s.base_url.clone(),
1181        allow_custom_endpoint: s.allow_custom_endpoint,
1182        stt_mode: if s.stt_mode.trim().is_empty() {
1183            default_stt_mode()
1184        } else {
1185            s.stt_mode.clone()
1186        },
1187        use_system_proxy: s.use_system_proxy,
1188    }
1189}
1190
1191fn openrouter_sections_disagree(a: &OpenRouterSection, b: &OpenRouterSection) -> bool {
1192    secrets_disagree(&a.api_key, &b.api_key)
1193        || a.model != b.model
1194        || a.base_url != b.base_url
1195        || a.allow_custom_endpoint != b.allow_custom_endpoint
1196        || normalize_stt_mode(&a.stt_mode) != normalize_stt_mode(&b.stt_mode)
1197        || a.use_system_proxy != b.use_system_proxy
1198}
1199
1200fn normalize_stt_mode(s: &str) -> String {
1201    let t = s.trim();
1202    if t.is_empty() {
1203        default_stt_mode()
1204    } else {
1205        t.to_ascii_lowercase()
1206    }
1207}
1208
1209fn secrets_disagree(a: &Option<SecretString>, b: &Option<SecretString>) -> bool {
1210    match (a, b) {
1211        (None, None) => false,
1212        (Some(x), Some(y)) => x.expose() != y.expose(),
1213        (Some(_), None) | (None, Some(_)) => true,
1214    }
1215}
1216
1217fn merge_provider_cred(
1218    file: Option<&ProviderCredentialSection>,
1219    env_key: &str,
1220) -> ProviderCredentialConfig {
1221    let from_file = file.cloned().unwrap_or_default();
1222    let api_key = std::env::var(env_key)
1223        .ok()
1224        .filter(|s| !s.is_empty())
1225        .map(SecretString::new)
1226        .or(from_file.api_key);
1227    ProviderCredentialConfig {
1228        api_key,
1229        base_url: from_file.base_url,
1230    }
1231}
1232
1233fn is_remote_provider(name: &str) -> bool {
1234    !matches!(name.to_ascii_lowercase().as_str(), "local" | "")
1235}
1236
1237fn validate_stt_provider(name: &str) -> Result<()> {
1238    match name.to_ascii_lowercase().as_str() {
1239        "local" | "openrouter" | "openai" | "xai" => Ok(()),
1240        "elevenlabs" => Err(UserError::InvalidConfig {
1241            reason: "provider 'elevenlabs' is not valid for STT (TTS only)\n  \
1242                     Hint: use local, openrouter, openai, or xai for speech-to-text"
1243                .into(),
1244        }
1245        .into()),
1246        other => Err(UserError::InvalidProvider {
1247            provider: other.into(),
1248        }
1249        .into()),
1250    }
1251}
1252
1253fn validate_tts_provider(name: &str) -> Result<()> {
1254    match name.to_ascii_lowercase().as_str() {
1255        "local" | "openrouter" | "openai" | "elevenlabs" | "xai" => Ok(()),
1256        other => Err(UserError::InvalidConfig {
1257            reason: format!(
1258                "unknown TTS provider '{other}'\n  \
1259                 Hint: use local, openrouter, openai, elevenlabs, or xai"
1260            ),
1261        }
1262        .into()),
1263    }
1264}
1265
1266/// On-disk / pre-merge configuration schema (raw).
1267pub type RawConfig = ConfigFile;
1268
1269/// Configuration that has passed [`Config::validate`] (JOE-1779 / JOE-1654).
1270#[derive(Clone)]
1271pub struct ValidatedConfig {
1272    inner: Config,
1273}
1274
1275impl std::fmt::Debug for ValidatedConfig {
1276    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1277        f.debug_struct("ValidatedConfig")
1278            .field("inner", &self.inner)
1279            .finish()
1280    }
1281}
1282
1283impl ValidatedConfig {
1284    /// Validate and wrap a raw runtime config.
1285    pub fn try_from_config(cfg: Config) -> Result<Self> {
1286        cfg.validate()?;
1287        Ok(Self { inner: cfg })
1288    }
1289
1290    /// Load defaults/file/env and validate.
1291    pub fn load() -> Result<Self> {
1292        Self::try_from_config(Config::load()?)
1293    }
1294
1295    /// Load an optional path (missing → defaults) then validate.
1296    pub fn load_from(path: &Path) -> Result<Self> {
1297        Self::try_from_config(Config::load_from(path)?)
1298    }
1299
1300    /// Load a required path then validate.
1301    pub fn load_from_required(path: &Path) -> Result<Self> {
1302        Self::try_from_config(Config::load_from_required(path)?)
1303    }
1304
1305    pub fn as_config(&self) -> &Config {
1306        &self.inner
1307    }
1308
1309    /// Consume into the underlying config (already validated).
1310    pub fn into_config(self) -> Config {
1311        self.inner
1312    }
1313
1314    /// Provider-scoped secret for [`crate::provider_platform::ProviderBuildContext`] (JOE-1935).
1315    pub fn provider_secret(&self, id: &ProviderId) -> Option<SecretString> {
1316        self.inner.provider_secret(id)
1317    }
1318
1319    /// Set offline policy and re-validate (fail closed on remote providers).
1320    pub fn with_local_only(mut self, local_only: bool) -> Result<Self> {
1321        self.inner.local_only = local_only;
1322        Self::try_from_config(self.inner)
1323    }
1324
1325    /// Apply CLI overrides then re-validate (fail closed).
1326    #[allow(clippy::too_many_arguments)]
1327    pub fn apply_cli(
1328        mut self,
1329        provider: Option<&str>,
1330        model: Option<&str>,
1331        language: Option<&str>,
1332        output: Option<&str>,
1333        output_file: Option<&Path>,
1334        timestamps: bool,
1335        verbose: bool,
1336        cleanup: Option<&str>,
1337        cleanup_provider: Option<&str>,
1338        cleanup_model: Option<&str>,
1339    ) -> Result<Self> {
1340        self.inner.apply_cli(
1341            provider,
1342            model,
1343            language,
1344            output,
1345            output_file,
1346            timestamps,
1347            verbose,
1348            cleanup,
1349            cleanup_provider,
1350            cleanup_model,
1351        );
1352        Self::try_from_config(self.inner)
1353    }
1354}
1355
1356impl AsRef<Config> for ValidatedConfig {
1357    fn as_ref(&self) -> &Config {
1358        &self.inner
1359    }
1360}
1361
1362impl std::ops::Deref for ValidatedConfig {
1363    type Target = Config;
1364    fn deref(&self) -> &Self::Target {
1365        &self.inner
1366    }
1367}
1368
1369/// Maximum config file size before TOML parse (JOE-1593).
1370pub const MAX_CONFIG_BYTES: u64 = 256 * 1024;
1371
1372fn load_config_file(path: &Path) -> Result<ConfigFile> {
1373    let meta = fs::metadata(path).map_err(|e| UserError::InvalidConfig {
1374        reason: format!("failed to stat {}: {e}", path.display()),
1375    })?;
1376    if meta.len() > MAX_CONFIG_BYTES {
1377        return Err(UserError::InvalidConfig {
1378            reason: format!(
1379                "config file {} is too large ({} > {MAX_CONFIG_BYTES} bytes)",
1380                path.display(),
1381                meta.len()
1382            ),
1383        }
1384        .into());
1385    }
1386    let contents = fs::read_to_string(path).map_err(|e| UserError::InvalidConfig {
1387        reason: format!("failed to read {}: {e}", path.display()),
1388    })?;
1389    toml::from_str(&contents).map_err(|e| {
1390        UserError::InvalidConfig {
1391            reason: format!("failed to parse {}: {e}", path.display()),
1392        }
1393        .into()
1394    })
1395}
1396
1397fn dirs_home() -> Result<PathBuf> {
1398    if let Ok(h) = std::env::var("HOME") {
1399        return Ok(PathBuf::from(h));
1400    }
1401    if let Ok(h) = std::env::var("USERPROFILE") {
1402        return Ok(PathBuf::from(h));
1403    }
1404    Err(UserError::InvalidConfig {
1405        reason: "could not determine home directory".into(),
1406    }
1407    .into())
1408}
1409
1410/// Write a starter config file if one does not already exist.
1411pub fn write_example_config(path: &Path) -> Result<()> {
1412    if path.exists() {
1413        return Ok(());
1414    }
1415    if let Some(parent) = path.parent() {
1416        fs::create_dir_all(parent)?;
1417    }
1418    let example = r#"# Aurum configuration
1419# Environment variables take precedence over values in this file for secrets.
1420# Prefer OPENROUTER_API_KEY / OPENAI_API_KEY / ELEVENLABS_API_KEY / XAI_API_KEY
1421# over api_key fields below. Never put live credentials in this file.
1422# TTS: AURUM_TTS_MODEL, AURUM_TTS_VOICE, AURUM_TTS_LANGUAGE override [tts].
1423#
1424# Provider is never inferred from key presence alone — omit STT/TTS provider to stay local.
1425
1426[stt]
1427provider = "local"
1428model = "base"
1429language = "auto"
1430
1431# Legacy STT section (still supported). Do not set conflicting values with [stt].
1432# [default]
1433# provider = "local"
1434# model = "base"
1435# language = "auto"
1436# output = "txt"
1437
1438[cleanup]
1439# style = "raw" # raw | clean | bullets | professional | summary
1440# provider = "rules" # rules (on-device) | openrouter
1441# openrouter_model = "google/gemini-2.5-flash"
1442
1443[tts]
1444provider = "local"
1445# model = "kitten-nano-int8"
1446# voice = "Luna"
1447# language = "en"
1448# speaking_rate = 1.0
1449# max_chars = 5000
1450# timeout_ms = 120000
1451
1452# Preferred OpenRouter home (legacy [openrouter] still works if this is absent).
1453# [providers.openrouter]
1454# stt_mode = "auto"
1455# model = "google/gemini-2.5-flash"
1456# base_url = "https://openrouter.ai/api/v1"
1457# allow_custom_endpoint = false
1458# use_system_proxy = false
1459
1460# [providers.openai]
1461# base_url = "https://api.openai.com/v1"
1462
1463# [providers.elevenlabs]
1464# [providers.xai]
1465"#;
1466    fs::write(path, example)?;
1467    Ok(())
1468}
1469
1470#[cfg(test)]
1471mod tests {
1472    use super::*;
1473    use std::io::Write;
1474    use tempfile::tempdir;
1475
1476    /// Serialize tests that mutate process environment (parallel cargo test).
1477    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1478
1479    /// Isolate env mutations for secret-related tests.
1480    struct EnvGuard {
1481        saved: Vec<(String, Option<String>)>,
1482        _lock: std::sync::MutexGuard<'static, ()>,
1483    }
1484
1485    impl EnvGuard {
1486        fn clear(keys: &[&str]) -> Self {
1487            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1488            let mut saved = Vec::new();
1489            for k in keys {
1490                saved.push((k.to_string(), std::env::var(k).ok()));
1491                // Safety: held under ENV_LOCK; single-threaded mutation of these keys.
1492                std::env::remove_var(k);
1493            }
1494            Self { saved, _lock: lock }
1495        }
1496
1497        fn set(&self, key: &str, val: &str) {
1498            std::env::set_var(key, val);
1499        }
1500    }
1501
1502    impl Drop for EnvGuard {
1503        fn drop(&mut self) {
1504            for (k, v) in self.saved.drain(..) {
1505                match v {
1506                    Some(val) => std::env::set_var(&k, val),
1507                    None => std::env::remove_var(&k),
1508                }
1509            }
1510        }
1511    }
1512
1513    #[test]
1514    fn parses_config_file() {
1515        let dir = tempdir().unwrap();
1516        let path = dir.path().join("config.toml");
1517        let mut f = fs::File::create(&path).unwrap();
1518        writeln!(
1519            f,
1520            r#"
1521[default]
1522provider = "openrouter"
1523model = "small"
1524language = "en"
1525output = "json"
1526
1527[openrouter]
1528api_key = "test-key"
1529model = "google/gemini-2.5-flash"
1530"#
1531        )
1532        .unwrap();
1533
1534        let _g = EnvGuard::clear(&["OPENROUTER_API_KEY"]);
1535        let cfg = Config::load_from(&path).unwrap();
1536        assert_eq!(cfg.provider, "openrouter");
1537        assert_eq!(cfg.model.as_deref(), Some("small"));
1538        assert_eq!(cfg.language, "en");
1539        assert_eq!(cfg.output, "json");
1540        assert_eq!(
1541            cfg.openrouter_api_key.as_ref().map(|s| s.expose()),
1542            Some("test-key")
1543        );
1544        assert!(!format!("{:?}", cfg).contains("test-key"));
1545        assert_eq!(cfg.openrouter_default_model, "google/gemini-2.5-flash");
1546        assert_eq!(cfg.cleanup_style, "raw");
1547        assert_eq!(cfg.cleanup_provider, "rules");
1548        assert!(cfg.validate().is_ok());
1549        let diag = cfg.effective_diagnostic();
1550        assert_eq!(diag.openrouter_api_key.as_deref(), Some("***"));
1551        assert_eq!(diag.providers.openrouter.api_key.as_deref(), Some("***"));
1552    }
1553
1554    #[test]
1555    fn load_from_required_missing_errors() {
1556        let dir = tempdir().unwrap();
1557        let path = dir.path().join("missing.toml");
1558        let err = Config::load_from_required(&path).unwrap_err();
1559        assert!(err.to_string().contains("not found"));
1560    }
1561
1562    #[test]
1563    fn parses_cleanup_section() {
1564        let dir = tempdir().unwrap();
1565        let path = dir.path().join("config.toml");
1566        fs::write(
1567            &path,
1568            r#"
1569[default]
1570provider = "local"
1571model = "base"
1572
1573[cleanup]
1574style = "clean"
1575provider = "rules"
1576openrouter_model = "google/gemini-2.5-flash"
1577"#,
1578        )
1579        .unwrap();
1580        let cfg = Config::load_from(&path).unwrap();
1581        assert_eq!(cfg.cleanup_style, "clean");
1582        assert_eq!(cfg.cleanup_provider, "rules");
1583        assert_eq!(
1584            cfg.cleanup_openrouter_model.as_deref(),
1585            Some("google/gemini-2.5-flash")
1586        );
1587    }
1588
1589    #[test]
1590    fn cli_overrides_file() {
1591        let dir = tempdir().unwrap();
1592        let path = dir.path().join("config.toml");
1593        fs::write(
1594            &path,
1595            r#"
1596[default]
1597provider = "local"
1598model = "base"
1599language = "auto"
1600output = "txt"
1601
1602[cleanup]
1603style = "clean"
1604provider = "rules"
1605"#,
1606        )
1607        .unwrap();
1608        let mut cfg = Config::load_from(&path).unwrap();
1609        cfg.apply_cli(
1610            Some("openrouter"),
1611            Some("google/gemini-2.5-flash"),
1612            Some("fr"),
1613            Some("srt"),
1614            Some(Path::new("out.srt")),
1615            true,
1616            true,
1617            Some("summary"),
1618            Some("openrouter"),
1619            Some("openai/gpt-audio-mini"),
1620        );
1621        assert_eq!(cfg.provider, "openrouter");
1622        assert_eq!(cfg.model.as_deref(), Some("google/gemini-2.5-flash"));
1623        assert_eq!(cfg.language, "fr");
1624        assert_eq!(cfg.output, "srt");
1625        assert_eq!(cfg.output_file.as_deref(), Some(Path::new("out.srt")));
1626        assert!(cfg.timestamps);
1627        assert!(cfg.verbose);
1628        assert_eq!(cfg.cleanup_style, "summary");
1629        assert_eq!(cfg.cleanup_provider, "openrouter");
1630        assert_eq!(
1631            cfg.cleanup_openrouter_model.as_deref(),
1632            Some("openai/gpt-audio-mini")
1633        );
1634    }
1635
1636    #[test]
1637    #[cfg(feature = "tts")]
1638    fn custom_tts_model_cannot_shadow_builtin_on_load() {
1639        let dir = tempdir().unwrap();
1640        let path = dir.path().join("config.toml");
1641        let default_id = crate::tts::DEFAULT_TTS_MODEL;
1642        fs::write(
1643            &path,
1644            format!(
1645                r#"
1646[tts]
1647model = "{default_id}"
1648
1649[[tts.custom_models]]
1650id = "{default_id}"
1651adapter = "fake-sine-v1"
1652pack_dir = "/tmp/does-not-matter"
1653trust = "verified"
1654"#
1655            ),
1656        )
1657        .unwrap();
1658        let err = Config::load_from(&path).unwrap_err();
1659        assert!(
1660            err.to_string().contains("collides") || err.to_string().contains("reserved"),
1661            "got: {err}"
1662        );
1663    }
1664
1665    #[test]
1666    fn defaults_when_missing_file() {
1667        let dir = tempdir().unwrap();
1668        let path = dir.path().join("nope.toml");
1669        let cfg = Config::load_from(&path).unwrap();
1670        assert_eq!(cfg.provider, "local");
1671        assert_eq!(cfg.language, "auto");
1672        assert_eq!(cfg.output, "txt");
1673        assert_eq!(cfg.cleanup_style, "raw");
1674        assert_eq!(cfg.cleanup_provider, "rules");
1675        assert_eq!(cfg.tts_provider, "local");
1676        assert!((cfg.tts_speaking_rate - 1.0).abs() < f32::EPSILON);
1677        assert!(!cfg.local_only);
1678    }
1679
1680    #[test]
1681    fn key_presence_does_not_select_provider() {
1682        let _g = EnvGuard::clear(&[
1683            "OPENROUTER_API_KEY",
1684            "OPENAI_API_KEY",
1685            "ELEVENLABS_API_KEY",
1686            "XAI_API_KEY",
1687        ]);
1688        let dir = tempdir().unwrap();
1689        let path = dir.path().join("config.toml");
1690        fs::write(
1691            &path,
1692            r#"
1693[providers.openrouter]
1694api_key = "sk-or-present-but-ignored-for-selection"
1695[providers.openai]
1696api_key = "sk-openai-present"
1697"#,
1698        )
1699        .unwrap();
1700        let cfg = Config::load_from(&path).unwrap();
1701        assert_eq!(
1702            cfg.provider, "local",
1703            "STT must stay local without explicit provider"
1704        );
1705        assert_eq!(cfg.tts_provider, "local");
1706        assert!(cfg.openrouter_api_key.is_some());
1707        assert!(cfg.providers.openai.api_key.is_some());
1708    }
1709
1710    #[test]
1711    fn new_stt_section_loads() {
1712        let dir = tempdir().unwrap();
1713        let path = dir.path().join("config.toml");
1714        fs::write(
1715            &path,
1716            r#"
1717[stt]
1718provider = "openrouter"
1719model = "google/gemini-2.5-flash"
1720language = "en"
1721"#,
1722        )
1723        .unwrap();
1724        let cfg = Config::load_from(&path).unwrap();
1725        assert_eq!(cfg.provider, "openrouter");
1726        assert_eq!(cfg.model.as_deref(), Some("google/gemini-2.5-flash"));
1727        assert_eq!(cfg.language, "en");
1728    }
1729
1730    #[test]
1731    fn legacy_default_migrates_identically() {
1732        let dir = tempdir().unwrap();
1733        let path = dir.path().join("config.toml");
1734        fs::write(
1735            &path,
1736            r#"
1737[default]
1738provider = "openrouter"
1739model = "base"
1740language = "de"
1741output = "srt"
1742"#,
1743        )
1744        .unwrap();
1745        let cfg = Config::load_from(&path).unwrap();
1746        assert_eq!(cfg.provider, "openrouter");
1747        assert_eq!(cfg.model.as_deref(), Some("base"));
1748        assert_eq!(cfg.language, "de");
1749        assert_eq!(cfg.output, "srt");
1750    }
1751
1752    #[test]
1753    fn conflicting_stt_and_default_fail_closed() {
1754        let dir = tempdir().unwrap();
1755        let path = dir.path().join("config.toml");
1756        fs::write(
1757            &path,
1758            r#"
1759[default]
1760provider = "local"
1761model = "base"
1762language = "auto"
1763
1764[stt]
1765provider = "openrouter"
1766model = "base"
1767language = "auto"
1768"#,
1769        )
1770        .unwrap();
1771        let err = Config::load_from(&path).unwrap_err();
1772        let msg = err.to_string();
1773        assert!(msg.contains("conflicting"), "{msg}");
1774        assert!(msg.contains("[stt]") || msg.contains("stt"), "{msg}");
1775    }
1776
1777    #[test]
1778    fn agreeing_stt_and_default_ok() {
1779        let dir = tempdir().unwrap();
1780        let path = dir.path().join("config.toml");
1781        fs::write(
1782            &path,
1783            r#"
1784[default]
1785provider = "local"
1786model = "base"
1787language = "auto"
1788output = "json"
1789
1790[stt]
1791provider = "local"
1792model = "base"
1793language = "auto"
1794"#,
1795        )
1796        .unwrap();
1797        let cfg = Config::load_from(&path).unwrap();
1798        assert_eq!(cfg.provider, "local");
1799        assert_eq!(cfg.output, "json");
1800    }
1801
1802    #[test]
1803    fn conflicting_openrouter_sections_fail_closed() {
1804        let dir = tempdir().unwrap();
1805        let path = dir.path().join("config.toml");
1806        fs::write(
1807            &path,
1808            r#"
1809[openrouter]
1810stt_mode = "chat"
1811
1812[providers.openrouter]
1813stt_mode = "transcriptions"
1814"#,
1815        )
1816        .unwrap();
1817        let err = Config::load_from(&path).unwrap_err();
1818        assert!(err.to_string().contains("conflicting"), "got: {err}");
1819    }
1820
1821    #[test]
1822    fn providers_openrouter_loads() {
1823        let _g = EnvGuard::clear(&["OPENROUTER_API_KEY", "OPENROUTER_BASE_URL"]);
1824        let dir = tempdir().unwrap();
1825        let path = dir.path().join("config.toml");
1826        fs::write(
1827            &path,
1828            r#"
1829[providers.openrouter]
1830api_key = "from-providers"
1831model = "openai/gpt-audio-mini"
1832stt_mode = "transcriptions"
1833base_url = "https://openrouter.ai/api/v1"
1834"#,
1835        )
1836        .unwrap();
1837        let cfg = Config::load_from(&path).unwrap();
1838        assert_eq!(
1839            cfg.openrouter_api_key.as_ref().map(|s| s.expose()),
1840            Some("from-providers")
1841        );
1842        assert_eq!(cfg.openrouter_default_model, "openai/gpt-audio-mini");
1843        assert_eq!(cfg.openrouter_stt_mode, "transcriptions");
1844    }
1845
1846    #[test]
1847    fn env_provider_keys_are_scoped() {
1848        let g = EnvGuard::clear(&[
1849            "OPENROUTER_API_KEY",
1850            "OPENAI_API_KEY",
1851            "ELEVENLABS_API_KEY",
1852            "XAI_API_KEY",
1853        ]);
1854        g.set("OPENAI_API_KEY", "sk-openai-env");
1855        g.set("ELEVENLABS_API_KEY", "sk-el-env");
1856        g.set("XAI_API_KEY", "sk-xai-env");
1857        g.set("OPENROUTER_API_KEY", "sk-or-env");
1858
1859        let dir = tempdir().unwrap();
1860        let path = dir.path().join("config.toml");
1861        fs::write(&path, "").unwrap();
1862        let cfg = Config::load_from(&path).unwrap();
1863
1864        assert_eq!(cfg.provider, "local");
1865        assert_eq!(
1866            cfg.openrouter_api_key.as_ref().map(|s| s.expose()),
1867            Some("sk-or-env")
1868        );
1869        assert_eq!(
1870            cfg.providers.openai.api_key.as_ref().map(|s| s.expose()),
1871            Some("sk-openai-env")
1872        );
1873        assert_eq!(
1874            cfg.providers
1875                .elevenlabs
1876                .api_key
1877                .as_ref()
1878                .map(|s| s.expose()),
1879            Some("sk-el-env")
1880        );
1881        assert_eq!(
1882            cfg.providers.xai.api_key.as_ref().map(|s| s.expose()),
1883            Some("sk-xai-env")
1884        );
1885
1886        let v = ValidatedConfig::try_from_config(cfg).unwrap();
1887        assert_eq!(
1888            v.provider_secret(&ProviderId::openrouter())
1889                .unwrap()
1890                .expose(),
1891            "sk-or-env"
1892        );
1893        assert_eq!(
1894            v.provider_secret(&ProviderId::must("openai"))
1895                .unwrap()
1896                .expose(),
1897            "sk-openai-env"
1898        );
1899        assert_eq!(
1900            v.provider_secret(&ProviderId::must("elevenlabs"))
1901                .unwrap()
1902                .expose(),
1903            "sk-el-env"
1904        );
1905        assert_eq!(
1906            v.provider_secret(&ProviderId::must("xai"))
1907                .unwrap()
1908                .expose(),
1909            "sk-xai-env"
1910        );
1911        assert!(v.provider_secret(&ProviderId::local()).is_none());
1912    }
1913
1914    #[test]
1915    fn env_overrides_file_secret() {
1916        let g = EnvGuard::clear(&["OPENAI_API_KEY"]);
1917        g.set("OPENAI_API_KEY", "from-env");
1918        let dir = tempdir().unwrap();
1919        let path = dir.path().join("config.toml");
1920        fs::write(
1921            &path,
1922            r#"
1923[providers.openai]
1924api_key = "from-file"
1925"#,
1926        )
1927        .unwrap();
1928        let cfg = Config::load_from(&path).unwrap();
1929        assert_eq!(
1930            cfg.providers.openai.api_key.as_ref().map(|s| s.expose()),
1931            Some("from-env")
1932        );
1933    }
1934
1935    #[test]
1936    fn local_only_rejects_remote_stt() {
1937        let dir = tempdir().unwrap();
1938        let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1939        cfg.provider = "openrouter".into();
1940        cfg.local_only = true;
1941        let err = ValidatedConfig::try_from_config(cfg).unwrap_err();
1942        assert!(err.to_string().contains("local_only"), "got: {err}");
1943    }
1944
1945    #[test]
1946    fn local_only_rejects_remote_tts() {
1947        let dir = tempdir().unwrap();
1948        let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1949        cfg.tts_provider = "elevenlabs".into();
1950        cfg.local_only = true;
1951        let err = ValidatedConfig::try_from_config(cfg).unwrap_err();
1952        assert!(err.to_string().contains("local_only"), "got: {err}");
1953    }
1954
1955    #[test]
1956    fn local_only_allows_local_providers() {
1957        let dir = tempdir().unwrap();
1958        let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1959        let v = ValidatedConfig::try_from_config(cfg)
1960            .unwrap()
1961            .with_local_only(true)
1962            .unwrap();
1963        assert!(v.local_only);
1964        assert_eq!(v.provider, "local");
1965        assert_eq!(v.tts_provider, "local");
1966    }
1967
1968    #[test]
1969    fn unknown_provider_section_fails_closed() {
1970        let dir = tempdir().unwrap();
1971        let path = dir.path().join("config.toml");
1972        fs::write(
1973            &path,
1974            r#"
1975[providers.notarealvendor]
1976api_key = "x"
1977"#,
1978        )
1979        .unwrap();
1980        let err = Config::load_from(&path).unwrap_err();
1981        assert!(
1982            err.to_string().contains("parse") || err.to_string().contains("unknown"),
1983            "got: {err}"
1984        );
1985    }
1986
1987    #[test]
1988    fn redacted_debug_and_diagnostic() {
1989        let _g = EnvGuard::clear(&[
1990            "OPENROUTER_API_KEY",
1991            "OPENAI_API_KEY",
1992            "ELEVENLABS_API_KEY",
1993            "XAI_API_KEY",
1994        ]);
1995        let dir = tempdir().unwrap();
1996        let path = dir.path().join("config.toml");
1997        fs::write(
1998            &path,
1999            r#"
2000[providers.openrouter]
2001api_key = "sk-or-canary-secret-value-xyz"
2002[providers.openai]
2003api_key = "sk-openai-canary-secret-value"
2004[providers.elevenlabs]
2005api_key = "sk-el-canary-secret-value"
2006[providers.xai]
2007api_key = "sk-xai-canary-secret-value"
2008"#,
2009        )
2010        .unwrap();
2011        let cfg = Config::load_from(&path).unwrap();
2012        let dbg = format!("{cfg:?}");
2013        assert!(!dbg.contains("sk-or-canary"));
2014        assert!(!dbg.contains("sk-openai-canary"));
2015        assert!(!dbg.contains("sk-el-canary"));
2016        assert!(!dbg.contains("sk-xai-canary"));
2017
2018        let diag = cfg.effective_diagnostic();
2019        let json = serde_json::to_string(&diag).unwrap();
2020        assert!(!json.contains("sk-or-canary"));
2021        assert!(!json.contains("sk-openai-canary"));
2022        assert_eq!(diag.providers.openai.api_key.as_deref(), Some("***"));
2023        assert_eq!(diag.providers.elevenlabs.api_key.as_deref(), Some("***"));
2024        assert_eq!(diag.providers.xai.api_key.as_deref(), Some("***"));
2025        assert_eq!(diag.providers.openrouter.api_key.as_deref(), Some("***"));
2026    }
2027
2028    #[test]
2029    fn tts_speaking_rate_and_provider() {
2030        let dir = tempdir().unwrap();
2031        let path = dir.path().join("config.toml");
2032        fs::write(
2033            &path,
2034            r#"
2035[tts]
2036provider = "local"
2037speaking_rate = 1.25
2038"#,
2039        )
2040        .unwrap();
2041        let cfg = Config::load_from(&path).unwrap();
2042        assert!((cfg.tts_speaking_rate - 1.25).abs() < 0.001);
2043        assert!(cfg.validate().is_ok());
2044    }
2045
2046    #[test]
2047    fn invalid_speaking_rate_rejected() {
2048        let dir = tempdir().unwrap();
2049        let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
2050        cfg.tts_speaking_rate = 0.0;
2051        assert!(cfg.validate().is_err());
2052        cfg.tts_speaking_rate = 10.0;
2053        assert!(cfg.validate().is_err());
2054    }
2055
2056    #[test]
2057    fn elevenlabs_invalid_for_stt() {
2058        let dir = tempdir().unwrap();
2059        let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
2060        cfg.provider = "elevenlabs".into();
2061        let err = cfg.validate().unwrap_err();
2062        assert!(err.to_string().contains("elevenlabs") || err.to_string().contains("STT"));
2063    }
2064
2065    #[test]
2066    fn validated_config_accepts_defaults() {
2067        let dir = tempdir().unwrap();
2068        let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
2069        let v = ValidatedConfig::try_from_config(cfg).unwrap();
2070        assert_eq!(v.provider, "local");
2071        assert_eq!(v.as_config().language, "auto");
2072    }
2073
2074    #[test]
2075    fn validated_config_rejects_bad_provider() {
2076        let dir = tempdir().unwrap();
2077        let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
2078        cfg.provider = "not-a-provider".into();
2079        let err = ValidatedConfig::try_from_config(cfg).unwrap_err();
2080        assert!(
2081            err.to_string().contains("provider") || err.to_string().contains("Invalid"),
2082            "got: {err}"
2083        );
2084    }
2085
2086    #[test]
2087    fn validated_apply_cli_revalidates() {
2088        let dir = tempdir().unwrap();
2089        let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
2090        let v = ValidatedConfig::try_from_config(cfg).unwrap();
2091        let err = v
2092            .apply_cli(
2093                Some("bogus"),
2094                None,
2095                None,
2096                None,
2097                None,
2098                false,
2099                false,
2100                None,
2101                None,
2102                None,
2103            )
2104            .unwrap_err();
2105        assert!(err.to_string().contains("provider") || err.to_string().contains("Invalid"));
2106    }
2107
2108    #[test]
2109    fn example_config_contains_no_live_credential_placeholder() {
2110        let dir = tempdir().unwrap();
2111        let path = dir.path().join("example.toml");
2112        write_example_config(&path).unwrap();
2113        let text = fs::read_to_string(&path).unwrap();
2114        assert!(!text.contains("sk-or-v1-"));
2115        assert!(!text.contains("sk-proj-"));
2116        assert!(text.contains("[stt]"));
2117        assert!(text.contains("[providers.openrouter]") || text.contains("providers.openrouter"));
2118    }
2119}