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<String>,
230 pub openrouter_base_url: String,
231 pub openrouter_default_model: String,
232 pub openrouter_allow_custom_endpoint: bool,
234 pub openrouter_stt_mode: String,
236 pub openrouter_use_system_proxy: bool,
237 pub cleanup_style: String,
239 pub cleanup_provider: String,
241 pub cleanup_openrouter_model: Option<String>,
243 pub tts_provider: String,
245 pub tts_model: String,
246 pub tts_voice: String,
247 pub tts_language: String,
248 pub tts_max_chars: usize,
249 pub tts_timeout_ms: u64,
250 pub tts_pack_dir: Option<PathBuf>,
252 pub tts_allow_unverified: bool,
253 pub tts_custom_models: Vec<CustomTtsModelConfig>,
255 pub config_path: Option<PathBuf>,
256 pub cache_dir: PathBuf,
257}
258
259impl std::fmt::Debug for Config {
260 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 f.debug_struct("Config")
262 .field("provider", &self.provider)
263 .field("model", &self.model)
264 .field("language", &self.language)
265 .field("output", &self.output)
266 .field("output_file", &self.output_file)
267 .field("timestamps", &self.timestamps)
268 .field("verbose", &self.verbose)
269 .field(
270 "openrouter_api_key",
271 &self.openrouter_api_key.as_ref().map(|_| "***"),
272 )
273 .field("openrouter_base_url", &self.openrouter_base_url)
274 .field("openrouter_default_model", &self.openrouter_default_model)
275 .field(
276 "openrouter_allow_custom_endpoint",
277 &self.openrouter_allow_custom_endpoint,
278 )
279 .field("openrouter_stt_mode", &self.openrouter_stt_mode)
280 .field(
281 "openrouter_use_system_proxy",
282 &self.openrouter_use_system_proxy,
283 )
284 .field("cleanup_style", &self.cleanup_style)
285 .field("cleanup_provider", &self.cleanup_provider)
286 .field("cleanup_openrouter_model", &self.cleanup_openrouter_model)
287 .field("tts_provider", &self.tts_provider)
288 .field("tts_model", &self.tts_model)
289 .field("tts_voice", &self.tts_voice)
290 .field("tts_language", &self.tts_language)
291 .field("tts_max_chars", &self.tts_max_chars)
292 .field("tts_timeout_ms", &self.tts_timeout_ms)
293 .field("tts_pack_dir", &self.tts_pack_dir)
294 .field("tts_allow_unverified", &self.tts_allow_unverified)
295 .field("tts_custom_models", &self.tts_custom_models)
296 .field("config_path", &self.config_path)
297 .field("cache_dir", &self.cache_dir)
298 .finish()
299 }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(rename_all = "snake_case")]
305pub enum ConfigValueSource {
306 Default,
307 File,
308 Environment,
309 Cli,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct ConfigSourceMap {
315 pub provider: ConfigValueSource,
316 pub openrouter_api_key: ConfigValueSource,
317 pub openrouter_base_url: ConfigValueSource,
318 pub tts_model: ConfigValueSource,
319}
320
321impl ConfigSourceMap {
322 fn default_attribution(cfg: &Config) -> Self {
323 let key_src = if std::env::var("OPENROUTER_API_KEY")
324 .ok()
325 .filter(|s| !s.is_empty())
326 .is_some()
327 {
328 ConfigValueSource::Environment
329 } else if cfg.openrouter_api_key.is_some() {
330 ConfigValueSource::File
331 } else {
332 ConfigValueSource::Default
333 };
334 let base_src = if std::env::var("OPENROUTER_BASE_URL")
335 .ok()
336 .filter(|s| !s.is_empty())
337 .is_some()
338 {
339 ConfigValueSource::Environment
340 } else {
341 ConfigValueSource::File
342 };
343 let tts_src = if std::env::var("AURUM_TTS_MODEL")
344 .ok()
345 .filter(|s| !s.is_empty())
346 .is_some()
347 {
348 ConfigValueSource::Environment
349 } else {
350 ConfigValueSource::File
351 };
352 Self {
353 provider: if cfg.config_path.is_some() {
354 ConfigValueSource::File
355 } else {
356 ConfigValueSource::Default
357 },
358 openrouter_api_key: key_src,
359 openrouter_base_url: base_src,
360 tts_model: tts_src,
361 }
362 }
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct EffectiveConfigDiagnostic {
368 pub provider: String,
369 pub model: Option<String>,
370 pub language: String,
371 pub output: String,
372 pub timestamps: bool,
373 pub openrouter_api_key: Option<String>,
374 pub openrouter_base_url: String,
375 pub openrouter_default_model: String,
376 pub openrouter_stt_mode: String,
377 pub openrouter_allow_custom_endpoint: bool,
378 pub cleanup_style: String,
379 pub cleanup_provider: String,
380 pub tts_model: String,
381 pub tts_voice: String,
382 pub tts_language: String,
383 pub tts_max_chars: usize,
384 pub tts_timeout_ms: u64,
385 pub tts_pack_dir: Option<String>,
386 pub tts_allow_unverified: bool,
387 pub tts_custom_model_ids: Vec<String>,
388 pub config_path: Option<String>,
389 pub cache_dir: String,
390 pub sources: ConfigSourceMap,
391}
392
393impl Config {
394 pub fn default_config_path() -> Option<PathBuf> {
396 ProjectDirs::from("", "", "aurum").map(|d| d.config_dir().join("config.toml"))
397 }
398
399 pub fn default_cache_dir() -> Result<PathBuf> {
401 if let Some(dirs) = ProjectDirs::from("", "", "aurum") {
403 return Ok(dirs.cache_dir().to_path_buf());
404 }
405 let home = dirs_home()?;
407 Ok(home.join(".cache").join("aurum"))
408 }
409
410 pub fn load() -> Result<Self> {
415 let path = Self::default_config_path();
416 let file = match &path {
417 Some(p) if p.exists() => Some(load_config_file(p)?),
418 _ => None,
419 };
420 let cfg = Self::from_parts(file, path);
421 cfg.validate_tts_custom_models()?;
422 Ok(cfg)
423 }
424
425 pub fn load_from(path: &Path) -> Result<Self> {
431 let file = if path.exists() {
432 Some(load_config_file(path)?)
433 } else {
434 None
435 };
436 let cfg = Self::from_parts(file, Some(path.to_path_buf()));
437 cfg.validate_tts_custom_models()?;
438 Ok(cfg)
439 }
440
441 pub fn load_from_required(path: &Path) -> Result<Self> {
443 if !path.exists() {
444 return Err(UserError::InvalidConfig {
445 reason: format!(
446 "config file not found: {}\n Hint: create it or omit --config to use defaults",
447 path.display()
448 ),
449 }
450 .into());
451 }
452 let file = load_config_file(path)?;
453 let cfg = Self::from_parts(Some(file), Some(path.to_path_buf()));
454 cfg.validate()?;
455 Ok(cfg)
456 }
457
458 pub fn validate(&self) -> Result<()> {
460 match self.provider.as_str() {
461 "local" | "openrouter" => {}
462 other => {
463 return Err(UserError::InvalidProvider {
464 provider: other.into(),
465 }
466 .into());
467 }
468 }
469 let _ = crate::output::OutputFormat::parse(&self.output)?;
470 let _ = crate::cleanup::CleanupStyle::parse(&self.cleanup_style)?;
471 let _ = crate::cleanup::CleanupProviderKind::parse(&self.cleanup_provider)?;
472 let _ = crate::providers::OpenRouterSttMode::parse(&self.openrouter_stt_mode)?;
473
474 if self.tts_max_chars == 0 {
475 return Err(UserError::InvalidConfig {
476 reason: "tts.max_chars must be >= 1".into(),
477 }
478 .into());
479 }
480 if self.tts_timeout_ms == 0 {
481 return Err(UserError::InvalidConfig {
482 reason: "tts.timeout_ms must be >= 1".into(),
483 }
484 .into());
485 }
486 if self.tts_max_chars > 500_000 {
488 return Err(UserError::InvalidConfig {
489 reason: format!(
490 "tts.max_chars {} exceeds safe ceiling 500000",
491 self.tts_max_chars
492 ),
493 }
494 .into());
495 }
496 if !self.openrouter_base_url.starts_with("https://")
497 && !self.openrouter_base_url.starts_with("http://localhost")
498 && !self.openrouter_base_url.contains("127.0.0.1")
499 {
500 if self.openrouter_base_url.starts_with("http://") {
502 return Err(UserError::InvalidConfig {
503 reason: format!(
504 "openrouter base_url must use https (got {})",
505 self.openrouter_base_url
506 ),
507 }
508 .into());
509 }
510 }
511
512 self.validate_tts_custom_models()?;
516 Ok(())
517 }
518
519 pub fn validate_tts_custom_models(&self) -> Result<()> {
522 #[cfg(feature = "tts")]
523 {
524 use crate::tts::{validate_custom_models, CustomTtsModelEntry, MAX_CUSTOM_MODELS};
525 if self.tts_custom_models.len() > MAX_CUSTOM_MODELS {
526 return Err(UserError::InvalidConfig {
527 reason: format!(
528 "too many [[tts.custom_models]] entries ({} > {MAX_CUSTOM_MODELS})",
529 self.tts_custom_models.len()
530 ),
531 }
532 .into());
533 }
534 let mut ids = std::collections::HashSet::new();
535 let mut present = Vec::new();
536 for e in &self.tts_custom_models {
537 let id = e.id.trim();
538 if id.is_empty() {
539 return Err(UserError::InvalidConfig {
540 reason: "custom TTS model id must be non-empty".into(),
541 }
542 .into());
543 }
544 if !ids.insert(id.to_string()) {
545 return Err(UserError::InvalidConfig {
546 reason: format!("duplicate custom TTS model id '{id}'"),
547 }
548 .into());
549 }
550 if id == crate::tts::DEFAULT_TTS_MODEL
551 || crate::tts::lookup_model(id)
552 .map(|m| m.shipped)
553 .unwrap_or(false)
554 {
555 return Err(UserError::InvalidConfig {
556 reason: format!(
557 "custom model id '{id}' collides with built-in catalogue entry"
558 ),
559 }
560 .into());
561 }
562 let _ = crate::tts::lookup_adapter(&e.adapter)?;
563 let trust = crate::tts::TrustMode::parse(&e.trust)?;
564 if matches!(trust, crate::tts::TrustMode::Builtin) {
565 return Err(UserError::InvalidConfig {
566 reason: "custom models cannot use trust=builtin".into(),
567 }
568 .into());
569 }
570 if let Some(dir) = e.pack_dir.as_ref().map(PathBuf::from) {
571 if dir.exists() {
572 present.push(CustomTtsModelEntry {
573 id: e.id.clone(),
574 adapter: e.adapter.clone(),
575 pack_dir: e.pack_dir.clone(),
576 trust: e.trust.clone(),
577 license: e.license.clone(),
578 notes: e.notes.clone(),
579 });
580 }
581 } else {
582 return Err(UserError::InvalidConfig {
583 reason: format!(
584 "custom model '{id}' requires pack_dir (remote custom packs \
585 are not enabled in v0.0.3)"
586 ),
587 }
588 .into());
589 }
590 }
591 if !present.is_empty() {
593 let _ = validate_custom_models(&present)?;
594 }
595 }
596 Ok(())
597 }
598
599 pub fn effective_diagnostic(&self) -> EffectiveConfigDiagnostic {
601 EffectiveConfigDiagnostic {
602 provider: self.provider.clone(),
603 model: self.model.clone(),
604 language: self.language.clone(),
605 output: self.output.clone(),
606 timestamps: self.timestamps,
607 openrouter_api_key: self.openrouter_api_key.as_ref().map(|_| "***".into()),
608 openrouter_base_url: self.openrouter_base_url.clone(),
609 openrouter_default_model: self.openrouter_default_model.clone(),
610 openrouter_stt_mode: self.openrouter_stt_mode.clone(),
611 openrouter_allow_custom_endpoint: self.openrouter_allow_custom_endpoint,
612 cleanup_style: self.cleanup_style.clone(),
613 cleanup_provider: self.cleanup_provider.clone(),
614 tts_model: self.tts_model.clone(),
615 tts_voice: self.tts_voice.clone(),
616 tts_language: self.tts_language.clone(),
617 tts_max_chars: self.tts_max_chars,
618 tts_timeout_ms: self.tts_timeout_ms,
619 tts_pack_dir: self.tts_pack_dir.as_ref().map(|p| p.display().to_string()),
620 tts_allow_unverified: self.tts_allow_unverified,
621 tts_custom_model_ids: self
622 .tts_custom_models
623 .iter()
624 .map(|m| m.id.clone())
625 .collect(),
626 config_path: self.config_path.as_ref().map(|p| p.display().to_string()),
627 cache_dir: self.cache_dir.display().to_string(),
628 sources: ConfigSourceMap::default_attribution(self),
629 }
630 }
631
632 fn from_parts(file: Option<ConfigFile>, config_path: Option<PathBuf>) -> Self {
633 let file = file.unwrap_or_default();
634
635 let openrouter_api_key = std::env::var("OPENROUTER_API_KEY")
636 .ok()
637 .filter(|s| !s.is_empty())
638 .or(file.openrouter.api_key.clone());
639
640 let openrouter_base_url = std::env::var("OPENROUTER_BASE_URL")
641 .ok()
642 .filter(|s| !s.is_empty())
643 .or(file.openrouter.base_url.clone())
644 .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string());
645
646 let openrouter_default_model = file
647 .openrouter
648 .model
649 .clone()
650 .unwrap_or_else(|| DEFAULT_OPENROUTER_MODEL.to_string());
651
652 let cache_dir =
656 Self::default_cache_dir().unwrap_or_else(|_| std::env::temp_dir().join("aurum-cache"));
657
658 let tts_model = std::env::var("AURUM_TTS_MODEL")
660 .ok()
661 .filter(|s| !s.is_empty())
662 .unwrap_or(file.tts.model);
663 let tts_voice = std::env::var("AURUM_TTS_VOICE")
664 .ok()
665 .filter(|s| !s.is_empty())
666 .unwrap_or(file.tts.voice);
667 let tts_language = std::env::var("AURUM_TTS_LANGUAGE")
668 .ok()
669 .filter(|s| !s.is_empty())
670 .unwrap_or(file.tts.language);
671
672 Self {
673 provider: file.default.provider,
674 model: Some(file.default.model),
675 language: file.default.language,
676 output: file.default.output,
677 output_file: None,
678 timestamps: false,
679 verbose: false,
680 openrouter_api_key,
681 openrouter_base_url,
682 openrouter_default_model,
683 openrouter_allow_custom_endpoint: file.openrouter.allow_custom_endpoint,
684 openrouter_stt_mode: if file.openrouter.stt_mode.trim().is_empty() {
685 default_stt_mode()
686 } else {
687 file.openrouter.stt_mode
688 },
689 openrouter_use_system_proxy: file.openrouter.use_system_proxy,
690 cleanup_style: file.cleanup.style,
691 cleanup_provider: file.cleanup.provider,
692 cleanup_openrouter_model: file.cleanup.openrouter_model,
693 tts_provider: file.tts.provider,
694 tts_model,
695 tts_voice,
696 tts_language,
697 tts_max_chars: file.tts.max_chars.max(1),
698 tts_timeout_ms: if file.tts.timeout_ms == 0 {
699 DEFAULT_TTS_TIMEOUT_MS
700 } else {
701 file.tts.timeout_ms
702 },
703 tts_pack_dir: file.tts.pack_dir.map(PathBuf::from),
704 tts_allow_unverified: file.tts.allow_unverified,
705 tts_custom_models: file.tts.custom_models,
706 config_path,
707 cache_dir,
708 }
709 }
710
711 #[allow(clippy::too_many_arguments)]
713 pub fn apply_cli(
714 &mut self,
715 provider: Option<&str>,
716 model: Option<&str>,
717 language: Option<&str>,
718 output: Option<&str>,
719 output_file: Option<&Path>,
720 timestamps: bool,
721 verbose: bool,
722 cleanup: Option<&str>,
723 cleanup_provider: Option<&str>,
724 cleanup_model: Option<&str>,
725 ) {
726 if let Some(p) = provider {
727 self.provider = p.to_string();
728 }
729 if let Some(m) = model {
730 self.model = Some(m.to_string());
731 }
732 if let Some(l) = language {
733 self.language = l.to_string();
734 }
735 if let Some(o) = output {
736 self.output = o.to_string();
737 }
738 if let Some(path) = output_file {
739 self.output_file = Some(path.to_path_buf());
740 }
741 if timestamps {
742 self.timestamps = true;
743 }
744 if verbose {
745 self.verbose = true;
746 }
747 if let Some(c) = cleanup {
748 self.cleanup_style = c.to_string();
749 }
750 if let Some(p) = cleanup_provider {
751 self.cleanup_provider = p.to_string();
752 }
753 if let Some(m) = cleanup_model {
754 self.cleanup_openrouter_model = Some(m.to_string());
755 }
756 }
757
758 pub fn resolve_model(&self, model_explicitly_set: bool) -> Result<String> {
768 if model_explicitly_set {
769 let m = self
770 .model
771 .clone()
772 .unwrap_or_else(|| self.default_model_for_provider());
773 if self.provider == "openrouter"
774 && !m.contains('/')
775 && (crate::model::lookup_model(&m).is_ok() || m == DEFAULT_LOCAL_MODEL)
776 {
777 return Err(UserError::Other {
778 message: format!(
779 "model '{m}' looks like a local whisper model, not an OpenRouter id.\n \
780 Hint: use e.g. google/gemini-2.5-flash-lite or openai/gpt-audio-mini, \
781 or omit --model to use the OpenRouter default."
782 ),
783 }
784 .into());
785 }
786 return Ok(m);
787 }
788 match self.provider.as_str() {
789 "openrouter" => {
790 let m = self
791 .model
792 .clone()
793 .unwrap_or_else(|| self.openrouter_default_model.clone());
794 if m.contains('/') {
795 Ok(m)
796 } else if m == DEFAULT_LOCAL_MODEL || crate::model::lookup_model(&m).is_ok() {
797 Ok(self.openrouter_default_model.clone())
798 } else {
799 Ok(m)
800 }
801 }
802 _ => Ok(self
803 .model
804 .clone()
805 .unwrap_or_else(|| DEFAULT_LOCAL_MODEL.to_string())),
806 }
807 }
808
809 fn default_model_for_provider(&self) -> String {
810 match self.provider.as_str() {
811 "openrouter" => self.openrouter_default_model.clone(),
812 _ => DEFAULT_LOCAL_MODEL.to_string(),
813 }
814 }
815}
816
817pub const MAX_CONFIG_BYTES: u64 = 256 * 1024;
819
820fn load_config_file(path: &Path) -> Result<ConfigFile> {
821 let meta = fs::metadata(path).map_err(|e| UserError::InvalidConfig {
822 reason: format!("failed to stat {}: {e}", path.display()),
823 })?;
824 if meta.len() > MAX_CONFIG_BYTES {
825 return Err(UserError::InvalidConfig {
826 reason: format!(
827 "config file {} is too large ({} > {MAX_CONFIG_BYTES} bytes)",
828 path.display(),
829 meta.len()
830 ),
831 }
832 .into());
833 }
834 let contents = fs::read_to_string(path).map_err(|e| UserError::InvalidConfig {
835 reason: format!("failed to read {}: {e}", path.display()),
836 })?;
837 toml::from_str(&contents).map_err(|e| {
838 UserError::InvalidConfig {
839 reason: format!("failed to parse {}: {e}", path.display()),
840 }
841 .into()
842 })
843}
844
845fn dirs_home() -> Result<PathBuf> {
846 if let Ok(h) = std::env::var("HOME") {
847 return Ok(PathBuf::from(h));
848 }
849 if let Ok(h) = std::env::var("USERPROFILE") {
850 return Ok(PathBuf::from(h));
851 }
852 Err(UserError::InvalidConfig {
853 reason: "could not determine home directory".into(),
854 }
855 .into())
856}
857
858pub fn write_example_config(path: &Path) -> Result<()> {
860 if path.exists() {
861 return Ok(());
862 }
863 if let Some(parent) = path.parent() {
864 fs::create_dir_all(parent)?;
865 }
866 let example = r#"# Aurum configuration
867# Environment variables take precedence over values in this file.
868# OPENROUTER_API_KEY is preferred over openrouter.api_key below.
869# TTS: AURUM_TTS_MODEL, AURUM_TTS_VOICE, AURUM_TTS_LANGUAGE override [tts].
870
871[default]
872provider = "local"
873model = "base"
874language = "auto"
875output = "txt"
876
877[cleanup]
878# style = "raw" # raw | clean | bullets | professional | summary
879# provider = "rules" # rules (on-device) | openrouter
880# openrouter_model = "google/gemini-2.5-flash"
881
882[tts]
883# provider = "local"
884# model = "kitten-nano-int8"
885# voice = "Luna"
886# language = "en"
887# max_chars = 5000
888# timeout_ms = 120000
889
890[openrouter]
891# api_key = "sk-or-..."
892# model = "google/gemini-2.5-flash"
893# base_url = "https://openrouter.ai/api/v1"
894"#;
895 fs::write(path, example)?;
896 Ok(())
897}
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902 use std::io::Write;
903 use tempfile::tempdir;
904
905 #[test]
906 fn parses_config_file() {
907 let dir = tempdir().unwrap();
908 let path = dir.path().join("config.toml");
909 let mut f = fs::File::create(&path).unwrap();
910 writeln!(
911 f,
912 r#"
913[default]
914provider = "openrouter"
915model = "small"
916language = "en"
917output = "json"
918
919[openrouter]
920api_key = "test-key"
921model = "google/gemini-2.5-flash"
922"#
923 )
924 .unwrap();
925
926 let prev = std::env::var("OPENROUTER_API_KEY").ok();
928 std::env::remove_var("OPENROUTER_API_KEY");
929 let cfg = Config::load_from(&path).unwrap();
930 if let Some(v) = prev {
931 std::env::set_var("OPENROUTER_API_KEY", v);
932 }
933 assert_eq!(cfg.provider, "openrouter");
934 assert_eq!(cfg.model.as_deref(), Some("small"));
935 assert_eq!(cfg.language, "en");
936 assert_eq!(cfg.output, "json");
937 assert_eq!(cfg.openrouter_api_key.as_deref(), Some("test-key"));
938 assert_eq!(cfg.openrouter_default_model, "google/gemini-2.5-flash");
939 assert_eq!(cfg.cleanup_style, "raw");
940 assert_eq!(cfg.cleanup_provider, "rules");
941 assert!(cfg.validate().is_ok());
942 let diag = cfg.effective_diagnostic();
943 assert_eq!(diag.openrouter_api_key.as_deref(), Some("***"));
944 }
945
946 #[test]
947 fn load_from_required_missing_errors() {
948 let dir = tempdir().unwrap();
949 let path = dir.path().join("missing.toml");
950 let err = Config::load_from_required(&path).unwrap_err();
951 assert!(err.to_string().contains("not found"));
952 }
953
954 #[test]
955 fn parses_cleanup_section() {
956 let dir = tempdir().unwrap();
957 let path = dir.path().join("config.toml");
958 fs::write(
959 &path,
960 r#"
961[default]
962provider = "local"
963model = "base"
964
965[cleanup]
966style = "clean"
967provider = "rules"
968openrouter_model = "google/gemini-2.5-flash"
969"#,
970 )
971 .unwrap();
972 let cfg = Config::load_from(&path).unwrap();
973 assert_eq!(cfg.cleanup_style, "clean");
974 assert_eq!(cfg.cleanup_provider, "rules");
975 assert_eq!(
976 cfg.cleanup_openrouter_model.as_deref(),
977 Some("google/gemini-2.5-flash")
978 );
979 }
980
981 #[test]
982 fn cli_overrides_file() {
983 let dir = tempdir().unwrap();
984 let path = dir.path().join("config.toml");
985 fs::write(
986 &path,
987 r#"
988[default]
989provider = "local"
990model = "base"
991language = "auto"
992output = "txt"
993
994[cleanup]
995style = "clean"
996provider = "rules"
997"#,
998 )
999 .unwrap();
1000 let mut cfg = Config::load_from(&path).unwrap();
1001 cfg.apply_cli(
1002 Some("openrouter"),
1003 Some("google/gemini-2.5-flash"),
1004 Some("fr"),
1005 Some("srt"),
1006 Some(Path::new("out.srt")),
1007 true,
1008 true,
1009 Some("summary"),
1010 Some("openrouter"),
1011 Some("openai/gpt-audio-mini"),
1012 );
1013 assert_eq!(cfg.provider, "openrouter");
1014 assert_eq!(cfg.model.as_deref(), Some("google/gemini-2.5-flash"));
1015 assert_eq!(cfg.language, "fr");
1016 assert_eq!(cfg.output, "srt");
1017 assert_eq!(cfg.output_file.as_deref(), Some(Path::new("out.srt")));
1018 assert!(cfg.timestamps);
1019 assert!(cfg.verbose);
1020 assert_eq!(cfg.cleanup_style, "summary");
1021 assert_eq!(cfg.cleanup_provider, "openrouter");
1022 assert_eq!(
1023 cfg.cleanup_openrouter_model.as_deref(),
1024 Some("openai/gpt-audio-mini")
1025 );
1026 }
1027
1028 #[test]
1029 fn custom_tts_model_cannot_shadow_builtin_on_load() {
1030 let dir = tempdir().unwrap();
1031 let path = dir.path().join("config.toml");
1032 let default_id = {
1033 #[cfg(feature = "tts")]
1034 {
1035 crate::tts::DEFAULT_TTS_MODEL
1036 }
1037 #[cfg(not(feature = "tts"))]
1038 {
1039 "kitten-nano-int8"
1040 }
1041 };
1042 fs::write(
1043 &path,
1044 format!(
1045 r#"
1046[tts]
1047model = "{default_id}"
1048
1049[[tts.custom_models]]
1050id = "{default_id}"
1051adapter = "fake-sine-v1"
1052pack_dir = "/tmp/does-not-matter"
1053trust = "verified"
1054"#
1055 ),
1056 )
1057 .unwrap();
1058 let err = Config::load_from(&path).unwrap_err();
1059 assert!(
1060 err.to_string().contains("collides") || err.to_string().contains("reserved"),
1061 "got: {err}"
1062 );
1063 }
1064
1065 #[test]
1066 fn defaults_when_missing_file() {
1067 let dir = tempdir().unwrap();
1068 let path = dir.path().join("nope.toml");
1069 let cfg = Config::load_from(&path).unwrap();
1070 assert_eq!(cfg.provider, "local");
1071 assert_eq!(cfg.language, "auto");
1072 assert_eq!(cfg.output, "txt");
1073 assert_eq!(cfg.cleanup_style, "raw");
1074 assert_eq!(cfg.cleanup_provider, "rules");
1075 }
1076}