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