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    pub openrouter_api_key: Option<String>,
230    pub openrouter_base_url: String,
231    pub openrouter_default_model: String,
232    /// Allow custom credentialed endpoints (JOE-1587).
233    pub openrouter_allow_custom_endpoint: bool,
234    /// `auto` | `chat` | `transcriptions` (JOE-1586).
235    pub openrouter_stt_mode: String,
236    pub openrouter_use_system_proxy: bool,
237    /// Cleanup style name (`raw`, `clean`, …).
238    pub cleanup_style: String,
239    /// Cleanup backend name (`rules`, `openrouter`).
240    pub cleanup_provider: String,
241    /// Optional dedicated model for OpenRouter cleanup.
242    pub cleanup_openrouter_model: Option<String>,
243    /// TTS provider name (`local` only in MVP).
244    pub tts_provider: String,
245    pub tts_model: String,
246    pub tts_voice: String,
247    pub tts_language: String,
248    pub tts_max_chars: usize,
249    pub tts_timeout_ms: u64,
250    /// Optional default pack directory for local override (JOE-1619).
251    pub tts_pack_dir: Option<PathBuf>,
252    pub tts_allow_unverified: bool,
253    /// Validated custom TTS catalogue entries (empty when packs not present yet).
254    pub tts_custom_models: Vec<CustomTtsModelConfig>,
255    pub config_path: Option<PathBuf>,
256    pub cache_dir: PathBuf,
257}
258
259impl std::fmt::Debug for Config {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        f.debug_struct("Config")
262            .field("provider", &self.provider)
263            .field("model", &self.model)
264            .field("language", &self.language)
265            .field("output", &self.output)
266            .field("output_file", &self.output_file)
267            .field("timestamps", &self.timestamps)
268            .field("verbose", &self.verbose)
269            .field(
270                "openrouter_api_key",
271                &self.openrouter_api_key.as_ref().map(|_| "***"),
272            )
273            .field("openrouter_base_url", &self.openrouter_base_url)
274            .field("openrouter_default_model", &self.openrouter_default_model)
275            .field(
276                "openrouter_allow_custom_endpoint",
277                &self.openrouter_allow_custom_endpoint,
278            )
279            .field("openrouter_stt_mode", &self.openrouter_stt_mode)
280            .field(
281                "openrouter_use_system_proxy",
282                &self.openrouter_use_system_proxy,
283            )
284            .field("cleanup_style", &self.cleanup_style)
285            .field("cleanup_provider", &self.cleanup_provider)
286            .field("cleanup_openrouter_model", &self.cleanup_openrouter_model)
287            .field("tts_provider", &self.tts_provider)
288            .field("tts_model", &self.tts_model)
289            .field("tts_voice", &self.tts_voice)
290            .field("tts_language", &self.tts_language)
291            .field("tts_max_chars", &self.tts_max_chars)
292            .field("tts_timeout_ms", &self.tts_timeout_ms)
293            .field("tts_pack_dir", &self.tts_pack_dir)
294            .field("tts_allow_unverified", &self.tts_allow_unverified)
295            .field("tts_custom_models", &self.tts_custom_models)
296            .field("config_path", &self.config_path)
297            .field("cache_dir", &self.cache_dir)
298            .finish()
299    }
300}
301
302/// Source of an effective config value.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(rename_all = "snake_case")]
305pub enum ConfigValueSource {
306    Default,
307    File,
308    Environment,
309    Cli,
310}
311
312/// Attribution for key fields (diagnostics only).
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct ConfigSourceMap {
315    pub provider: ConfigValueSource,
316    pub openrouter_api_key: ConfigValueSource,
317    pub openrouter_base_url: ConfigValueSource,
318    pub tts_model: ConfigValueSource,
319}
320
321impl ConfigSourceMap {
322    fn default_attribution(cfg: &Config) -> Self {
323        let key_src = if std::env::var("OPENROUTER_API_KEY")
324            .ok()
325            .filter(|s| !s.is_empty())
326            .is_some()
327        {
328            ConfigValueSource::Environment
329        } else if cfg.openrouter_api_key.is_some() {
330            ConfigValueSource::File
331        } else {
332            ConfigValueSource::Default
333        };
334        let base_src = if std::env::var("OPENROUTER_BASE_URL")
335            .ok()
336            .filter(|s| !s.is_empty())
337            .is_some()
338        {
339            ConfigValueSource::Environment
340        } else {
341            ConfigValueSource::File
342        };
343        let tts_src = if std::env::var("AURUM_TTS_MODEL")
344            .ok()
345            .filter(|s| !s.is_empty())
346            .is_some()
347        {
348            ConfigValueSource::Environment
349        } else {
350            ConfigValueSource::File
351        };
352        Self {
353            provider: if cfg.config_path.is_some() {
354                ConfigValueSource::File
355            } else {
356                ConfigValueSource::Default
357            },
358            openrouter_api_key: key_src,
359            openrouter_base_url: base_src,
360            tts_model: tts_src,
361        }
362    }
363}
364
365/// Redacted JSON-serializable effective config.
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct EffectiveConfigDiagnostic {
368    pub provider: String,
369    pub model: Option<String>,
370    pub language: String,
371    pub output: String,
372    pub timestamps: bool,
373    pub openrouter_api_key: Option<String>,
374    pub openrouter_base_url: String,
375    pub openrouter_default_model: String,
376    pub openrouter_stt_mode: String,
377    pub openrouter_allow_custom_endpoint: bool,
378    pub cleanup_style: String,
379    pub cleanup_provider: String,
380    pub tts_model: String,
381    pub tts_voice: String,
382    pub tts_language: String,
383    pub tts_max_chars: usize,
384    pub tts_timeout_ms: u64,
385    pub tts_pack_dir: Option<String>,
386    pub tts_allow_unverified: bool,
387    pub tts_custom_model_ids: Vec<String>,
388    pub config_path: Option<String>,
389    pub cache_dir: String,
390    pub sources: ConfigSourceMap,
391}
392
393impl Config {
394    /// Resolve the platform-appropriate config file path.
395    pub fn default_config_path() -> Option<PathBuf> {
396        ProjectDirs::from("", "", "aurum").map(|d| d.config_dir().join("config.toml"))
397    }
398
399    /// Resolve the platform-appropriate cache directory (models live under `models/`).
400    pub fn default_cache_dir() -> Result<PathBuf> {
401        // Prefer XDG-style cache via the `directories` crate.
402        if let Some(dirs) = ProjectDirs::from("", "", "aurum") {
403            return Ok(dirs.cache_dir().to_path_buf());
404        }
405        // Fallback: ~/.cache/aurum
406        let home = dirs_home()?;
407        Ok(home.join(".cache").join("aurum"))
408    }
409
410    /// Load config file from the default location (if present) and merge with env vars.
411    ///
412    /// Always validates `[[tts.custom_models]]` so reserved/builtin IDs cannot
413    /// silently shadow the catalogue (JOE-1576/1620).
414    pub fn load() -> Result<Self> {
415        let path = Self::default_config_path();
416        let file = match &path {
417            Some(p) if p.exists() => Some(load_config_file(p)?),
418            _ => None,
419        };
420        let cfg = Self::from_parts(file, path);
421        cfg.validate_tts_custom_models()?;
422        Ok(cfg)
423    }
424
425    /// Load from an explicit config file path (used in tests / CLI `--config`).
426    ///
427    /// When the path does not exist, returns defaults (historical behavior).
428    /// Prefer [`Self::load_from_required`] when a missing file must be an error.
429    /// Validates custom TTS catalogue entries when present.
430    pub fn load_from(path: &Path) -> Result<Self> {
431        let file = if path.exists() {
432            Some(load_config_file(path)?)
433        } else {
434            None
435        };
436        let cfg = Self::from_parts(file, Some(path.to_path_buf()));
437        cfg.validate_tts_custom_models()?;
438        Ok(cfg)
439    }
440
441    /// Load from an explicit path; **error** if the file is missing (JOE-1608).
442    pub fn load_from_required(path: &Path) -> Result<Self> {
443        if !path.exists() {
444            return Err(UserError::InvalidConfig {
445                reason: format!(
446                    "config file not found: {}\n  Hint: create it or omit --config to use defaults",
447                    path.display()
448                ),
449            }
450            .into());
451        }
452        let file = load_config_file(path)?;
453        let cfg = Self::from_parts(Some(file), Some(path.to_path_buf()));
454        cfg.validate()?;
455        Ok(cfg)
456    }
457
458    /// Validate provider/model/style/limit cross-fields (JOE-1608).
459    pub fn validate(&self) -> Result<()> {
460        match self.provider.as_str() {
461            "local" | "openrouter" => {}
462            other => {
463                return Err(UserError::InvalidProvider {
464                    provider: other.into(),
465                }
466                .into());
467            }
468        }
469        let _ = crate::output::OutputFormat::parse(&self.output)?;
470        let _ = crate::cleanup::CleanupStyle::parse(&self.cleanup_style)?;
471        let _ = crate::cleanup::CleanupProviderKind::parse(&self.cleanup_provider)?;
472        let _ = crate::providers::OpenRouterSttMode::parse(&self.openrouter_stt_mode)?;
473
474        if self.tts_max_chars == 0 {
475            return Err(UserError::InvalidConfig {
476                reason: "tts.max_chars must be >= 1".into(),
477            }
478            .into());
479        }
480        if self.tts_timeout_ms == 0 {
481            return Err(UserError::InvalidConfig {
482                reason: "tts.timeout_ms must be >= 1".into(),
483            }
484            .into());
485        }
486        // Soft global ceilings (fail rather than silent clamp).
487        if self.tts_max_chars > 500_000 {
488            return Err(UserError::InvalidConfig {
489                reason: format!(
490                    "tts.max_chars {} exceeds safe ceiling 500000",
491                    self.tts_max_chars
492                ),
493            }
494            .into());
495        }
496        if !self.openrouter_base_url.starts_with("https://")
497            && !self.openrouter_base_url.starts_with("http://localhost")
498            && !self.openrouter_base_url.contains("127.0.0.1")
499        {
500            // Allow http only for localhost test servers.
501            if self.openrouter_base_url.starts_with("http://") {
502                return Err(UserError::InvalidConfig {
503                    reason: format!(
504                        "openrouter base_url must use https (got {})",
505                        self.openrouter_base_url
506                    ),
507                }
508                .into());
509            }
510        }
511
512        // Custom TTS catalogue validation (JOE-1620). Entries that reference
513        // missing pack dirs fail closed only when the packs are expected to
514        // exist — we always validate schema uniqueness / reserved ids here.
515        self.validate_tts_custom_models()?;
516        Ok(())
517    }
518
519    /// Validate `[[tts.custom_models]]` uniqueness and reserved namespaces.
520    /// Full pack verification runs when `pack_dir` paths exist on disk.
521    pub fn validate_tts_custom_models(&self) -> Result<()> {
522        #[cfg(feature = "tts")]
523        {
524            use crate::tts::{validate_custom_models, CustomTtsModelEntry, MAX_CUSTOM_MODELS};
525            if self.tts_custom_models.len() > MAX_CUSTOM_MODELS {
526                return Err(UserError::InvalidConfig {
527                    reason: format!(
528                        "too many [[tts.custom_models]] entries ({} > {MAX_CUSTOM_MODELS})",
529                        self.tts_custom_models.len()
530                    ),
531                }
532                .into());
533            }
534            let mut ids = std::collections::HashSet::new();
535            let mut present = Vec::new();
536            for e in &self.tts_custom_models {
537                let id = e.id.trim();
538                if id.is_empty() {
539                    return Err(UserError::InvalidConfig {
540                        reason: "custom TTS model id must be non-empty".into(),
541                    }
542                    .into());
543                }
544                if !ids.insert(id.to_string()) {
545                    return Err(UserError::InvalidConfig {
546                        reason: format!("duplicate custom TTS model id '{id}'"),
547                    }
548                    .into());
549                }
550                if id == crate::tts::DEFAULT_TTS_MODEL
551                    || crate::tts::lookup_model(id)
552                        .map(|m| m.shipped)
553                        .unwrap_or(false)
554                {
555                    return Err(UserError::InvalidConfig {
556                        reason: format!(
557                            "custom model id '{id}' collides with built-in catalogue entry"
558                        ),
559                    }
560                    .into());
561                }
562                let _ = crate::tts::lookup_adapter(&e.adapter)?;
563                let trust = crate::tts::TrustMode::parse(&e.trust)?;
564                if matches!(trust, crate::tts::TrustMode::Builtin) {
565                    return Err(UserError::InvalidConfig {
566                        reason: "custom models cannot use trust=builtin".into(),
567                    }
568                    .into());
569                }
570                if let Some(dir) = e.pack_dir.as_ref().map(PathBuf::from) {
571                    if dir.exists() {
572                        present.push(CustomTtsModelEntry {
573                            id: e.id.clone(),
574                            adapter: e.adapter.clone(),
575                            pack_dir: e.pack_dir.clone(),
576                            trust: e.trust.clone(),
577                            license: e.license.clone(),
578                            notes: e.notes.clone(),
579                        });
580                    }
581                } else {
582                    return Err(UserError::InvalidConfig {
583                        reason: format!(
584                            "custom model '{id}' requires pack_dir (remote custom packs \
585                             are not enabled in v0.0.3)"
586                        ),
587                    }
588                    .into());
589                }
590            }
591            // Verify on-disk packs fail closed.
592            if !present.is_empty() {
593                let _ = validate_custom_models(&present)?;
594            }
595        }
596        Ok(())
597    }
598
599    /// Redacted diagnostic view for `--print-effective-config` (JOE-1608).
600    pub fn effective_diagnostic(&self) -> EffectiveConfigDiagnostic {
601        EffectiveConfigDiagnostic {
602            provider: self.provider.clone(),
603            model: self.model.clone(),
604            language: self.language.clone(),
605            output: self.output.clone(),
606            timestamps: self.timestamps,
607            openrouter_api_key: self.openrouter_api_key.as_ref().map(|_| "***".into()),
608            openrouter_base_url: self.openrouter_base_url.clone(),
609            openrouter_default_model: self.openrouter_default_model.clone(),
610            openrouter_stt_mode: self.openrouter_stt_mode.clone(),
611            openrouter_allow_custom_endpoint: self.openrouter_allow_custom_endpoint,
612            cleanup_style: self.cleanup_style.clone(),
613            cleanup_provider: self.cleanup_provider.clone(),
614            tts_model: self.tts_model.clone(),
615            tts_voice: self.tts_voice.clone(),
616            tts_language: self.tts_language.clone(),
617            tts_max_chars: self.tts_max_chars,
618            tts_timeout_ms: self.tts_timeout_ms,
619            tts_pack_dir: self.tts_pack_dir.as_ref().map(|p| p.display().to_string()),
620            tts_allow_unverified: self.tts_allow_unverified,
621            tts_custom_model_ids: self
622                .tts_custom_models
623                .iter()
624                .map(|m| m.id.clone())
625                .collect(),
626            config_path: self.config_path.as_ref().map(|p| p.display().to_string()),
627            cache_dir: self.cache_dir.display().to_string(),
628            sources: ConfigSourceMap::default_attribution(self),
629        }
630    }
631
632    fn from_parts(file: Option<ConfigFile>, config_path: Option<PathBuf>) -> Self {
633        let file = file.unwrap_or_default();
634
635        let openrouter_api_key = std::env::var("OPENROUTER_API_KEY")
636            .ok()
637            .filter(|s| !s.is_empty())
638            .or(file.openrouter.api_key.clone());
639
640        let openrouter_base_url = std::env::var("OPENROUTER_BASE_URL")
641            .ok()
642            .filter(|s| !s.is_empty())
643            .or(file.openrouter.base_url.clone())
644            .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string());
645
646        let openrouter_default_model = file
647            .openrouter
648            .model
649            .clone()
650            .unwrap_or_else(|| DEFAULT_OPENROUTER_MODEL.to_string());
651
652        // Prefer platform cache; fall back to temp only when ProjectDirs fails
653        // (documented). Library callers that require a real cache should call
654        // `default_cache_dir()` / `validate()` themselves (JOE-1608).
655        let cache_dir =
656            Self::default_cache_dir().unwrap_or_else(|_| std::env::temp_dir().join("aurum-cache"));
657
658        // Env overrides for TTS (no secrets).
659        let tts_model = std::env::var("AURUM_TTS_MODEL")
660            .ok()
661            .filter(|s| !s.is_empty())
662            .unwrap_or(file.tts.model);
663        let tts_voice = std::env::var("AURUM_TTS_VOICE")
664            .ok()
665            .filter(|s| !s.is_empty())
666            .unwrap_or(file.tts.voice);
667        let tts_language = std::env::var("AURUM_TTS_LANGUAGE")
668            .ok()
669            .filter(|s| !s.is_empty())
670            .unwrap_or(file.tts.language);
671
672        Self {
673            provider: file.default.provider,
674            model: Some(file.default.model),
675            language: file.default.language,
676            output: file.default.output,
677            output_file: None,
678            timestamps: false,
679            verbose: false,
680            openrouter_api_key,
681            openrouter_base_url,
682            openrouter_default_model,
683            openrouter_allow_custom_endpoint: file.openrouter.allow_custom_endpoint,
684            openrouter_stt_mode: if file.openrouter.stt_mode.trim().is_empty() {
685                default_stt_mode()
686            } else {
687                file.openrouter.stt_mode
688            },
689            openrouter_use_system_proxy: file.openrouter.use_system_proxy,
690            cleanup_style: file.cleanup.style,
691            cleanup_provider: file.cleanup.provider,
692            cleanup_openrouter_model: file.cleanup.openrouter_model,
693            tts_provider: file.tts.provider,
694            tts_model,
695            tts_voice,
696            tts_language,
697            tts_max_chars: file.tts.max_chars.max(1),
698            tts_timeout_ms: if file.tts.timeout_ms == 0 {
699                DEFAULT_TTS_TIMEOUT_MS
700            } else {
701                file.tts.timeout_ms
702            },
703            tts_pack_dir: file.tts.pack_dir.map(PathBuf::from),
704            tts_allow_unverified: file.tts.allow_unverified,
705            tts_custom_models: file.tts.custom_models,
706            config_path,
707            cache_dir,
708        }
709    }
710
711    /// Apply CLI overrides on top of the loaded config.
712    #[allow(clippy::too_many_arguments)]
713    pub fn apply_cli(
714        &mut self,
715        provider: Option<&str>,
716        model: Option<&str>,
717        language: Option<&str>,
718        output: Option<&str>,
719        output_file: Option<&Path>,
720        timestamps: bool,
721        verbose: bool,
722        cleanup: Option<&str>,
723        cleanup_provider: Option<&str>,
724        cleanup_model: Option<&str>,
725    ) {
726        if let Some(p) = provider {
727            self.provider = p.to_string();
728        }
729        if let Some(m) = model {
730            self.model = Some(m.to_string());
731        }
732        if let Some(l) = language {
733            self.language = l.to_string();
734        }
735        if let Some(o) = output {
736            self.output = o.to_string();
737        }
738        if let Some(path) = output_file {
739            self.output_file = Some(path.to_path_buf());
740        }
741        if timestamps {
742            self.timestamps = true;
743        }
744        if verbose {
745            self.verbose = true;
746        }
747        if let Some(c) = cleanup {
748            self.cleanup_style = c.to_string();
749        }
750        if let Some(p) = cleanup_provider {
751            self.cleanup_provider = p.to_string();
752        }
753        if let Some(m) = cleanup_model {
754            self.cleanup_openrouter_model = Some(m.to_string());
755        }
756    }
757
758    /// Resolve the effective model for the active provider.
759    ///
760    /// When `model_explicitly_set` is false and the provider is openrouter, a bare
761    /// local whisper name (e.g. config default `base`) is replaced with the
762    /// openrouter default model id.
763    /// Resolve model for the active provider.
764    ///
765    /// Returns `Err` if OpenRouter is selected with an explicit bare local whisper name
766    /// (e.g. `--provider openrouter --model tiny`) — that cannot be a remote model id.
767    pub fn resolve_model(&self, model_explicitly_set: bool) -> Result<String> {
768        if model_explicitly_set {
769            let m = self
770                .model
771                .clone()
772                .unwrap_or_else(|| self.default_model_for_provider());
773            if self.provider == "openrouter"
774                && !m.contains('/')
775                && (crate::model::lookup_model(&m).is_ok() || m == DEFAULT_LOCAL_MODEL)
776            {
777                return Err(UserError::Other {
778                    message: format!(
779                        "model '{m}' looks like a local whisper model, not an OpenRouter id.\n \
780 Hint: use e.g. google/gemini-2.5-flash-lite or openai/gpt-audio-mini, \
781 or omit --model to use the OpenRouter default."
782                    ),
783                }
784                .into());
785            }
786            return Ok(m);
787        }
788        match self.provider.as_str() {
789            "openrouter" => {
790                let m = self
791                    .model
792                    .clone()
793                    .unwrap_or_else(|| self.openrouter_default_model.clone());
794                if m.contains('/') {
795                    Ok(m)
796                } else if m == DEFAULT_LOCAL_MODEL || crate::model::lookup_model(&m).is_ok() {
797                    Ok(self.openrouter_default_model.clone())
798                } else {
799                    Ok(m)
800                }
801            }
802            _ => Ok(self
803                .model
804                .clone()
805                .unwrap_or_else(|| DEFAULT_LOCAL_MODEL.to_string())),
806        }
807    }
808
809    fn default_model_for_provider(&self) -> String {
810        match self.provider.as_str() {
811            "openrouter" => self.openrouter_default_model.clone(),
812            _ => DEFAULT_LOCAL_MODEL.to_string(),
813        }
814    }
815}
816
817/// Maximum config file size before TOML parse (JOE-1593).
818pub const MAX_CONFIG_BYTES: u64 = 256 * 1024;
819
820fn load_config_file(path: &Path) -> Result<ConfigFile> {
821    let meta = fs::metadata(path).map_err(|e| UserError::InvalidConfig {
822        reason: format!("failed to stat {}: {e}", path.display()),
823    })?;
824    if meta.len() > MAX_CONFIG_BYTES {
825        return Err(UserError::InvalidConfig {
826            reason: format!(
827                "config file {} is too large ({} > {MAX_CONFIG_BYTES} bytes)",
828                path.display(),
829                meta.len()
830            ),
831        }
832        .into());
833    }
834    let contents = fs::read_to_string(path).map_err(|e| UserError::InvalidConfig {
835        reason: format!("failed to read {}: {e}", path.display()),
836    })?;
837    toml::from_str(&contents).map_err(|e| {
838        UserError::InvalidConfig {
839            reason: format!("failed to parse {}: {e}", path.display()),
840        }
841        .into()
842    })
843}
844
845fn dirs_home() -> Result<PathBuf> {
846    if let Ok(h) = std::env::var("HOME") {
847        return Ok(PathBuf::from(h));
848    }
849    if let Ok(h) = std::env::var("USERPROFILE") {
850        return Ok(PathBuf::from(h));
851    }
852    Err(UserError::InvalidConfig {
853        reason: "could not determine home directory".into(),
854    }
855    .into())
856}
857
858/// Write a starter config file if one does not already exist.
859pub fn write_example_config(path: &Path) -> Result<()> {
860    if path.exists() {
861        return Ok(());
862    }
863    if let Some(parent) = path.parent() {
864        fs::create_dir_all(parent)?;
865    }
866    let example = r#"# Aurum configuration
867# Environment variables take precedence over values in this file.
868# OPENROUTER_API_KEY is preferred over openrouter.api_key below.
869# TTS: AURUM_TTS_MODEL, AURUM_TTS_VOICE, AURUM_TTS_LANGUAGE override [tts].
870
871[default]
872provider = "local"
873model = "base"
874language = "auto"
875output = "txt"
876
877[cleanup]
878# style = "raw" # raw | clean | bullets | professional | summary
879# provider = "rules" # rules (on-device) | openrouter
880# openrouter_model = "google/gemini-2.5-flash"
881
882[tts]
883# provider = "local"
884# model = "kitten-nano-int8"
885# voice = "Luna"
886# language = "en"
887# max_chars = 5000
888# timeout_ms = 120000
889
890[openrouter]
891# api_key = "sk-or-..."
892# model = "google/gemini-2.5-flash"
893# base_url = "https://openrouter.ai/api/v1"
894"#;
895    fs::write(path, example)?;
896    Ok(())
897}
898
899#[cfg(test)]
900mod tests {
901    use super::*;
902    use std::io::Write;
903    use tempfile::tempdir;
904
905    #[test]
906    fn parses_config_file() {
907        let dir = tempdir().unwrap();
908        let path = dir.path().join("config.toml");
909        let mut f = fs::File::create(&path).unwrap();
910        writeln!(
911            f,
912            r#"
913[default]
914provider = "openrouter"
915model = "small"
916language = "en"
917output = "json"
918
919[openrouter]
920api_key = "test-key"
921model = "google/gemini-2.5-flash"
922"#
923        )
924        .unwrap();
925
926        // Env takes precedence for the key; isolate this test (JOE-1608).
927        let prev = std::env::var("OPENROUTER_API_KEY").ok();
928        std::env::remove_var("OPENROUTER_API_KEY");
929        let cfg = Config::load_from(&path).unwrap();
930        if let Some(v) = prev {
931            std::env::set_var("OPENROUTER_API_KEY", v);
932        }
933        assert_eq!(cfg.provider, "openrouter");
934        assert_eq!(cfg.model.as_deref(), Some("small"));
935        assert_eq!(cfg.language, "en");
936        assert_eq!(cfg.output, "json");
937        assert_eq!(cfg.openrouter_api_key.as_deref(), Some("test-key"));
938        assert_eq!(cfg.openrouter_default_model, "google/gemini-2.5-flash");
939        assert_eq!(cfg.cleanup_style, "raw");
940        assert_eq!(cfg.cleanup_provider, "rules");
941        assert!(cfg.validate().is_ok());
942        let diag = cfg.effective_diagnostic();
943        assert_eq!(diag.openrouter_api_key.as_deref(), Some("***"));
944    }
945
946    #[test]
947    fn load_from_required_missing_errors() {
948        let dir = tempdir().unwrap();
949        let path = dir.path().join("missing.toml");
950        let err = Config::load_from_required(&path).unwrap_err();
951        assert!(err.to_string().contains("not found"));
952    }
953
954    #[test]
955    fn parses_cleanup_section() {
956        let dir = tempdir().unwrap();
957        let path = dir.path().join("config.toml");
958        fs::write(
959            &path,
960            r#"
961[default]
962provider = "local"
963model = "base"
964
965[cleanup]
966style = "clean"
967provider = "rules"
968openrouter_model = "google/gemini-2.5-flash"
969"#,
970        )
971        .unwrap();
972        let cfg = Config::load_from(&path).unwrap();
973        assert_eq!(cfg.cleanup_style, "clean");
974        assert_eq!(cfg.cleanup_provider, "rules");
975        assert_eq!(
976            cfg.cleanup_openrouter_model.as_deref(),
977            Some("google/gemini-2.5-flash")
978        );
979    }
980
981    #[test]
982    fn cli_overrides_file() {
983        let dir = tempdir().unwrap();
984        let path = dir.path().join("config.toml");
985        fs::write(
986            &path,
987            r#"
988[default]
989provider = "local"
990model = "base"
991language = "auto"
992output = "txt"
993
994[cleanup]
995style = "clean"
996provider = "rules"
997"#,
998        )
999        .unwrap();
1000        let mut cfg = Config::load_from(&path).unwrap();
1001        cfg.apply_cli(
1002            Some("openrouter"),
1003            Some("google/gemini-2.5-flash"),
1004            Some("fr"),
1005            Some("srt"),
1006            Some(Path::new("out.srt")),
1007            true,
1008            true,
1009            Some("summary"),
1010            Some("openrouter"),
1011            Some("openai/gpt-audio-mini"),
1012        );
1013        assert_eq!(cfg.provider, "openrouter");
1014        assert_eq!(cfg.model.as_deref(), Some("google/gemini-2.5-flash"));
1015        assert_eq!(cfg.language, "fr");
1016        assert_eq!(cfg.output, "srt");
1017        assert_eq!(cfg.output_file.as_deref(), Some(Path::new("out.srt")));
1018        assert!(cfg.timestamps);
1019        assert!(cfg.verbose);
1020        assert_eq!(cfg.cleanup_style, "summary");
1021        assert_eq!(cfg.cleanup_provider, "openrouter");
1022        assert_eq!(
1023            cfg.cleanup_openrouter_model.as_deref(),
1024            Some("openai/gpt-audio-mini")
1025        );
1026    }
1027
1028    #[test]
1029    fn custom_tts_model_cannot_shadow_builtin_on_load() {
1030        let dir = tempdir().unwrap();
1031        let path = dir.path().join("config.toml");
1032        let default_id = {
1033            #[cfg(feature = "tts")]
1034            {
1035                crate::tts::DEFAULT_TTS_MODEL
1036            }
1037            #[cfg(not(feature = "tts"))]
1038            {
1039                "kitten-nano-int8"
1040            }
1041        };
1042        fs::write(
1043            &path,
1044            format!(
1045                r#"
1046[tts]
1047model = "{default_id}"
1048
1049[[tts.custom_models]]
1050id = "{default_id}"
1051adapter = "fake-sine-v1"
1052pack_dir = "/tmp/does-not-matter"
1053trust = "verified"
1054"#
1055            ),
1056        )
1057        .unwrap();
1058        let err = Config::load_from(&path).unwrap_err();
1059        assert!(
1060            err.to_string().contains("collides") || err.to_string().contains("reserved"),
1061            "got: {err}"
1062        );
1063    }
1064
1065    #[test]
1066    fn defaults_when_missing_file() {
1067        let dir = tempdir().unwrap();
1068        let path = dir.path().join("nope.toml");
1069        let cfg = Config::load_from(&path).unwrap();
1070        assert_eq!(cfg.provider, "local");
1071        assert_eq!(cfg.language, "auto");
1072        assert_eq!(cfg.output, "txt");
1073        assert_eq!(cfg.cleanup_style, "raw");
1074        assert_eq!(cfg.cleanup_provider, "rules");
1075    }
1076}