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