use serde::{Deserialize, Serialize};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
pub const OPENAI_COMPATIBLE_DESKTOP_API_KEY_ENV: &str = "MINUTES_OPENAI_COMPATIBLE_API_KEY";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub output_dir: PathBuf,
pub transcription: TranscriptionConfig,
pub diarization: DiarizationConfig,
pub summarization: SummarizationConfig,
pub search: SearchConfig,
pub daily_notes: DailyNotesConfig,
pub security: SecurityConfig,
pub watch: WatchConfig,
pub assistant: AssistantConfig,
pub privacy: PrivacyConfig,
pub consent: ConsentConfig,
pub screen_context: ScreenContextConfig,
pub desktop_context: DesktopContextConfig,
pub calendar: CalendarConfig,
pub call_detection: CallDetectionConfig,
pub identity: IdentityConfig,
pub vault: VaultConfig,
pub dictation: DictationConfig,
pub voice: VoiceConfig,
pub live_transcript: LiveTranscriptConfig,
pub recording: RecordingConfig,
pub retention: RetentionConfig,
pub hooks: HooksConfig,
pub knowledge: KnowledgeConfig,
pub palette: PaletteConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PaletteConfig {
pub shortcut_enabled: bool,
pub shortcut: String,
}
impl Default for PaletteConfig {
fn default() -> Self {
Self {
shortcut_enabled: true,
shortcut: "CmdOrCtrl+Shift+K".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VoiceConfig {
pub enabled: bool,
pub match_threshold: f32,
}
impl Default for VoiceConfig {
fn default() -> Self {
Self {
enabled: true,
match_threshold: 0.65,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TranscriptionConfig {
pub engine: String,
pub model: String,
pub model_path: PathBuf,
pub min_words: usize,
pub language: Option<String>,
pub vad_model: String,
pub vad_engine: String,
pub noise_reduction: bool,
pub parakeet_binary: String,
pub parakeet_model: String,
pub parakeet_boost_limit: usize,
pub parakeet_boost_score: f32,
pub parakeet_fp16: bool,
#[serde(
default,
deserialize_with = "de_sidecar_tristate",
serialize_with = "ser_sidecar_tristate",
skip_serializing_if = "Option::is_none"
)]
pub parakeet_sidecar_enabled: Option<bool>,
pub parakeet_fp16_blacklist_reset: bool,
pub parakeet_vocab: String,
pub partial_max_secs: u32,
}
pub const VALID_PARAKEET_MODELS: &[&str] = &["tdt-ctc-110m", "tdt-600m"];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DiarizationConfig {
pub engine: String,
pub model_path: PathBuf,
pub threshold: f32,
pub embedding_model: String,
pub stem_correlation_threshold: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SummarizationConfig {
pub engine: String,
pub agent_command: String,
pub agent_timeout_secs: u64,
pub chunk_max_tokens: usize,
pub ollama_url: String,
pub ollama_model: String,
pub openai_compatible_base_url: String,
pub openai_compatible_model: String,
pub openai_compatible_api_key_env: String,
pub mistral_model: String,
pub language: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SearchConfig {
pub engine: String,
pub qmd_collection: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DailyNotesConfig {
pub enabled: bool,
pub path: PathBuf,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SecurityConfig {
pub allowed_audio_dirs: Vec<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct WatchConfig {
pub paths: Vec<PathBuf>,
pub extensions: Vec<String>,
pub r#type: String,
pub diarize: bool,
pub delete_source: bool,
pub settle_delay_ms: u64,
pub dictation_threshold_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ScreenContextConfig {
pub enabled: bool,
pub interval_secs: u64,
pub keep_after_summary: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct DesktopContextConfig {
pub enabled: bool,
pub capture_window_titles: bool,
pub capture_browser_context: bool,
pub allowed_apps: Vec<String>,
pub denied_apps: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PrivacyConfig {
pub hide_from_screen_share: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ConsentConfig {
pub mode: ConsentMode,
pub disclosure_script: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_basis: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ConsentMode {
Off,
#[default]
Remind,
Require,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RetentionConfig {
pub successful_audio_days: u32,
pub failed_audio_days: u32,
pub keep_pinned_audio: bool,
pub auto_cleanup: bool,
pub cleanup_on_startup: bool,
pub warn_above_gb: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AssistantConfig {
pub agent: String,
pub agent_args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CalendarConfig {
pub enabled: bool,
}
impl Default for CalendarConfig {
fn default() -> Self {
Self { enabled: true }
}
}
impl Default for PrivacyConfig {
fn default() -> Self {
Self {
hide_from_screen_share: true,
}
}
}
impl Default for ConsentConfig {
fn default() -> Self {
Self {
mode: ConsentMode::Remind,
disclosure_script: "Heads up — I'm using Minutes to transcribe this conversation locally on my device for my own notes. Let me know if you'd prefer I didn't.".into(),
default_basis: None,
}
}
}
impl Default for RetentionConfig {
fn default() -> Self {
Self {
successful_audio_days: 30,
failed_audio_days: 90,
keep_pinned_audio: true,
auto_cleanup: false,
cleanup_on_startup: false,
warn_above_gb: 2,
}
}
}
impl Default for DesktopContextConfig {
fn default() -> Self {
Self {
enabled: false,
capture_window_titles: true,
capture_browser_context: false,
allowed_apps: vec![],
denied_apps: vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CallDetectionConfig {
pub enabled: bool,
pub poll_interval_secs: u64,
pub cooldown_minutes: u64,
pub apps: Vec<String>,
pub stop_when_call_ends: bool,
pub call_end_stop_countdown_secs: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct IdentityConfig {
pub name: Option<String>,
pub email: Option<String>,
pub emails: Vec<String>,
pub aliases: Vec<String>,
}
impl IdentityConfig {
pub fn all_user_aliases(&self) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
let mut push = |s: &str| {
let trimmed = s.trim();
if trimmed.is_empty() {
return;
}
if seen.insert(trimmed.to_ascii_lowercase()) {
out.push(trimmed.to_string());
}
};
if let Some(email) = &self.email {
push(email);
}
for email in &self.emails {
push(email);
}
for alias in &self.aliases {
push(alias);
}
out
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DictationConfig {
pub backend: String,
pub destination: String,
pub accumulate: bool,
pub daily_note_log: bool,
pub cleanup_engine: String,
pub auto_paste: bool,
pub auto_paste_restore: bool,
pub silence_timeout_ms: u64,
pub max_utterance_secs: u64,
pub destination_file: String,
pub destination_command: String,
pub model: String,
pub shortcut_enabled: bool,
pub shortcut: String,
pub hotkey_enabled: bool,
pub hotkey_keycode: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VaultConfig {
pub enabled: bool,
pub path: PathBuf,
pub meetings_subdir: String,
pub strategy: String,
}
impl Default for DictationConfig {
fn default() -> Self {
Self {
backend: "whisper".into(),
destination: "clipboard".into(),
accumulate: true,
daily_note_log: true,
cleanup_engine: String::new(),
auto_paste: false,
auto_paste_restore: true,
silence_timeout_ms: 2000,
max_utterance_secs: 120,
destination_file: String::new(),
destination_command: String::new(),
model: "base".into(),
shortcut_enabled: false,
shortcut: "CmdOrCtrl+Shift+Space".into(),
hotkey_enabled: false,
hotkey_keycode: 57, }
}
}
impl Default for VaultConfig {
fn default() -> Self {
Self {
enabled: false,
path: PathBuf::new(),
meetings_subdir: "areas/meetings".into(),
strategy: "auto".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RecordingConfig {
pub silence_reminder_secs: u64,
pub silence_threshold: u32,
pub silence_auto_stop_secs: u64,
pub max_duration_secs: u64,
pub min_disk_space_mb: u64,
pub device: Option<String>,
pub auto_call_intent: bool,
pub allow_degraded_call_capture: bool,
pub capture_backend: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sources: Option<SourcesConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SourcesConfig {
pub voice: Option<String>,
pub call: Option<String>,
}
impl Default for RecordingConfig {
fn default() -> Self {
Self {
silence_reminder_secs: 300,
silence_threshold: 3,
silence_auto_stop_secs: 1800,
max_duration_secs: 28800,
min_disk_space_mb: 500,
device: None,
auto_call_intent: false,
allow_degraded_call_capture: false,
capture_backend: "cpal".into(),
sources: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct KnowledgeConfig {
pub enabled: bool,
pub path: PathBuf,
pub adapter: String,
pub engine: String,
pub agent_command: String,
pub log_file: String,
pub index_file: String,
pub min_confidence: String,
}
impl Default for KnowledgeConfig {
fn default() -> Self {
Self {
enabled: false,
path: PathBuf::new(),
adapter: "wiki".into(),
engine: "none".into(),
agent_command: "claude".into(),
log_file: "log.md".into(),
index_file: "index.md".into(),
min_confidence: "strong".into(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct HooksConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_record: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LiveTranscriptConfig {
pub backend: String,
pub model: String,
pub max_utterance_secs: u64,
pub save_wav: bool,
pub shortcut_enabled: bool,
pub shortcut: String,
}
impl Default for LiveTranscriptConfig {
fn default() -> Self {
Self {
backend: LIVE_TRANSCRIPT_BACKEND_INHERIT.into(),
model: String::new(), max_utterance_secs: 30,
save_wav: true,
shortcut_enabled: false,
shortcut: "CmdOrCtrl+Shift+L".into(),
}
}
}
pub const LIVE_TRANSCRIPT_BACKEND_INHERIT: &str = "inherit";
pub const VALID_LIVE_TRANSCRIPT_BACKENDS: &[&str] = &[
LIVE_TRANSCRIPT_BACKEND_INHERIT,
"whisper",
"parakeet",
"apple-speech",
];
impl Default for ScreenContextConfig {
fn default() -> Self {
Self {
enabled: false,
interval_secs: 30,
keep_after_summary: false,
}
}
}
impl Default for AssistantConfig {
fn default() -> Self {
Self {
agent: "claude".into(),
agent_args: vec![],
}
}
}
impl Default for CallDetectionConfig {
fn default() -> Self {
Self {
enabled: true,
poll_interval_secs: 1,
cooldown_minutes: 5,
apps: vec!["zoom.us".into(), "Microsoft Teams".into(), "Webex".into()],
stop_when_call_ends: false,
call_end_stop_countdown_secs: 30,
}
}
}
fn de_sidecar_tristate<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Bool(bool),
Text(String),
}
match Option::<Raw>::deserialize(deserializer)? {
None => Ok(None),
Some(Raw::Bool(true)) => Ok(Some(true)),
Some(Raw::Bool(false)) => Ok(None),
Some(Raw::Text(text)) => match text.trim().to_ascii_lowercase().as_str() {
"" | "auto" => Ok(None),
"on" | "true" => Ok(Some(true)),
"off" | "false" => Ok(Some(false)),
other => Err(serde::de::Error::custom(format!(
"unknown parakeet_sidecar_enabled '{other}'. Valid: auto, on, off"
))),
},
}
}
fn ser_sidecar_tristate<S>(value: &Option<bool>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match value {
Some(true) => serializer.serialize_bool(true),
Some(false) => serializer.serialize_str("off"),
None => serializer.serialize_none(),
}
}
fn home_dir() -> PathBuf {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home);
}
#[cfg(windows)]
if let Some(up) = std::env::var_os("USERPROFILE") {
return PathBuf::from(up);
}
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"))
}
fn minutes_dir() -> PathBuf {
home_dir().join(".minutes")
}
fn config_base_dir_from(xdg_config_home: Option<OsString>, home: PathBuf) -> PathBuf {
match xdg_config_home {
Some(path) if !path.is_empty() => PathBuf::from(path),
_ => home.join(".config"),
}
}
fn config_base_dir() -> PathBuf {
config_base_dir_from(std::env::var_os("XDG_CONFIG_HOME"), home_dir())
}
#[cfg(test)]
fn config_path_from(xdg_config_home: Option<OsString>, home: PathBuf) -> PathBuf {
config_base_dir_from(xdg_config_home, home)
.join("minutes")
.join("config.toml")
}
impl Default for Config {
fn default() -> Self {
Self {
output_dir: home_dir().join("meetings"),
transcription: TranscriptionConfig::default(),
diarization: DiarizationConfig::default(),
summarization: SummarizationConfig::default(),
search: SearchConfig::default(),
daily_notes: DailyNotesConfig::default(),
security: SecurityConfig::default(),
watch: WatchConfig::default(),
assistant: AssistantConfig::default(),
privacy: PrivacyConfig::default(),
consent: ConsentConfig::default(),
screen_context: ScreenContextConfig::default(),
desktop_context: DesktopContextConfig::default(),
calendar: CalendarConfig::default(),
call_detection: CallDetectionConfig::default(),
identity: IdentityConfig::default(),
vault: VaultConfig::default(),
dictation: DictationConfig::default(),
voice: VoiceConfig::default(),
live_transcript: LiveTranscriptConfig::default(),
recording: RecordingConfig::default(),
retention: RetentionConfig::default(),
hooks: HooksConfig::default(),
knowledge: KnowledgeConfig::default(),
palette: PaletteConfig::default(),
}
}
}
impl Default for TranscriptionConfig {
fn default() -> Self {
Self {
engine: "whisper".into(),
model: "small".into(),
model_path: minutes_dir().join("models"),
min_words: 3,
language: None,
vad_model: "silero-v6.2.0".into(),
vad_engine: "ort-silero".into(),
noise_reduction: true,
parakeet_binary: "parakeet".into(),
parakeet_model: "tdt-600m".into(),
parakeet_boost_limit: 0,
parakeet_boost_score: 2.0,
parakeet_fp16: true,
parakeet_sidecar_enabled: None,
parakeet_fp16_blacklist_reset: false,
parakeet_vocab: "tdt-600m.tokenizer.vocab".into(),
partial_max_secs: 30,
}
}
}
impl Default for DiarizationConfig {
fn default() -> Self {
Self {
engine: "auto".into(),
model_path: minutes_dir().join("models").join("diarization"),
threshold: 0.4,
embedding_model: "cam++".into(),
stem_correlation_threshold: 0.85,
}
}
}
impl Default for SummarizationConfig {
fn default() -> Self {
Self {
engine: "none".into(),
agent_command: "claude".into(),
agent_timeout_secs: 300,
chunk_max_tokens: 4000,
ollama_url: "http://localhost:11434".into(),
ollama_model: "llama3.2".into(),
openai_compatible_base_url: "http://localhost:11434/v1".into(),
openai_compatible_model: "llama3.2".into(),
openai_compatible_api_key_env: String::new(),
mistral_model: "mistral-large-latest".into(),
language: "auto".into(),
}
}
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
engine: "builtin".into(),
qmd_collection: None,
}
}
}
impl Default for DailyNotesConfig {
fn default() -> Self {
Self {
enabled: false,
path: home_dir().join("meetings").join("daily"),
}
}
}
impl Default for WatchConfig {
fn default() -> Self {
Self {
paths: vec![minutes_dir().join("inbox")],
extensions: vec![
"m4a".into(),
"wav".into(),
"mp3".into(),
"ogg".into(),
"webm".into(),
],
r#type: "memo".into(),
diarize: false,
delete_source: false,
settle_delay_ms: 2000,
dictation_threshold_secs: 120,
}
}
}
impl Config {
pub fn effective_live_transcript_backend(&self) -> &str {
let backend = self.live_transcript.backend.trim();
if backend.is_empty() || backend == LIVE_TRANSCRIPT_BACKEND_INHERIT {
if self
.transcription
.engine
.eq_ignore_ascii_case("apple-speech")
{
"apple-speech"
} else {
&self.transcription.engine
}
} else {
&self.live_transcript.backend
}
}
pub fn standalone_live_backend_setting(&self) -> &str {
let backend = self.live_transcript.backend.trim();
if backend.is_empty() {
LIVE_TRANSCRIPT_BACKEND_INHERIT
} else {
&self.live_transcript.backend
}
}
pub fn config_path() -> PathBuf {
config_base_dir().join("minutes").join("config.toml")
}
pub fn load() -> Self {
let path = Self::config_path();
Self::load_from(&path)
}
pub fn load_with_migrations() -> Self {
let path = Self::config_path();
Self::load_with_migrations_from(&path)
}
pub fn load_with_migrations_from(path: &Path) -> Self {
let file_existed = path.exists();
let raw_toml = if file_existed {
std::fs::read_to_string(path).ok()
} else {
None
};
let raw_compat = raw_toml
.as_deref()
.map(inspect_raw_toml_compat)
.unwrap_or_default();
let mut config = Self::load_from(path);
let mut migrated_toml: Option<String> = None;
if file_existed
&& config
.transcription
.engine
.eq_ignore_ascii_case("apple-speech")
&& raw_toml.as_deref().is_some_and(|raw| {
!raw_toml_has_setting_in_section(raw, "live_transcript", "backend")
})
{
config.transcription.engine = "whisper".into();
config.live_transcript.backend = "apple-speech".into();
migrated_toml = toml::to_string_pretty(&config).ok();
tracing::info!(
"live transcript backend migration: moved legacy apple-speech engine setting into [live_transcript].backend at {}",
path.display()
);
}
if file_existed {
if raw_compat.preserve_legacy_auto_summarization {
tracing::info!(
"summarization migration: preserving legacy auto engine for sparse config at {}",
path.display()
);
}
if raw_compat.clear_desktop_openai_compatible_env_marker {
tracing::info!(
"summarization migration: clearing desktop-only key env marker from shared config at {}",
path.display()
);
migrated_toml = toml::to_string_pretty(&config).ok();
}
}
if file_existed && migrated_toml.is_none() {
if let Some(raw) = raw_toml.as_deref() {
if !raw_toml_has_section(raw, "palette") {
migrated_toml = Some(append_palette_section(raw, &config.palette));
tracing::info!(
"palette migration: persisting [palette] section in existing config at {}",
path.display()
);
}
}
}
if let Some(migrated_toml) = migrated_toml {
if let Err(e) = std::fs::write(path, migrated_toml) {
tracing::warn!(
"failed to persist config migration to {}: {}",
path.display(),
e
);
}
}
config
}
pub fn load_from(path: &Path) -> Self {
if !path.exists() {
return Self::default();
}
match std::fs::read_to_string(path) {
Ok(contents) => match toml::from_str(&contents) {
Ok(mut config) => {
apply_raw_toml_compat(&mut config, inspect_raw_toml_compat(&contents));
config
}
Err(e) => {
tracing::warn!(
"invalid config at {}: {}. Using defaults.",
path.display(),
e
);
Self::default()
}
},
Err(e) => {
tracing::warn!(
"could not read config at {}: {}. Using defaults.",
path.display(),
e
);
Self::default()
}
}
}
pub fn save(&self) -> std::io::Result<()> {
let path = Self::config_path();
Self::save_to(self, &path)
}
pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(self)
.map_err(|e| std::io::Error::other(format!("TOML serialize: {}", e)))?;
std::fs::write(path, contents)?;
tracing::info!(path = %path.display(), "config saved");
Ok(())
}
pub fn ensure_dirs(&self) -> std::io::Result<()> {
std::fs::create_dir_all(&self.output_dir)?;
std::fs::create_dir_all(self.output_dir.join("memos"))?;
if self.daily_notes.enabled {
std::fs::create_dir_all(&self.daily_notes.path)?;
}
std::fs::create_dir_all(minutes_dir())?;
std::fs::create_dir_all(minutes_dir().join("inbox"))?;
std::fs::create_dir_all(minutes_dir().join("inbox").join("processed"))?;
std::fs::create_dir_all(minutes_dir().join("inbox").join("failed"))?;
std::fs::create_dir_all(minutes_dir().join("logs"))?;
for dir in [&self.output_dir, &minutes_dir()] {
let marker = dir.join(".metadata_never_index");
if !marker.exists() {
std::fs::write(&marker, "").ok();
}
}
Ok(())
}
pub fn minutes_dir() -> PathBuf {
minutes_dir()
}
}
#[derive(Debug, Clone, Copy, Default)]
struct RawTomlCompat {
preserve_legacy_auto_summarization: bool,
clear_desktop_openai_compatible_env_marker: bool,
}
fn inspect_raw_toml_compat(raw: &str) -> RawTomlCompat {
RawTomlCompat {
preserve_legacy_auto_summarization: !raw_toml_has_setting_in_section(
raw,
"summarization",
"engine",
),
clear_desktop_openai_compatible_env_marker: raw_toml_setting_equals_in_section(
raw,
"summarization",
"openai_compatible_api_key_env",
OPENAI_COMPATIBLE_DESKTOP_API_KEY_ENV,
),
}
}
fn apply_raw_toml_compat(config: &mut Config, compat: RawTomlCompat) {
if compat.preserve_legacy_auto_summarization {
config.summarization.engine = "auto".into();
}
if compat.clear_desktop_openai_compatible_env_marker {
config.summarization.openai_compatible_api_key_env.clear();
}
}
pub fn openai_compatible_base_url_is_local(base_url: &str) -> bool {
let trimmed = base_url.trim();
if trimmed.is_empty() {
return false;
}
let without_scheme = trimmed
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(trimmed);
let authority = without_scheme.split('/').next().unwrap_or(without_scheme);
let host_port = authority.rsplit('@').next().unwrap_or(authority);
let host = if let Some(stripped) = host_port.strip_prefix('[') {
stripped.split(']').next().unwrap_or(stripped)
} else {
host_port.split(':').next().unwrap_or(host_port)
};
matches!(
host.to_ascii_lowercase().as_str(),
"localhost" | "127.0.0.1" | "0.0.0.0" | "::1"
)
}
fn raw_toml_has_section(raw: &str, section: &str) -> bool {
let target = format!("[{}]", section);
for line in raw.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
if trimmed == target {
return true;
}
}
false
}
fn raw_toml_has_setting_in_section(raw: &str, section: &str, key: &str) -> bool {
let target = format!("[{}]", section);
let key_prefix = format!("{} =", key);
let mut in_section = false;
for line in raw.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') {
in_section = trimmed == target;
continue;
}
if in_section && trimmed.starts_with(&key_prefix) {
return true;
}
}
false
}
fn raw_toml_setting_equals_in_section(raw: &str, section: &str, key: &str, expected: &str) -> bool {
let target = format!("[{}]", section);
let key_prefix = format!("{} =", key);
let expected_value = toml::Value::String(expected.to_string()).to_string();
let mut in_section = false;
for line in raw.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') {
in_section = trimmed == target;
continue;
}
if in_section && trimmed.starts_with(&key_prefix) {
return trimmed[key_prefix.len()..].trim() == expected_value;
}
}
false
}
fn append_palette_section(raw: &str, palette: &PaletteConfig) -> String {
let mut output = raw.trim_end_matches('\n').to_string();
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str("[palette]\n");
output.push_str(&format!(
"shortcut_enabled = {}\n",
palette.shortcut_enabled
));
output.push_str(&format!(
"shortcut = {}\n",
toml::Value::String(palette.shortcut.clone())
));
output
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn default_config_is_valid() {
let config = Config::default();
assert_eq!(config.transcription.engine, "whisper");
assert_eq!(
config.live_transcript.backend,
LIVE_TRANSCRIPT_BACKEND_INHERIT
);
assert_eq!(config.transcription.model, "small");
assert_eq!(config.transcription.min_words, 3);
assert_eq!(config.transcription.vad_engine, "ort-silero");
assert_eq!(config.transcription.vad_model, "silero-v6.2.0");
assert_eq!(config.transcription.parakeet_binary, "parakeet");
assert_eq!(config.transcription.parakeet_model, "tdt-600m");
assert_eq!(config.transcription.parakeet_boost_limit, 0);
assert_eq!(config.transcription.parakeet_boost_score, 2.0);
assert!(config.transcription.parakeet_fp16);
assert!(config.transcription.parakeet_sidecar_enabled.is_none());
assert_eq!(
config.transcription.parakeet_vocab,
"tdt-600m.tokenizer.vocab"
);
assert_eq!(config.diarization.engine, "auto");
assert_eq!(config.summarization.engine, "none");
assert_eq!(config.search.engine, "builtin");
assert!(!config.daily_notes.enabled);
assert_eq!(config.dictation.backend, "whisper");
assert!(config.dictation.accumulate);
assert!(config.call_detection.enabled);
assert_eq!(config.watch.settle_delay_ms, 2000);
assert!(!config.watch.extensions.is_empty());
assert!(!config.recording.auto_call_intent);
assert!(!config.recording.allow_degraded_call_capture);
assert_eq!(config.recording.capture_backend, "cpal");
assert_eq!(config.consent.mode, ConsentMode::Remind);
assert!(config.consent.default_basis.is_none());
assert!(config.consent.disclosure_script.contains("Minutes"));
}
#[test]
fn consent_config_deserializes_modes_and_defaults() {
let parsed: Config = toml::from_str(
r#"
[consent]
mode = "require"
disclosure_script = "Please acknowledge recording."
default_basis = "notice_in_invite"
"#,
)
.unwrap();
assert_eq!(parsed.consent.mode, ConsentMode::Require);
assert_eq!(
parsed.consent.disclosure_script,
"Please acknowledge recording."
);
assert_eq!(
parsed.consent.default_basis.as_deref(),
Some("notice_in_invite")
);
}
#[test]
fn missing_consent_config_uses_defaults() {
let parsed: Config = toml::from_str("").unwrap();
assert_eq!(parsed.consent.mode, ConsentMode::Remind);
assert!(parsed.consent.default_basis.is_none());
}
#[test]
fn missing_config_file_returns_defaults() {
let config = Config::load_from(Path::new("/nonexistent/config.toml"));
assert_eq!(config.transcription.model, "small");
}
#[test]
fn config_path_falls_back_to_home_dot_config_when_xdg_unset() {
let home = PathBuf::from("/tmp/test-home");
let path = config_path_from(None, home.clone());
assert_eq!(path, home.join(".config/minutes/config.toml"));
}
#[test]
fn config_path_uses_xdg_config_home_when_set() {
let path = config_path_from(
Some(OsString::from("/tmp/test-config")),
PathBuf::from("/tmp/test-home"),
);
assert_eq!(path, PathBuf::from("/tmp/test-config/minutes/config.toml"));
}
#[test]
fn config_path_falls_back_when_xdg_config_home_is_empty() {
let home = PathBuf::from("/tmp/test-home");
let path = config_path_from(Some(OsString::new()), home.clone());
assert_eq!(path, home.join(".config/minutes/config.toml"));
}
#[test]
fn partial_config_merges_with_defaults() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
model = "large-v3"
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.transcription.model, "large-v3");
assert_eq!(config.transcription.min_words, 3);
assert_eq!(config.diarization.engine, "auto");
assert!(!config.daily_notes.enabled);
assert!(config.dictation.accumulate);
}
#[test]
fn default_language_is_none() {
let config = Config::default();
assert_eq!(config.transcription.language, None);
}
#[test]
fn language_can_be_set_from_toml() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
language = "es"
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.transcription.language, Some("es".into()));
}
#[test]
fn omitted_language_defaults_to_none() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
model = "tiny"
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.transcription.language, None);
}
#[test]
fn invalid_toml_returns_defaults() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "this is not valid toml {{{").unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.transcription.model, "small");
assert!(config.dictation.accumulate);
}
#[test]
fn parakeet_config_from_toml() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
engine = "parakeet"
parakeet_model = "tdt-600m"
parakeet_binary = "/usr/local/bin/parakeet"
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.transcription.engine, "parakeet");
assert_eq!(config.transcription.parakeet_model, "tdt-600m");
assert_eq!(
config.transcription.parakeet_binary,
"/usr/local/bin/parakeet"
);
assert!(config.transcription.parakeet_sidecar_enabled.is_none());
assert_eq!(config.transcription.model, "small");
assert_eq!(config.transcription.min_words, 3);
}
#[test]
fn omitted_engine_defaults_to_whisper() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
model = "tiny"
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.transcription.engine, "whisper");
assert_eq!(config.transcription.parakeet_binary, "parakeet");
}
#[test]
fn effective_live_transcript_backend_inherits_batch_engine_by_default() {
let mut config = Config::default();
config.transcription.engine = "parakeet".into();
assert_eq!(config.standalone_live_backend_setting(), "inherit");
assert_eq!(config.effective_live_transcript_backend(), "parakeet");
}
#[test]
fn effective_live_transcript_backend_preserves_legacy_apple_engine_configs() {
let mut config = Config::default();
config.transcription.engine = "apple-speech".into();
assert_eq!(config.standalone_live_backend_setting(), "inherit");
assert_eq!(config.effective_live_transcript_backend(), "apple-speech");
}
#[test]
fn live_transcript_backend_can_be_set_from_toml() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
engine = "whisper"
[live_transcript]
backend = "apple-speech"
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.live_transcript.backend, "apple-speech");
assert_eq!(config.effective_live_transcript_backend(), "apple-speech");
assert_eq!(config.transcription.engine, "whisper");
}
#[test]
fn parakeet_sidecar_flag_can_be_enabled_from_toml() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
parakeet_sidecar_enabled = true
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.transcription.parakeet_sidecar_enabled, Some(true));
}
#[test]
fn parakeet_fp16_blacklist_reset_flag_can_be_enabled_from_toml() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
parakeet_fp16_blacklist_reset = true
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert!(config.transcription.parakeet_fp16_blacklist_reset);
}
#[test]
fn dictation_accumulate_can_be_disabled_from_toml() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[dictation]
accumulate = false
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert!(!config.dictation.accumulate);
}
#[test]
fn dictation_backend_can_be_selected_from_toml() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[dictation]
backend = "apple-speech"
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.dictation.backend, "apple-speech");
assert_eq!(config.transcription.engine, "whisper");
}
#[test]
fn dictation_backend_accepts_parakeet_without_changing_batch_engine() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[dictation]
backend = "parakeet"
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.dictation.backend, "parakeet");
assert_eq!(config.transcription.engine, "whisper");
}
#[test]
fn stop_when_call_ends_is_off_by_default() {
let config = Config::default();
assert!(!config.call_detection.stop_when_call_ends);
assert_eq!(config.call_detection.call_end_stop_countdown_secs, 30);
}
#[test]
fn stop_when_call_ends_round_trips_through_toml() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[call_detection]
enabled = true
stop_when_call_ends = true
call_end_stop_countdown_secs = 45
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert!(config.call_detection.stop_when_call_ends);
assert_eq!(config.call_detection.call_end_stop_countdown_secs, 45);
assert_eq!(config.call_detection.poll_interval_secs, 1);
assert!(!config.call_detection.apps.is_empty());
}
#[test]
fn stop_when_call_ends_omitted_keeps_default_off() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[call_detection]
enabled = true
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert!(!config.call_detection.stop_when_call_ends);
assert_eq!(config.call_detection.call_end_stop_countdown_secs, 30);
}
#[test]
fn palette_default_is_enabled() {
let config = Config::default();
assert!(config.palette.shortcut_enabled);
assert_eq!(config.palette.shortcut, "CmdOrCtrl+Shift+K");
}
#[test]
fn raw_toml_has_section_matches_top_level_headers() {
assert!(raw_toml_has_section("[palette]\nx = 1\n", "palette"));
assert!(raw_toml_has_section("# header\n[palette]", "palette"));
assert!(raw_toml_has_section(
"[other]\nx=1\n\n[palette]\ny=2\n",
"palette"
));
}
#[test]
fn raw_toml_has_section_ignores_commented_headers() {
assert!(!raw_toml_has_section("# [palette]\n", "palette"));
assert!(!raw_toml_has_section(" # [palette]\n", "palette"));
}
#[test]
fn raw_toml_has_section_rejects_non_matching_sections() {
assert!(!raw_toml_has_section("[dictation]\n", "palette"));
assert!(!raw_toml_has_section("[palette.inner]\n", "palette"));
}
#[test]
fn raw_toml_has_setting_in_section_matches_exact_key() {
let raw = r#"
[live_transcript]
backend = "apple-speech"
shortcut = "CmdOrCtrl+Shift+L"
"#;
assert!(raw_toml_has_setting_in_section(
raw,
"live_transcript",
"backend"
));
assert!(!raw_toml_has_setting_in_section(
raw,
"live_transcript",
"missing"
));
}
#[test]
fn append_palette_section_preserves_existing_text() {
let raw = "# keep this comment\nunknown_key = 7\n";
let appended = append_palette_section(raw, &PaletteConfig::default());
assert!(appended.starts_with("# keep this comment\nunknown_key = 7\n"));
assert!(raw_toml_has_section(&appended, "palette"));
assert!(appended.contains("shortcut_enabled = true"));
assert!(appended.contains("shortcut = \"CmdOrCtrl+Shift+K\""));
}
#[test]
fn fresh_install_keeps_palette_enabled() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
let config = Config::load_with_migrations_from(&config_path);
assert!(
config.palette.shortcut_enabled,
"fresh install should default palette shortcut to ENABLED"
);
assert!(
!config_path.exists(),
"migration should not materialize a config file on fresh install"
);
}
#[test]
fn upgrade_persists_palette_section_at_default_enabled() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
model = "small"
"#,
)
.unwrap();
let config = Config::load_with_migrations_from(&config_path);
assert!(
config.palette.shortcut_enabled,
"upgrade path should keep palette shortcut ENABLED at the default"
);
let reloaded = std::fs::read_to_string(&config_path).unwrap();
assert!(
raw_toml_has_section(&reloaded, "palette"),
"migration should persist a [palette] section to disk"
);
assert!(
reloaded.contains("[transcription]\nmodel = \"small\""),
"migration should preserve existing config text, got:\n{}",
reloaded
);
assert!(
reloaded.contains("shortcut_enabled = true"),
"persisted migration must encode shortcut_enabled = true, got:\n{}",
reloaded
);
let second = Config::load_with_migrations_from(&config_path);
assert!(second.palette.shortcut_enabled);
}
#[test]
fn upgrade_respects_explicit_palette_section() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
model = "small"
[palette]
shortcut_enabled = true
shortcut = "CmdOrCtrl+Shift+K"
"#,
)
.unwrap();
let config = Config::load_with_migrations_from(&config_path);
assert!(
config.palette.shortcut_enabled,
"explicit [palette] section must not be overridden by migration"
);
let reloaded = std::fs::read_to_string(&config_path).unwrap();
assert!(reloaded.contains("shortcut_enabled = true"));
}
#[test]
fn upgrade_respects_user_disabled_palette_section() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[palette]
shortcut_enabled = false
"#,
)
.unwrap();
let config = Config::load_with_migrations_from(&config_path);
assert!(!config.palette.shortcut_enabled);
}
#[test]
fn upgrade_preserves_comments_and_unknown_keys_when_adding_palette() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"# top comment\nmystery = \"keep-me\"\n\n[transcription]\nmodel = \"small\"\n",
)
.unwrap();
let _ = Config::load_with_migrations_from(&config_path);
let reloaded = std::fs::read_to_string(&config_path).unwrap();
assert!(reloaded.contains("# top comment"));
assert!(reloaded.contains("mystery = \"keep-me\""));
assert!(raw_toml_has_section(&reloaded, "palette"));
}
#[test]
fn upgrade_migrates_legacy_apple_engine_into_live_backend() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
engine = "apple-speech"
model = "small"
"#,
)
.unwrap();
let config = Config::load_with_migrations_from(&config_path);
assert_eq!(config.transcription.engine, "whisper");
assert_eq!(config.live_transcript.backend, "apple-speech");
assert_eq!(config.effective_live_transcript_backend(), "apple-speech");
let reloaded = std::fs::read_to_string(&config_path).unwrap();
assert!(reloaded.contains("engine = \"whisper\""));
assert!(reloaded.contains("[live_transcript]"));
assert!(reloaded.contains("backend = \"apple-speech\""));
}
#[test]
fn upgrade_does_not_override_explicit_live_backend_setting() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
engine = "apple-speech"
[live_transcript]
backend = "whisper"
"#,
)
.unwrap();
let config = Config::load_with_migrations_from(&config_path);
assert_eq!(config.transcription.engine, "apple-speech");
assert_eq!(config.live_transcript.backend, "whisper");
assert_eq!(config.effective_live_transcript_backend(), "whisper");
}
#[test]
fn upgrade_preserves_legacy_auto_summarization_when_engine_is_missing() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
model = "small"
[palette]
shortcut_enabled = true
"#,
)
.unwrap();
let config = Config::load_with_migrations_from(&config_path);
assert_eq!(config.summarization.engine, "auto");
let reloaded = std::fs::read_to_string(&config_path).unwrap();
assert!(reloaded.contains("[transcription]\nmodel = \"small\""));
assert!(reloaded.contains("[palette]\nshortcut_enabled = true"));
assert!(!reloaded.contains("[summarization]"));
}
#[test]
fn load_from_preserves_legacy_auto_summarization_when_engine_is_missing() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[transcription]
model = "small"
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.summarization.engine, "auto");
}
#[test]
fn upgrade_clears_desktop_only_openai_compatible_env_marker() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
format!(
r#"
[summarization]
engine = "openai-compatible"
openai_compatible_base_url = "https://openrouter.ai/api/v1"
openai_compatible_model = "openai/gpt-4o-mini"
openai_compatible_api_key_env = "{}"
"#,
OPENAI_COMPATIBLE_DESKTOP_API_KEY_ENV
),
)
.unwrap();
let config = Config::load_with_migrations_from(&config_path);
assert!(config
.summarization
.openai_compatible_api_key_env
.is_empty());
let reloaded = std::fs::read_to_string(&config_path).unwrap();
assert!(reloaded.contains("openai_compatible_api_key_env = \"\""));
}
#[test]
fn load_from_clears_desktop_only_openai_compatible_env_marker() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
format!(
r#"
[summarization]
engine = "openai-compatible"
openai_compatible_api_key_env = "{}"
"#,
OPENAI_COMPATIBLE_DESKTOP_API_KEY_ENV
),
)
.unwrap();
let config = Config::load_from(&config_path);
assert!(config
.summarization
.openai_compatible_api_key_env
.is_empty());
}
#[test]
fn openai_compatible_base_url_detects_local_hosts() {
assert!(openai_compatible_base_url_is_local(
"http://localhost:11434/v1"
));
assert!(openai_compatible_base_url_is_local(
"http://127.0.0.1:11434/v1"
));
assert!(openai_compatible_base_url_is_local("http://[::1]:11434/v1"));
assert!(!openai_compatible_base_url_is_local(
"https://openrouter.ai/api/v1"
));
}
#[test]
fn summarization_language_defaults_to_auto() {
let config = Config::default();
assert_eq!(config.summarization.language, "auto");
}
#[test]
fn summarization_language_can_be_set_from_toml() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[summarization]
language = "fr"
"#,
)
.unwrap();
let config = Config::load_from(&config_path);
assert_eq!(config.summarization.language, "fr");
}
}
#[cfg(test)]
mod sidecar_tristate_tests {
use super::*;
fn parse(snippet: &str) -> Config {
toml::from_str(&format!("[transcription]\n{snippet}\n")).unwrap()
}
#[test]
fn absent_key_is_auto() {
assert_eq!(
parse("engine = \"parakeet\"")
.transcription
.parakeet_sidecar_enabled,
None
);
}
#[test]
fn legacy_bool_false_is_treated_as_auto() {
assert_eq!(
parse("parakeet_sidecar_enabled = false")
.transcription
.parakeet_sidecar_enabled,
None
);
}
#[test]
fn bool_true_forces_on() {
assert_eq!(
parse("parakeet_sidecar_enabled = true")
.transcription
.parakeet_sidecar_enabled,
Some(true)
);
}
#[test]
fn string_off_forces_off_and_roundtrips() {
let config = parse("parakeet_sidecar_enabled = \"off\"");
assert_eq!(config.transcription.parakeet_sidecar_enabled, Some(false));
let out = toml::to_string_pretty(&config).unwrap();
assert!(out.contains("parakeet_sidecar_enabled = \"off\""));
let again: Config = toml::from_str(&out).unwrap();
assert_eq!(again.transcription.parakeet_sidecar_enabled, Some(false));
}
#[test]
fn auto_serializes_to_no_key() {
let config = Config::default();
let out = toml::to_string_pretty(&config).unwrap();
assert!(!out.contains("parakeet_sidecar_enabled"));
}
#[test]
fn string_auto_and_on_parse() {
assert_eq!(
parse("parakeet_sidecar_enabled = \"auto\"")
.transcription
.parakeet_sidecar_enabled,
None
);
assert_eq!(
parse("parakeet_sidecar_enabled = \"on\"")
.transcription
.parakeet_sidecar_enabled,
Some(true)
);
}
}
#[cfg(test)]
mod sidecar_tristate_rejection_tests {
use super::*;
#[test]
fn integer_value_is_rejected() {
let result = toml::from_str::<Config>("[transcription]\nparakeet_sidecar_enabled = 0\n");
assert!(result.is_err(), "integer must not silently coerce");
}
#[test]
fn datetime_value_is_rejected() {
let result =
toml::from_str::<Config>("[transcription]\nparakeet_sidecar_enabled = 2026-06-10\n");
assert!(result.is_err(), "datetime must not silently coerce");
}
#[test]
fn unknown_string_is_rejected() {
let result =
toml::from_str::<Config>("[transcription]\nparakeet_sidecar_enabled = \"sometimes\"\n");
assert!(result.is_err(), "unknown strings must error, not default");
}
}