Skip to main content

aurum_core/
config.rs

1//! Configuration loading for Aurum.
2//!
3//! Precedence (highest wins):
4//! 1. CLI flags
5//! 2. Environment variables for OpenRouter only (`OPENROUTER_API_KEY`, `OPENROUTER_BASE_URL`)
6//! 3. Config file (`~/.config/aurum/config.toml` on Linux; platform-appropriate elsewhere)
7//! 4. Built-in defaults
8
9use crate::error::{Result, UserError};
10use directories::ProjectDirs;
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::{Path, PathBuf};
14
15/// Built-in defaults used when nothing else is set.
16pub const DEFAULT_PROVIDER: &str = "local";
17pub const DEFAULT_LOCAL_MODEL: &str = "base";
18pub const DEFAULT_OPENROUTER_MODEL: &str = "google/gemini-2.5-flash";
19pub const DEFAULT_LANGUAGE: &str = "auto";
20pub const DEFAULT_OUTPUT: &str = "txt";
21pub const DEFAULT_CLEANUP: &str = "raw";
22pub const DEFAULT_CLEANUP_PROVIDER: &str = "rules";
23pub const DEFAULT_TTS_PROVIDER: &str = "local";
24pub const DEFAULT_TTS_LANGUAGE: &str = "en";
25pub const DEFAULT_TTS_MAX_CHARS: usize = 5_000;
26pub const DEFAULT_TTS_TIMEOUT_MS: u64 = 120_000;
27
28/// On-disk configuration file schema.
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30pub struct ConfigFile {
31    #[serde(default)]
32    pub default: DefaultSection,
33    #[serde(default)]
34    pub openrouter: OpenRouterSection,
35    #[serde(default)]
36    pub cleanup: CleanupSection,
37    #[serde(default)]
38    pub tts: TtsSection,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct DefaultSection {
43    #[serde(default = "default_provider")]
44    pub provider: String,
45    #[serde(default = "default_local_model")]
46    pub model: String,
47    #[serde(default = "default_language")]
48    pub language: String,
49    #[serde(default = "default_output")]
50    pub output: String,
51}
52
53impl Default for DefaultSection {
54    fn default() -> Self {
55        Self {
56            provider: default_provider(),
57            model: default_local_model(),
58            language: default_language(),
59            output: default_output(),
60        }
61    }
62}
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct OpenRouterSection {
66    /// Prefer `OPENROUTER_API_KEY` env var over this field.
67    pub api_key: Option<String>,
68    /// Default remote model when provider is openrouter.
69    pub model: Option<String>,
70    /// Optional custom base URL (for testing / proxies).
71    pub base_url: Option<String>,
72    /// Allow credentialed non-OpenRouter HTTPS endpoints (JOE-1587). Default false.
73    #[serde(default)]
74    pub allow_custom_endpoint: bool,
75    /// STT path mode: `auto` | `chat` | `transcriptions` (JOE-1586).
76    #[serde(default = "default_stt_mode")]
77    pub stt_mode: String,
78    /// Use system HTTP(S)_PROXY (privacy implications). Default false.
79    #[serde(default)]
80    pub use_system_proxy: bool,
81}
82
83fn default_stt_mode() -> String {
84    "auto".into()
85}
86
87/// Post-ASR cleanup defaults ( flow).
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct CleanupSection {
90    /// `raw` | `clean` | `bullets` | `professional` | `summary`
91    #[serde(default = "default_cleanup")]
92    pub style: String,
93    /// `rules` (on-device) | `openrouter`
94    #[serde(default = "default_cleanup_provider")]
95    pub provider: String,
96    /// Optional model id when provider is openrouter.
97    pub openrouter_model: Option<String>,
98}
99
100impl Default for CleanupSection {
101    fn default() -> Self {
102        Self {
103            style: default_cleanup(),
104            provider: default_cleanup_provider(),
105            openrouter_model: None,
106        }
107    }
108}
109
110/// Local TTS defaults (`[tts]`).
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct TtsSection {
113    /// Only `local` in MVP.
114    #[serde(default = "default_tts_provider")]
115    pub provider: String,
116    #[serde(default = "default_tts_model")]
117    pub model: String,
118    #[serde(default = "default_tts_voice")]
119    pub voice: String,
120    #[serde(default = "default_tts_language")]
121    pub language: String,
122    #[serde(default = "default_tts_max_chars")]
123    pub max_chars: usize,
124    #[serde(default = "default_tts_timeout_ms")]
125    pub timeout_ms: u64,
126    /// Optional default local model-pack directory (JOE-1619). CLI `--pack-dir`
127    /// overrides this. Never shadows built-in catalogue cache identity.
128    #[serde(default)]
129    pub pack_dir: Option<String>,
130    /// Allow `local_unverified` packs when `pack_dir` / CLI pack is used.
131    #[serde(default)]
132    pub allow_unverified: bool,
133    /// Custom catalogue entries for supported adapters (JOE-1620).
134    #[serde(default)]
135    pub custom_models: Vec<CustomTtsModelConfig>,
136}
137
138/// Config file form of `[[tts.custom_models]]` (JOE-1620).
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140pub struct CustomTtsModelConfig {
141    pub id: String,
142    pub adapter: String,
143    #[serde(default)]
144    pub pack_dir: Option<String>,
145    pub trust: String,
146    #[serde(default)]
147    pub license: Option<String>,
148    #[serde(default)]
149    pub notes: Option<String>,
150}
151
152impl Default for TtsSection {
153    fn default() -> Self {
154        Self {
155            provider: default_tts_provider(),
156            model: default_tts_model(),
157            voice: default_tts_voice(),
158            language: default_tts_language(),
159            max_chars: default_tts_max_chars(),
160            timeout_ms: default_tts_timeout_ms(),
161            pack_dir: None,
162            allow_unverified: false,
163            custom_models: Vec::new(),
164        }
165    }
166}
167
168fn default_provider() -> String {
169    DEFAULT_PROVIDER.to_string()
170}
171fn default_local_model() -> String {
172    DEFAULT_LOCAL_MODEL.to_string()
173}
174fn default_language() -> String {
175    DEFAULT_LANGUAGE.to_string()
176}
177fn default_output() -> String {
178    DEFAULT_OUTPUT.to_string()
179}
180fn default_cleanup() -> String {
181    DEFAULT_CLEANUP.to_string()
182}
183fn default_cleanup_provider() -> String {
184    DEFAULT_CLEANUP_PROVIDER.to_string()
185}
186fn default_tts_provider() -> String {
187    DEFAULT_TTS_PROVIDER.to_string()
188}
189fn default_tts_model() -> String {
190    #[cfg(feature = "tts")]
191    {
192        crate::tts::DEFAULT_TTS_MODEL.to_string()
193    }
194    #[cfg(not(feature = "tts"))]
195    {
196        "kitten-nano-int8".to_string()
197    }
198}
199fn default_tts_voice() -> String {
200    #[cfg(feature = "tts")]
201    {
202        crate::tts::DEFAULT_TTS_VOICE.to_string()
203    }
204    #[cfg(not(feature = "tts"))]
205    {
206        "Luna".to_string()
207    }
208}
209fn default_tts_language() -> String {
210    DEFAULT_TTS_LANGUAGE.to_string()
211}
212fn default_tts_max_chars() -> usize {
213    DEFAULT_TTS_MAX_CHARS
214}
215fn default_tts_timeout_ms() -> u64 {
216    DEFAULT_TTS_TIMEOUT_MS
217}
218
219/// Fully-resolved runtime configuration after merging all sources.
220#[derive(Clone)]
221pub struct Config {
222    pub provider: String,
223    pub model: Option<String>,
224    pub language: String,
225    pub output: String,
226    pub output_file: Option<PathBuf>,
227    pub timestamps: bool,
228    pub verbose: bool,
229    /// Remote API key — redacted via [`crate::secret::SecretString`] Debug/Display (JOE-1779).
230    pub openrouter_api_key: Option<crate::secret::SecretString>,
231    pub openrouter_base_url: String,
232    pub openrouter_default_model: String,
233    /// Allow custom credentialed endpoints (JOE-1587).
234    pub openrouter_allow_custom_endpoint: bool,
235    /// `auto` | `chat` | `transcriptions` (JOE-1586).
236    pub openrouter_stt_mode: String,
237    pub openrouter_use_system_proxy: bool,
238    /// Cleanup style name (`raw`, `clean`, …).
239    pub cleanup_style: String,
240    /// Cleanup backend name (`rules`, `openrouter`).
241    pub cleanup_provider: String,
242    /// Optional dedicated model for OpenRouter cleanup.
243    pub cleanup_openrouter_model: Option<String>,
244    /// TTS provider name (`local` only in MVP).
245    pub tts_provider: String,
246    pub tts_model: String,
247    pub tts_voice: String,
248    pub tts_language: String,
249    pub tts_max_chars: usize,
250    pub tts_timeout_ms: u64,
251    /// Optional default pack directory for local override (JOE-1619).
252    pub tts_pack_dir: Option<PathBuf>,
253    pub tts_allow_unverified: bool,
254    /// Validated custom TTS catalogue entries (empty when packs not present yet).
255    pub tts_custom_models: Vec<CustomTtsModelConfig>,
256    pub config_path: Option<PathBuf>,
257    pub cache_dir: PathBuf,
258}
259
260impl std::fmt::Debug for Config {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        f.debug_struct("Config")
263            .field("provider", &self.provider)
264            .field("model", &self.model)
265            .field("language", &self.language)
266            .field("output", &self.output)
267            .field("output_file", &self.output_file)
268            .field("timestamps", &self.timestamps)
269            .field("verbose", &self.verbose)
270            .field("openrouter_api_key", &self.openrouter_api_key)
271            .field("openrouter_base_url", &self.openrouter_base_url)
272            .field("openrouter_default_model", &self.openrouter_default_model)
273            .field(
274                "openrouter_allow_custom_endpoint",
275                &self.openrouter_allow_custom_endpoint,
276            )
277            .field("openrouter_stt_mode", &self.openrouter_stt_mode)
278            .field(
279                "openrouter_use_system_proxy",
280                &self.openrouter_use_system_proxy,
281            )
282            .field("cleanup_style", &self.cleanup_style)
283            .field("cleanup_provider", &self.cleanup_provider)
284            .field("cleanup_openrouter_model", &self.cleanup_openrouter_model)
285            .field("tts_provider", &self.tts_provider)
286            .field("tts_model", &self.tts_model)
287            .field("tts_voice", &self.tts_voice)
288            .field("tts_language", &self.tts_language)
289            .field("tts_max_chars", &self.tts_max_chars)
290            .field("tts_timeout_ms", &self.tts_timeout_ms)
291            .field("tts_pack_dir", &self.tts_pack_dir)
292            .field("tts_allow_unverified", &self.tts_allow_unverified)
293            .field("tts_custom_models", &self.tts_custom_models)
294            .field("config_path", &self.config_path)
295            .field("cache_dir", &self.cache_dir)
296            .finish()
297    }
298}
299
300/// Source of an effective config value.
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
302#[serde(rename_all = "snake_case")]
303pub enum ConfigValueSource {
304    Default,
305    File,
306    Environment,
307    Cli,
308}
309
310/// Attribution for key fields (diagnostics only).
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct ConfigSourceMap {
313    pub provider: ConfigValueSource,
314    pub openrouter_api_key: ConfigValueSource,
315    pub openrouter_base_url: ConfigValueSource,
316    pub tts_model: ConfigValueSource,
317}
318
319impl ConfigSourceMap {
320    fn default_attribution(cfg: &Config) -> Self {
321        let key_src = if std::env::var("OPENROUTER_API_KEY")
322            .ok()
323            .filter(|s| !s.is_empty())
324            .is_some()
325        {
326            ConfigValueSource::Environment
327        } else if cfg.openrouter_api_key.is_some() {
328            ConfigValueSource::File
329        } else {
330            ConfigValueSource::Default
331        };
332        let base_src = if std::env::var("OPENROUTER_BASE_URL")
333            .ok()
334            .filter(|s| !s.is_empty())
335            .is_some()
336        {
337            ConfigValueSource::Environment
338        } else {
339            ConfigValueSource::File
340        };
341        let tts_src = if std::env::var("AURUM_TTS_MODEL")
342            .ok()
343            .filter(|s| !s.is_empty())
344            .is_some()
345        {
346            ConfigValueSource::Environment
347        } else {
348            ConfigValueSource::File
349        };
350        Self {
351            provider: if cfg.config_path.is_some() {
352                ConfigValueSource::File
353            } else {
354                ConfigValueSource::Default
355            },
356            openrouter_api_key: key_src,
357            openrouter_base_url: base_src,
358            tts_model: tts_src,
359        }
360    }
361}
362
363/// Redacted JSON-serializable effective config.
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct EffectiveConfigDiagnostic {
366    pub provider: String,
367    pub model: Option<String>,
368    pub language: String,
369    pub output: String,
370    pub timestamps: bool,
371    pub openrouter_api_key: Option<String>,
372    pub openrouter_base_url: String,
373    pub openrouter_default_model: String,
374    pub openrouter_stt_mode: String,
375    pub openrouter_allow_custom_endpoint: bool,
376    pub cleanup_style: String,
377    pub cleanup_provider: String,
378    pub tts_model: String,
379    pub tts_voice: String,
380    pub tts_language: String,
381    pub tts_max_chars: usize,
382    pub tts_timeout_ms: u64,
383    pub tts_pack_dir: Option<String>,
384    pub tts_allow_unverified: bool,
385    pub tts_custom_model_ids: Vec<String>,
386    pub config_path: Option<String>,
387    pub cache_dir: String,
388    pub sources: ConfigSourceMap,
389}
390
391impl Config {
392    /// Resolve the platform-appropriate config file path.
393    pub fn default_config_path() -> Option<PathBuf> {
394        ProjectDirs::from("", "", "aurum").map(|d| d.config_dir().join("config.toml"))
395    }
396
397    /// Resolve the platform-appropriate cache directory (models live under `models/`).
398    pub fn default_cache_dir() -> Result<PathBuf> {
399        // Prefer XDG-style cache via the `directories` crate.
400        if let Some(dirs) = ProjectDirs::from("", "", "aurum") {
401            return Ok(dirs.cache_dir().to_path_buf());
402        }
403        // Fallback: ~/.cache/aurum
404        let home = dirs_home()?;
405        Ok(home.join(".cache").join("aurum"))
406    }
407
408    /// Load config file from the default location (if present) and merge with env vars.
409    ///
410    /// Always validates `[[tts.custom_models]]` so reserved/builtin IDs cannot
411    /// silently shadow the catalogue (JOE-1576/1620).
412    pub fn load() -> Result<Self> {
413        let path = Self::default_config_path();
414        let file = match &path {
415            Some(p) if p.exists() => Some(load_config_file(p)?),
416            _ => None,
417        };
418        let cfg = Self::from_parts(file, path);
419        cfg.validate_tts_custom_models()?;
420        Ok(cfg)
421    }
422
423    /// Load from an explicit config file path (used in tests / CLI `--config`).
424    ///
425    /// When the path does not exist, returns defaults (historical behavior).
426    /// Prefer [`Self::load_from_required`] when a missing file must be an error.
427    /// Validates custom TTS catalogue entries when present.
428    pub fn load_from(path: &Path) -> Result<Self> {
429        let file = if path.exists() {
430            Some(load_config_file(path)?)
431        } else {
432            None
433        };
434        let cfg = Self::from_parts(file, Some(path.to_path_buf()));
435        cfg.validate_tts_custom_models()?;
436        Ok(cfg)
437    }
438
439    /// Load from an explicit path; **error** if the file is missing (JOE-1608).
440    pub fn load_from_required(path: &Path) -> Result<Self> {
441        if !path.exists() {
442            return Err(UserError::InvalidConfig {
443                reason: format!(
444                    "config file not found: {}\n  Hint: create it or omit --config to use defaults",
445                    path.display()
446                ),
447            }
448            .into());
449        }
450        let file = load_config_file(path)?;
451        let cfg = Self::from_parts(Some(file), Some(path.to_path_buf()));
452        cfg.validate()?;
453        Ok(cfg)
454    }
455
456    /// Expose the OpenRouter API key as a plain string for provider construction.
457    /// Prefer not logging the return value.
458    pub fn openrouter_api_key_exposed(&self) -> Option<String> {
459        self.openrouter_api_key
460            .as_ref()
461            .map(|s| s.expose().to_string())
462    }
463
464    /// Validate provider/model/style/limit cross-fields (JOE-1608).
465    pub fn validate(&self) -> Result<()> {
466        match self.provider.as_str() {
467            "local" | "openrouter" => {}
468            other => {
469                return Err(UserError::InvalidProvider {
470                    provider: other.into(),
471                }
472                .into());
473            }
474        }
475        let _ = crate::output::OutputFormat::parse(&self.output)?;
476        let _ = crate::cleanup::CleanupStyle::parse(&self.cleanup_style)?;
477        let _ = crate::cleanup::CleanupProviderKind::parse(&self.cleanup_provider)?;
478        let _ = crate::providers::OpenRouterSttMode::parse(&self.openrouter_stt_mode)?;
479
480        if self.tts_max_chars == 0 {
481            return Err(UserError::InvalidConfig {
482                reason: "tts.max_chars must be >= 1".into(),
483            }
484            .into());
485        }
486        if self.tts_timeout_ms == 0 {
487            return Err(UserError::InvalidConfig {
488                reason: "tts.timeout_ms must be >= 1".into(),
489            }
490            .into());
491        }
492        // Soft global ceilings (fail rather than silent clamp).
493        if self.tts_max_chars > 500_000 {
494            return Err(UserError::InvalidConfig {
495                reason: format!(
496                    "tts.max_chars {} exceeds safe ceiling 500000",
497                    self.tts_max_chars
498                ),
499            }
500            .into());
501        }
502        if !self.openrouter_base_url.starts_with("https://")
503            && !self.openrouter_base_url.starts_with("http://localhost")
504            && !self.openrouter_base_url.contains("127.0.0.1")
505        {
506            // Allow http only for localhost test servers.
507            if self.openrouter_base_url.starts_with("http://") {
508                return Err(UserError::InvalidConfig {
509                    reason: format!(
510                        "openrouter base_url must use https (got {})",
511                        self.openrouter_base_url
512                    ),
513                }
514                .into());
515            }
516        }
517
518        // Custom TTS catalogue validation (JOE-1620). Entries that reference
519        // missing pack dirs fail closed only when the packs are expected to
520        // exist — we always validate schema uniqueness / reserved ids here.
521        self.validate_tts_custom_models()?;
522        Ok(())
523    }
524
525    /// Validate `[[tts.custom_models]]` uniqueness and reserved namespaces.
526    /// Full pack verification runs when `pack_dir` paths exist on disk.
527    pub fn validate_tts_custom_models(&self) -> Result<()> {
528        #[cfg(feature = "tts")]
529        {
530            use crate::tts::{validate_custom_models, CustomTtsModelEntry, MAX_CUSTOM_MODELS};
531            if self.tts_custom_models.len() > MAX_CUSTOM_MODELS {
532                return Err(UserError::InvalidConfig {
533                    reason: format!(
534                        "too many [[tts.custom_models]] entries ({} > {MAX_CUSTOM_MODELS})",
535                        self.tts_custom_models.len()
536                    ),
537                }
538                .into());
539            }
540            let mut ids = std::collections::HashSet::new();
541            let mut present = Vec::new();
542            for e in &self.tts_custom_models {
543                let id = e.id.trim();
544                if id.is_empty() {
545                    return Err(UserError::InvalidConfig {
546                        reason: "custom TTS model id must be non-empty".into(),
547                    }
548                    .into());
549                }
550                if !ids.insert(id.to_string()) {
551                    return Err(UserError::InvalidConfig {
552                        reason: format!("duplicate custom TTS model id '{id}'"),
553                    }
554                    .into());
555                }
556                if id == crate::tts::DEFAULT_TTS_MODEL
557                    || crate::tts::lookup_model(id)
558                        .map(|m| m.shipped)
559                        .unwrap_or(false)
560                {
561                    return Err(UserError::InvalidConfig {
562                        reason: format!(
563                            "custom model id '{id}' collides with built-in catalogue entry"
564                        ),
565                    }
566                    .into());
567                }
568                let _ = crate::tts::lookup_adapter(&e.adapter)?;
569                let trust = crate::tts::TrustMode::parse(&e.trust)?;
570                if matches!(trust, crate::tts::TrustMode::Builtin) {
571                    return Err(UserError::InvalidConfig {
572                        reason: "custom models cannot use trust=builtin".into(),
573                    }
574                    .into());
575                }
576                if let Some(dir) = e.pack_dir.as_ref().map(PathBuf::from) {
577                    if dir.exists() {
578                        present.push(CustomTtsModelEntry {
579                            id: e.id.clone(),
580                            adapter: e.adapter.clone(),
581                            pack_dir: e.pack_dir.clone(),
582                            trust: e.trust.clone(),
583                            license: e.license.clone(),
584                            notes: e.notes.clone(),
585                        });
586                    }
587                } else {
588                    return Err(UserError::InvalidConfig {
589                        reason: format!(
590                            "custom model '{id}' requires pack_dir (remote custom packs \
591                             are not enabled in v0.0.3)"
592                        ),
593                    }
594                    .into());
595                }
596            }
597            // Verify on-disk packs fail closed.
598            if !present.is_empty() {
599                let _ = validate_custom_models(&present)?;
600            }
601        }
602        Ok(())
603    }
604
605    /// Redacted diagnostic view for `--print-effective-config` (JOE-1608).
606    pub fn effective_diagnostic(&self) -> EffectiveConfigDiagnostic {
607        EffectiveConfigDiagnostic {
608            provider: self.provider.clone(),
609            model: self.model.clone(),
610            language: self.language.clone(),
611            output: self.output.clone(),
612            timestamps: self.timestamps,
613            openrouter_api_key: self.openrouter_api_key.as_ref().map(|_| "***".into()),
614            openrouter_base_url: self.openrouter_base_url.clone(),
615            openrouter_default_model: self.openrouter_default_model.clone(),
616            openrouter_stt_mode: self.openrouter_stt_mode.clone(),
617            openrouter_allow_custom_endpoint: self.openrouter_allow_custom_endpoint,
618            cleanup_style: self.cleanup_style.clone(),
619            cleanup_provider: self.cleanup_provider.clone(),
620            tts_model: self.tts_model.clone(),
621            tts_voice: self.tts_voice.clone(),
622            tts_language: self.tts_language.clone(),
623            tts_max_chars: self.tts_max_chars,
624            tts_timeout_ms: self.tts_timeout_ms,
625            tts_pack_dir: self.tts_pack_dir.as_ref().map(|p| p.display().to_string()),
626            tts_allow_unverified: self.tts_allow_unverified,
627            tts_custom_model_ids: self
628                .tts_custom_models
629                .iter()
630                .map(|m| m.id.clone())
631                .collect(),
632            config_path: self.config_path.as_ref().map(|p| p.display().to_string()),
633            cache_dir: self.cache_dir.display().to_string(),
634            sources: ConfigSourceMap::default_attribution(self),
635        }
636    }
637
638    fn from_parts(file: Option<ConfigFile>, config_path: Option<PathBuf>) -> Self {
639        let file = file.unwrap_or_default();
640
641        let openrouter_api_key = std::env::var("OPENROUTER_API_KEY")
642            .ok()
643            .filter(|s| !s.is_empty())
644            .or(file.openrouter.api_key.clone())
645            .map(crate::secret::SecretString::new);
646
647        let openrouter_base_url = std::env::var("OPENROUTER_BASE_URL")
648            .ok()
649            .filter(|s| !s.is_empty())
650            .or(file.openrouter.base_url.clone())
651            .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string());
652
653        let openrouter_default_model = file
654            .openrouter
655            .model
656            .clone()
657            .unwrap_or_else(|| DEFAULT_OPENROUTER_MODEL.to_string());
658
659        // Prefer platform cache; fall back to temp only when ProjectDirs fails
660        // (documented). Library callers that require a real cache should call
661        // `default_cache_dir()` / `validate()` themselves (JOE-1608).
662        let cache_dir =
663            Self::default_cache_dir().unwrap_or_else(|_| std::env::temp_dir().join("aurum-cache"));
664
665        // Env overrides for TTS (no secrets).
666        let tts_model = std::env::var("AURUM_TTS_MODEL")
667            .ok()
668            .filter(|s| !s.is_empty())
669            .unwrap_or(file.tts.model);
670        let tts_voice = std::env::var("AURUM_TTS_VOICE")
671            .ok()
672            .filter(|s| !s.is_empty())
673            .unwrap_or(file.tts.voice);
674        let tts_language = std::env::var("AURUM_TTS_LANGUAGE")
675            .ok()
676            .filter(|s| !s.is_empty())
677            .unwrap_or(file.tts.language);
678
679        Self {
680            provider: file.default.provider,
681            model: Some(file.default.model),
682            language: file.default.language,
683            output: file.default.output,
684            output_file: None,
685            timestamps: false,
686            verbose: false,
687            openrouter_api_key,
688            openrouter_base_url,
689            openrouter_default_model,
690            openrouter_allow_custom_endpoint: file.openrouter.allow_custom_endpoint,
691            openrouter_stt_mode: if file.openrouter.stt_mode.trim().is_empty() {
692                default_stt_mode()
693            } else {
694                file.openrouter.stt_mode
695            },
696            openrouter_use_system_proxy: file.openrouter.use_system_proxy,
697            cleanup_style: file.cleanup.style,
698            cleanup_provider: file.cleanup.provider,
699            cleanup_openrouter_model: file.cleanup.openrouter_model,
700            tts_provider: file.tts.provider,
701            tts_model,
702            tts_voice,
703            tts_language,
704            tts_max_chars: file.tts.max_chars.max(1),
705            tts_timeout_ms: if file.tts.timeout_ms == 0 {
706                DEFAULT_TTS_TIMEOUT_MS
707            } else {
708                file.tts.timeout_ms
709            },
710            tts_pack_dir: file.tts.pack_dir.map(PathBuf::from),
711            tts_allow_unverified: file.tts.allow_unverified,
712            tts_custom_models: file.tts.custom_models,
713            config_path,
714            cache_dir,
715        }
716    }
717
718    /// Apply CLI overrides on top of the loaded config.
719    #[allow(clippy::too_many_arguments)]
720    pub fn apply_cli(
721        &mut self,
722        provider: Option<&str>,
723        model: Option<&str>,
724        language: Option<&str>,
725        output: Option<&str>,
726        output_file: Option<&Path>,
727        timestamps: bool,
728        verbose: bool,
729        cleanup: Option<&str>,
730        cleanup_provider: Option<&str>,
731        cleanup_model: Option<&str>,
732    ) {
733        if let Some(p) = provider {
734            self.provider = p.to_string();
735        }
736        if let Some(m) = model {
737            self.model = Some(m.to_string());
738        }
739        if let Some(l) = language {
740            self.language = l.to_string();
741        }
742        if let Some(o) = output {
743            self.output = o.to_string();
744        }
745        if let Some(path) = output_file {
746            self.output_file = Some(path.to_path_buf());
747        }
748        if timestamps {
749            self.timestamps = true;
750        }
751        if verbose {
752            self.verbose = true;
753        }
754        if let Some(c) = cleanup {
755            self.cleanup_style = c.to_string();
756        }
757        if let Some(p) = cleanup_provider {
758            self.cleanup_provider = p.to_string();
759        }
760        if let Some(m) = cleanup_model {
761            self.cleanup_openrouter_model = Some(m.to_string());
762        }
763    }
764
765    /// Resolve the effective model for the active provider.
766    ///
767    /// When `model_explicitly_set` is false and the provider is openrouter, a bare
768    /// local whisper name (e.g. config default `base`) is replaced with the
769    /// openrouter default model id.
770    /// Resolve model for the active provider.
771    ///
772    /// Returns `Err` if OpenRouter is selected with an explicit bare local whisper name
773    /// (e.g. `--provider openrouter --model tiny`) — that cannot be a remote model id.
774    pub fn resolve_model(&self, model_explicitly_set: bool) -> Result<String> {
775        if model_explicitly_set {
776            let m = self
777                .model
778                .clone()
779                .unwrap_or_else(|| self.default_model_for_provider());
780            if self.provider == "openrouter"
781                && !m.contains('/')
782                && (crate::model::lookup_model(&m).is_ok() || m == DEFAULT_LOCAL_MODEL)
783            {
784                return Err(UserError::Other {
785                    message: format!(
786                        "model '{m}' looks like a local whisper model, not an OpenRouter id.\n \
787 Hint: use e.g. google/gemini-2.5-flash-lite or openai/gpt-audio-mini, \
788 or omit --model to use the OpenRouter default."
789                    ),
790                }
791                .into());
792            }
793            return Ok(m);
794        }
795        match self.provider.as_str() {
796            "openrouter" => {
797                let m = self
798                    .model
799                    .clone()
800                    .unwrap_or_else(|| self.openrouter_default_model.clone());
801                if m.contains('/') {
802                    Ok(m)
803                } else if m == DEFAULT_LOCAL_MODEL || crate::model::lookup_model(&m).is_ok() {
804                    Ok(self.openrouter_default_model.clone())
805                } else {
806                    Ok(m)
807                }
808            }
809            _ => Ok(self
810                .model
811                .clone()
812                .unwrap_or_else(|| DEFAULT_LOCAL_MODEL.to_string())),
813        }
814    }
815
816    fn default_model_for_provider(&self) -> String {
817        match self.provider.as_str() {
818            "openrouter" => self.openrouter_default_model.clone(),
819            _ => DEFAULT_LOCAL_MODEL.to_string(),
820        }
821    }
822}
823
824/// On-disk / pre-merge configuration schema (raw).
825///
826/// Prefer [`ValidatedConfig`] at engine and library boundaries (JOE-1779).
827pub type RawConfig = ConfigFile;
828
829/// Configuration that has passed [`Config::validate`] (JOE-1779 / JOE-1654).
830///
831/// Construct only via [`ValidatedConfig::try_from_config`], [`ValidatedConfig::load`],
832/// or related loaders — never by freely mutating a raw [`Config`] without re-validation.
833#[derive(Clone)]
834pub struct ValidatedConfig {
835    inner: Config,
836}
837
838impl std::fmt::Debug for ValidatedConfig {
839    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
840        f.debug_struct("ValidatedConfig")
841            .field("inner", &self.inner)
842            .finish()
843    }
844}
845
846impl ValidatedConfig {
847    /// Validate and wrap a raw runtime config.
848    pub fn try_from_config(cfg: Config) -> Result<Self> {
849        cfg.validate()?;
850        Ok(Self { inner: cfg })
851    }
852
853    /// Load defaults/file/env and validate.
854    pub fn load() -> Result<Self> {
855        Self::try_from_config(Config::load()?)
856    }
857
858    /// Load an optional path (missing → defaults) then validate.
859    pub fn load_from(path: &Path) -> Result<Self> {
860        Self::try_from_config(Config::load_from(path)?)
861    }
862
863    /// Load a required path then validate.
864    pub fn load_from_required(path: &Path) -> Result<Self> {
865        Self::try_from_config(Config::load_from_required(path)?)
866    }
867
868    pub fn as_config(&self) -> &Config {
869        &self.inner
870    }
871
872    /// Consume into the underlying config (already validated).
873    pub fn into_config(self) -> Config {
874        self.inner
875    }
876
877    /// Apply CLI overrides then re-validate (fail closed).
878    #[allow(clippy::too_many_arguments)]
879    pub fn apply_cli(
880        mut self,
881        provider: Option<&str>,
882        model: Option<&str>,
883        language: Option<&str>,
884        output: Option<&str>,
885        output_file: Option<&Path>,
886        timestamps: bool,
887        verbose: bool,
888        cleanup: Option<&str>,
889        cleanup_provider: Option<&str>,
890        cleanup_model: Option<&str>,
891    ) -> Result<Self> {
892        self.inner.apply_cli(
893            provider,
894            model,
895            language,
896            output,
897            output_file,
898            timestamps,
899            verbose,
900            cleanup,
901            cleanup_provider,
902            cleanup_model,
903        );
904        Self::try_from_config(self.inner)
905    }
906}
907
908impl AsRef<Config> for ValidatedConfig {
909    fn as_ref(&self) -> &Config {
910        &self.inner
911    }
912}
913
914impl std::ops::Deref for ValidatedConfig {
915    type Target = Config;
916    fn deref(&self) -> &Self::Target {
917        &self.inner
918    }
919}
920
921/// Maximum config file size before TOML parse (JOE-1593).
922pub const MAX_CONFIG_BYTES: u64 = 256 * 1024;
923
924fn load_config_file(path: &Path) -> Result<ConfigFile> {
925    let meta = fs::metadata(path).map_err(|e| UserError::InvalidConfig {
926        reason: format!("failed to stat {}: {e}", path.display()),
927    })?;
928    if meta.len() > MAX_CONFIG_BYTES {
929        return Err(UserError::InvalidConfig {
930            reason: format!(
931                "config file {} is too large ({} > {MAX_CONFIG_BYTES} bytes)",
932                path.display(),
933                meta.len()
934            ),
935        }
936        .into());
937    }
938    let contents = fs::read_to_string(path).map_err(|e| UserError::InvalidConfig {
939        reason: format!("failed to read {}: {e}", path.display()),
940    })?;
941    toml::from_str(&contents).map_err(|e| {
942        UserError::InvalidConfig {
943            reason: format!("failed to parse {}: {e}", path.display()),
944        }
945        .into()
946    })
947}
948
949fn dirs_home() -> Result<PathBuf> {
950    if let Ok(h) = std::env::var("HOME") {
951        return Ok(PathBuf::from(h));
952    }
953    if let Ok(h) = std::env::var("USERPROFILE") {
954        return Ok(PathBuf::from(h));
955    }
956    Err(UserError::InvalidConfig {
957        reason: "could not determine home directory".into(),
958    }
959    .into())
960}
961
962/// Write a starter config file if one does not already exist.
963pub fn write_example_config(path: &Path) -> Result<()> {
964    if path.exists() {
965        return Ok(());
966    }
967    if let Some(parent) = path.parent() {
968        fs::create_dir_all(parent)?;
969    }
970    let example = r#"# Aurum configuration
971# Environment variables take precedence over values in this file.
972# OPENROUTER_API_KEY is preferred over openrouter.api_key below.
973# TTS: AURUM_TTS_MODEL, AURUM_TTS_VOICE, AURUM_TTS_LANGUAGE override [tts].
974
975[default]
976provider = "local"
977model = "base"
978language = "auto"
979output = "txt"
980
981[cleanup]
982# style = "raw" # raw | clean | bullets | professional | summary
983# provider = "rules" # rules (on-device) | openrouter
984# openrouter_model = "google/gemini-2.5-flash"
985
986[tts]
987# provider = "local"
988# model = "kitten-nano-int8"
989# voice = "Luna"
990# language = "en"
991# max_chars = 5000
992# timeout_ms = 120000
993
994[openrouter]
995# api_key = "sk-or-..."
996# model = "google/gemini-2.5-flash"
997# base_url = "https://openrouter.ai/api/v1"
998"#;
999    fs::write(path, example)?;
1000    Ok(())
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005    use super::*;
1006    use std::io::Write;
1007    use tempfile::tempdir;
1008
1009    #[test]
1010    fn parses_config_file() {
1011        let dir = tempdir().unwrap();
1012        let path = dir.path().join("config.toml");
1013        let mut f = fs::File::create(&path).unwrap();
1014        writeln!(
1015            f,
1016            r#"
1017[default]
1018provider = "openrouter"
1019model = "small"
1020language = "en"
1021output = "json"
1022
1023[openrouter]
1024api_key = "test-key"
1025model = "google/gemini-2.5-flash"
1026"#
1027        )
1028        .unwrap();
1029
1030        // Env takes precedence for the key; isolate this test (JOE-1608).
1031        let prev = std::env::var("OPENROUTER_API_KEY").ok();
1032        std::env::remove_var("OPENROUTER_API_KEY");
1033        let cfg = Config::load_from(&path).unwrap();
1034        if let Some(v) = prev {
1035            std::env::set_var("OPENROUTER_API_KEY", v);
1036        }
1037        assert_eq!(cfg.provider, "openrouter");
1038        assert_eq!(cfg.model.as_deref(), Some("small"));
1039        assert_eq!(cfg.language, "en");
1040        assert_eq!(cfg.output, "json");
1041        assert_eq!(
1042            cfg.openrouter_api_key.as_ref().map(|s| s.expose()),
1043            Some("test-key")
1044        );
1045        assert!(!format!("{:?}", cfg).contains("test-key"));
1046        assert_eq!(cfg.openrouter_default_model, "google/gemini-2.5-flash");
1047        assert_eq!(cfg.cleanup_style, "raw");
1048        assert_eq!(cfg.cleanup_provider, "rules");
1049        assert!(cfg.validate().is_ok());
1050        let diag = cfg.effective_diagnostic();
1051        assert_eq!(diag.openrouter_api_key.as_deref(), Some("***"));
1052    }
1053
1054    #[test]
1055    fn load_from_required_missing_errors() {
1056        let dir = tempdir().unwrap();
1057        let path = dir.path().join("missing.toml");
1058        let err = Config::load_from_required(&path).unwrap_err();
1059        assert!(err.to_string().contains("not found"));
1060    }
1061
1062    #[test]
1063    fn parses_cleanup_section() {
1064        let dir = tempdir().unwrap();
1065        let path = dir.path().join("config.toml");
1066        fs::write(
1067            &path,
1068            r#"
1069[default]
1070provider = "local"
1071model = "base"
1072
1073[cleanup]
1074style = "clean"
1075provider = "rules"
1076openrouter_model = "google/gemini-2.5-flash"
1077"#,
1078        )
1079        .unwrap();
1080        let cfg = Config::load_from(&path).unwrap();
1081        assert_eq!(cfg.cleanup_style, "clean");
1082        assert_eq!(cfg.cleanup_provider, "rules");
1083        assert_eq!(
1084            cfg.cleanup_openrouter_model.as_deref(),
1085            Some("google/gemini-2.5-flash")
1086        );
1087    }
1088
1089    #[test]
1090    fn cli_overrides_file() {
1091        let dir = tempdir().unwrap();
1092        let path = dir.path().join("config.toml");
1093        fs::write(
1094            &path,
1095            r#"
1096[default]
1097provider = "local"
1098model = "base"
1099language = "auto"
1100output = "txt"
1101
1102[cleanup]
1103style = "clean"
1104provider = "rules"
1105"#,
1106        )
1107        .unwrap();
1108        let mut cfg = Config::load_from(&path).unwrap();
1109        cfg.apply_cli(
1110            Some("openrouter"),
1111            Some("google/gemini-2.5-flash"),
1112            Some("fr"),
1113            Some("srt"),
1114            Some(Path::new("out.srt")),
1115            true,
1116            true,
1117            Some("summary"),
1118            Some("openrouter"),
1119            Some("openai/gpt-audio-mini"),
1120        );
1121        assert_eq!(cfg.provider, "openrouter");
1122        assert_eq!(cfg.model.as_deref(), Some("google/gemini-2.5-flash"));
1123        assert_eq!(cfg.language, "fr");
1124        assert_eq!(cfg.output, "srt");
1125        assert_eq!(cfg.output_file.as_deref(), Some(Path::new("out.srt")));
1126        assert!(cfg.timestamps);
1127        assert!(cfg.verbose);
1128        assert_eq!(cfg.cleanup_style, "summary");
1129        assert_eq!(cfg.cleanup_provider, "openrouter");
1130        assert_eq!(
1131            cfg.cleanup_openrouter_model.as_deref(),
1132            Some("openai/gpt-audio-mini")
1133        );
1134    }
1135
1136    #[test]
1137    #[cfg(feature = "tts")]
1138    fn custom_tts_model_cannot_shadow_builtin_on_load() {
1139        // Builtin catalogue shadow checks run only with the `tts` feature
1140        // (`validate_tts_custom_models`). STT-only builds skip pack validation.
1141        let dir = tempdir().unwrap();
1142        let path = dir.path().join("config.toml");
1143        let default_id = crate::tts::DEFAULT_TTS_MODEL;
1144        fs::write(
1145            &path,
1146            format!(
1147                r#"
1148[tts]
1149model = "{default_id}"
1150
1151[[tts.custom_models]]
1152id = "{default_id}"
1153adapter = "fake-sine-v1"
1154pack_dir = "/tmp/does-not-matter"
1155trust = "verified"
1156"#
1157            ),
1158        )
1159        .unwrap();
1160        let err = Config::load_from(&path).unwrap_err();
1161        assert!(
1162            err.to_string().contains("collides") || err.to_string().contains("reserved"),
1163            "got: {err}"
1164        );
1165    }
1166
1167    #[test]
1168    fn defaults_when_missing_file() {
1169        let dir = tempdir().unwrap();
1170        let path = dir.path().join("nope.toml");
1171        let cfg = Config::load_from(&path).unwrap();
1172        assert_eq!(cfg.provider, "local");
1173        assert_eq!(cfg.language, "auto");
1174        assert_eq!(cfg.output, "txt");
1175        assert_eq!(cfg.cleanup_style, "raw");
1176        assert_eq!(cfg.cleanup_provider, "rules");
1177    }
1178
1179    #[test]
1180    fn validated_config_accepts_defaults() {
1181        let dir = tempdir().unwrap();
1182        let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1183        let v = ValidatedConfig::try_from_config(cfg).unwrap();
1184        assert_eq!(v.provider, "local");
1185        assert_eq!(v.as_config().language, "auto");
1186    }
1187
1188    #[test]
1189    fn validated_config_rejects_bad_provider() {
1190        let dir = tempdir().unwrap();
1191        let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1192        cfg.provider = "not-a-provider".into();
1193        let err = ValidatedConfig::try_from_config(cfg).unwrap_err();
1194        assert!(
1195            err.to_string().contains("provider") || err.to_string().contains("Invalid"),
1196            "got: {err}"
1197        );
1198    }
1199
1200    #[test]
1201    fn validated_apply_cli_revalidates() {
1202        let dir = tempdir().unwrap();
1203        let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1204        let v = ValidatedConfig::try_from_config(cfg).unwrap();
1205        let err = v
1206            .apply_cli(
1207                Some("bogus"),
1208                None,
1209                None,
1210                None,
1211                None,
1212                false,
1213                false,
1214                None,
1215                None,
1216                None,
1217            )
1218            .unwrap_err();
1219        assert!(err.to_string().contains("provider") || err.to_string().contains("Invalid"));
1220    }
1221}