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