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}
73
74/// Post-ASR cleanup defaults ( flow).
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct CleanupSection {
77    /// `raw` | `clean` | `bullets` | `professional` | `summary`
78    #[serde(default = "default_cleanup")]
79    pub style: String,
80    /// `rules` (on-device) | `openrouter`
81    #[serde(default = "default_cleanup_provider")]
82    pub provider: String,
83    /// Optional model id when provider is openrouter.
84    pub openrouter_model: Option<String>,
85}
86
87impl Default for CleanupSection {
88    fn default() -> Self {
89        Self {
90            style: default_cleanup(),
91            provider: default_cleanup_provider(),
92            openrouter_model: None,
93        }
94    }
95}
96
97/// Local TTS defaults (`[tts]`).
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct TtsSection {
100    /// Only `local` in MVP.
101    #[serde(default = "default_tts_provider")]
102    pub provider: String,
103    #[serde(default = "default_tts_model")]
104    pub model: String,
105    #[serde(default = "default_tts_voice")]
106    pub voice: String,
107    #[serde(default = "default_tts_language")]
108    pub language: String,
109    #[serde(default = "default_tts_max_chars")]
110    pub max_chars: usize,
111    #[serde(default = "default_tts_timeout_ms")]
112    pub timeout_ms: u64,
113}
114
115impl Default for TtsSection {
116    fn default() -> Self {
117        Self {
118            provider: default_tts_provider(),
119            model: default_tts_model(),
120            voice: default_tts_voice(),
121            language: default_tts_language(),
122            max_chars: default_tts_max_chars(),
123            timeout_ms: default_tts_timeout_ms(),
124        }
125    }
126}
127
128fn default_provider() -> String {
129    DEFAULT_PROVIDER.to_string()
130}
131fn default_local_model() -> String {
132    DEFAULT_LOCAL_MODEL.to_string()
133}
134fn default_language() -> String {
135    DEFAULT_LANGUAGE.to_string()
136}
137fn default_output() -> String {
138    DEFAULT_OUTPUT.to_string()
139}
140fn default_cleanup() -> String {
141    DEFAULT_CLEANUP.to_string()
142}
143fn default_cleanup_provider() -> String {
144    DEFAULT_CLEANUP_PROVIDER.to_string()
145}
146fn default_tts_provider() -> String {
147    DEFAULT_TTS_PROVIDER.to_string()
148}
149fn default_tts_model() -> String {
150    #[cfg(feature = "tts")]
151    {
152        crate::tts::DEFAULT_TTS_MODEL.to_string()
153    }
154    #[cfg(not(feature = "tts"))]
155    {
156        "kitten-nano-int8".to_string()
157    }
158}
159fn default_tts_voice() -> String {
160    #[cfg(feature = "tts")]
161    {
162        crate::tts::DEFAULT_TTS_VOICE.to_string()
163    }
164    #[cfg(not(feature = "tts"))]
165    {
166        "Luna".to_string()
167    }
168}
169fn default_tts_language() -> String {
170    DEFAULT_TTS_LANGUAGE.to_string()
171}
172fn default_tts_max_chars() -> usize {
173    DEFAULT_TTS_MAX_CHARS
174}
175fn default_tts_timeout_ms() -> u64 {
176    DEFAULT_TTS_TIMEOUT_MS
177}
178
179/// Fully-resolved runtime configuration after merging all sources.
180#[derive(Clone)]
181pub struct Config {
182    pub provider: String,
183    pub model: Option<String>,
184    pub language: String,
185    pub output: String,
186    pub output_file: Option<PathBuf>,
187    pub timestamps: bool,
188    pub verbose: bool,
189    pub openrouter_api_key: Option<String>,
190    pub openrouter_base_url: String,
191    pub openrouter_default_model: String,
192    /// Cleanup style name (`raw`, `clean`, …).
193    pub cleanup_style: String,
194    /// Cleanup backend name (`rules`, `openrouter`).
195    pub cleanup_provider: String,
196    /// Optional dedicated model for OpenRouter cleanup.
197    pub cleanup_openrouter_model: Option<String>,
198    /// TTS provider name (`local` only in MVP).
199    pub tts_provider: String,
200    pub tts_model: String,
201    pub tts_voice: String,
202    pub tts_language: String,
203    pub tts_max_chars: usize,
204    pub tts_timeout_ms: u64,
205    pub config_path: Option<PathBuf>,
206    pub cache_dir: PathBuf,
207}
208
209impl std::fmt::Debug for Config {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        f.debug_struct("Config")
212            .field("provider", &self.provider)
213            .field("model", &self.model)
214            .field("language", &self.language)
215            .field("output", &self.output)
216            .field("output_file", &self.output_file)
217            .field("timestamps", &self.timestamps)
218            .field("verbose", &self.verbose)
219            .field(
220                "openrouter_api_key",
221                &self.openrouter_api_key.as_ref().map(|_| "***"),
222            )
223            .field("openrouter_base_url", &self.openrouter_base_url)
224            .field("openrouter_default_model", &self.openrouter_default_model)
225            .field("cleanup_style", &self.cleanup_style)
226            .field("cleanup_provider", &self.cleanup_provider)
227            .field("cleanup_openrouter_model", &self.cleanup_openrouter_model)
228            .field("tts_provider", &self.tts_provider)
229            .field("tts_model", &self.tts_model)
230            .field("tts_voice", &self.tts_voice)
231            .field("tts_language", &self.tts_language)
232            .field("tts_max_chars", &self.tts_max_chars)
233            .field("tts_timeout_ms", &self.tts_timeout_ms)
234            .field("config_path", &self.config_path)
235            .field("cache_dir", &self.cache_dir)
236            .finish()
237    }
238}
239
240impl Config {
241    /// Resolve the platform-appropriate config file path.
242    pub fn default_config_path() -> Option<PathBuf> {
243        ProjectDirs::from("", "", "aurum").map(|d| d.config_dir().join("config.toml"))
244    }
245
246    /// Resolve the platform-appropriate cache directory (models live under `models/`).
247    pub fn default_cache_dir() -> Result<PathBuf> {
248        // Prefer XDG-style cache via the `directories` crate.
249        if let Some(dirs) = ProjectDirs::from("", "", "aurum") {
250            return Ok(dirs.cache_dir().to_path_buf());
251        }
252        // Fallback: ~/.cache/aurum
253        let home = dirs_home()?;
254        Ok(home.join(".cache").join("aurum"))
255    }
256
257    /// Load config file from the default location (if present) and merge with env vars.
258    pub fn load() -> Result<Self> {
259        let path = Self::default_config_path();
260        let file = match &path {
261            Some(p) if p.exists() => Some(load_config_file(p)?),
262            _ => None,
263        };
264        Ok(Self::from_parts(file, path))
265    }
266
267    /// Load from an explicit config file path (used in tests).
268    pub fn load_from(path: &Path) -> Result<Self> {
269        let file = if path.exists() {
270            Some(load_config_file(path)?)
271        } else {
272            None
273        };
274        Ok(Self::from_parts(file, Some(path.to_path_buf())))
275    }
276
277    fn from_parts(file: Option<ConfigFile>, config_path: Option<PathBuf>) -> Self {
278        let file = file.unwrap_or_default();
279
280        let openrouter_api_key = std::env::var("OPENROUTER_API_KEY")
281            .ok()
282            .filter(|s| !s.is_empty())
283            .or(file.openrouter.api_key.clone());
284
285        let openrouter_base_url = std::env::var("OPENROUTER_BASE_URL")
286            .ok()
287            .filter(|s| !s.is_empty())
288            .or(file.openrouter.base_url.clone())
289            .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string());
290
291        let openrouter_default_model = file
292            .openrouter
293            .model
294            .clone()
295            .unwrap_or_else(|| DEFAULT_OPENROUTER_MODEL.to_string());
296
297        let cache_dir =
298            Self::default_cache_dir().unwrap_or_else(|_| std::env::temp_dir().join("aurum-cache"));
299
300        // Env overrides for TTS (no secrets).
301        let tts_model = std::env::var("AURUM_TTS_MODEL")
302            .ok()
303            .filter(|s| !s.is_empty())
304            .unwrap_or(file.tts.model);
305        let tts_voice = std::env::var("AURUM_TTS_VOICE")
306            .ok()
307            .filter(|s| !s.is_empty())
308            .unwrap_or(file.tts.voice);
309        let tts_language = std::env::var("AURUM_TTS_LANGUAGE")
310            .ok()
311            .filter(|s| !s.is_empty())
312            .unwrap_or(file.tts.language);
313
314        Self {
315            provider: file.default.provider,
316            model: Some(file.default.model),
317            language: file.default.language,
318            output: file.default.output,
319            output_file: None,
320            timestamps: false,
321            verbose: false,
322            openrouter_api_key,
323            openrouter_base_url,
324            openrouter_default_model,
325            cleanup_style: file.cleanup.style,
326            cleanup_provider: file.cleanup.provider,
327            cleanup_openrouter_model: file.cleanup.openrouter_model,
328            tts_provider: file.tts.provider,
329            tts_model,
330            tts_voice,
331            tts_language,
332            tts_max_chars: file.tts.max_chars.max(1),
333            tts_timeout_ms: if file.tts.timeout_ms == 0 {
334                DEFAULT_TTS_TIMEOUT_MS
335            } else {
336                file.tts.timeout_ms
337            },
338            config_path,
339            cache_dir,
340        }
341    }
342
343    /// Apply CLI overrides on top of the loaded config.
344    #[allow(clippy::too_many_arguments)]
345    pub fn apply_cli(
346        &mut self,
347        provider: Option<&str>,
348        model: Option<&str>,
349        language: Option<&str>,
350        output: Option<&str>,
351        output_file: Option<&Path>,
352        timestamps: bool,
353        verbose: bool,
354        cleanup: Option<&str>,
355        cleanup_provider: Option<&str>,
356        cleanup_model: Option<&str>,
357    ) {
358        if let Some(p) = provider {
359            self.provider = p.to_string();
360        }
361        if let Some(m) = model {
362            self.model = Some(m.to_string());
363        }
364        if let Some(l) = language {
365            self.language = l.to_string();
366        }
367        if let Some(o) = output {
368            self.output = o.to_string();
369        }
370        if let Some(path) = output_file {
371            self.output_file = Some(path.to_path_buf());
372        }
373        if timestamps {
374            self.timestamps = true;
375        }
376        if verbose {
377            self.verbose = true;
378        }
379        if let Some(c) = cleanup {
380            self.cleanup_style = c.to_string();
381        }
382        if let Some(p) = cleanup_provider {
383            self.cleanup_provider = p.to_string();
384        }
385        if let Some(m) = cleanup_model {
386            self.cleanup_openrouter_model = Some(m.to_string());
387        }
388    }
389
390    /// Resolve the effective model for the active provider.
391    ///
392    /// When `model_explicitly_set` is false and the provider is openrouter, a bare
393    /// local whisper name (e.g. config default `base`) is replaced with the
394    /// openrouter default model id.
395    /// Resolve model for the active provider.
396    ///
397    /// Returns `Err` if OpenRouter is selected with an explicit bare local whisper name
398    /// (e.g. `--provider openrouter --model tiny`) — that cannot be a remote model id.
399    pub fn resolve_model(&self, model_explicitly_set: bool) -> Result<String> {
400        if model_explicitly_set {
401            let m = self
402                .model
403                .clone()
404                .unwrap_or_else(|| self.default_model_for_provider());
405            if self.provider == "openrouter"
406                && !m.contains('/')
407                && (crate::model::lookup_model(&m).is_ok() || m == DEFAULT_LOCAL_MODEL)
408            {
409                return Err(UserError::Other {
410                    message: format!(
411                        "model '{m}' looks like a local whisper model, not an OpenRouter id.\n \
412 Hint: use e.g. google/gemini-2.5-flash-lite or openai/gpt-audio-mini, \
413 or omit --model to use the OpenRouter default."
414                    ),
415                }
416                .into());
417            }
418            return Ok(m);
419        }
420        match self.provider.as_str() {
421            "openrouter" => {
422                let m = self
423                    .model
424                    .clone()
425                    .unwrap_or_else(|| self.openrouter_default_model.clone());
426                if m.contains('/') {
427                    Ok(m)
428                } else if m == DEFAULT_LOCAL_MODEL || crate::model::lookup_model(&m).is_ok() {
429                    Ok(self.openrouter_default_model.clone())
430                } else {
431                    Ok(m)
432                }
433            }
434            _ => Ok(self
435                .model
436                .clone()
437                .unwrap_or_else(|| DEFAULT_LOCAL_MODEL.to_string())),
438        }
439    }
440
441    fn default_model_for_provider(&self) -> String {
442        match self.provider.as_str() {
443            "openrouter" => self.openrouter_default_model.clone(),
444            _ => DEFAULT_LOCAL_MODEL.to_string(),
445        }
446    }
447}
448
449fn load_config_file(path: &Path) -> Result<ConfigFile> {
450    let contents = fs::read_to_string(path).map_err(|e| UserError::InvalidConfig {
451        reason: format!("failed to read {}: {e}", path.display()),
452    })?;
453    toml::from_str(&contents).map_err(|e| {
454        UserError::InvalidConfig {
455            reason: format!("failed to parse {}: {e}", path.display()),
456        }
457        .into()
458    })
459}
460
461fn dirs_home() -> Result<PathBuf> {
462    if let Ok(h) = std::env::var("HOME") {
463        return Ok(PathBuf::from(h));
464    }
465    if let Ok(h) = std::env::var("USERPROFILE") {
466        return Ok(PathBuf::from(h));
467    }
468    Err(UserError::InvalidConfig {
469        reason: "could not determine home directory".into(),
470    }
471    .into())
472}
473
474/// Write a starter config file if one does not already exist.
475pub fn write_example_config(path: &Path) -> Result<()> {
476    if path.exists() {
477        return Ok(());
478    }
479    if let Some(parent) = path.parent() {
480        fs::create_dir_all(parent)?;
481    }
482    let example = r#"# Aurum configuration
483# Environment variables take precedence over values in this file.
484# OPENROUTER_API_KEY is preferred over openrouter.api_key below.
485# TTS: AURUM_TTS_MODEL, AURUM_TTS_VOICE, AURUM_TTS_LANGUAGE override [tts].
486
487[default]
488provider = "local"
489model = "base"
490language = "auto"
491output = "txt"
492
493[cleanup]
494# style = "raw" # raw | clean | bullets | professional | summary
495# provider = "rules" # rules (on-device) | openrouter
496# openrouter_model = "google/gemini-2.5-flash"
497
498[tts]
499# provider = "local"
500# model = "kitten-nano-int8"
501# voice = "Luna"
502# language = "en"
503# max_chars = 5000
504# timeout_ms = 120000
505
506[openrouter]
507# api_key = "sk-or-..."
508# model = "google/gemini-2.5-flash"
509# base_url = "https://openrouter.ai/api/v1"
510"#;
511    fs::write(path, example)?;
512    Ok(())
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use std::io::Write;
519    use tempfile::tempdir;
520
521    #[test]
522    fn parses_config_file() {
523        let dir = tempdir().unwrap();
524        let path = dir.path().join("config.toml");
525        let mut f = fs::File::create(&path).unwrap();
526        writeln!(
527            f,
528            r#"
529[default]
530provider = "openrouter"
531model = "small"
532language = "en"
533output = "json"
534
535[openrouter]
536api_key = "test-key"
537model = "google/gemini-2.5-flash"
538"#
539        )
540        .unwrap();
541
542        let cfg = Config::load_from(&path).unwrap();
543        assert_eq!(cfg.provider, "openrouter");
544        assert_eq!(cfg.model.as_deref(), Some("small"));
545        assert_eq!(cfg.language, "en");
546        assert_eq!(cfg.output, "json");
547        assert_eq!(cfg.openrouter_api_key.as_deref(), Some("test-key"));
548        assert_eq!(cfg.openrouter_default_model, "google/gemini-2.5-flash");
549        assert_eq!(cfg.cleanup_style, "raw");
550        assert_eq!(cfg.cleanup_provider, "rules");
551    }
552
553    #[test]
554    fn parses_cleanup_section() {
555        let dir = tempdir().unwrap();
556        let path = dir.path().join("config.toml");
557        fs::write(
558            &path,
559            r#"
560[default]
561provider = "local"
562model = "base"
563
564[cleanup]
565style = "clean"
566provider = "rules"
567openrouter_model = "google/gemini-2.5-flash"
568"#,
569        )
570        .unwrap();
571        let cfg = Config::load_from(&path).unwrap();
572        assert_eq!(cfg.cleanup_style, "clean");
573        assert_eq!(cfg.cleanup_provider, "rules");
574        assert_eq!(
575            cfg.cleanup_openrouter_model.as_deref(),
576            Some("google/gemini-2.5-flash")
577        );
578    }
579
580    #[test]
581    fn cli_overrides_file() {
582        let dir = tempdir().unwrap();
583        let path = dir.path().join("config.toml");
584        fs::write(
585            &path,
586            r#"
587[default]
588provider = "local"
589model = "base"
590language = "auto"
591output = "txt"
592
593[cleanup]
594style = "clean"
595provider = "rules"
596"#,
597        )
598        .unwrap();
599        let mut cfg = Config::load_from(&path).unwrap();
600        cfg.apply_cli(
601            Some("openrouter"),
602            Some("google/gemini-2.5-flash"),
603            Some("fr"),
604            Some("srt"),
605            Some(Path::new("out.srt")),
606            true,
607            true,
608            Some("summary"),
609            Some("openrouter"),
610            Some("openai/gpt-audio-mini"),
611        );
612        assert_eq!(cfg.provider, "openrouter");
613        assert_eq!(cfg.model.as_deref(), Some("google/gemini-2.5-flash"));
614        assert_eq!(cfg.language, "fr");
615        assert_eq!(cfg.output, "srt");
616        assert_eq!(cfg.output_file.as_deref(), Some(Path::new("out.srt")));
617        assert!(cfg.timestamps);
618        assert!(cfg.verbose);
619        assert_eq!(cfg.cleanup_style, "summary");
620        assert_eq!(cfg.cleanup_provider, "openrouter");
621        assert_eq!(
622            cfg.cleanup_openrouter_model.as_deref(),
623            Some("openai/gpt-audio-mini")
624        );
625    }
626
627    #[test]
628    fn defaults_when_missing_file() {
629        let dir = tempdir().unwrap();
630        let path = dir.path().join("nope.toml");
631        let cfg = Config::load_from(&path).unwrap();
632        assert_eq!(cfg.provider, "local");
633        assert_eq!(cfg.language, "auto");
634        assert_eq!(cfg.output, "txt");
635        assert_eq!(cfg.cleanup_style, "raw");
636        assert_eq!(cfg.cleanup_provider, "rules");
637    }
638}