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";
23
24#[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 pub api_key: Option<String>,
62 pub model: Option<String>,
64 pub base_url: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CleanupSection {
71 #[serde(default = "default_cleanup")]
73 pub style: String,
74 #[serde(default = "default_cleanup_provider")]
76 pub provider: String,
77 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#[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 pub cleanup_style: String,
125 pub cleanup_provider: String,
127 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 pub fn default_config_path() -> Option<PathBuf> {
161 ProjectDirs::from("", "", "aurum").map(|d| d.config_dir().join("config.toml"))
162 }
163
164 pub fn default_cache_dir() -> Result<PathBuf> {
166 if let Some(dirs) = ProjectDirs::from("", "", "aurum") {
168 return Ok(dirs.cache_dir().to_path_buf());
169 }
170 let home = dirs_home()?;
172 Ok(home.join(".cache").join("aurum"))
173 }
174
175 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 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 #[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 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
368pub 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}