use crate::error::{Result, UserError};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
pub const DEFAULT_PROVIDER: &str = "local";
pub const DEFAULT_LOCAL_MODEL: &str = "base";
pub const DEFAULT_OPENROUTER_MODEL: &str = "google/gemini-2.5-flash";
pub const DEFAULT_LANGUAGE: &str = "auto";
pub const DEFAULT_OUTPUT: &str = "txt";
pub const DEFAULT_CLEANUP: &str = "raw";
pub const DEFAULT_CLEANUP_PROVIDER: &str = "rules";
pub const DEFAULT_TTS_PROVIDER: &str = "local";
pub const DEFAULT_TTS_LANGUAGE: &str = "en";
pub const DEFAULT_TTS_MAX_CHARS: usize = 5_000;
pub const DEFAULT_TTS_TIMEOUT_MS: u64 = 120_000;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConfigFile {
#[serde(default)]
pub default: DefaultSection,
#[serde(default)]
pub openrouter: OpenRouterSection,
#[serde(default)]
pub cleanup: CleanupSection,
#[serde(default)]
pub tts: TtsSection,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DefaultSection {
#[serde(default = "default_provider")]
pub provider: String,
#[serde(default = "default_local_model")]
pub model: String,
#[serde(default = "default_language")]
pub language: String,
#[serde(default = "default_output")]
pub output: String,
}
impl Default for DefaultSection {
fn default() -> Self {
Self {
provider: default_provider(),
model: default_local_model(),
language: default_language(),
output: default_output(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OpenRouterSection {
pub api_key: Option<String>,
pub model: Option<String>,
pub base_url: Option<String>,
#[serde(default)]
pub allow_custom_endpoint: bool,
#[serde(default = "default_stt_mode")]
pub stt_mode: String,
#[serde(default)]
pub use_system_proxy: bool,
}
fn default_stt_mode() -> String {
"auto".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CleanupSection {
#[serde(default = "default_cleanup")]
pub style: String,
#[serde(default = "default_cleanup_provider")]
pub provider: String,
pub openrouter_model: Option<String>,
}
impl Default for CleanupSection {
fn default() -> Self {
Self {
style: default_cleanup(),
provider: default_cleanup_provider(),
openrouter_model: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TtsSection {
#[serde(default = "default_tts_provider")]
pub provider: String,
#[serde(default = "default_tts_model")]
pub model: String,
#[serde(default = "default_tts_voice")]
pub voice: String,
#[serde(default = "default_tts_language")]
pub language: String,
#[serde(default = "default_tts_max_chars")]
pub max_chars: usize,
#[serde(default = "default_tts_timeout_ms")]
pub timeout_ms: u64,
#[serde(default)]
pub pack_dir: Option<String>,
#[serde(default)]
pub allow_unverified: bool,
#[serde(default)]
pub custom_models: Vec<CustomTtsModelConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CustomTtsModelConfig {
pub id: String,
pub adapter: String,
#[serde(default)]
pub pack_dir: Option<String>,
pub trust: String,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub notes: Option<String>,
}
impl Default for TtsSection {
fn default() -> Self {
Self {
provider: default_tts_provider(),
model: default_tts_model(),
voice: default_tts_voice(),
language: default_tts_language(),
max_chars: default_tts_max_chars(),
timeout_ms: default_tts_timeout_ms(),
pack_dir: None,
allow_unverified: false,
custom_models: Vec::new(),
}
}
}
fn default_provider() -> String {
DEFAULT_PROVIDER.to_string()
}
fn default_local_model() -> String {
DEFAULT_LOCAL_MODEL.to_string()
}
fn default_language() -> String {
DEFAULT_LANGUAGE.to_string()
}
fn default_output() -> String {
DEFAULT_OUTPUT.to_string()
}
fn default_cleanup() -> String {
DEFAULT_CLEANUP.to_string()
}
fn default_cleanup_provider() -> String {
DEFAULT_CLEANUP_PROVIDER.to_string()
}
fn default_tts_provider() -> String {
DEFAULT_TTS_PROVIDER.to_string()
}
fn default_tts_model() -> String {
#[cfg(feature = "tts")]
{
crate::tts::DEFAULT_TTS_MODEL.to_string()
}
#[cfg(not(feature = "tts"))]
{
"kitten-nano-int8".to_string()
}
}
fn default_tts_voice() -> String {
#[cfg(feature = "tts")]
{
crate::tts::DEFAULT_TTS_VOICE.to_string()
}
#[cfg(not(feature = "tts"))]
{
"Luna".to_string()
}
}
fn default_tts_language() -> String {
DEFAULT_TTS_LANGUAGE.to_string()
}
fn default_tts_max_chars() -> usize {
DEFAULT_TTS_MAX_CHARS
}
fn default_tts_timeout_ms() -> u64 {
DEFAULT_TTS_TIMEOUT_MS
}
#[derive(Clone)]
pub struct Config {
pub provider: String,
pub model: Option<String>,
pub language: String,
pub output: String,
pub output_file: Option<PathBuf>,
pub timestamps: bool,
pub verbose: bool,
pub openrouter_api_key: Option<crate::secret::SecretString>,
pub openrouter_base_url: String,
pub openrouter_default_model: String,
pub openrouter_allow_custom_endpoint: bool,
pub openrouter_stt_mode: String,
pub openrouter_use_system_proxy: bool,
pub cleanup_style: String,
pub cleanup_provider: String,
pub cleanup_openrouter_model: Option<String>,
pub tts_provider: String,
pub tts_model: String,
pub tts_voice: String,
pub tts_language: String,
pub tts_max_chars: usize,
pub tts_timeout_ms: u64,
pub tts_pack_dir: Option<PathBuf>,
pub tts_allow_unverified: bool,
pub tts_custom_models: Vec<CustomTtsModelConfig>,
pub config_path: Option<PathBuf>,
pub cache_dir: PathBuf,
}
impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
.field("provider", &self.provider)
.field("model", &self.model)
.field("language", &self.language)
.field("output", &self.output)
.field("output_file", &self.output_file)
.field("timestamps", &self.timestamps)
.field("verbose", &self.verbose)
.field("openrouter_api_key", &self.openrouter_api_key)
.field("openrouter_base_url", &self.openrouter_base_url)
.field("openrouter_default_model", &self.openrouter_default_model)
.field(
"openrouter_allow_custom_endpoint",
&self.openrouter_allow_custom_endpoint,
)
.field("openrouter_stt_mode", &self.openrouter_stt_mode)
.field(
"openrouter_use_system_proxy",
&self.openrouter_use_system_proxy,
)
.field("cleanup_style", &self.cleanup_style)
.field("cleanup_provider", &self.cleanup_provider)
.field("cleanup_openrouter_model", &self.cleanup_openrouter_model)
.field("tts_provider", &self.tts_provider)
.field("tts_model", &self.tts_model)
.field("tts_voice", &self.tts_voice)
.field("tts_language", &self.tts_language)
.field("tts_max_chars", &self.tts_max_chars)
.field("tts_timeout_ms", &self.tts_timeout_ms)
.field("tts_pack_dir", &self.tts_pack_dir)
.field("tts_allow_unverified", &self.tts_allow_unverified)
.field("tts_custom_models", &self.tts_custom_models)
.field("config_path", &self.config_path)
.field("cache_dir", &self.cache_dir)
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfigValueSource {
Default,
File,
Environment,
Cli,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigSourceMap {
pub provider: ConfigValueSource,
pub openrouter_api_key: ConfigValueSource,
pub openrouter_base_url: ConfigValueSource,
pub tts_model: ConfigValueSource,
}
impl ConfigSourceMap {
fn default_attribution(cfg: &Config) -> Self {
let key_src = if std::env::var("OPENROUTER_API_KEY")
.ok()
.filter(|s| !s.is_empty())
.is_some()
{
ConfigValueSource::Environment
} else if cfg.openrouter_api_key.is_some() {
ConfigValueSource::File
} else {
ConfigValueSource::Default
};
let base_src = if std::env::var("OPENROUTER_BASE_URL")
.ok()
.filter(|s| !s.is_empty())
.is_some()
{
ConfigValueSource::Environment
} else {
ConfigValueSource::File
};
let tts_src = if std::env::var("AURUM_TTS_MODEL")
.ok()
.filter(|s| !s.is_empty())
.is_some()
{
ConfigValueSource::Environment
} else {
ConfigValueSource::File
};
Self {
provider: if cfg.config_path.is_some() {
ConfigValueSource::File
} else {
ConfigValueSource::Default
},
openrouter_api_key: key_src,
openrouter_base_url: base_src,
tts_model: tts_src,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EffectiveConfigDiagnostic {
pub provider: String,
pub model: Option<String>,
pub language: String,
pub output: String,
pub timestamps: bool,
pub openrouter_api_key: Option<String>,
pub openrouter_base_url: String,
pub openrouter_default_model: String,
pub openrouter_stt_mode: String,
pub openrouter_allow_custom_endpoint: bool,
pub cleanup_style: String,
pub cleanup_provider: String,
pub tts_model: String,
pub tts_voice: String,
pub tts_language: String,
pub tts_max_chars: usize,
pub tts_timeout_ms: u64,
pub tts_pack_dir: Option<String>,
pub tts_allow_unverified: bool,
pub tts_custom_model_ids: Vec<String>,
pub config_path: Option<String>,
pub cache_dir: String,
pub sources: ConfigSourceMap,
}
impl Config {
pub fn default_config_path() -> Option<PathBuf> {
ProjectDirs::from("", "", "aurum").map(|d| d.config_dir().join("config.toml"))
}
pub fn default_cache_dir() -> Result<PathBuf> {
if let Some(dirs) = ProjectDirs::from("", "", "aurum") {
return Ok(dirs.cache_dir().to_path_buf());
}
let home = dirs_home()?;
Ok(home.join(".cache").join("aurum"))
}
pub fn load() -> Result<Self> {
let path = Self::default_config_path();
let file = match &path {
Some(p) if p.exists() => Some(load_config_file(p)?),
_ => None,
};
let cfg = Self::from_parts(file, path);
cfg.validate_tts_custom_models()?;
Ok(cfg)
}
pub fn load_from(path: &Path) -> Result<Self> {
let file = if path.exists() {
Some(load_config_file(path)?)
} else {
None
};
let cfg = Self::from_parts(file, Some(path.to_path_buf()));
cfg.validate_tts_custom_models()?;
Ok(cfg)
}
pub fn load_from_required(path: &Path) -> Result<Self> {
if !path.exists() {
return Err(UserError::InvalidConfig {
reason: format!(
"config file not found: {}\n Hint: create it or omit --config to use defaults",
path.display()
),
}
.into());
}
let file = load_config_file(path)?;
let cfg = Self::from_parts(Some(file), Some(path.to_path_buf()));
cfg.validate()?;
Ok(cfg)
}
pub fn openrouter_api_key_exposed(&self) -> Option<String> {
self.openrouter_api_key
.as_ref()
.map(|s| s.expose().to_string())
}
pub fn validate(&self) -> Result<()> {
match self.provider.as_str() {
"local" | "openrouter" => {}
other => {
return Err(UserError::InvalidProvider {
provider: other.into(),
}
.into());
}
}
let _ = crate::output::OutputFormat::parse(&self.output)?;
let _ = crate::cleanup::CleanupStyle::parse(&self.cleanup_style)?;
let _ = crate::cleanup::CleanupProviderKind::parse(&self.cleanup_provider)?;
let _ = crate::providers::OpenRouterSttMode::parse(&self.openrouter_stt_mode)?;
if self.tts_max_chars == 0 {
return Err(UserError::InvalidConfig {
reason: "tts.max_chars must be >= 1".into(),
}
.into());
}
if self.tts_timeout_ms == 0 {
return Err(UserError::InvalidConfig {
reason: "tts.timeout_ms must be >= 1".into(),
}
.into());
}
if self.tts_max_chars > 500_000 {
return Err(UserError::InvalidConfig {
reason: format!(
"tts.max_chars {} exceeds safe ceiling 500000",
self.tts_max_chars
),
}
.into());
}
if !self.openrouter_base_url.starts_with("https://")
&& !self.openrouter_base_url.starts_with("http://localhost")
&& !self.openrouter_base_url.contains("127.0.0.1")
{
if self.openrouter_base_url.starts_with("http://") {
return Err(UserError::InvalidConfig {
reason: format!(
"openrouter base_url must use https (got {})",
self.openrouter_base_url
),
}
.into());
}
}
self.validate_tts_custom_models()?;
Ok(())
}
pub fn validate_tts_custom_models(&self) -> Result<()> {
#[cfg(feature = "tts")]
{
use crate::tts::{validate_custom_models, CustomTtsModelEntry, MAX_CUSTOM_MODELS};
if self.tts_custom_models.len() > MAX_CUSTOM_MODELS {
return Err(UserError::InvalidConfig {
reason: format!(
"too many [[tts.custom_models]] entries ({} > {MAX_CUSTOM_MODELS})",
self.tts_custom_models.len()
),
}
.into());
}
let mut ids = std::collections::HashSet::new();
let mut present = Vec::new();
for e in &self.tts_custom_models {
let id = e.id.trim();
if id.is_empty() {
return Err(UserError::InvalidConfig {
reason: "custom TTS model id must be non-empty".into(),
}
.into());
}
if !ids.insert(id.to_string()) {
return Err(UserError::InvalidConfig {
reason: format!("duplicate custom TTS model id '{id}'"),
}
.into());
}
if id == crate::tts::DEFAULT_TTS_MODEL
|| crate::tts::lookup_model(id)
.map(|m| m.shipped)
.unwrap_or(false)
{
return Err(UserError::InvalidConfig {
reason: format!(
"custom model id '{id}' collides with built-in catalogue entry"
),
}
.into());
}
let _ = crate::tts::lookup_adapter(&e.adapter)?;
let trust = crate::tts::TrustMode::parse(&e.trust)?;
if matches!(trust, crate::tts::TrustMode::Builtin) {
return Err(UserError::InvalidConfig {
reason: "custom models cannot use trust=builtin".into(),
}
.into());
}
if let Some(dir) = e.pack_dir.as_ref().map(PathBuf::from) {
if dir.exists() {
present.push(CustomTtsModelEntry {
id: e.id.clone(),
adapter: e.adapter.clone(),
pack_dir: e.pack_dir.clone(),
trust: e.trust.clone(),
license: e.license.clone(),
notes: e.notes.clone(),
});
}
} else {
return Err(UserError::InvalidConfig {
reason: format!(
"custom model '{id}' requires pack_dir (remote custom packs \
are not enabled in v0.0.3)"
),
}
.into());
}
}
if !present.is_empty() {
let _ = validate_custom_models(&present)?;
}
}
Ok(())
}
pub fn effective_diagnostic(&self) -> EffectiveConfigDiagnostic {
EffectiveConfigDiagnostic {
provider: self.provider.clone(),
model: self.model.clone(),
language: self.language.clone(),
output: self.output.clone(),
timestamps: self.timestamps,
openrouter_api_key: self.openrouter_api_key.as_ref().map(|_| "***".into()),
openrouter_base_url: self.openrouter_base_url.clone(),
openrouter_default_model: self.openrouter_default_model.clone(),
openrouter_stt_mode: self.openrouter_stt_mode.clone(),
openrouter_allow_custom_endpoint: self.openrouter_allow_custom_endpoint,
cleanup_style: self.cleanup_style.clone(),
cleanup_provider: self.cleanup_provider.clone(),
tts_model: self.tts_model.clone(),
tts_voice: self.tts_voice.clone(),
tts_language: self.tts_language.clone(),
tts_max_chars: self.tts_max_chars,
tts_timeout_ms: self.tts_timeout_ms,
tts_pack_dir: self.tts_pack_dir.as_ref().map(|p| p.display().to_string()),
tts_allow_unverified: self.tts_allow_unverified,
tts_custom_model_ids: self
.tts_custom_models
.iter()
.map(|m| m.id.clone())
.collect(),
config_path: self.config_path.as_ref().map(|p| p.display().to_string()),
cache_dir: self.cache_dir.display().to_string(),
sources: ConfigSourceMap::default_attribution(self),
}
}
fn from_parts(file: Option<ConfigFile>, config_path: Option<PathBuf>) -> Self {
let file = file.unwrap_or_default();
let openrouter_api_key = std::env::var("OPENROUTER_API_KEY")
.ok()
.filter(|s| !s.is_empty())
.or(file.openrouter.api_key.clone())
.map(crate::secret::SecretString::new);
let openrouter_base_url = std::env::var("OPENROUTER_BASE_URL")
.ok()
.filter(|s| !s.is_empty())
.or(file.openrouter.base_url.clone())
.unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string());
let openrouter_default_model = file
.openrouter
.model
.clone()
.unwrap_or_else(|| DEFAULT_OPENROUTER_MODEL.to_string());
let cache_dir =
Self::default_cache_dir().unwrap_or_else(|_| std::env::temp_dir().join("aurum-cache"));
let tts_model = std::env::var("AURUM_TTS_MODEL")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or(file.tts.model);
let tts_voice = std::env::var("AURUM_TTS_VOICE")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or(file.tts.voice);
let tts_language = std::env::var("AURUM_TTS_LANGUAGE")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or(file.tts.language);
Self {
provider: file.default.provider,
model: Some(file.default.model),
language: file.default.language,
output: file.default.output,
output_file: None,
timestamps: false,
verbose: false,
openrouter_api_key,
openrouter_base_url,
openrouter_default_model,
openrouter_allow_custom_endpoint: file.openrouter.allow_custom_endpoint,
openrouter_stt_mode: if file.openrouter.stt_mode.trim().is_empty() {
default_stt_mode()
} else {
file.openrouter.stt_mode
},
openrouter_use_system_proxy: file.openrouter.use_system_proxy,
cleanup_style: file.cleanup.style,
cleanup_provider: file.cleanup.provider,
cleanup_openrouter_model: file.cleanup.openrouter_model,
tts_provider: file.tts.provider,
tts_model,
tts_voice,
tts_language,
tts_max_chars: file.tts.max_chars.max(1),
tts_timeout_ms: if file.tts.timeout_ms == 0 {
DEFAULT_TTS_TIMEOUT_MS
} else {
file.tts.timeout_ms
},
tts_pack_dir: file.tts.pack_dir.map(PathBuf::from),
tts_allow_unverified: file.tts.allow_unverified,
tts_custom_models: file.tts.custom_models,
config_path,
cache_dir,
}
}
#[allow(clippy::too_many_arguments)]
pub fn apply_cli(
&mut self,
provider: Option<&str>,
model: Option<&str>,
language: Option<&str>,
output: Option<&str>,
output_file: Option<&Path>,
timestamps: bool,
verbose: bool,
cleanup: Option<&str>,
cleanup_provider: Option<&str>,
cleanup_model: Option<&str>,
) {
if let Some(p) = provider {
self.provider = p.to_string();
}
if let Some(m) = model {
self.model = Some(m.to_string());
}
if let Some(l) = language {
self.language = l.to_string();
}
if let Some(o) = output {
self.output = o.to_string();
}
if let Some(path) = output_file {
self.output_file = Some(path.to_path_buf());
}
if timestamps {
self.timestamps = true;
}
if verbose {
self.verbose = true;
}
if let Some(c) = cleanup {
self.cleanup_style = c.to_string();
}
if let Some(p) = cleanup_provider {
self.cleanup_provider = p.to_string();
}
if let Some(m) = cleanup_model {
self.cleanup_openrouter_model = Some(m.to_string());
}
}
pub fn resolve_model(&self, model_explicitly_set: bool) -> Result<String> {
if model_explicitly_set {
let m = self
.model
.clone()
.unwrap_or_else(|| self.default_model_for_provider());
if self.provider == "openrouter"
&& !m.contains('/')
&& (crate::model::lookup_model(&m).is_ok() || m == DEFAULT_LOCAL_MODEL)
{
return Err(UserError::Other {
message: format!(
"model '{m}' looks like a local whisper model, not an OpenRouter id.\n \
Hint: use e.g. google/gemini-2.5-flash-lite or openai/gpt-audio-mini, \
or omit --model to use the OpenRouter default."
),
}
.into());
}
return Ok(m);
}
match self.provider.as_str() {
"openrouter" => {
let m = self
.model
.clone()
.unwrap_or_else(|| self.openrouter_default_model.clone());
if m.contains('/') {
Ok(m)
} else if m == DEFAULT_LOCAL_MODEL || crate::model::lookup_model(&m).is_ok() {
Ok(self.openrouter_default_model.clone())
} else {
Ok(m)
}
}
_ => Ok(self
.model
.clone()
.unwrap_or_else(|| DEFAULT_LOCAL_MODEL.to_string())),
}
}
fn default_model_for_provider(&self) -> String {
match self.provider.as_str() {
"openrouter" => self.openrouter_default_model.clone(),
_ => DEFAULT_LOCAL_MODEL.to_string(),
}
}
}
pub type RawConfig = ConfigFile;
#[derive(Clone)]
pub struct ValidatedConfig {
inner: Config,
}
impl std::fmt::Debug for ValidatedConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ValidatedConfig")
.field("inner", &self.inner)
.finish()
}
}
impl ValidatedConfig {
pub fn try_from_config(cfg: Config) -> Result<Self> {
cfg.validate()?;
Ok(Self { inner: cfg })
}
pub fn load() -> Result<Self> {
Self::try_from_config(Config::load()?)
}
pub fn load_from(path: &Path) -> Result<Self> {
Self::try_from_config(Config::load_from(path)?)
}
pub fn load_from_required(path: &Path) -> Result<Self> {
Self::try_from_config(Config::load_from_required(path)?)
}
pub fn as_config(&self) -> &Config {
&self.inner
}
pub fn into_config(self) -> Config {
self.inner
}
#[allow(clippy::too_many_arguments)]
pub fn apply_cli(
mut self,
provider: Option<&str>,
model: Option<&str>,
language: Option<&str>,
output: Option<&str>,
output_file: Option<&Path>,
timestamps: bool,
verbose: bool,
cleanup: Option<&str>,
cleanup_provider: Option<&str>,
cleanup_model: Option<&str>,
) -> Result<Self> {
self.inner.apply_cli(
provider,
model,
language,
output,
output_file,
timestamps,
verbose,
cleanup,
cleanup_provider,
cleanup_model,
);
Self::try_from_config(self.inner)
}
}
impl AsRef<Config> for ValidatedConfig {
fn as_ref(&self) -> &Config {
&self.inner
}
}
impl std::ops::Deref for ValidatedConfig {
type Target = Config;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
pub const MAX_CONFIG_BYTES: u64 = 256 * 1024;
fn load_config_file(path: &Path) -> Result<ConfigFile> {
let meta = fs::metadata(path).map_err(|e| UserError::InvalidConfig {
reason: format!("failed to stat {}: {e}", path.display()),
})?;
if meta.len() > MAX_CONFIG_BYTES {
return Err(UserError::InvalidConfig {
reason: format!(
"config file {} is too large ({} > {MAX_CONFIG_BYTES} bytes)",
path.display(),
meta.len()
),
}
.into());
}
let contents = fs::read_to_string(path).map_err(|e| UserError::InvalidConfig {
reason: format!("failed to read {}: {e}", path.display()),
})?;
toml::from_str(&contents).map_err(|e| {
UserError::InvalidConfig {
reason: format!("failed to parse {}: {e}", path.display()),
}
.into()
})
}
fn dirs_home() -> Result<PathBuf> {
if let Ok(h) = std::env::var("HOME") {
return Ok(PathBuf::from(h));
}
if let Ok(h) = std::env::var("USERPROFILE") {
return Ok(PathBuf::from(h));
}
Err(UserError::InvalidConfig {
reason: "could not determine home directory".into(),
}
.into())
}
pub fn write_example_config(path: &Path) -> Result<()> {
if path.exists() {
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let example = r#"# Aurum configuration
# Environment variables take precedence over values in this file.
# OPENROUTER_API_KEY is preferred over openrouter.api_key below.
# TTS: AURUM_TTS_MODEL, AURUM_TTS_VOICE, AURUM_TTS_LANGUAGE override [tts].
[default]
provider = "local"
model = "base"
language = "auto"
output = "txt"
[cleanup]
# style = "raw" # raw | clean | bullets | professional | summary
# provider = "rules" # rules (on-device) | openrouter
# openrouter_model = "google/gemini-2.5-flash"
[tts]
# provider = "local"
# model = "kitten-nano-int8"
# voice = "Luna"
# language = "en"
# max_chars = 5000
# timeout_ms = 120000
[openrouter]
# api_key = "sk-or-..."
# model = "google/gemini-2.5-flash"
# base_url = "https://openrouter.ai/api/v1"
"#;
fs::write(path, example)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn parses_config_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = fs::File::create(&path).unwrap();
writeln!(
f,
r#"
[default]
provider = "openrouter"
model = "small"
language = "en"
output = "json"
[openrouter]
api_key = "test-key"
model = "google/gemini-2.5-flash"
"#
)
.unwrap();
let prev = std::env::var("OPENROUTER_API_KEY").ok();
std::env::remove_var("OPENROUTER_API_KEY");
let cfg = Config::load_from(&path).unwrap();
if let Some(v) = prev {
std::env::set_var("OPENROUTER_API_KEY", v);
}
assert_eq!(cfg.provider, "openrouter");
assert_eq!(cfg.model.as_deref(), Some("small"));
assert_eq!(cfg.language, "en");
assert_eq!(cfg.output, "json");
assert_eq!(
cfg.openrouter_api_key.as_ref().map(|s| s.expose()),
Some("test-key")
);
assert!(!format!("{:?}", cfg).contains("test-key"));
assert_eq!(cfg.openrouter_default_model, "google/gemini-2.5-flash");
assert_eq!(cfg.cleanup_style, "raw");
assert_eq!(cfg.cleanup_provider, "rules");
assert!(cfg.validate().is_ok());
let diag = cfg.effective_diagnostic();
assert_eq!(diag.openrouter_api_key.as_deref(), Some("***"));
}
#[test]
fn load_from_required_missing_errors() {
let dir = tempdir().unwrap();
let path = dir.path().join("missing.toml");
let err = Config::load_from_required(&path).unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn parses_cleanup_section() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(
&path,
r#"
[default]
provider = "local"
model = "base"
[cleanup]
style = "clean"
provider = "rules"
openrouter_model = "google/gemini-2.5-flash"
"#,
)
.unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(cfg.cleanup_style, "clean");
assert_eq!(cfg.cleanup_provider, "rules");
assert_eq!(
cfg.cleanup_openrouter_model.as_deref(),
Some("google/gemini-2.5-flash")
);
}
#[test]
fn cli_overrides_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(
&path,
r#"
[default]
provider = "local"
model = "base"
language = "auto"
output = "txt"
[cleanup]
style = "clean"
provider = "rules"
"#,
)
.unwrap();
let mut cfg = Config::load_from(&path).unwrap();
cfg.apply_cli(
Some("openrouter"),
Some("google/gemini-2.5-flash"),
Some("fr"),
Some("srt"),
Some(Path::new("out.srt")),
true,
true,
Some("summary"),
Some("openrouter"),
Some("openai/gpt-audio-mini"),
);
assert_eq!(cfg.provider, "openrouter");
assert_eq!(cfg.model.as_deref(), Some("google/gemini-2.5-flash"));
assert_eq!(cfg.language, "fr");
assert_eq!(cfg.output, "srt");
assert_eq!(cfg.output_file.as_deref(), Some(Path::new("out.srt")));
assert!(cfg.timestamps);
assert!(cfg.verbose);
assert_eq!(cfg.cleanup_style, "summary");
assert_eq!(cfg.cleanup_provider, "openrouter");
assert_eq!(
cfg.cleanup_openrouter_model.as_deref(),
Some("openai/gpt-audio-mini")
);
}
#[test]
fn custom_tts_model_cannot_shadow_builtin_on_load() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
let default_id = {
#[cfg(feature = "tts")]
{
crate::tts::DEFAULT_TTS_MODEL
}
#[cfg(not(feature = "tts"))]
{
"kitten-nano-int8"
}
};
fs::write(
&path,
format!(
r#"
[tts]
model = "{default_id}"
[[tts.custom_models]]
id = "{default_id}"
adapter = "fake-sine-v1"
pack_dir = "/tmp/does-not-matter"
trust = "verified"
"#
),
)
.unwrap();
let err = Config::load_from(&path).unwrap_err();
assert!(
err.to_string().contains("collides") || err.to_string().contains("reserved"),
"got: {err}"
);
}
#[test]
fn defaults_when_missing_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("nope.toml");
let cfg = Config::load_from(&path).unwrap();
assert_eq!(cfg.provider, "local");
assert_eq!(cfg.language, "auto");
assert_eq!(cfg.output, "txt");
assert_eq!(cfg.cleanup_style, "raw");
assert_eq!(cfg.cleanup_provider, "rules");
}
#[test]
fn validated_config_accepts_defaults() {
let dir = tempdir().unwrap();
let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
let v = ValidatedConfig::try_from_config(cfg).unwrap();
assert_eq!(v.provider, "local");
assert_eq!(v.as_config().language, "auto");
}
#[test]
fn validated_config_rejects_bad_provider() {
let dir = tempdir().unwrap();
let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
cfg.provider = "not-a-provider".into();
let err = ValidatedConfig::try_from_config(cfg).unwrap_err();
assert!(
err.to_string().contains("provider") || err.to_string().contains("Invalid"),
"got: {err}"
);
}
#[test]
fn validated_apply_cli_revalidates() {
let dir = tempdir().unwrap();
let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
let v = ValidatedConfig::try_from_config(cfg).unwrap();
let err = v
.apply_cli(
Some("bogus"),
None,
None,
None,
None,
false,
false,
None,
None,
None,
)
.unwrap_err();
assert!(err.to_string().contains("provider") || err.to_string().contains("Invalid"));
}
}