1use crate::error::{Result, UserError};
45use crate::provider_platform::ProviderId;
46use crate::secret::SecretString;
47use directories::ProjectDirs;
48use serde::{Deserialize, Serialize};
49use std::fs;
50use std::path::{Path, PathBuf};
51
52pub const DEFAULT_PROVIDER: &str = "local";
54pub const DEFAULT_LOCAL_MODEL: &str = "base";
55pub const DEFAULT_OPENROUTER_MODEL: &str = "google/gemini-2.5-flash";
56pub const DEFAULT_LANGUAGE: &str = "auto";
57pub const DEFAULT_OUTPUT: &str = "txt";
58pub const DEFAULT_CLEANUP: &str = "raw";
59pub const DEFAULT_CLEANUP_PROVIDER: &str = "rules";
60pub const DEFAULT_TTS_PROVIDER: &str = "local";
61pub const DEFAULT_TTS_LANGUAGE: &str = "en";
62pub const DEFAULT_TTS_MAX_CHARS: usize = 5_000;
63pub const DEFAULT_TTS_TIMEOUT_MS: u64 = 120_000;
64pub const DEFAULT_TTS_SPEAKING_RATE: f32 = 1.0;
65pub const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct ConfigFile {
71 #[serde(default)]
73 pub stt: Option<SttSection>,
74 #[serde(default)]
75 pub cleanup: CleanupSection,
76 #[serde(default)]
77 pub tts: TtsSection,
78 #[serde(default)]
80 pub providers: ProvidersFileSection,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct SttSection {
86 #[serde(default = "default_provider")]
87 pub provider: String,
88 #[serde(default = "default_local_model")]
89 pub model: String,
90 #[serde(default = "default_language")]
91 pub language: String,
92 #[serde(default = "default_output")]
93 pub output: String,
94}
95
96impl Default for SttSection {
97 fn default() -> Self {
98 Self {
99 provider: default_provider(),
100 model: default_local_model(),
101 language: default_language(),
102 output: default_output(),
103 }
104 }
105}
106
107#[derive(Clone, Default, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct OpenRouterSection {
111 pub api_key: Option<SecretString>,
116 pub model: Option<String>,
118 pub base_url: Option<String>,
120 #[serde(default)]
122 pub allow_custom_endpoint: bool,
123 #[serde(default = "default_stt_mode")]
125 pub stt_mode: String,
126 #[serde(default)]
128 pub use_system_proxy: bool,
129}
130
131impl std::fmt::Debug for OpenRouterSection {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 f.debug_struct("OpenRouterSection")
134 .field("api_key", &self.api_key)
135 .field("model", &self.model)
136 .field("base_url", &self.base_url)
137 .field("allow_custom_endpoint", &self.allow_custom_endpoint)
138 .field("stt_mode", &self.stt_mode)
139 .field("use_system_proxy", &self.use_system_proxy)
140 .finish()
141 }
142}
143
144fn default_stt_mode() -> String {
145 "auto".into()
146}
147
148#[derive(Clone, Default, Serialize, Deserialize)]
150#[serde(deny_unknown_fields)]
151pub struct ProviderCredentialSection {
152 pub api_key: Option<SecretString>,
153 pub base_url: Option<String>,
154}
155
156impl std::fmt::Debug for ProviderCredentialSection {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 f.debug_struct("ProviderCredentialSection")
159 .field("api_key", &self.api_key)
160 .field("base_url", &self.base_url)
161 .finish()
162 }
163}
164
165#[derive(Debug, Clone, Default, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct ProvidersFileSection {
169 #[serde(default)]
170 pub openrouter: Option<OpenRouterSection>,
171 #[serde(default)]
172 pub openai: Option<ProviderCredentialSection>,
173 #[serde(default)]
174 pub elevenlabs: Option<ProviderCredentialSection>,
175 #[serde(default)]
176 pub xai: Option<ProviderCredentialSection>,
177}
178
179#[derive(Clone, Default)]
181pub struct ProviderCredentialConfig {
182 pub api_key: Option<SecretString>,
183 pub base_url: Option<String>,
184}
185
186impl std::fmt::Debug for ProviderCredentialConfig {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 f.debug_struct("ProviderCredentialConfig")
189 .field("api_key", &self.api_key)
190 .field("base_url", &self.base_url)
191 .finish()
192 }
193}
194
195#[derive(Clone, Default)]
197pub struct ProvidersConfig {
198 pub openai: ProviderCredentialConfig,
199 pub elevenlabs: ProviderCredentialConfig,
200 pub xai: ProviderCredentialConfig,
201}
202
203impl std::fmt::Debug for ProvidersConfig {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 f.debug_struct("ProvidersConfig")
206 .field("openai", &self.openai)
207 .field("elevenlabs", &self.elevenlabs)
208 .field("xai", &self.xai)
209 .finish()
210 }
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct CleanupSection {
216 #[serde(default = "default_cleanup")]
218 pub style: String,
219 #[serde(default = "default_cleanup_provider")]
221 pub provider: String,
222 pub openrouter_model: Option<String>,
224}
225
226impl Default for CleanupSection {
227 fn default() -> Self {
228 Self {
229 style: default_cleanup(),
230 provider: default_cleanup_provider(),
231 openrouter_model: None,
232 }
233 }
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct TtsSection {
239 #[serde(default = "default_tts_provider")]
240 pub provider: String,
241 #[serde(default = "default_tts_model")]
242 pub model: String,
243 #[serde(default = "default_tts_voice")]
244 pub voice: String,
245 #[serde(default = "default_tts_language")]
246 pub language: String,
247 #[serde(default = "default_tts_speaking_rate")]
249 pub speaking_rate: f32,
250 #[serde(default = "default_tts_max_chars")]
251 pub max_chars: usize,
252 #[serde(default = "default_tts_timeout_ms")]
253 pub timeout_ms: u64,
254 #[serde(default)]
257 pub pack_dir: Option<String>,
258 #[serde(default)]
260 pub allow_unverified: bool,
261 #[serde(default)]
263 pub custom_models: Vec<CustomTtsModelConfig>,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
268pub struct CustomTtsModelConfig {
269 pub id: String,
270 pub adapter: String,
271 #[serde(default)]
272 pub pack_dir: Option<String>,
273 pub trust: String,
274 #[serde(default)]
275 pub license: Option<String>,
276 #[serde(default)]
277 pub notes: Option<String>,
278}
279
280impl Default for TtsSection {
281 fn default() -> Self {
282 Self {
283 provider: default_tts_provider(),
284 model: default_tts_model(),
285 voice: default_tts_voice(),
286 language: default_tts_language(),
287 speaking_rate: default_tts_speaking_rate(),
288 max_chars: default_tts_max_chars(),
289 timeout_ms: default_tts_timeout_ms(),
290 pack_dir: None,
291 allow_unverified: false,
292 custom_models: Vec::new(),
293 }
294 }
295}
296
297fn default_provider() -> String {
298 DEFAULT_PROVIDER.to_string()
299}
300fn default_local_model() -> String {
301 DEFAULT_LOCAL_MODEL.to_string()
302}
303fn default_language() -> String {
304 DEFAULT_LANGUAGE.to_string()
305}
306fn default_output() -> String {
307 DEFAULT_OUTPUT.to_string()
308}
309fn default_cleanup() -> String {
310 DEFAULT_CLEANUP.to_string()
311}
312fn default_cleanup_provider() -> String {
313 DEFAULT_CLEANUP_PROVIDER.to_string()
314}
315fn default_tts_provider() -> String {
316 DEFAULT_TTS_PROVIDER.to_string()
317}
318fn default_tts_model() -> String {
319 #[cfg(feature = "tts")]
320 {
321 crate::tts::DEFAULT_TTS_MODEL.to_string()
322 }
323 #[cfg(not(feature = "tts"))]
324 {
325 "kitten-nano-int8".to_string()
326 }
327}
328fn default_tts_voice() -> String {
329 #[cfg(feature = "tts")]
330 {
331 crate::tts::DEFAULT_TTS_VOICE.to_string()
332 }
333 #[cfg(not(feature = "tts"))]
334 {
335 "Luna".to_string()
336 }
337}
338fn default_tts_language() -> String {
339 DEFAULT_TTS_LANGUAGE.to_string()
340}
341fn default_tts_max_chars() -> usize {
342 DEFAULT_TTS_MAX_CHARS
343}
344fn default_tts_timeout_ms() -> u64 {
345 DEFAULT_TTS_TIMEOUT_MS
346}
347fn default_tts_speaking_rate() -> f32 {
348 DEFAULT_TTS_SPEAKING_RATE
349}
350
351#[derive(Clone)]
358pub struct Config {
359 pub provider: String,
361 pub model: Option<String>,
362 pub language: String,
363 pub output: String,
364 pub output_file: Option<PathBuf>,
365 pub timestamps: bool,
366 pub verbose: bool,
367 pub openrouter_api_key: Option<SecretString>,
369 pub openrouter_base_url: String,
370 pub openrouter_default_model: String,
371 pub openrouter_allow_custom_endpoint: bool,
373 pub openrouter_stt_mode: String,
375 pub openrouter_use_system_proxy: bool,
376 pub providers: ProvidersConfig,
378 pub cleanup_style: String,
380 pub cleanup_provider: String,
382 pub cleanup_openrouter_model: Option<String>,
384 pub tts_provider: String,
386 pub tts_model: String,
387 pub tts_voice: String,
388 pub tts_language: String,
389 pub tts_speaking_rate: f32,
390 pub tts_max_chars: usize,
391 pub tts_timeout_ms: u64,
392 pub tts_pack_dir: Option<PathBuf>,
394 pub tts_allow_unverified: bool,
395 pub tts_custom_models: Vec<CustomTtsModelConfig>,
397 pub local_only: bool,
399 pub config_path: Option<PathBuf>,
400 pub cache_dir: PathBuf,
401}
402
403impl std::fmt::Debug for Config {
404 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405 f.debug_struct("Config")
406 .field("provider", &self.provider)
407 .field("model", &self.model)
408 .field("language", &self.language)
409 .field("output", &self.output)
410 .field("output_file", &self.output_file)
411 .field("timestamps", &self.timestamps)
412 .field("verbose", &self.verbose)
413 .field("openrouter_api_key", &self.openrouter_api_key)
414 .field("openrouter_base_url", &self.openrouter_base_url)
415 .field("openrouter_default_model", &self.openrouter_default_model)
416 .field(
417 "openrouter_allow_custom_endpoint",
418 &self.openrouter_allow_custom_endpoint,
419 )
420 .field("openrouter_stt_mode", &self.openrouter_stt_mode)
421 .field(
422 "openrouter_use_system_proxy",
423 &self.openrouter_use_system_proxy,
424 )
425 .field("providers", &self.providers)
426 .field("cleanup_style", &self.cleanup_style)
427 .field("cleanup_provider", &self.cleanup_provider)
428 .field("cleanup_openrouter_model", &self.cleanup_openrouter_model)
429 .field("tts_provider", &self.tts_provider)
430 .field("tts_model", &self.tts_model)
431 .field("tts_voice", &self.tts_voice)
432 .field("tts_language", &self.tts_language)
433 .field("tts_speaking_rate", &self.tts_speaking_rate)
434 .field("tts_max_chars", &self.tts_max_chars)
435 .field("tts_timeout_ms", &self.tts_timeout_ms)
436 .field("tts_pack_dir", &self.tts_pack_dir)
437 .field("tts_allow_unverified", &self.tts_allow_unverified)
438 .field("tts_custom_models", &self.tts_custom_models)
439 .field("local_only", &self.local_only)
440 .field("config_path", &self.config_path)
441 .field("cache_dir", &self.cache_dir)
442 .finish()
443 }
444}
445
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
448#[serde(rename_all = "snake_case")]
449pub enum ConfigValueSource {
450 Default,
451 File,
452 Environment,
453 Cli,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct ConfigSourceMap {
459 pub provider: ConfigValueSource,
460 pub openrouter_api_key: ConfigValueSource,
461 pub openrouter_base_url: ConfigValueSource,
462 pub openai_api_key: ConfigValueSource,
463 pub elevenlabs_api_key: ConfigValueSource,
464 pub xai_api_key: ConfigValueSource,
465 pub tts_model: ConfigValueSource,
466}
467
468impl ConfigSourceMap {
469 fn default_attribution(cfg: &Config) -> Self {
470 let key_src =
471 env_or_file_or_default("OPENROUTER_API_KEY", cfg.openrouter_api_key.is_some());
472 let base_src = if env_nonempty("OPENROUTER_BASE_URL") {
473 ConfigValueSource::Environment
474 } else {
475 ConfigValueSource::File
476 };
477 let tts_src = if env_nonempty("AURUM_TTS_MODEL") {
478 ConfigValueSource::Environment
479 } else {
480 ConfigValueSource::File
481 };
482 Self {
483 provider: if cfg.config_path.is_some() {
484 ConfigValueSource::File
485 } else {
486 ConfigValueSource::Default
487 },
488 openrouter_api_key: key_src,
489 openrouter_base_url: base_src,
490 openai_api_key: env_or_file_or_default(
491 "OPENAI_API_KEY",
492 cfg.providers.openai.api_key.is_some(),
493 ),
494 elevenlabs_api_key: env_or_file_or_default(
495 "ELEVENLABS_API_KEY",
496 cfg.providers.elevenlabs.api_key.is_some(),
497 ),
498 xai_api_key: env_or_file_or_default("XAI_API_KEY", cfg.providers.xai.api_key.is_some()),
499 tts_model: tts_src,
500 }
501 }
502}
503
504fn env_nonempty(name: &str) -> bool {
505 std::env::var(name).ok().filter(|s| !s.is_empty()).is_some()
506}
507
508fn env_or_file_or_default(env_name: &str, file_present: bool) -> ConfigValueSource {
509 if env_nonempty(env_name) {
510 ConfigValueSource::Environment
511 } else if file_present {
512 ConfigValueSource::File
513 } else {
514 ConfigValueSource::Default
515 }
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize)]
520pub struct ProviderSecretDiagnostic {
521 pub api_key: Option<String>,
523 pub base_url: Option<String>,
524 pub api_key_source: ConfigValueSource,
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct EffectiveConfigDiagnostic {
530 pub provider: String,
531 pub model: Option<String>,
532 pub language: String,
533 pub output: String,
534 pub timestamps: bool,
535 pub openrouter_api_key: Option<String>,
536 pub openrouter_base_url: String,
537 pub openrouter_default_model: String,
538 pub openrouter_stt_mode: String,
539 pub openrouter_allow_custom_endpoint: bool,
540 pub providers: ProvidersDiagnostic,
542 pub cleanup_style: String,
543 pub cleanup_provider: String,
544 pub tts_provider: String,
545 pub tts_model: String,
546 pub tts_voice: String,
547 pub tts_language: String,
548 pub tts_speaking_rate: f32,
549 pub tts_max_chars: usize,
550 pub tts_timeout_ms: u64,
551 pub tts_pack_dir: Option<String>,
552 pub tts_allow_unverified: bool,
553 pub tts_custom_model_ids: Vec<String>,
554 pub local_only: bool,
555 pub config_path: Option<String>,
556 pub cache_dir: String,
557 pub sources: ConfigSourceMap,
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize)]
562pub struct ProvidersDiagnostic {
563 pub openrouter: ProviderSecretDiagnostic,
564 pub openai: ProviderSecretDiagnostic,
565 pub elevenlabs: ProviderSecretDiagnostic,
566 pub xai: ProviderSecretDiagnostic,
567}
568
569impl Config {
570 pub fn default_config_path() -> Option<PathBuf> {
572 ProjectDirs::from("", "", "aurum").map(|d| d.config_dir().join("config.toml"))
573 }
574
575 pub fn default_cache_dir() -> Result<PathBuf> {
577 if let Some(dirs) = ProjectDirs::from("", "", "aurum") {
578 return Ok(dirs.cache_dir().to_path_buf());
579 }
580 let home = dirs_home()?;
581 Ok(home.join(".cache").join("aurum"))
582 }
583
584 pub fn load() -> Result<Self> {
586 let path = Self::default_config_path();
587 let file = match &path {
588 Some(p) if p.exists() => Some(load_config_file(p)?),
589 _ => None,
590 };
591 let cfg = Self::from_parts(file, path)?;
592 cfg.validate_tts_custom_models()?;
593 Ok(cfg)
594 }
595
596 pub fn load_from(path: &Path) -> Result<Self> {
598 let file = if path.exists() {
599 Some(load_config_file(path)?)
600 } else {
601 None
602 };
603 let cfg = Self::from_parts(file, Some(path.to_path_buf()))?;
604 cfg.validate_tts_custom_models()?;
605 Ok(cfg)
606 }
607
608 pub fn load_from_required(path: &Path) -> Result<Self> {
610 if !path.exists() {
611 return Err(UserError::InvalidConfig {
612 reason: format!(
613 "config file not found: {}\n Hint: create it or omit --config to use defaults",
614 path.display()
615 ),
616 }
617 .into());
618 }
619 let file = load_config_file(path)?;
620 let cfg = Self::from_parts(Some(file), Some(path.to_path_buf()))?;
621 cfg.validate()?;
622 Ok(cfg)
623 }
624
625 pub fn provider_secret(&self, id: &ProviderId) -> Option<SecretString> {
627 match id.as_str() {
628 "openrouter" => self.openrouter_api_key.clone(),
629 "openai" => self.providers.openai.api_key.clone(),
630 "elevenlabs" => self.providers.elevenlabs.api_key.clone(),
631 "xai" => self.providers.xai.api_key.clone(),
632 _ => None,
633 }
634 }
635
636 pub fn validate(&self) -> Result<()> {
638 validate_stt_provider(&self.provider)?;
639 validate_tts_provider(&self.tts_provider)?;
640 let _ = crate::output::OutputFormat::parse(&self.output)?;
641 let _ = crate::cleanup::CleanupStyle::parse(&self.cleanup_style)?;
642 let _ = crate::cleanup::CleanupProviderKind::parse(&self.cleanup_provider)?;
643 let _ = crate::providers::OpenRouterSttMode::parse(&self.openrouter_stt_mode)?;
644
645 if self.tts_max_chars == 0 {
646 return Err(UserError::InvalidConfig {
647 reason: "tts.max_chars must be >= 1".into(),
648 }
649 .into());
650 }
651 if self.tts_timeout_ms == 0 {
652 return Err(UserError::InvalidConfig {
653 reason: "tts.timeout_ms must be >= 1".into(),
654 }
655 .into());
656 }
657 if self.tts_max_chars > 500_000 {
658 return Err(UserError::InvalidConfig {
659 reason: format!(
660 "tts.max_chars {} exceeds safe ceiling 500000",
661 self.tts_max_chars
662 ),
663 }
664 .into());
665 }
666 if !self.tts_speaking_rate.is_finite()
667 || self.tts_speaking_rate <= 0.0
668 || self.tts_speaking_rate > 4.0
669 {
670 return Err(UserError::InvalidConfig {
671 reason: format!(
672 "tts.speaking_rate must be finite and in (0, 4] (got {})",
673 self.tts_speaking_rate
674 ),
675 }
676 .into());
677 }
678 if !self.openrouter_base_url.starts_with("https://")
679 && !self.openrouter_base_url.starts_with("http://localhost")
680 && !self.openrouter_base_url.contains("127.0.0.1")
681 && self.openrouter_base_url.starts_with("http://")
682 {
683 return Err(UserError::InvalidConfig {
684 reason: format!(
685 "openrouter base_url must use https (got {})",
686 self.openrouter_base_url
687 ),
688 }
689 .into());
690 }
691
692 if self.local_only {
693 if is_remote_provider(&self.provider) {
694 return Err(UserError::InvalidConfig {
695 reason: format!(
696 "local_only=true rejects remote STT provider '{}'\n \
697 Hint: set [stt] provider = \"local\" or unset local_only",
698 self.provider
699 ),
700 }
701 .into());
702 }
703 if is_remote_provider(&self.tts_provider) {
704 return Err(UserError::InvalidConfig {
705 reason: format!(
706 "local_only=true rejects remote TTS provider '{}'\n \
707 Hint: set [tts] provider = \"local\" or unset local_only",
708 self.tts_provider
709 ),
710 }
711 .into());
712 }
713 }
714
715 self.validate_tts_custom_models()?;
716 Ok(())
717 }
718
719 pub fn validate_tts_custom_models(&self) -> Result<()> {
721 #[cfg(feature = "tts")]
722 {
723 use crate::tts::{validate_custom_models, CustomTtsModelEntry, MAX_CUSTOM_MODELS};
724 if self.tts_custom_models.len() > MAX_CUSTOM_MODELS {
725 return Err(UserError::InvalidConfig {
726 reason: format!(
727 "too many [[tts.custom_models]] entries ({} > {MAX_CUSTOM_MODELS})",
728 self.tts_custom_models.len()
729 ),
730 }
731 .into());
732 }
733 let mut ids = std::collections::HashSet::new();
734 let mut present = Vec::new();
735 for e in &self.tts_custom_models {
736 let id = e.id.trim();
737 if id.is_empty() {
738 return Err(UserError::InvalidConfig {
739 reason: "custom TTS model id must be non-empty".into(),
740 }
741 .into());
742 }
743 if !ids.insert(id.to_string()) {
744 return Err(UserError::InvalidConfig {
745 reason: format!("duplicate custom TTS model id '{id}'"),
746 }
747 .into());
748 }
749 if id == crate::tts::DEFAULT_TTS_MODEL
750 || crate::tts::lookup_model(id)
751 .map(|m| m.shipped)
752 .unwrap_or(false)
753 {
754 return Err(UserError::InvalidConfig {
755 reason: format!(
756 "custom model id '{id}' collides with built-in catalogue entry"
757 ),
758 }
759 .into());
760 }
761 let _ = crate::tts::lookup_adapter(&e.adapter)?;
762 let trust = crate::tts::TrustMode::parse(&e.trust)?;
763 if matches!(trust, crate::tts::TrustMode::Builtin) {
764 return Err(UserError::InvalidConfig {
765 reason: "custom models cannot use trust=builtin".into(),
766 }
767 .into());
768 }
769 if let Some(dir) = e.pack_dir.as_ref().map(PathBuf::from) {
770 if dir.exists() {
771 present.push(CustomTtsModelEntry {
772 id: e.id.clone(),
773 adapter: e.adapter.clone(),
774 pack_dir: e.pack_dir.clone(),
775 trust: e.trust.clone(),
776 license: e.license.clone(),
777 notes: e.notes.clone(),
778 });
779 }
780 } else {
781 return Err(UserError::InvalidConfig {
782 reason: format!(
783 "custom model '{id}' requires pack_dir (remote custom packs \
784 are not enabled in v0.0.3)"
785 ),
786 }
787 .into());
788 }
789 }
790 if !present.is_empty() {
791 let _ = validate_custom_models(&present)?;
792 }
793 }
794 Ok(())
795 }
796
797 pub fn effective_diagnostic(&self) -> EffectiveConfigDiagnostic {
799 let sources = ConfigSourceMap::default_attribution(self);
800 EffectiveConfigDiagnostic {
801 provider: self.provider.clone(),
802 model: self.model.clone(),
803 language: self.language.clone(),
804 output: self.output.clone(),
805 timestamps: self.timestamps,
806 openrouter_api_key: self.openrouter_api_key.as_ref().map(|_| "***".into()),
807 openrouter_base_url: self.openrouter_base_url.clone(),
808 openrouter_default_model: self.openrouter_default_model.clone(),
809 openrouter_stt_mode: self.openrouter_stt_mode.clone(),
810 openrouter_allow_custom_endpoint: self.openrouter_allow_custom_endpoint,
811 providers: ProvidersDiagnostic {
812 openrouter: ProviderSecretDiagnostic {
813 api_key: self.openrouter_api_key.as_ref().map(|_| "***".into()),
814 base_url: Some(self.openrouter_base_url.clone()),
815 api_key_source: sources.openrouter_api_key,
816 },
817 openai: ProviderSecretDiagnostic {
818 api_key: self.providers.openai.api_key.as_ref().map(|_| "***".into()),
819 base_url: self.providers.openai.base_url.clone(),
820 api_key_source: sources.openai_api_key,
821 },
822 elevenlabs: ProviderSecretDiagnostic {
823 api_key: self
824 .providers
825 .elevenlabs
826 .api_key
827 .as_ref()
828 .map(|_| "***".into()),
829 base_url: self.providers.elevenlabs.base_url.clone(),
830 api_key_source: sources.elevenlabs_api_key,
831 },
832 xai: ProviderSecretDiagnostic {
833 api_key: self.providers.xai.api_key.as_ref().map(|_| "***".into()),
834 base_url: self.providers.xai.base_url.clone(),
835 api_key_source: sources.xai_api_key,
836 },
837 },
838 cleanup_style: self.cleanup_style.clone(),
839 cleanup_provider: self.cleanup_provider.clone(),
840 tts_provider: self.tts_provider.clone(),
841 tts_model: self.tts_model.clone(),
842 tts_voice: self.tts_voice.clone(),
843 tts_language: self.tts_language.clone(),
844 tts_speaking_rate: self.tts_speaking_rate,
845 tts_max_chars: self.tts_max_chars,
846 tts_timeout_ms: self.tts_timeout_ms,
847 tts_pack_dir: self.tts_pack_dir.as_ref().map(|p| p.display().to_string()),
848 tts_allow_unverified: self.tts_allow_unverified,
849 tts_custom_model_ids: self
850 .tts_custom_models
851 .iter()
852 .map(|m| m.id.clone())
853 .collect(),
854 local_only: self.local_only,
855 config_path: self.config_path.as_ref().map(|p| p.display().to_string()),
856 cache_dir: self.cache_dir.display().to_string(),
857 sources,
858 }
859 }
860
861 fn from_parts(file: Option<ConfigFile>, config_path: Option<PathBuf>) -> Result<Self> {
862 let file = file.unwrap_or_default();
863
864 let (provider, model, language, output) = resolve_stt(&file);
865 let openrouter = resolve_openrouter(&file);
866
867 let openrouter_api_key = std::env::var("OPENROUTER_API_KEY")
868 .ok()
869 .filter(|s| !s.is_empty())
870 .map(SecretString::new)
871 .or(openrouter.api_key);
872
873 let openrouter_base_url = std::env::var("OPENROUTER_BASE_URL")
874 .ok()
875 .filter(|s| !s.is_empty())
876 .or(openrouter.base_url)
877 .unwrap_or_else(|| DEFAULT_OPENROUTER_BASE_URL.to_string());
878
879 let openrouter_default_model = openrouter
880 .model
881 .unwrap_or_else(|| DEFAULT_OPENROUTER_MODEL.to_string());
882
883 let openai = merge_provider_cred(file.providers.openai.as_ref(), "OPENAI_API_KEY");
884 let elevenlabs =
885 merge_provider_cred(file.providers.elevenlabs.as_ref(), "ELEVENLABS_API_KEY");
886 let xai = merge_provider_cred(file.providers.xai.as_ref(), "XAI_API_KEY");
887
888 let cache_dir =
889 Self::default_cache_dir().unwrap_or_else(|_| std::env::temp_dir().join("aurum-cache"));
890
891 let tts_model = std::env::var("AURUM_TTS_MODEL")
892 .ok()
893 .filter(|s| !s.is_empty())
894 .unwrap_or_else(|| file.tts.model.clone());
895 let tts_voice = std::env::var("AURUM_TTS_VOICE")
896 .ok()
897 .filter(|s| !s.is_empty())
898 .unwrap_or_else(|| file.tts.voice.clone());
899 let tts_language = std::env::var("AURUM_TTS_LANGUAGE")
900 .ok()
901 .filter(|s| !s.is_empty())
902 .unwrap_or_else(|| file.tts.language.clone());
903
904 Ok(Self {
905 provider,
906 model: Some(model),
907 language,
908 output,
909 output_file: None,
910 timestamps: false,
911 verbose: false,
912 openrouter_api_key,
913 openrouter_base_url,
914 openrouter_default_model,
915 openrouter_allow_custom_endpoint: openrouter.allow_custom_endpoint,
916 openrouter_stt_mode: if openrouter.stt_mode.trim().is_empty() {
917 default_stt_mode()
918 } else {
919 openrouter.stt_mode
920 },
921 openrouter_use_system_proxy: openrouter.use_system_proxy,
922 providers: ProvidersConfig {
923 openai,
924 elevenlabs,
925 xai,
926 },
927 cleanup_style: file.cleanup.style,
928 cleanup_provider: file.cleanup.provider,
929 cleanup_openrouter_model: file.cleanup.openrouter_model,
930 tts_provider: file.tts.provider,
931 tts_model,
932 tts_voice,
933 tts_language,
934 tts_speaking_rate: file.tts.speaking_rate,
935 tts_max_chars: file.tts.max_chars.max(1),
936 tts_timeout_ms: if file.tts.timeout_ms == 0 {
937 DEFAULT_TTS_TIMEOUT_MS
938 } else {
939 file.tts.timeout_ms
940 },
941 tts_pack_dir: file.tts.pack_dir.map(PathBuf::from),
942 tts_allow_unverified: file.tts.allow_unverified,
943 tts_custom_models: file.tts.custom_models,
944 local_only: false,
945 config_path,
946 cache_dir,
947 })
948 }
949
950 #[allow(clippy::too_many_arguments)]
952 pub fn apply_cli(
953 &mut self,
954 provider: Option<&str>,
955 model: Option<&str>,
956 language: Option<&str>,
957 output: Option<&str>,
958 output_file: Option<&Path>,
959 timestamps: bool,
960 verbose: bool,
961 cleanup: Option<&str>,
962 cleanup_provider: Option<&str>,
963 cleanup_model: Option<&str>,
964 ) {
965 if let Some(p) = provider {
966 self.provider = p.to_string();
967 }
968 if let Some(m) = model {
969 self.model = Some(m.to_string());
970 }
971 if let Some(l) = language {
972 self.language = l.to_string();
973 }
974 if let Some(o) = output {
975 self.output = o.to_string();
976 }
977 if let Some(path) = output_file {
978 self.output_file = Some(path.to_path_buf());
979 }
980 if timestamps {
981 self.timestamps = true;
982 }
983 if verbose {
984 self.verbose = true;
985 }
986 if let Some(c) = cleanup {
987 self.cleanup_style = c.to_string();
988 }
989 if let Some(p) = cleanup_provider {
990 self.cleanup_provider = p.to_string();
991 }
992 if let Some(m) = cleanup_model {
993 self.cleanup_openrouter_model = Some(m.to_string());
994 }
995 }
996
997 pub fn resolve_model(&self, model_explicitly_set: bool) -> Result<String> {
999 if model_explicitly_set {
1000 let m = self
1001 .model
1002 .clone()
1003 .unwrap_or_else(|| self.default_model_for_provider());
1004 if self.provider == "openrouter"
1005 && !m.contains('/')
1006 && (crate::model::lookup_model(&m).is_ok() || m == DEFAULT_LOCAL_MODEL)
1007 {
1008 return Err(UserError::Other {
1009 message: format!(
1010 "model '{m}' looks like a local whisper model, not an OpenRouter id.\n \
1011 Hint: use e.g. google/gemini-2.5-flash-lite or openai/gpt-audio-mini, \
1012 or omit --model to use the OpenRouter default."
1013 ),
1014 }
1015 .into());
1016 }
1017 return Ok(m);
1018 }
1019 match self.provider.as_str() {
1020 "openrouter" => {
1021 let m = self
1022 .model
1023 .clone()
1024 .unwrap_or_else(|| self.openrouter_default_model.clone());
1025 if m.contains('/') {
1026 Ok(m)
1027 } else if m == DEFAULT_LOCAL_MODEL || crate::model::lookup_model(&m).is_ok() {
1028 Ok(self.openrouter_default_model.clone())
1029 } else {
1030 Ok(m)
1031 }
1032 }
1033 _ => Ok(self
1034 .model
1035 .clone()
1036 .unwrap_or_else(|| DEFAULT_LOCAL_MODEL.to_string())),
1037 }
1038 }
1039
1040 fn default_model_for_provider(&self) -> String {
1041 match self.provider.as_str() {
1042 "openrouter" => self.openrouter_default_model.clone(),
1043 _ => DEFAULT_LOCAL_MODEL.to_string(),
1044 }
1045 }
1046}
1047
1048struct MergedOpenRouter {
1051 api_key: Option<SecretString>,
1052 model: Option<String>,
1053 base_url: Option<String>,
1054 allow_custom_endpoint: bool,
1055 stt_mode: String,
1056 use_system_proxy: bool,
1057}
1058
1059fn resolve_stt(file: &ConfigFile) -> (String, String, String, String) {
1061 match file.stt.as_ref() {
1062 Some(s) => (
1063 s.provider.clone(),
1064 s.model.clone(),
1065 s.language.clone(),
1066 s.output.clone(),
1067 ),
1068 None => (
1069 default_provider(),
1070 default_local_model(),
1071 default_language(),
1072 default_output(),
1073 ),
1074 }
1075}
1076
1077fn resolve_openrouter(file: &ConfigFile) -> MergedOpenRouter {
1079 match file.providers.openrouter.as_ref() {
1080 Some(s) => MergedOpenRouter {
1081 api_key: s.api_key.clone(),
1082 model: s.model.clone(),
1083 base_url: s.base_url.clone(),
1084 allow_custom_endpoint: s.allow_custom_endpoint,
1085 stt_mode: if s.stt_mode.trim().is_empty() {
1086 default_stt_mode()
1087 } else {
1088 s.stt_mode.clone()
1089 },
1090 use_system_proxy: s.use_system_proxy,
1091 },
1092 None => MergedOpenRouter {
1093 api_key: None,
1094 model: None,
1095 base_url: None,
1096 allow_custom_endpoint: false,
1097 stt_mode: default_stt_mode(),
1098 use_system_proxy: false,
1099 },
1100 }
1101}
1102
1103fn merge_provider_cred(
1104 file: Option<&ProviderCredentialSection>,
1105 env_key: &str,
1106) -> ProviderCredentialConfig {
1107 let from_file = file.cloned().unwrap_or_default();
1108 let api_key = std::env::var(env_key)
1109 .ok()
1110 .filter(|s| !s.is_empty())
1111 .map(SecretString::new)
1112 .or(from_file.api_key);
1113 ProviderCredentialConfig {
1114 api_key,
1115 base_url: from_file.base_url,
1116 }
1117}
1118
1119fn is_remote_provider(name: &str) -> bool {
1120 !matches!(name.to_ascii_lowercase().as_str(), "local" | "")
1121}
1122
1123fn validate_stt_provider(name: &str) -> Result<()> {
1124 match name.to_ascii_lowercase().as_str() {
1125 "local" | "openrouter" | "openai" | "xai" => Ok(()),
1126 "elevenlabs" => Err(UserError::InvalidConfig {
1127 reason: "provider 'elevenlabs' is not valid for STT (TTS only)\n \
1128 Hint: use local, openrouter, openai, or xai for speech-to-text"
1129 .into(),
1130 }
1131 .into()),
1132 other => Err(UserError::InvalidProvider {
1133 provider: other.into(),
1134 }
1135 .into()),
1136 }
1137}
1138
1139fn validate_tts_provider(name: &str) -> Result<()> {
1140 match name.to_ascii_lowercase().as_str() {
1141 "local" | "openrouter" | "openai" | "elevenlabs" | "xai" => Ok(()),
1142 other => Err(UserError::InvalidConfig {
1143 reason: format!(
1144 "unknown TTS provider '{other}'\n \
1145 Hint: use local, openrouter, openai, elevenlabs, or xai"
1146 ),
1147 }
1148 .into()),
1149 }
1150}
1151
1152#[derive(Clone)]
1154pub struct ValidatedConfig {
1155 inner: Config,
1156}
1157
1158impl std::fmt::Debug for ValidatedConfig {
1159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1160 f.debug_struct("ValidatedConfig")
1161 .field("inner", &self.inner)
1162 .finish()
1163 }
1164}
1165
1166impl ValidatedConfig {
1167 pub fn try_from_config(cfg: Config) -> Result<Self> {
1169 cfg.validate()?;
1170 Ok(Self { inner: cfg })
1171 }
1172
1173 pub fn load() -> Result<Self> {
1175 Self::try_from_config(Config::load()?)
1176 }
1177
1178 pub fn load_from(path: &Path) -> Result<Self> {
1180 Self::try_from_config(Config::load_from(path)?)
1181 }
1182
1183 pub fn load_from_required(path: &Path) -> Result<Self> {
1185 Self::try_from_config(Config::load_from_required(path)?)
1186 }
1187
1188 pub fn as_config(&self) -> &Config {
1189 &self.inner
1190 }
1191
1192 pub fn into_config(self) -> Config {
1194 self.inner
1195 }
1196
1197 pub fn provider_secret(&self, id: &ProviderId) -> Option<SecretString> {
1199 self.inner.provider_secret(id)
1200 }
1201
1202 pub fn with_local_only(mut self, local_only: bool) -> Result<Self> {
1204 self.inner.local_only = local_only;
1205 Self::try_from_config(self.inner)
1206 }
1207
1208 #[allow(clippy::too_many_arguments)]
1210 pub fn apply_cli(
1211 mut self,
1212 provider: Option<&str>,
1213 model: Option<&str>,
1214 language: Option<&str>,
1215 output: Option<&str>,
1216 output_file: Option<&Path>,
1217 timestamps: bool,
1218 verbose: bool,
1219 cleanup: Option<&str>,
1220 cleanup_provider: Option<&str>,
1221 cleanup_model: Option<&str>,
1222 ) -> Result<Self> {
1223 self.inner.apply_cli(
1224 provider,
1225 model,
1226 language,
1227 output,
1228 output_file,
1229 timestamps,
1230 verbose,
1231 cleanup,
1232 cleanup_provider,
1233 cleanup_model,
1234 );
1235 Self::try_from_config(self.inner)
1236 }
1237}
1238
1239impl AsRef<Config> for ValidatedConfig {
1240 fn as_ref(&self) -> &Config {
1241 &self.inner
1242 }
1243}
1244
1245impl std::ops::Deref for ValidatedConfig {
1246 type Target = Config;
1247 fn deref(&self) -> &Self::Target {
1248 &self.inner
1249 }
1250}
1251
1252pub const MAX_CONFIG_BYTES: u64 = 256 * 1024;
1254
1255fn load_config_file(path: &Path) -> Result<ConfigFile> {
1256 let meta = fs::metadata(path).map_err(|e| UserError::InvalidConfig {
1257 reason: format!("failed to stat {}: {e}", path.display()),
1258 })?;
1259 if meta.len() > MAX_CONFIG_BYTES {
1260 return Err(UserError::InvalidConfig {
1261 reason: format!(
1262 "config file {} is too large ({} > {MAX_CONFIG_BYTES} bytes)",
1263 path.display(),
1264 meta.len()
1265 ),
1266 }
1267 .into());
1268 }
1269 let contents = fs::read_to_string(path).map_err(|e| UserError::InvalidConfig {
1270 reason: format!("failed to read {}: {e}", path.display()),
1271 })?;
1272 toml::from_str(&contents).map_err(|e| {
1273 UserError::InvalidConfig {
1274 reason: format!("failed to parse {}: {e}", path.display()),
1275 }
1276 .into()
1277 })
1278}
1279
1280fn dirs_home() -> Result<PathBuf> {
1281 if let Ok(h) = std::env::var("HOME") {
1282 return Ok(PathBuf::from(h));
1283 }
1284 if let Ok(h) = std::env::var("USERPROFILE") {
1285 return Ok(PathBuf::from(h));
1286 }
1287 Err(UserError::InvalidConfig {
1288 reason: "could not determine home directory".into(),
1289 }
1290 .into())
1291}
1292
1293pub fn write_example_config(path: &Path) -> Result<()> {
1295 if path.exists() {
1296 return Ok(());
1297 }
1298 if let Some(parent) = path.parent() {
1299 fs::create_dir_all(parent)?;
1300 }
1301 let example = r#"# Aurum configuration
1302# Environment variables take precedence over values in this file for secrets.
1303# Prefer OPENROUTER_API_KEY / OPENAI_API_KEY / ELEVENLABS_API_KEY / XAI_API_KEY
1304# over api_key fields below. Never put live credentials in this file.
1305# TTS: AURUM_TTS_MODEL, AURUM_TTS_VOICE, AURUM_TTS_LANGUAGE override [tts].
1306#
1307# Provider is never inferred from key presence alone — omit STT/TTS provider to stay local.
1308# Only canonical sections are accepted: [stt], [cleanup], [tts], [providers.*].
1309
1310[stt]
1311provider = "local"
1312model = "base"
1313language = "auto"
1314# output = "txt" # txt | srt | json
1315
1316[cleanup]
1317# style = "raw" # raw | clean | bullets | professional | summary
1318# provider = "rules" # rules (on-device) | openrouter
1319# openrouter_model = "google/gemini-2.5-flash"
1320
1321[tts]
1322provider = "local"
1323# model = "kitten-nano-int8"
1324# voice = "Luna"
1325# language = "en"
1326# speaking_rate = 1.0
1327# max_chars = 5000
1328# timeout_ms = 120000
1329
1330# [providers.openrouter]
1331# stt_mode = "auto"
1332# model = "google/gemini-2.5-flash"
1333# base_url = "https://openrouter.ai/api/v1"
1334# allow_custom_endpoint = false
1335# use_system_proxy = false
1336
1337# [providers.openai]
1338# base_url = "https://api.openai.com/v1"
1339
1340# [providers.elevenlabs]
1341# [providers.xai]
1342"#;
1343 fs::write(path, example)?;
1344 Ok(())
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349 use super::*;
1350 use std::io::Write;
1351 use tempfile::tempdir;
1352
1353 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1355
1356 struct EnvGuard {
1358 saved: Vec<(String, Option<String>)>,
1359 _lock: std::sync::MutexGuard<'static, ()>,
1360 }
1361
1362 impl EnvGuard {
1363 fn clear(keys: &[&str]) -> Self {
1364 let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1365 let mut saved = Vec::new();
1366 for k in keys {
1367 saved.push((k.to_string(), std::env::var(k).ok()));
1368 std::env::remove_var(k);
1370 }
1371 Self { saved, _lock: lock }
1372 }
1373
1374 fn set(&self, key: &str, val: &str) {
1375 std::env::set_var(key, val);
1376 }
1377 }
1378
1379 impl Drop for EnvGuard {
1380 fn drop(&mut self) {
1381 for (k, v) in self.saved.drain(..) {
1382 match v {
1383 Some(val) => std::env::set_var(&k, val),
1384 None => std::env::remove_var(&k),
1385 }
1386 }
1387 }
1388 }
1389
1390 #[test]
1391 fn parses_config_file() {
1392 let dir = tempdir().unwrap();
1393 let path = dir.path().join("config.toml");
1394 let mut f = fs::File::create(&path).unwrap();
1395 writeln!(
1396 f,
1397 r#"
1398[stt]
1399provider = "openrouter"
1400model = "small"
1401language = "en"
1402output = "json"
1403
1404[providers.openrouter]
1405api_key = "test-key"
1406model = "google/gemini-2.5-flash"
1407"#
1408 )
1409 .unwrap();
1410
1411 let _g = EnvGuard::clear(&["OPENROUTER_API_KEY"]);
1412 let cfg = Config::load_from(&path).unwrap();
1413 assert_eq!(cfg.provider, "openrouter");
1414 assert_eq!(cfg.model.as_deref(), Some("small"));
1415 assert_eq!(cfg.language, "en");
1416 assert_eq!(cfg.output, "json");
1417 assert_eq!(
1418 cfg.openrouter_api_key.as_ref().map(|s| s.expose()),
1419 Some("test-key")
1420 );
1421 assert!(!format!("{:?}", cfg).contains("test-key"));
1422 assert_eq!(cfg.openrouter_default_model, "google/gemini-2.5-flash");
1423 assert_eq!(cfg.cleanup_style, "raw");
1424 assert_eq!(cfg.cleanup_provider, "rules");
1425 assert!(cfg.validate().is_ok());
1426 let diag = cfg.effective_diagnostic();
1427 assert_eq!(diag.openrouter_api_key.as_deref(), Some("***"));
1428 assert_eq!(diag.providers.openrouter.api_key.as_deref(), Some("***"));
1429 }
1430
1431 #[test]
1432 fn load_from_required_missing_errors() {
1433 let dir = tempdir().unwrap();
1434 let path = dir.path().join("missing.toml");
1435 let err = Config::load_from_required(&path).unwrap_err();
1436 assert!(err.to_string().contains("not found"));
1437 }
1438
1439 #[test]
1440 fn parses_cleanup_section() {
1441 let dir = tempdir().unwrap();
1442 let path = dir.path().join("config.toml");
1443 fs::write(
1444 &path,
1445 r#"
1446[stt]
1447provider = "local"
1448model = "base"
1449
1450[cleanup]
1451style = "clean"
1452provider = "rules"
1453openrouter_model = "google/gemini-2.5-flash"
1454"#,
1455 )
1456 .unwrap();
1457 let cfg = Config::load_from(&path).unwrap();
1458 assert_eq!(cfg.cleanup_style, "clean");
1459 assert_eq!(cfg.cleanup_provider, "rules");
1460 assert_eq!(
1461 cfg.cleanup_openrouter_model.as_deref(),
1462 Some("google/gemini-2.5-flash")
1463 );
1464 }
1465
1466 #[test]
1467 fn cli_overrides_file() {
1468 let dir = tempdir().unwrap();
1469 let path = dir.path().join("config.toml");
1470 fs::write(
1471 &path,
1472 r#"
1473[stt]
1474provider = "local"
1475model = "base"
1476language = "auto"
1477output = "txt"
1478
1479[cleanup]
1480style = "clean"
1481provider = "rules"
1482"#,
1483 )
1484 .unwrap();
1485 let mut cfg = Config::load_from(&path).unwrap();
1486 cfg.apply_cli(
1487 Some("openrouter"),
1488 Some("google/gemini-2.5-flash"),
1489 Some("fr"),
1490 Some("srt"),
1491 Some(Path::new("out.srt")),
1492 true,
1493 true,
1494 Some("summary"),
1495 Some("openrouter"),
1496 Some("openai/gpt-audio-mini"),
1497 );
1498 assert_eq!(cfg.provider, "openrouter");
1499 assert_eq!(cfg.model.as_deref(), Some("google/gemini-2.5-flash"));
1500 assert_eq!(cfg.language, "fr");
1501 assert_eq!(cfg.output, "srt");
1502 assert_eq!(cfg.output_file.as_deref(), Some(Path::new("out.srt")));
1503 assert!(cfg.timestamps);
1504 assert!(cfg.verbose);
1505 assert_eq!(cfg.cleanup_style, "summary");
1506 assert_eq!(cfg.cleanup_provider, "openrouter");
1507 assert_eq!(
1508 cfg.cleanup_openrouter_model.as_deref(),
1509 Some("openai/gpt-audio-mini")
1510 );
1511 }
1512
1513 #[test]
1514 #[cfg(feature = "tts")]
1515 fn custom_tts_model_cannot_shadow_builtin_on_load() {
1516 let dir = tempdir().unwrap();
1517 let path = dir.path().join("config.toml");
1518 let default_id = crate::tts::DEFAULT_TTS_MODEL;
1519 fs::write(
1520 &path,
1521 format!(
1522 r#"
1523[tts]
1524model = "{default_id}"
1525
1526[[tts.custom_models]]
1527id = "{default_id}"
1528adapter = "fake-sine-v1"
1529pack_dir = "/tmp/does-not-matter"
1530trust = "verified"
1531"#
1532 ),
1533 )
1534 .unwrap();
1535 let err = Config::load_from(&path).unwrap_err();
1536 assert!(
1537 err.to_string().contains("collides") || err.to_string().contains("reserved"),
1538 "got: {err}"
1539 );
1540 }
1541
1542 #[test]
1543 fn defaults_when_missing_file() {
1544 let dir = tempdir().unwrap();
1545 let path = dir.path().join("nope.toml");
1546 let cfg = Config::load_from(&path).unwrap();
1547 assert_eq!(cfg.provider, "local");
1548 assert_eq!(cfg.language, "auto");
1549 assert_eq!(cfg.output, "txt");
1550 assert_eq!(cfg.cleanup_style, "raw");
1551 assert_eq!(cfg.cleanup_provider, "rules");
1552 assert_eq!(cfg.tts_provider, "local");
1553 assert!((cfg.tts_speaking_rate - 1.0).abs() < f32::EPSILON);
1554 assert!(!cfg.local_only);
1555 }
1556
1557 #[test]
1558 fn key_presence_does_not_select_provider() {
1559 let _g = EnvGuard::clear(&[
1560 "OPENROUTER_API_KEY",
1561 "OPENAI_API_KEY",
1562 "ELEVENLABS_API_KEY",
1563 "XAI_API_KEY",
1564 ]);
1565 let dir = tempdir().unwrap();
1566 let path = dir.path().join("config.toml");
1567 fs::write(
1568 &path,
1569 r#"
1570[providers.openrouter]
1571api_key = "sk-or-present-but-ignored-for-selection"
1572[providers.openai]
1573api_key = "sk-openai-present"
1574"#,
1575 )
1576 .unwrap();
1577 let cfg = Config::load_from(&path).unwrap();
1578 assert_eq!(
1579 cfg.provider, "local",
1580 "STT must stay local without explicit provider"
1581 );
1582 assert_eq!(cfg.tts_provider, "local");
1583 assert!(cfg.openrouter_api_key.is_some());
1584 assert!(cfg.providers.openai.api_key.is_some());
1585 }
1586
1587 #[test]
1588 fn unknown_top_level_section_fails() {
1589 let dir = tempdir().unwrap();
1590 let path = dir.path().join("config.toml");
1591 fs::write(
1592 &path,
1593 r#"
1594[default]
1595provider = "local"
1596"#,
1597 )
1598 .unwrap();
1599 let err = Config::load_from(&path).unwrap_err();
1600 let msg = err.to_string();
1601 assert!(
1602 msg.contains("unknown") || msg.contains("default") || msg.contains("Invalid"),
1603 "got: {msg}"
1604 );
1605 }
1606
1607 #[test]
1608 fn new_stt_section_loads() {
1609 let dir = tempdir().unwrap();
1610 let path = dir.path().join("config.toml");
1611 fs::write(
1612 &path,
1613 r#"
1614[stt]
1615provider = "openrouter"
1616model = "google/gemini-2.5-flash"
1617language = "en"
1618"#,
1619 )
1620 .unwrap();
1621 let cfg = Config::load_from(&path).unwrap();
1622 assert_eq!(cfg.provider, "openrouter");
1623 assert_eq!(cfg.model.as_deref(), Some("google/gemini-2.5-flash"));
1624 assert_eq!(cfg.language, "en");
1625 }
1626
1627 #[test]
1628 fn providers_openrouter_loads() {
1629 let _g = EnvGuard::clear(&["OPENROUTER_API_KEY", "OPENROUTER_BASE_URL"]);
1630 let dir = tempdir().unwrap();
1631 let path = dir.path().join("config.toml");
1632 fs::write(
1633 &path,
1634 r#"
1635[providers.openrouter]
1636api_key = "from-providers"
1637model = "openai/gpt-audio-mini"
1638stt_mode = "transcriptions"
1639base_url = "https://openrouter.ai/api/v1"
1640"#,
1641 )
1642 .unwrap();
1643 let cfg = Config::load_from(&path).unwrap();
1644 assert_eq!(
1645 cfg.openrouter_api_key.as_ref().map(|s| s.expose()),
1646 Some("from-providers")
1647 );
1648 assert_eq!(cfg.openrouter_default_model, "openai/gpt-audio-mini");
1649 assert_eq!(cfg.openrouter_stt_mode, "transcriptions");
1650 }
1651
1652 #[test]
1653 fn env_provider_keys_are_scoped() {
1654 let g = EnvGuard::clear(&[
1655 "OPENROUTER_API_KEY",
1656 "OPENAI_API_KEY",
1657 "ELEVENLABS_API_KEY",
1658 "XAI_API_KEY",
1659 ]);
1660 g.set("OPENAI_API_KEY", "sk-openai-env");
1661 g.set("ELEVENLABS_API_KEY", "sk-el-env");
1662 g.set("XAI_API_KEY", "sk-xai-env");
1663 g.set("OPENROUTER_API_KEY", "sk-or-env");
1664
1665 let dir = tempdir().unwrap();
1666 let path = dir.path().join("config.toml");
1667 fs::write(&path, "").unwrap();
1668 let cfg = Config::load_from(&path).unwrap();
1669
1670 assert_eq!(cfg.provider, "local");
1671 assert_eq!(
1672 cfg.openrouter_api_key.as_ref().map(|s| s.expose()),
1673 Some("sk-or-env")
1674 );
1675 assert_eq!(
1676 cfg.providers.openai.api_key.as_ref().map(|s| s.expose()),
1677 Some("sk-openai-env")
1678 );
1679 assert_eq!(
1680 cfg.providers
1681 .elevenlabs
1682 .api_key
1683 .as_ref()
1684 .map(|s| s.expose()),
1685 Some("sk-el-env")
1686 );
1687 assert_eq!(
1688 cfg.providers.xai.api_key.as_ref().map(|s| s.expose()),
1689 Some("sk-xai-env")
1690 );
1691
1692 let v = ValidatedConfig::try_from_config(cfg).unwrap();
1693 assert_eq!(
1694 v.provider_secret(&ProviderId::openrouter())
1695 .unwrap()
1696 .expose(),
1697 "sk-or-env"
1698 );
1699 assert_eq!(
1700 v.provider_secret(&ProviderId::must("openai"))
1701 .unwrap()
1702 .expose(),
1703 "sk-openai-env"
1704 );
1705 assert_eq!(
1706 v.provider_secret(&ProviderId::must("elevenlabs"))
1707 .unwrap()
1708 .expose(),
1709 "sk-el-env"
1710 );
1711 assert_eq!(
1712 v.provider_secret(&ProviderId::must("xai"))
1713 .unwrap()
1714 .expose(),
1715 "sk-xai-env"
1716 );
1717 assert!(v.provider_secret(&ProviderId::local()).is_none());
1718 }
1719
1720 #[test]
1721 fn env_overrides_file_secret() {
1722 let g = EnvGuard::clear(&["OPENAI_API_KEY"]);
1723 g.set("OPENAI_API_KEY", "from-env");
1724 let dir = tempdir().unwrap();
1725 let path = dir.path().join("config.toml");
1726 fs::write(
1727 &path,
1728 r#"
1729[providers.openai]
1730api_key = "from-file"
1731"#,
1732 )
1733 .unwrap();
1734 let cfg = Config::load_from(&path).unwrap();
1735 assert_eq!(
1736 cfg.providers.openai.api_key.as_ref().map(|s| s.expose()),
1737 Some("from-env")
1738 );
1739 }
1740
1741 #[test]
1742 fn local_only_rejects_remote_stt() {
1743 let dir = tempdir().unwrap();
1744 let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1745 cfg.provider = "openrouter".into();
1746 cfg.local_only = true;
1747 let err = ValidatedConfig::try_from_config(cfg).unwrap_err();
1748 assert!(err.to_string().contains("local_only"), "got: {err}");
1749 }
1750
1751 #[test]
1752 fn local_only_rejects_remote_tts() {
1753 let dir = tempdir().unwrap();
1754 let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1755 cfg.tts_provider = "elevenlabs".into();
1756 cfg.local_only = true;
1757 let err = ValidatedConfig::try_from_config(cfg).unwrap_err();
1758 assert!(err.to_string().contains("local_only"), "got: {err}");
1759 }
1760
1761 #[test]
1762 fn local_only_allows_local_providers() {
1763 let dir = tempdir().unwrap();
1764 let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1765 let v = ValidatedConfig::try_from_config(cfg)
1766 .unwrap()
1767 .with_local_only(true)
1768 .unwrap();
1769 assert!(v.local_only);
1770 assert_eq!(v.provider, "local");
1771 assert_eq!(v.tts_provider, "local");
1772 }
1773
1774 #[test]
1775 fn unknown_provider_section_fails_closed() {
1776 let dir = tempdir().unwrap();
1777 let path = dir.path().join("config.toml");
1778 fs::write(
1779 &path,
1780 r#"
1781[providers.notarealvendor]
1782api_key = "x"
1783"#,
1784 )
1785 .unwrap();
1786 let err = Config::load_from(&path).unwrap_err();
1787 assert!(
1788 err.to_string().contains("parse") || err.to_string().contains("unknown"),
1789 "got: {err}"
1790 );
1791 }
1792
1793 #[test]
1794 fn redacted_debug_and_diagnostic() {
1795 let _g = EnvGuard::clear(&[
1796 "OPENROUTER_API_KEY",
1797 "OPENAI_API_KEY",
1798 "ELEVENLABS_API_KEY",
1799 "XAI_API_KEY",
1800 ]);
1801 let dir = tempdir().unwrap();
1802 let path = dir.path().join("config.toml");
1803 fs::write(
1804 &path,
1805 r#"
1806[providers.openrouter]
1807api_key = "sk-or-canary-secret-value-xyz"
1808[providers.openai]
1809api_key = "sk-openai-canary-secret-value"
1810[providers.elevenlabs]
1811api_key = "sk-el-canary-secret-value"
1812[providers.xai]
1813api_key = "sk-xai-canary-secret-value"
1814"#,
1815 )
1816 .unwrap();
1817 let cfg = Config::load_from(&path).unwrap();
1818 let dbg = format!("{cfg:?}");
1819 assert!(!dbg.contains("sk-or-canary"));
1820 assert!(!dbg.contains("sk-openai-canary"));
1821 assert!(!dbg.contains("sk-el-canary"));
1822 assert!(!dbg.contains("sk-xai-canary"));
1823
1824 let diag = cfg.effective_diagnostic();
1825 let json = serde_json::to_string(&diag).unwrap();
1826 assert!(!json.contains("sk-or-canary"));
1827 assert!(!json.contains("sk-openai-canary"));
1828 assert_eq!(diag.providers.openai.api_key.as_deref(), Some("***"));
1829 assert_eq!(diag.providers.elevenlabs.api_key.as_deref(), Some("***"));
1830 assert_eq!(diag.providers.xai.api_key.as_deref(), Some("***"));
1831 assert_eq!(diag.providers.openrouter.api_key.as_deref(), Some("***"));
1832 }
1833
1834 #[test]
1835 fn tts_speaking_rate_and_provider() {
1836 let dir = tempdir().unwrap();
1837 let path = dir.path().join("config.toml");
1838 fs::write(
1839 &path,
1840 r#"
1841[tts]
1842provider = "local"
1843speaking_rate = 1.25
1844"#,
1845 )
1846 .unwrap();
1847 let cfg = Config::load_from(&path).unwrap();
1848 assert!((cfg.tts_speaking_rate - 1.25).abs() < 0.001);
1849 assert!(cfg.validate().is_ok());
1850 }
1851
1852 #[test]
1853 fn invalid_speaking_rate_rejected() {
1854 let dir = tempdir().unwrap();
1855 let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1856 cfg.tts_speaking_rate = 0.0;
1857 assert!(cfg.validate().is_err());
1858 cfg.tts_speaking_rate = 10.0;
1859 assert!(cfg.validate().is_err());
1860 }
1861
1862 #[test]
1863 fn elevenlabs_invalid_for_stt() {
1864 let dir = tempdir().unwrap();
1865 let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1866 cfg.provider = "elevenlabs".into();
1867 let err = cfg.validate().unwrap_err();
1868 assert!(err.to_string().contains("elevenlabs") || err.to_string().contains("STT"));
1869 }
1870
1871 #[test]
1872 fn validated_config_accepts_defaults() {
1873 let dir = tempdir().unwrap();
1874 let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1875 let v = ValidatedConfig::try_from_config(cfg).unwrap();
1876 assert_eq!(v.provider, "local");
1877 assert_eq!(v.as_config().language, "auto");
1878 }
1879
1880 #[test]
1881 fn validated_config_rejects_bad_provider() {
1882 let dir = tempdir().unwrap();
1883 let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1884 cfg.provider = "not-a-provider".into();
1885 let err = ValidatedConfig::try_from_config(cfg).unwrap_err();
1886 assert!(
1887 err.to_string().contains("provider") || err.to_string().contains("Invalid"),
1888 "got: {err}"
1889 );
1890 }
1891
1892 #[test]
1893 fn validated_apply_cli_revalidates() {
1894 let dir = tempdir().unwrap();
1895 let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
1896 let v = ValidatedConfig::try_from_config(cfg).unwrap();
1897 let err = v
1898 .apply_cli(
1899 Some("bogus"),
1900 None,
1901 None,
1902 None,
1903 None,
1904 false,
1905 false,
1906 None,
1907 None,
1908 None,
1909 )
1910 .unwrap_err();
1911 assert!(err.to_string().contains("provider") || err.to_string().contains("Invalid"));
1912 }
1913
1914 #[test]
1915 fn example_config_contains_no_live_credential_placeholder() {
1916 let dir = tempdir().unwrap();
1917 let path = dir.path().join("example.toml");
1918 write_example_config(&path).unwrap();
1919 let text = fs::read_to_string(&path).unwrap();
1920 assert!(!text.contains("sk-or-v1-"));
1921 assert!(!text.contains("sk-proj-"));
1922 assert!(text.contains("[stt]"));
1923 assert!(text.contains("[providers.openrouter]") || text.contains("providers.openrouter"));
1924 }
1925}