1use crate::error::{Result, UserError};
10use directories::ProjectDirs;
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::{Path, PathBuf};
14
15pub 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#[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 pub api_key: Option<String>,
68 pub model: Option<String>,
70 pub base_url: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct CleanupSection {
77 #[serde(default = "default_cleanup")]
79 pub style: String,
80 #[serde(default = "default_cleanup_provider")]
82 pub provider: String,
83 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#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct TtsSection {
100 #[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#[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 pub cleanup_style: String,
194 pub cleanup_provider: String,
196 pub cleanup_openrouter_model: Option<String>,
198 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 pub fn default_config_path() -> Option<PathBuf> {
243 ProjectDirs::from("", "", "aurum").map(|d| d.config_dir().join("config.toml"))
244 }
245
246 pub fn default_cache_dir() -> Result<PathBuf> {
248 if let Some(dirs) = ProjectDirs::from("", "", "aurum") {
250 return Ok(dirs.cache_dir().to_path_buf());
251 }
252 let home = dirs_home()?;
254 Ok(home.join(".cache").join("aurum"))
255 }
256
257 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 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 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 #[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 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
474pub 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}