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";
23
24/// On-disk configuration file schema.
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26pub struct ConfigFile {
27    #[serde(default)]
28    pub default: DefaultSection,
29    #[serde(default)]
30    pub openrouter: OpenRouterSection,
31    #[serde(default)]
32    pub cleanup: CleanupSection,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct DefaultSection {
37    #[serde(default = "default_provider")]
38    pub provider: String,
39    #[serde(default = "default_local_model")]
40    pub model: String,
41    #[serde(default = "default_language")]
42    pub language: String,
43    #[serde(default = "default_output")]
44    pub output: String,
45}
46
47impl Default for DefaultSection {
48    fn default() -> Self {
49        Self {
50            provider: default_provider(),
51            model: default_local_model(),
52            language: default_language(),
53            output: default_output(),
54        }
55    }
56}
57
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct OpenRouterSection {
60    /// Prefer `OPENROUTER_API_KEY` env var over this field.
61    pub api_key: Option<String>,
62    /// Default remote model when provider is openrouter.
63    pub model: Option<String>,
64    /// Optional custom base URL (for testing / proxies).
65    pub base_url: Option<String>,
66}
67
68/// Post-ASR cleanup defaults ( flow).
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CleanupSection {
71    /// `raw` | `clean` | `bullets` | `professional` | `summary`
72    #[serde(default = "default_cleanup")]
73    pub style: String,
74    /// `rules` (on-device) | `openrouter`
75    #[serde(default = "default_cleanup_provider")]
76    pub provider: String,
77    /// Optional model id when provider is openrouter.
78    pub openrouter_model: Option<String>,
79}
80
81impl Default for CleanupSection {
82    fn default() -> Self {
83        Self {
84            style: default_cleanup(),
85            provider: default_cleanup_provider(),
86            openrouter_model: None,
87        }
88    }
89}
90
91fn default_provider() -> String {
92    DEFAULT_PROVIDER.to_string()
93}
94fn default_local_model() -> String {
95    DEFAULT_LOCAL_MODEL.to_string()
96}
97fn default_language() -> String {
98    DEFAULT_LANGUAGE.to_string()
99}
100fn default_output() -> String {
101    DEFAULT_OUTPUT.to_string()
102}
103fn default_cleanup() -> String {
104    DEFAULT_CLEANUP.to_string()
105}
106fn default_cleanup_provider() -> String {
107    DEFAULT_CLEANUP_PROVIDER.to_string()
108}
109
110/// Fully-resolved runtime configuration after merging all sources.
111#[derive(Clone)]
112pub struct Config {
113    pub provider: String,
114    pub model: Option<String>,
115    pub language: String,
116    pub output: String,
117    pub output_file: Option<PathBuf>,
118    pub timestamps: bool,
119    pub verbose: bool,
120    pub openrouter_api_key: Option<String>,
121    pub openrouter_base_url: String,
122    pub openrouter_default_model: String,
123    /// Cleanup style name (`raw`, `clean`, …).
124    pub cleanup_style: String,
125    /// Cleanup backend name (`rules`, `openrouter`).
126    pub cleanup_provider: String,
127    /// Optional dedicated model for OpenRouter cleanup.
128    pub cleanup_openrouter_model: Option<String>,
129    pub config_path: Option<PathBuf>,
130    pub cache_dir: PathBuf,
131}
132
133impl std::fmt::Debug for Config {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.debug_struct("Config")
136            .field("provider", &self.provider)
137            .field("model", &self.model)
138            .field("language", &self.language)
139            .field("output", &self.output)
140            .field("output_file", &self.output_file)
141            .field("timestamps", &self.timestamps)
142            .field("verbose", &self.verbose)
143            .field(
144                "openrouter_api_key",
145                &self.openrouter_api_key.as_ref().map(|_| "***"),
146            )
147            .field("openrouter_base_url", &self.openrouter_base_url)
148            .field("openrouter_default_model", &self.openrouter_default_model)
149            .field("cleanup_style", &self.cleanup_style)
150            .field("cleanup_provider", &self.cleanup_provider)
151            .field("cleanup_openrouter_model", &self.cleanup_openrouter_model)
152            .field("config_path", &self.config_path)
153            .field("cache_dir", &self.cache_dir)
154            .finish()
155    }
156}
157
158impl Config {
159    /// Resolve the platform-appropriate config file path.
160    pub fn default_config_path() -> Option<PathBuf> {
161        ProjectDirs::from("", "", "aurum").map(|d| d.config_dir().join("config.toml"))
162    }
163
164    /// Resolve the platform-appropriate cache directory (models live under `models/`).
165    pub fn default_cache_dir() -> Result<PathBuf> {
166        // Prefer XDG-style cache via the `directories` crate.
167        if let Some(dirs) = ProjectDirs::from("", "", "aurum") {
168            return Ok(dirs.cache_dir().to_path_buf());
169        }
170        // Fallback: ~/.cache/aurum
171        let home = dirs_home()?;
172        Ok(home.join(".cache").join("aurum"))
173    }
174
175    /// Load config file from the default location (if present) and merge with env vars.
176    pub fn load() -> Result<Self> {
177        let path = Self::default_config_path();
178        let file = match &path {
179            Some(p) if p.exists() => Some(load_config_file(p)?),
180            _ => None,
181        };
182        Ok(Self::from_parts(file, path))
183    }
184
185    /// Load from an explicit config file path (used in tests).
186    pub fn load_from(path: &Path) -> Result<Self> {
187        let file = if path.exists() {
188            Some(load_config_file(path)?)
189        } else {
190            None
191        };
192        Ok(Self::from_parts(file, Some(path.to_path_buf())))
193    }
194
195    fn from_parts(file: Option<ConfigFile>, config_path: Option<PathBuf>) -> Self {
196        let file = file.unwrap_or_default();
197
198        let openrouter_api_key = std::env::var("OPENROUTER_API_KEY")
199            .ok()
200            .filter(|s| !s.is_empty())
201            .or(file.openrouter.api_key.clone());
202
203        let openrouter_base_url = std::env::var("OPENROUTER_BASE_URL")
204            .ok()
205            .filter(|s| !s.is_empty())
206            .or(file.openrouter.base_url.clone())
207            .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string());
208
209        let openrouter_default_model = file
210            .openrouter
211            .model
212            .clone()
213            .unwrap_or_else(|| DEFAULT_OPENROUTER_MODEL.to_string());
214
215        let cache_dir =
216            Self::default_cache_dir().unwrap_or_else(|_| std::env::temp_dir().join("aurum-cache"));
217
218        Self {
219            provider: file.default.provider,
220            model: Some(file.default.model),
221            language: file.default.language,
222            output: file.default.output,
223            output_file: None,
224            timestamps: false,
225            verbose: false,
226            openrouter_api_key,
227            openrouter_base_url,
228            openrouter_default_model,
229            cleanup_style: file.cleanup.style,
230            cleanup_provider: file.cleanup.provider,
231            cleanup_openrouter_model: file.cleanup.openrouter_model,
232            config_path,
233            cache_dir,
234        }
235    }
236
237    /// Apply CLI overrides on top of the loaded config.
238    #[allow(clippy::too_many_arguments)]
239    pub fn apply_cli(
240        &mut self,
241        provider: Option<&str>,
242        model: Option<&str>,
243        language: Option<&str>,
244        output: Option<&str>,
245        output_file: Option<&Path>,
246        timestamps: bool,
247        verbose: bool,
248        cleanup: Option<&str>,
249        cleanup_provider: Option<&str>,
250        cleanup_model: Option<&str>,
251    ) {
252        if let Some(p) = provider {
253            self.provider = p.to_string();
254        }
255        if let Some(m) = model {
256            self.model = Some(m.to_string());
257        }
258        if let Some(l) = language {
259            self.language = l.to_string();
260        }
261        if let Some(o) = output {
262            self.output = o.to_string();
263        }
264        if let Some(path) = output_file {
265            self.output_file = Some(path.to_path_buf());
266        }
267        if timestamps {
268            self.timestamps = true;
269        }
270        if verbose {
271            self.verbose = true;
272        }
273        if let Some(c) = cleanup {
274            self.cleanup_style = c.to_string();
275        }
276        if let Some(p) = cleanup_provider {
277            self.cleanup_provider = p.to_string();
278        }
279        if let Some(m) = cleanup_model {
280            self.cleanup_openrouter_model = Some(m.to_string());
281        }
282    }
283
284    /// Resolve the effective model for the active provider.
285    ///
286    /// When `model_explicitly_set` is false and the provider is openrouter, a bare
287    /// local whisper name (e.g. config default `base`) is replaced with the
288    /// openrouter default model id.
289    /// Resolve model for the active provider.
290    ///
291    /// Returns `Err` if OpenRouter is selected with an explicit bare local whisper name
292    /// (e.g. `--provider openrouter --model tiny`) — that cannot be a remote model id.
293    pub fn resolve_model(&self, model_explicitly_set: bool) -> Result<String> {
294        if model_explicitly_set {
295            let m = self
296                .model
297                .clone()
298                .unwrap_or_else(|| self.default_model_for_provider());
299            if self.provider == "openrouter"
300                && !m.contains('/')
301                && (crate::model::lookup_model(&m).is_ok() || m == DEFAULT_LOCAL_MODEL)
302            {
303                return Err(UserError::Other {
304                    message: format!(
305                        "model '{m}' looks like a local whisper model, not an OpenRouter id.\n \
306 Hint: use e.g. google/gemini-2.5-flash-lite or openai/gpt-audio-mini, \
307 or omit --model to use the OpenRouter default."
308                    ),
309                }
310                .into());
311            }
312            return Ok(m);
313        }
314        match self.provider.as_str() {
315            "openrouter" => {
316                let m = self
317                    .model
318                    .clone()
319                    .unwrap_or_else(|| self.openrouter_default_model.clone());
320                if m.contains('/') {
321                    Ok(m)
322                } else if m == DEFAULT_LOCAL_MODEL || crate::model::lookup_model(&m).is_ok() {
323                    Ok(self.openrouter_default_model.clone())
324                } else {
325                    Ok(m)
326                }
327            }
328            _ => Ok(self
329                .model
330                .clone()
331                .unwrap_or_else(|| DEFAULT_LOCAL_MODEL.to_string())),
332        }
333    }
334
335    fn default_model_for_provider(&self) -> String {
336        match self.provider.as_str() {
337            "openrouter" => self.openrouter_default_model.clone(),
338            _ => DEFAULT_LOCAL_MODEL.to_string(),
339        }
340    }
341}
342
343fn load_config_file(path: &Path) -> Result<ConfigFile> {
344    let contents = fs::read_to_string(path).map_err(|e| UserError::InvalidConfig {
345        reason: format!("failed to read {}: {e}", path.display()),
346    })?;
347    toml::from_str(&contents).map_err(|e| {
348        UserError::InvalidConfig {
349            reason: format!("failed to parse {}: {e}", path.display()),
350        }
351        .into()
352    })
353}
354
355fn dirs_home() -> Result<PathBuf> {
356    if let Ok(h) = std::env::var("HOME") {
357        return Ok(PathBuf::from(h));
358    }
359    if let Ok(h) = std::env::var("USERPROFILE") {
360        return Ok(PathBuf::from(h));
361    }
362    Err(UserError::InvalidConfig {
363        reason: "could not determine home directory".into(),
364    }
365    .into())
366}
367
368/// Write a starter config file if one does not already exist.
369pub fn write_example_config(path: &Path) -> Result<()> {
370    if path.exists() {
371        return Ok(());
372    }
373    if let Some(parent) = path.parent() {
374        fs::create_dir_all(parent)?;
375    }
376    let example = r#"# Aurum configuration
377# Environment variables take precedence over values in this file.
378# OPENROUTER_API_KEY is preferred over openrouter.api_key below.
379
380[default]
381provider = "local"
382model = "base"
383language = "auto"
384output = "txt"
385
386[cleanup]
387# style = "raw" # raw | clean | bullets | professional | summary
388# provider = "rules" # rules (on-device) | openrouter
389# openrouter_model = "google/gemini-2.5-flash"
390
391[openrouter]
392# api_key = "sk-or-..."
393# model = "google/gemini-2.5-flash"
394# base_url = "https://openrouter.ai/api/v1"
395"#;
396    fs::write(path, example)?;
397    Ok(())
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use std::io::Write;
404    use tempfile::tempdir;
405
406    #[test]
407    fn parses_config_file() {
408        let dir = tempdir().unwrap();
409        let path = dir.path().join("config.toml");
410        let mut f = fs::File::create(&path).unwrap();
411        writeln!(
412            f,
413            r#"
414[default]
415provider = "openrouter"
416model = "small"
417language = "en"
418output = "json"
419
420[openrouter]
421api_key = "test-key"
422model = "google/gemini-2.5-flash"
423"#
424        )
425        .unwrap();
426
427        let cfg = Config::load_from(&path).unwrap();
428        assert_eq!(cfg.provider, "openrouter");
429        assert_eq!(cfg.model.as_deref(), Some("small"));
430        assert_eq!(cfg.language, "en");
431        assert_eq!(cfg.output, "json");
432        assert_eq!(cfg.openrouter_api_key.as_deref(), Some("test-key"));
433        assert_eq!(cfg.openrouter_default_model, "google/gemini-2.5-flash");
434        assert_eq!(cfg.cleanup_style, "raw");
435        assert_eq!(cfg.cleanup_provider, "rules");
436    }
437
438    #[test]
439    fn parses_cleanup_section() {
440        let dir = tempdir().unwrap();
441        let path = dir.path().join("config.toml");
442        fs::write(
443            &path,
444            r#"
445[default]
446provider = "local"
447model = "base"
448
449[cleanup]
450style = "clean"
451provider = "rules"
452openrouter_model = "google/gemini-2.5-flash"
453"#,
454        )
455        .unwrap();
456        let cfg = Config::load_from(&path).unwrap();
457        assert_eq!(cfg.cleanup_style, "clean");
458        assert_eq!(cfg.cleanup_provider, "rules");
459        assert_eq!(
460            cfg.cleanup_openrouter_model.as_deref(),
461            Some("google/gemini-2.5-flash")
462        );
463    }
464
465    #[test]
466    fn cli_overrides_file() {
467        let dir = tempdir().unwrap();
468        let path = dir.path().join("config.toml");
469        fs::write(
470            &path,
471            r#"
472[default]
473provider = "local"
474model = "base"
475language = "auto"
476output = "txt"
477
478[cleanup]
479style = "clean"
480provider = "rules"
481"#,
482        )
483        .unwrap();
484        let mut cfg = Config::load_from(&path).unwrap();
485        cfg.apply_cli(
486            Some("openrouter"),
487            Some("google/gemini-2.5-flash"),
488            Some("fr"),
489            Some("srt"),
490            Some(Path::new("out.srt")),
491            true,
492            true,
493            Some("summary"),
494            Some("openrouter"),
495            Some("openai/gpt-audio-mini"),
496        );
497        assert_eq!(cfg.provider, "openrouter");
498        assert_eq!(cfg.model.as_deref(), Some("google/gemini-2.5-flash"));
499        assert_eq!(cfg.language, "fr");
500        assert_eq!(cfg.output, "srt");
501        assert_eq!(cfg.output_file.as_deref(), Some(Path::new("out.srt")));
502        assert!(cfg.timestamps);
503        assert!(cfg.verbose);
504        assert_eq!(cfg.cleanup_style, "summary");
505        assert_eq!(cfg.cleanup_provider, "openrouter");
506        assert_eq!(
507            cfg.cleanup_openrouter_model.as_deref(),
508            Some("openai/gpt-audio-mini")
509        );
510    }
511
512    #[test]
513    fn defaults_when_missing_file() {
514        let dir = tempdir().unwrap();
515        let path = dir.path().join("nope.toml");
516        let cfg = Config::load_from(&path).unwrap();
517        assert_eq!(cfg.provider, "local");
518        assert_eq!(cfg.language, "auto");
519        assert_eq!(cfg.output, "txt");
520        assert_eq!(cfg.cleanup_style, "raw");
521        assert_eq!(cfg.cleanup_provider, "rules");
522    }
523}