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