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(Clone, Default, Serialize, Deserialize)]
65pub struct OpenRouterSection {
66 pub api_key: Option<crate::secret::SecretString>,
71 pub model: Option<String>,
73 pub base_url: Option<String>,
75 #[serde(default)]
77 pub allow_custom_endpoint: bool,
78 #[serde(default = "default_stt_mode")]
80 pub stt_mode: String,
81 #[serde(default)]
83 pub use_system_proxy: bool,
84}
85
86impl std::fmt::Debug for OpenRouterSection {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.debug_struct("OpenRouterSection")
89 .field("api_key", &self.api_key)
90 .field("model", &self.model)
91 .field("base_url", &self.base_url)
92 .field("allow_custom_endpoint", &self.allow_custom_endpoint)
93 .field("stt_mode", &self.stt_mode)
94 .field("use_system_proxy", &self.use_system_proxy)
95 .finish()
96 }
97}
98
99fn default_stt_mode() -> String {
100 "auto".into()
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct CleanupSection {
106 #[serde(default = "default_cleanup")]
108 pub style: String,
109 #[serde(default = "default_cleanup_provider")]
111 pub provider: String,
112 pub openrouter_model: Option<String>,
114}
115
116impl Default for CleanupSection {
117 fn default() -> Self {
118 Self {
119 style: default_cleanup(),
120 provider: default_cleanup_provider(),
121 openrouter_model: None,
122 }
123 }
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct TtsSection {
129 #[serde(default = "default_tts_provider")]
131 pub provider: String,
132 #[serde(default = "default_tts_model")]
133 pub model: String,
134 #[serde(default = "default_tts_voice")]
135 pub voice: String,
136 #[serde(default = "default_tts_language")]
137 pub language: String,
138 #[serde(default = "default_tts_max_chars")]
139 pub max_chars: usize,
140 #[serde(default = "default_tts_timeout_ms")]
141 pub timeout_ms: u64,
142 #[serde(default)]
145 pub pack_dir: Option<String>,
146 #[serde(default)]
148 pub allow_unverified: bool,
149 #[serde(default)]
151 pub custom_models: Vec<CustomTtsModelConfig>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156pub struct CustomTtsModelConfig {
157 pub id: String,
158 pub adapter: String,
159 #[serde(default)]
160 pub pack_dir: Option<String>,
161 pub trust: String,
162 #[serde(default)]
163 pub license: Option<String>,
164 #[serde(default)]
165 pub notes: Option<String>,
166}
167
168impl Default for TtsSection {
169 fn default() -> Self {
170 Self {
171 provider: default_tts_provider(),
172 model: default_tts_model(),
173 voice: default_tts_voice(),
174 language: default_tts_language(),
175 max_chars: default_tts_max_chars(),
176 timeout_ms: default_tts_timeout_ms(),
177 pack_dir: None,
178 allow_unverified: false,
179 custom_models: Vec::new(),
180 }
181 }
182}
183
184fn default_provider() -> String {
185 DEFAULT_PROVIDER.to_string()
186}
187fn default_local_model() -> String {
188 DEFAULT_LOCAL_MODEL.to_string()
189}
190fn default_language() -> String {
191 DEFAULT_LANGUAGE.to_string()
192}
193fn default_output() -> String {
194 DEFAULT_OUTPUT.to_string()
195}
196fn default_cleanup() -> String {
197 DEFAULT_CLEANUP.to_string()
198}
199fn default_cleanup_provider() -> String {
200 DEFAULT_CLEANUP_PROVIDER.to_string()
201}
202fn default_tts_provider() -> String {
203 DEFAULT_TTS_PROVIDER.to_string()
204}
205fn default_tts_model() -> String {
206 #[cfg(feature = "tts")]
207 {
208 crate::tts::DEFAULT_TTS_MODEL.to_string()
209 }
210 #[cfg(not(feature = "tts"))]
211 {
212 "kitten-nano-int8".to_string()
213 }
214}
215fn default_tts_voice() -> String {
216 #[cfg(feature = "tts")]
217 {
218 crate::tts::DEFAULT_TTS_VOICE.to_string()
219 }
220 #[cfg(not(feature = "tts"))]
221 {
222 "Luna".to_string()
223 }
224}
225fn default_tts_language() -> String {
226 DEFAULT_TTS_LANGUAGE.to_string()
227}
228fn default_tts_max_chars() -> usize {
229 DEFAULT_TTS_MAX_CHARS
230}
231fn default_tts_timeout_ms() -> u64 {
232 DEFAULT_TTS_TIMEOUT_MS
233}
234
235#[derive(Clone)]
237pub struct Config {
238 pub provider: String,
239 pub model: Option<String>,
240 pub language: String,
241 pub output: String,
242 pub output_file: Option<PathBuf>,
243 pub timestamps: bool,
244 pub verbose: bool,
245 pub openrouter_api_key: Option<crate::secret::SecretString>,
247 pub openrouter_base_url: String,
248 pub openrouter_default_model: String,
249 pub openrouter_allow_custom_endpoint: bool,
251 pub openrouter_stt_mode: String,
253 pub openrouter_use_system_proxy: bool,
254 pub cleanup_style: String,
256 pub cleanup_provider: String,
258 pub cleanup_openrouter_model: Option<String>,
260 pub tts_provider: String,
262 pub tts_model: String,
263 pub tts_voice: String,
264 pub tts_language: String,
265 pub tts_max_chars: usize,
266 pub tts_timeout_ms: u64,
267 pub tts_pack_dir: Option<PathBuf>,
269 pub tts_allow_unverified: bool,
270 pub tts_custom_models: Vec<CustomTtsModelConfig>,
272 pub config_path: Option<PathBuf>,
273 pub cache_dir: PathBuf,
274}
275
276impl std::fmt::Debug for Config {
277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 f.debug_struct("Config")
279 .field("provider", &self.provider)
280 .field("model", &self.model)
281 .field("language", &self.language)
282 .field("output", &self.output)
283 .field("output_file", &self.output_file)
284 .field("timestamps", &self.timestamps)
285 .field("verbose", &self.verbose)
286 .field("openrouter_api_key", &self.openrouter_api_key)
287 .field("openrouter_base_url", &self.openrouter_base_url)
288 .field("openrouter_default_model", &self.openrouter_default_model)
289 .field(
290 "openrouter_allow_custom_endpoint",
291 &self.openrouter_allow_custom_endpoint,
292 )
293 .field("openrouter_stt_mode", &self.openrouter_stt_mode)
294 .field(
295 "openrouter_use_system_proxy",
296 &self.openrouter_use_system_proxy,
297 )
298 .field("cleanup_style", &self.cleanup_style)
299 .field("cleanup_provider", &self.cleanup_provider)
300 .field("cleanup_openrouter_model", &self.cleanup_openrouter_model)
301 .field("tts_provider", &self.tts_provider)
302 .field("tts_model", &self.tts_model)
303 .field("tts_voice", &self.tts_voice)
304 .field("tts_language", &self.tts_language)
305 .field("tts_max_chars", &self.tts_max_chars)
306 .field("tts_timeout_ms", &self.tts_timeout_ms)
307 .field("tts_pack_dir", &self.tts_pack_dir)
308 .field("tts_allow_unverified", &self.tts_allow_unverified)
309 .field("tts_custom_models", &self.tts_custom_models)
310 .field("config_path", &self.config_path)
311 .field("cache_dir", &self.cache_dir)
312 .finish()
313 }
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "snake_case")]
319pub enum ConfigValueSource {
320 Default,
321 File,
322 Environment,
323 Cli,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct ConfigSourceMap {
329 pub provider: ConfigValueSource,
330 pub openrouter_api_key: ConfigValueSource,
331 pub openrouter_base_url: ConfigValueSource,
332 pub tts_model: ConfigValueSource,
333}
334
335impl ConfigSourceMap {
336 fn default_attribution(cfg: &Config) -> Self {
337 let key_src = if std::env::var("OPENROUTER_API_KEY")
338 .ok()
339 .filter(|s| !s.is_empty())
340 .is_some()
341 {
342 ConfigValueSource::Environment
343 } else if cfg.openrouter_api_key.is_some() {
344 ConfigValueSource::File
345 } else {
346 ConfigValueSource::Default
347 };
348 let base_src = if std::env::var("OPENROUTER_BASE_URL")
349 .ok()
350 .filter(|s| !s.is_empty())
351 .is_some()
352 {
353 ConfigValueSource::Environment
354 } else {
355 ConfigValueSource::File
356 };
357 let tts_src = if std::env::var("AURUM_TTS_MODEL")
358 .ok()
359 .filter(|s| !s.is_empty())
360 .is_some()
361 {
362 ConfigValueSource::Environment
363 } else {
364 ConfigValueSource::File
365 };
366 Self {
367 provider: if cfg.config_path.is_some() {
368 ConfigValueSource::File
369 } else {
370 ConfigValueSource::Default
371 },
372 openrouter_api_key: key_src,
373 openrouter_base_url: base_src,
374 tts_model: tts_src,
375 }
376 }
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct EffectiveConfigDiagnostic {
382 pub provider: String,
383 pub model: Option<String>,
384 pub language: String,
385 pub output: String,
386 pub timestamps: bool,
387 pub openrouter_api_key: Option<String>,
388 pub openrouter_base_url: String,
389 pub openrouter_default_model: String,
390 pub openrouter_stt_mode: String,
391 pub openrouter_allow_custom_endpoint: bool,
392 pub cleanup_style: String,
393 pub cleanup_provider: String,
394 pub tts_model: String,
395 pub tts_voice: String,
396 pub tts_language: String,
397 pub tts_max_chars: usize,
398 pub tts_timeout_ms: u64,
399 pub tts_pack_dir: Option<String>,
400 pub tts_allow_unverified: bool,
401 pub tts_custom_model_ids: Vec<String>,
402 pub config_path: Option<String>,
403 pub cache_dir: String,
404 pub sources: ConfigSourceMap,
405}
406
407impl Config {
408 pub fn default_config_path() -> Option<PathBuf> {
410 ProjectDirs::from("", "", "aurum").map(|d| d.config_dir().join("config.toml"))
411 }
412
413 pub fn default_cache_dir() -> Result<PathBuf> {
415 if let Some(dirs) = ProjectDirs::from("", "", "aurum") {
417 return Ok(dirs.cache_dir().to_path_buf());
418 }
419 let home = dirs_home()?;
421 Ok(home.join(".cache").join("aurum"))
422 }
423
424 pub fn load() -> Result<Self> {
429 let path = Self::default_config_path();
430 let file = match &path {
431 Some(p) if p.exists() => Some(load_config_file(p)?),
432 _ => None,
433 };
434 let cfg = Self::from_parts(file, path);
435 cfg.validate_tts_custom_models()?;
436 Ok(cfg)
437 }
438
439 pub fn load_from(path: &Path) -> Result<Self> {
445 let file = if path.exists() {
446 Some(load_config_file(path)?)
447 } else {
448 None
449 };
450 let cfg = Self::from_parts(file, Some(path.to_path_buf()));
451 cfg.validate_tts_custom_models()?;
452 Ok(cfg)
453 }
454
455 pub fn load_from_required(path: &Path) -> Result<Self> {
457 if !path.exists() {
458 return Err(UserError::InvalidConfig {
459 reason: format!(
460 "config file not found: {}\n Hint: create it or omit --config to use defaults",
461 path.display()
462 ),
463 }
464 .into());
465 }
466 let file = load_config_file(path)?;
467 let cfg = Self::from_parts(Some(file), Some(path.to_path_buf()));
468 cfg.validate()?;
469 Ok(cfg)
470 }
471
472 pub fn openrouter_api_key_exposed(&self) -> Option<String> {
475 self.openrouter_api_key
476 .as_ref()
477 .map(|s| s.expose().to_string())
478 }
479
480 pub fn validate(&self) -> Result<()> {
482 match self.provider.as_str() {
483 "local" | "openrouter" => {}
484 other => {
485 return Err(UserError::InvalidProvider {
486 provider: other.into(),
487 }
488 .into());
489 }
490 }
491 let _ = crate::output::OutputFormat::parse(&self.output)?;
492 let _ = crate::cleanup::CleanupStyle::parse(&self.cleanup_style)?;
493 let _ = crate::cleanup::CleanupProviderKind::parse(&self.cleanup_provider)?;
494 let _ = crate::providers::OpenRouterSttMode::parse(&self.openrouter_stt_mode)?;
495
496 if self.tts_max_chars == 0 {
497 return Err(UserError::InvalidConfig {
498 reason: "tts.max_chars must be >= 1".into(),
499 }
500 .into());
501 }
502 if self.tts_timeout_ms == 0 {
503 return Err(UserError::InvalidConfig {
504 reason: "tts.timeout_ms must be >= 1".into(),
505 }
506 .into());
507 }
508 if self.tts_max_chars > 500_000 {
510 return Err(UserError::InvalidConfig {
511 reason: format!(
512 "tts.max_chars {} exceeds safe ceiling 500000",
513 self.tts_max_chars
514 ),
515 }
516 .into());
517 }
518 if !self.openrouter_base_url.starts_with("https://")
519 && !self.openrouter_base_url.starts_with("http://localhost")
520 && !self.openrouter_base_url.contains("127.0.0.1")
521 {
522 if self.openrouter_base_url.starts_with("http://") {
524 return Err(UserError::InvalidConfig {
525 reason: format!(
526 "openrouter base_url must use https (got {})",
527 self.openrouter_base_url
528 ),
529 }
530 .into());
531 }
532 }
533
534 self.validate_tts_custom_models()?;
538 Ok(())
539 }
540
541 pub fn validate_tts_custom_models(&self) -> Result<()> {
544 #[cfg(feature = "tts")]
545 {
546 use crate::tts::{validate_custom_models, CustomTtsModelEntry, MAX_CUSTOM_MODELS};
547 if self.tts_custom_models.len() > MAX_CUSTOM_MODELS {
548 return Err(UserError::InvalidConfig {
549 reason: format!(
550 "too many [[tts.custom_models]] entries ({} > {MAX_CUSTOM_MODELS})",
551 self.tts_custom_models.len()
552 ),
553 }
554 .into());
555 }
556 let mut ids = std::collections::HashSet::new();
557 let mut present = Vec::new();
558 for e in &self.tts_custom_models {
559 let id = e.id.trim();
560 if id.is_empty() {
561 return Err(UserError::InvalidConfig {
562 reason: "custom TTS model id must be non-empty".into(),
563 }
564 .into());
565 }
566 if !ids.insert(id.to_string()) {
567 return Err(UserError::InvalidConfig {
568 reason: format!("duplicate custom TTS model id '{id}'"),
569 }
570 .into());
571 }
572 if id == crate::tts::DEFAULT_TTS_MODEL
573 || crate::tts::lookup_model(id)
574 .map(|m| m.shipped)
575 .unwrap_or(false)
576 {
577 return Err(UserError::InvalidConfig {
578 reason: format!(
579 "custom model id '{id}' collides with built-in catalogue entry"
580 ),
581 }
582 .into());
583 }
584 let _ = crate::tts::lookup_adapter(&e.adapter)?;
585 let trust = crate::tts::TrustMode::parse(&e.trust)?;
586 if matches!(trust, crate::tts::TrustMode::Builtin) {
587 return Err(UserError::InvalidConfig {
588 reason: "custom models cannot use trust=builtin".into(),
589 }
590 .into());
591 }
592 if let Some(dir) = e.pack_dir.as_ref().map(PathBuf::from) {
593 if dir.exists() {
594 present.push(CustomTtsModelEntry {
595 id: e.id.clone(),
596 adapter: e.adapter.clone(),
597 pack_dir: e.pack_dir.clone(),
598 trust: e.trust.clone(),
599 license: e.license.clone(),
600 notes: e.notes.clone(),
601 });
602 }
603 } else {
604 return Err(UserError::InvalidConfig {
605 reason: format!(
606 "custom model '{id}' requires pack_dir (remote custom packs \
607 are not enabled in v0.0.3)"
608 ),
609 }
610 .into());
611 }
612 }
613 if !present.is_empty() {
615 let _ = validate_custom_models(&present)?;
616 }
617 }
618 Ok(())
619 }
620
621 pub fn effective_diagnostic(&self) -> EffectiveConfigDiagnostic {
623 EffectiveConfigDiagnostic {
624 provider: self.provider.clone(),
625 model: self.model.clone(),
626 language: self.language.clone(),
627 output: self.output.clone(),
628 timestamps: self.timestamps,
629 openrouter_api_key: self.openrouter_api_key.as_ref().map(|_| "***".into()),
630 openrouter_base_url: self.openrouter_base_url.clone(),
631 openrouter_default_model: self.openrouter_default_model.clone(),
632 openrouter_stt_mode: self.openrouter_stt_mode.clone(),
633 openrouter_allow_custom_endpoint: self.openrouter_allow_custom_endpoint,
634 cleanup_style: self.cleanup_style.clone(),
635 cleanup_provider: self.cleanup_provider.clone(),
636 tts_model: self.tts_model.clone(),
637 tts_voice: self.tts_voice.clone(),
638 tts_language: self.tts_language.clone(),
639 tts_max_chars: self.tts_max_chars,
640 tts_timeout_ms: self.tts_timeout_ms,
641 tts_pack_dir: self.tts_pack_dir.as_ref().map(|p| p.display().to_string()),
642 tts_allow_unverified: self.tts_allow_unverified,
643 tts_custom_model_ids: self
644 .tts_custom_models
645 .iter()
646 .map(|m| m.id.clone())
647 .collect(),
648 config_path: self.config_path.as_ref().map(|p| p.display().to_string()),
649 cache_dir: self.cache_dir.display().to_string(),
650 sources: ConfigSourceMap::default_attribution(self),
651 }
652 }
653
654 fn from_parts(file: Option<ConfigFile>, config_path: Option<PathBuf>) -> Self {
655 let file = file.unwrap_or_default();
656
657 let openrouter_api_key = std::env::var("OPENROUTER_API_KEY")
658 .ok()
659 .filter(|s| !s.is_empty())
660 .map(crate::secret::SecretString::new)
661 .or(file.openrouter.api_key.clone());
662
663 let openrouter_base_url = std::env::var("OPENROUTER_BASE_URL")
664 .ok()
665 .filter(|s| !s.is_empty())
666 .or(file.openrouter.base_url.clone())
667 .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string());
668
669 let openrouter_default_model = file
670 .openrouter
671 .model
672 .clone()
673 .unwrap_or_else(|| DEFAULT_OPENROUTER_MODEL.to_string());
674
675 let cache_dir =
679 Self::default_cache_dir().unwrap_or_else(|_| std::env::temp_dir().join("aurum-cache"));
680
681 let tts_model = std::env::var("AURUM_TTS_MODEL")
683 .ok()
684 .filter(|s| !s.is_empty())
685 .unwrap_or(file.tts.model);
686 let tts_voice = std::env::var("AURUM_TTS_VOICE")
687 .ok()
688 .filter(|s| !s.is_empty())
689 .unwrap_or(file.tts.voice);
690 let tts_language = std::env::var("AURUM_TTS_LANGUAGE")
691 .ok()
692 .filter(|s| !s.is_empty())
693 .unwrap_or(file.tts.language);
694
695 Self {
696 provider: file.default.provider,
697 model: Some(file.default.model),
698 language: file.default.language,
699 output: file.default.output,
700 output_file: None,
701 timestamps: false,
702 verbose: false,
703 openrouter_api_key,
704 openrouter_base_url,
705 openrouter_default_model,
706 openrouter_allow_custom_endpoint: file.openrouter.allow_custom_endpoint,
707 openrouter_stt_mode: if file.openrouter.stt_mode.trim().is_empty() {
708 default_stt_mode()
709 } else {
710 file.openrouter.stt_mode
711 },
712 openrouter_use_system_proxy: file.openrouter.use_system_proxy,
713 cleanup_style: file.cleanup.style,
714 cleanup_provider: file.cleanup.provider,
715 cleanup_openrouter_model: file.cleanup.openrouter_model,
716 tts_provider: file.tts.provider,
717 tts_model,
718 tts_voice,
719 tts_language,
720 tts_max_chars: file.tts.max_chars.max(1),
721 tts_timeout_ms: if file.tts.timeout_ms == 0 {
722 DEFAULT_TTS_TIMEOUT_MS
723 } else {
724 file.tts.timeout_ms
725 },
726 tts_pack_dir: file.tts.pack_dir.map(PathBuf::from),
727 tts_allow_unverified: file.tts.allow_unverified,
728 tts_custom_models: file.tts.custom_models,
729 config_path,
730 cache_dir,
731 }
732 }
733
734 #[allow(clippy::too_many_arguments)]
736 pub fn apply_cli(
737 &mut self,
738 provider: Option<&str>,
739 model: Option<&str>,
740 language: Option<&str>,
741 output: Option<&str>,
742 output_file: Option<&Path>,
743 timestamps: bool,
744 verbose: bool,
745 cleanup: Option<&str>,
746 cleanup_provider: Option<&str>,
747 cleanup_model: Option<&str>,
748 ) {
749 if let Some(p) = provider {
750 self.provider = p.to_string();
751 }
752 if let Some(m) = model {
753 self.model = Some(m.to_string());
754 }
755 if let Some(l) = language {
756 self.language = l.to_string();
757 }
758 if let Some(o) = output {
759 self.output = o.to_string();
760 }
761 if let Some(path) = output_file {
762 self.output_file = Some(path.to_path_buf());
763 }
764 if timestamps {
765 self.timestamps = true;
766 }
767 if verbose {
768 self.verbose = true;
769 }
770 if let Some(c) = cleanup {
771 self.cleanup_style = c.to_string();
772 }
773 if let Some(p) = cleanup_provider {
774 self.cleanup_provider = p.to_string();
775 }
776 if let Some(m) = cleanup_model {
777 self.cleanup_openrouter_model = Some(m.to_string());
778 }
779 }
780
781 pub fn resolve_model(&self, model_explicitly_set: bool) -> Result<String> {
791 if model_explicitly_set {
792 let m = self
793 .model
794 .clone()
795 .unwrap_or_else(|| self.default_model_for_provider());
796 if self.provider == "openrouter"
797 && !m.contains('/')
798 && (crate::model::lookup_model(&m).is_ok() || m == DEFAULT_LOCAL_MODEL)
799 {
800 return Err(UserError::Other {
801 message: format!(
802 "model '{m}' looks like a local whisper model, not an OpenRouter id.\n \
803 Hint: use e.g. google/gemini-2.5-flash-lite or openai/gpt-audio-mini, \
804 or omit --model to use the OpenRouter default."
805 ),
806 }
807 .into());
808 }
809 return Ok(m);
810 }
811 match self.provider.as_str() {
812 "openrouter" => {
813 let m = self
814 .model
815 .clone()
816 .unwrap_or_else(|| self.openrouter_default_model.clone());
817 if m.contains('/') {
818 Ok(m)
819 } else if m == DEFAULT_LOCAL_MODEL || crate::model::lookup_model(&m).is_ok() {
820 Ok(self.openrouter_default_model.clone())
821 } else {
822 Ok(m)
823 }
824 }
825 _ => Ok(self
826 .model
827 .clone()
828 .unwrap_or_else(|| DEFAULT_LOCAL_MODEL.to_string())),
829 }
830 }
831
832 fn default_model_for_provider(&self) -> String {
833 match self.provider.as_str() {
834 "openrouter" => self.openrouter_default_model.clone(),
835 _ => DEFAULT_LOCAL_MODEL.to_string(),
836 }
837 }
838}
839
840pub type RawConfig = ConfigFile;
844
845#[derive(Clone)]
850pub struct ValidatedConfig {
851 inner: Config,
852}
853
854impl std::fmt::Debug for ValidatedConfig {
855 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
856 f.debug_struct("ValidatedConfig")
857 .field("inner", &self.inner)
858 .finish()
859 }
860}
861
862impl ValidatedConfig {
863 pub fn try_from_config(cfg: Config) -> Result<Self> {
865 cfg.validate()?;
866 Ok(Self { inner: cfg })
867 }
868
869 pub fn load() -> Result<Self> {
871 Self::try_from_config(Config::load()?)
872 }
873
874 pub fn load_from(path: &Path) -> Result<Self> {
876 Self::try_from_config(Config::load_from(path)?)
877 }
878
879 pub fn load_from_required(path: &Path) -> Result<Self> {
881 Self::try_from_config(Config::load_from_required(path)?)
882 }
883
884 pub fn as_config(&self) -> &Config {
885 &self.inner
886 }
887
888 pub fn into_config(self) -> Config {
890 self.inner
891 }
892
893 #[allow(clippy::too_many_arguments)]
895 pub fn apply_cli(
896 mut self,
897 provider: Option<&str>,
898 model: Option<&str>,
899 language: Option<&str>,
900 output: Option<&str>,
901 output_file: Option<&Path>,
902 timestamps: bool,
903 verbose: bool,
904 cleanup: Option<&str>,
905 cleanup_provider: Option<&str>,
906 cleanup_model: Option<&str>,
907 ) -> Result<Self> {
908 self.inner.apply_cli(
909 provider,
910 model,
911 language,
912 output,
913 output_file,
914 timestamps,
915 verbose,
916 cleanup,
917 cleanup_provider,
918 cleanup_model,
919 );
920 Self::try_from_config(self.inner)
921 }
922}
923
924impl AsRef<Config> for ValidatedConfig {
925 fn as_ref(&self) -> &Config {
926 &self.inner
927 }
928}
929
930impl std::ops::Deref for ValidatedConfig {
931 type Target = Config;
932 fn deref(&self) -> &Self::Target {
933 &self.inner
934 }
935}
936
937pub const MAX_CONFIG_BYTES: u64 = 256 * 1024;
939
940fn load_config_file(path: &Path) -> Result<ConfigFile> {
941 let meta = fs::metadata(path).map_err(|e| UserError::InvalidConfig {
942 reason: format!("failed to stat {}: {e}", path.display()),
943 })?;
944 if meta.len() > MAX_CONFIG_BYTES {
945 return Err(UserError::InvalidConfig {
946 reason: format!(
947 "config file {} is too large ({} > {MAX_CONFIG_BYTES} bytes)",
948 path.display(),
949 meta.len()
950 ),
951 }
952 .into());
953 }
954 let contents = fs::read_to_string(path).map_err(|e| UserError::InvalidConfig {
955 reason: format!("failed to read {}: {e}", path.display()),
956 })?;
957 toml::from_str(&contents).map_err(|e| {
958 UserError::InvalidConfig {
959 reason: format!("failed to parse {}: {e}", path.display()),
960 }
961 .into()
962 })
963}
964
965fn dirs_home() -> Result<PathBuf> {
966 if let Ok(h) = std::env::var("HOME") {
967 return Ok(PathBuf::from(h));
968 }
969 if let Ok(h) = std::env::var("USERPROFILE") {
970 return Ok(PathBuf::from(h));
971 }
972 Err(UserError::InvalidConfig {
973 reason: "could not determine home directory".into(),
974 }
975 .into())
976}
977
978pub fn write_example_config(path: &Path) -> Result<()> {
980 if path.exists() {
981 return Ok(());
982 }
983 if let Some(parent) = path.parent() {
984 fs::create_dir_all(parent)?;
985 }
986 let example = r#"# Aurum configuration
987# Environment variables take precedence over values in this file.
988# OPENROUTER_API_KEY is preferred over openrouter.api_key below.
989# TTS: AURUM_TTS_MODEL, AURUM_TTS_VOICE, AURUM_TTS_LANGUAGE override [tts].
990
991[default]
992provider = "local"
993model = "base"
994language = "auto"
995output = "txt"
996
997[cleanup]
998# style = "raw" # raw | clean | bullets | professional | summary
999# provider = "rules" # rules (on-device) | openrouter
1000# openrouter_model = "google/gemini-2.5-flash"
1001
1002[tts]
1003# provider = "local"
1004# model = "kitten-nano-int8"
1005# voice = "Luna"
1006# language = "en"
1007# max_chars = 5000
1008# timeout_ms = 120000
1009
1010[openrouter]
1011# api_key = "sk-or-..."
1012# model = "google/gemini-2.5-flash"
1013# base_url = "https://openrouter.ai/api/v1"
1014"#;
1015 fs::write(path, example)?;
1016 Ok(())
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::*;
1022 use std::io::Write;
1023 use tempfile::tempdir;
1024
1025 #[test]
1026 fn parses_config_file() {
1027 let dir = tempdir().unwrap();
1028 let path = dir.path().join("config.toml");
1029 let mut f = fs::File::create(&path).unwrap();
1030 writeln!(
1031 f,
1032 r#"
1033[default]
1034provider = "openrouter"
1035model = "small"
1036language = "en"
1037output = "json"
1038
1039[openrouter]
1040api_key = "test-key"
1041model = "google/gemini-2.5-flash"
1042"#
1043 )
1044 .unwrap();
1045
1046 let prev = std::env::var("OPENROUTER_API_KEY").ok();
1048 std::env::remove_var("OPENROUTER_API_KEY");
1049 let cfg = Config::load_from(&path).unwrap();
1050 if let Some(v) = prev {
1051 std::env::set_var("OPENROUTER_API_KEY", v);
1052 }
1053 assert_eq!(cfg.provider, "openrouter");
1054 assert_eq!(cfg.model.as_deref(), Some("small"));
1055 assert_eq!(cfg.language, "en");
1056 assert_eq!(cfg.output, "json");
1057 assert_eq!(
1058 cfg.openrouter_api_key.as_ref().map(|s| s.expose()),
1059 Some("test-key")
1060 );
1061 assert!(!format!("{:?}", cfg).contains("test-key"));
1062 assert_eq!(cfg.openrouter_default_model, "google/gemini-2.5-flash");
1063 assert_eq!(cfg.cleanup_style, "raw");
1064 assert_eq!(cfg.cleanup_provider, "rules");
1065 assert!(cfg.validate().is_ok());
1066 let diag = cfg.effective_diagnostic();
1067 assert_eq!(diag.openrouter_api_key.as_deref(), Some("***"));
1068 }
1069
1070 #[test]
1071 fn load_from_required_missing_errors() {
1072 let dir = tempdir().unwrap();
1073 let path = dir.path().join("missing.toml");
1074 let err = Config::load_from_required(&path).unwrap_err();
1075 assert!(err.to_string().contains("not found"));
1076 }
1077
1078 #[test]
1079 fn parses_cleanup_section() {
1080 let dir = tempdir().unwrap();
1081 let path = dir.path().join("config.toml");
1082 fs::write(
1083 &path,
1084 r#"
1085[default]
1086provider = "local"
1087model = "base"
1088
1089[cleanup]
1090style = "clean"
1091provider = "rules"
1092openrouter_model = "google/gemini-2.5-flash"
1093"#,
1094 )
1095 .unwrap();
1096 let cfg = Config::load_from(&path).unwrap();
1097 assert_eq!(cfg.cleanup_style, "clean");
1098 assert_eq!(cfg.cleanup_provider, "rules");
1099 assert_eq!(
1100 cfg.cleanup_openrouter_model.as_deref(),
1101 Some("google/gemini-2.5-flash")
1102 );
1103 }
1104
1105 #[test]
1106 fn cli_overrides_file() {
1107 let dir = tempdir().unwrap();
1108 let path = dir.path().join("config.toml");
1109 fs::write(
1110 &path,
1111 r#"
1112[default]
1113provider = "local"
1114model = "base"
1115language = "auto"
1116output = "txt"
1117
1118[cleanup]
1119style = "clean"
1120provider = "rules"
1121"#,
1122 )
1123 .unwrap();
1124 let mut cfg = Config::load_from(&path).unwrap();
1125 cfg.apply_cli(
1126 Some("openrouter"),
1127 Some("google/gemini-2.5-flash"),
1128 Some("fr"),
1129 Some("srt"),
1130 Some(Path::new("out.srt")),
1131 true,
1132 true,
1133 Some("summary"),
1134 Some("openrouter"),
1135 Some("openai/gpt-audio-mini"),
1136 );
1137 assert_eq!(cfg.provider, "openrouter");
1138 assert_eq!(cfg.model.as_deref(), Some("google/gemini-2.5-flash"));
1139 assert_eq!(cfg.language, "fr");
1140 assert_eq!(cfg.output, "srt");
1141 assert_eq!(cfg.output_file.as_deref(), Some(Path::new("out.srt")));
1142 assert!(cfg.timestamps);
1143 assert!(cfg.verbose);
1144 assert_eq!(cfg.cleanup_style, "summary");
1145 assert_eq!(cfg.cleanup_provider, "openrouter");
1146 assert_eq!(
1147 cfg.cleanup_openrouter_model.as_deref(),
1148 Some("openai/gpt-audio-mini")
1149 );
1150 }
1151
1152 #[test]
1153 #[cfg(feature = "tts")]
1154 fn custom_tts_model_cannot_shadow_builtin_on_load() {
1155 let dir = tempdir().unwrap();
1158 let path = dir.path().join("config.toml");
1159 let default_id = crate::tts::DEFAULT_TTS_MODEL;
1160 fs::write(
1161 &path,
1162 format!(
1163 r#"
1164[tts]
1165model = "{default_id}"
1166
1167[[tts.custom_models]]
1168id = "{default_id}"
1169adapter = "fake-sine-v1"
1170pack_dir = "/tmp/does-not-matter"
1171trust = "verified"
1172"#
1173 ),
1174 )
1175 .unwrap();
1176 let err = Config::load_from(&path).unwrap_err();
1177 assert!(
1178 err.to_string().contains("collides") || err.to_string().contains("reserved"),
1179 "got: {err}"
1180 );
1181 }
1182
1183 #[test]
1184 fn defaults_when_missing_file() {
1185 let dir = tempdir().unwrap();
1186 let path = dir.path().join("nope.toml");
1187 let cfg = Config::load_from(&path).unwrap();
1188 assert_eq!(cfg.provider, "local");
1189 assert_eq!(cfg.language, "auto");
1190 assert_eq!(cfg.output, "txt");
1191 assert_eq!(cfg.cleanup_style, "raw");
1192 assert_eq!(cfg.cleanup_provider, "rules");
1193 }
1194
1195 #[test]
1196 fn validated_config_accepts_defaults() {
1197 let dir = tempdir().unwrap();
1198 let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1199 let v = ValidatedConfig::try_from_config(cfg).unwrap();
1200 assert_eq!(v.provider, "local");
1201 assert_eq!(v.as_config().language, "auto");
1202 }
1203
1204 #[test]
1205 fn validated_config_rejects_bad_provider() {
1206 let dir = tempdir().unwrap();
1207 let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1208 cfg.provider = "not-a-provider".into();
1209 let err = ValidatedConfig::try_from_config(cfg).unwrap_err();
1210 assert!(
1211 err.to_string().contains("provider") || err.to_string().contains("Invalid"),
1212 "got: {err}"
1213 );
1214 }
1215
1216 #[test]
1217 fn validated_apply_cli_revalidates() {
1218 let dir = tempdir().unwrap();
1219 let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1220 let v = ValidatedConfig::try_from_config(cfg).unwrap();
1221 let err = v
1222 .apply_cli(
1223 Some("bogus"),
1224 None,
1225 None,
1226 None,
1227 None,
1228 false,
1229 false,
1230 None,
1231 None,
1232 None,
1233 )
1234 .unwrap_err();
1235 assert!(err.to_string().contains("provider") || err.to_string().contains("Invalid"));
1236 }
1237}