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 copilot: CopilotConfig,
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,
pub global_hotkey: GlobalHotkeyConfig,
pub notifications: NotificationsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GlobalHotkeyConfig {
pub shortcut_enabled: bool,
pub shortcut: String,
}
impl Default for GlobalHotkeyConfig {
fn default() -> Self {
Self {
shortcut_enabled: false,
shortcut: "CmdOrCtrl+Shift+M".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct NotificationsConfig {
pub completion_enabled: bool,
pub copilot_critical_enabled: bool,
}
impl Default for NotificationsConfig {
fn default() -> Self {
Self {
completion_enabled: true,
copilot_critical_enabled: false,
}
}
}
#[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 sherpa_model_dir: 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,
#[serde(default)]
pub name_correction: NameCorrectionMode,
}
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 speaker_mapping_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 CopilotConfig {
pub enabled: bool,
pub surface: String,
pub mode: String,
pub fast_provider: String,
pub fast_model: String,
pub allow_cloud: bool,
pub meeting_goal: Option<String>,
pub arming_behavior: CopilotArmingBehavior,
pub critical_notifications_only: bool,
pub onboarding_seen: bool,
pub nudge_ttl_ms: u64,
pub target_latency_ms: u64,
pub history_grounding: bool,
pub live_partials: bool,
pub partial_debounce_ms: u64,
pub depth_refresh_secs: u64,
pub grounding_refresh_secs: u64,
}
pub const LEGACY_COPILOT_MODEL: &str = "llama3.2";
pub const DEFAULT_COPILOT_MODEL: &str = "qwen3.5:4b";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CopilotModelTier {
pub name: &'static str,
pub min_ram_gb: u64,
pub apple_silicon_model: &'static str,
pub portable_model: &'static str,
pub approx_download_gb: u64,
pub notes: &'static str,
}
impl CopilotModelTier {
pub fn model_for(self, apple_silicon: bool) -> &'static str {
if apple_silicon {
self.apple_silicon_model
} else {
self.portable_model
}
}
}
pub const COPILOT_MODEL_TIERS: &[CopilotModelTier] = &[
CopilotModelTier {
name: "beast",
min_ram_gb: 64,
apple_silicon_model: "qwen3.5:35b-a3b-nvfp4",
portable_model: "qwen3.5:35b-a3b",
approx_download_gb: 22,
notes: "highest-quality sparse model for high-memory machines",
},
CopilotModelTier {
name: "strong",
min_ram_gb: 32,
apple_silicon_model: "gemma4:26b-mlx",
portable_model: "gemma4:26b",
approx_download_gb: 18,
notes: "complete coaching nudges with a sparse active working set",
},
CopilotModelTier {
name: "mainstream",
min_ram_gb: 16,
apple_silicon_model: "qwen3.5:9b-mlx",
portable_model: "qwen3.5:9b",
approx_download_gb: 9,
notes: "focused single-question coaching for mainstream machines",
},
CopilotModelTier {
name: "modest",
min_ram_gb: 0,
apple_silicon_model: "qwen3.5:4b-mlx",
portable_model: DEFAULT_COPILOT_MODEL,
approx_download_gb: 4,
notes: "smallest reviewed model; used when no higher tier fits",
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CopilotModelProbeResult {
pub model_tag: String,
pub within_budget: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CopilotModelDecision {
UserOverride {
model_tag: String,
},
ProbeManifest {
tier: &'static str,
model_tag: &'static str,
approx_download_gb: u64,
},
SelectedManifest {
tier: &'static str,
model_tag: &'static str,
approx_download_gb: u64,
},
NoManifestModelWithinBudget,
}
pub fn decide_copilot_model(
ram_gb: u64,
apple_silicon: bool,
user_override: Option<&str>,
probe_results: &[CopilotModelProbeResult],
) -> CopilotModelDecision {
if let Some(model_tag) = user_override.map(str::trim).filter(|tag| !tag.is_empty()) {
return CopilotModelDecision::UserOverride {
model_tag: model_tag.to_string(),
};
}
let first_fitting = COPILOT_MODEL_TIERS
.iter()
.position(|tier| ram_gb >= tier.min_ram_gb)
.unwrap_or(COPILOT_MODEL_TIERS.len() - 1);
for tier in &COPILOT_MODEL_TIERS[first_fitting..] {
let model_tag = tier.model_for(apple_silicon);
match probe_results
.iter()
.find(|probe| probe.model_tag.eq_ignore_ascii_case(model_tag))
{
Some(probe) if probe.within_budget => {
return CopilotModelDecision::SelectedManifest {
tier: tier.name,
model_tag,
approx_download_gb: tier.approx_download_gb,
};
}
Some(_) => continue,
None => {
return CopilotModelDecision::ProbeManifest {
tier: tier.name,
model_tag,
approx_download_gb: tier.approx_download_gb,
};
}
}
}
CopilotModelDecision::NoManifestModelWithinBudget
}
pub fn copilot_manifest_contains(model_tag: &str) -> bool {
let model_tag = model_tag.trim();
COPILOT_MODEL_TIERS.iter().any(|tier| {
tier.apple_silicon_model.eq_ignore_ascii_case(model_tag)
|| tier.portable_model.eq_ignore_ascii_case(model_tag)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum CopilotArmingBehavior {
Automatic,
#[default]
AskEachMeeting,
Off,
}
impl CopilotArmingBehavior {
pub fn as_str(self) -> &'static str {
match self {
Self::Automatic => "automatic",
Self::AskEachMeeting => "ask-each-meeting",
Self::Off => "off",
}
}
}
impl CopilotConfig {
pub fn resolved_fast_provider(&self) -> &str {
match self.fast_provider.trim() {
"" | "auto-local" => "auto-local",
provider => provider,
}
}
}
#[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, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum NameCorrectionMode {
#[default]
Off,
Conservative,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RetentionConfig {
pub successful_audio_days: u32,
pub failed_audio_days: u32,
pub restricted_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,
restricted_audio_days: 7,
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 cleanup_remove_fillers: bool,
pub cleanup_spoken_punctuation: bool,
pub cleanup_apply_vocabulary: bool,
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: "insert".into(),
accumulate: true,
daily_note_log: true,
cleanup_engine: "rules".into(),
cleanup_remove_fillers: true,
cleanup_spoken_punctuation: false,
cleanup_apply_vocabulary: false,
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 promote_on_stop: LiveTranscriptPromoteOnStop,
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,
promote_on_stop: LiveTranscriptPromoteOnStop::Process,
shortcut_enabled: false,
shortcut: "CmdOrCtrl+Shift+L".into(),
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum LiveTranscriptPromoteOnStop {
#[default]
Process,
Preserve,
Off,
}
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(),
copilot: CopilotConfig::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(),
global_hotkey: GlobalHotkeyConfig::default(),
notifications: NotificationsConfig::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(),
sherpa_model_dir: String::new(),
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,
name_correction: NameCorrectionMode::Off,
}
}
}
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,
speaker_mapping_timeout_secs: 30,
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 CopilotConfig {
fn default() -> Self {
Self {
enabled: false,
surface: "tui".into(),
mode: "generic".into(),
fast_provider: "auto-local".into(),
fast_model: DEFAULT_COPILOT_MODEL.into(),
allow_cloud: false,
meeting_goal: None,
arming_behavior: CopilotArmingBehavior::AskEachMeeting,
critical_notifications_only: true,
onboarding_seen: false,
nudge_ttl_ms: 12_000,
target_latency_ms: 5_000,
history_grounding: true,
live_partials: true,
partial_debounce_ms: 250,
depth_refresh_secs: 60,
grounding_refresh_secs: 15,
}
}
}
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.eq_ignore_ascii_case(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!(!config.copilot.enabled);
assert_eq!(config.copilot.surface, "tui");
assert_eq!(config.copilot.mode, "generic");
assert_eq!(config.copilot.fast_provider, "auto-local");
assert_eq!(config.copilot.resolved_fast_provider(), "auto-local");
assert_eq!(config.copilot.fast_model, DEFAULT_COPILOT_MODEL);
assert!(!config.copilot.allow_cloud);
assert_eq!(
config.copilot.arming_behavior,
CopilotArmingBehavior::AskEachMeeting
);
assert!(config.copilot.meeting_goal.is_none());
assert!(config.copilot.critical_notifications_only);
assert!(!config.copilot.onboarding_seen);
assert_eq!(config.copilot.nudge_ttl_ms, 12_000);
assert_eq!(config.copilot.target_latency_ms, 5_000);
assert!(config.copilot.history_grounding);
assert!(config.copilot.live_partials);
assert_eq!(config.copilot.partial_debounce_ms, 250);
assert_eq!(config.copilot.depth_refresh_secs, 60);
assert_eq!(config.copilot.grounding_refresh_secs, 15);
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 copilot_model_manifest_selects_every_ram_and_platform_tier() {
let cases = [
(128, true, "beast", "qwen3.5:35b-a3b-nvfp4"),
(64, true, "beast", "qwen3.5:35b-a3b-nvfp4"),
(63, true, "strong", "gemma4:26b-mlx"),
(32, true, "strong", "gemma4:26b-mlx"),
(31, true, "mainstream", "qwen3.5:9b-mlx"),
(16, true, "mainstream", "qwen3.5:9b-mlx"),
(15, true, "modest", "qwen3.5:4b-mlx"),
(4, true, "modest", "qwen3.5:4b-mlx"),
(128, false, "beast", "qwen3.5:35b-a3b"),
(48, false, "strong", "gemma4:26b"),
(24, false, "mainstream", "qwen3.5:9b"),
(8, false, "modest", "qwen3.5:4b"),
];
for (ram_gb, apple_silicon, expected_tier, expected_model) in cases {
assert_eq!(
decide_copilot_model(ram_gb, apple_silicon, None, &[]),
CopilotModelDecision::ProbeManifest {
tier: expected_tier,
model_tag: expected_model,
approx_download_gb: COPILOT_MODEL_TIERS
.iter()
.find(|tier| tier.name == expected_tier)
.unwrap()
.approx_download_gb,
}
);
}
}
#[test]
fn copilot_model_decision_steps_down_after_slow_probes() {
let slow_beast = CopilotModelProbeResult {
model_tag: "qwen3.5:35b-a3b-nvfp4".into(),
within_budget: false,
};
assert!(matches!(
decide_copilot_model(128, true, None, std::slice::from_ref(&slow_beast)),
CopilotModelDecision::ProbeManifest {
tier: "strong",
model_tag: "gemma4:26b-mlx",
..
}
));
let probes = [
slow_beast,
CopilotModelProbeResult {
model_tag: "gemma4:26b-mlx".into(),
within_budget: false,
},
CopilotModelProbeResult {
model_tag: "qwen3.5:9b-mlx".into(),
within_budget: true,
},
];
assert!(matches!(
decide_copilot_model(128, true, None, &probes),
CopilotModelDecision::SelectedManifest {
tier: "mainstream",
model_tag: "qwen3.5:9b-mlx",
..
}
));
}
#[test]
fn copilot_model_decision_never_clobbers_user_override() {
assert_eq!(
decide_copilot_model(
128,
true,
Some("custom/private-coach:latest"),
&[CopilotModelProbeResult {
model_tag: "custom/private-coach:latest".into(),
within_budget: false,
}],
),
CopilotModelDecision::UserOverride {
model_tag: "custom/private-coach:latest".into(),
}
);
}
#[test]
fn copilot_model_decision_reports_when_every_tier_is_slow() {
let probes = COPILOT_MODEL_TIERS
.iter()
.map(|tier| CopilotModelProbeResult {
model_tag: tier.apple_silicon_model.into(),
within_budget: false,
})
.collect::<Vec<_>>();
assert_eq!(
decide_copilot_model(128, true, None, &probes),
CopilotModelDecision::NoManifestModelWithinBudget
);
}
#[test]
fn copilot_manifest_excludes_disqualified_legacy_model() {
assert!(!copilot_manifest_contains(LEGACY_COPILOT_MODEL));
for tier in COPILOT_MODEL_TIERS {
assert!(copilot_manifest_contains(tier.apple_silicon_model));
assert!(copilot_manifest_contains(tier.portable_model));
}
}
#[test]
fn copilot_config_deserializes_independently_from_summarization() {
let parsed: Config = toml::from_str(
r#"
[summarization]
engine = "none"
[copilot]
enabled = true
surface = "stdout"
mode = "decision"
fast_provider = "ollama"
fast_model = "qwen3:4b"
allow_cloud = false
meeting_goal = "Leave with a clear next step"
arming_behavior = "automatic"
critical_notifications_only = false
onboarding_seen = true
nudge_ttl_ms = 9000
target_latency_ms = 3500
history_grounding = false
live_partials = false
partial_debounce_ms = 400
depth_refresh_secs = 75
grounding_refresh_secs = 20
"#,
)
.unwrap();
assert_eq!(parsed.summarization.engine, "none");
assert!(parsed.copilot.enabled);
assert_eq!(parsed.copilot.surface, "stdout");
assert_eq!(parsed.copilot.mode, "decision");
assert_eq!(parsed.copilot.resolved_fast_provider(), "ollama");
assert_eq!(parsed.copilot.fast_model, "qwen3:4b");
assert_eq!(
parsed.copilot.meeting_goal.as_deref(),
Some("Leave with a clear next step")
);
assert_eq!(
parsed.copilot.arming_behavior,
CopilotArmingBehavior::Automatic
);
assert!(!parsed.copilot.critical_notifications_only);
assert!(parsed.copilot.onboarding_seen);
assert_eq!(parsed.copilot.nudge_ttl_ms, 9_000);
assert_eq!(parsed.copilot.target_latency_ms, 3_500);
assert!(!parsed.copilot.history_grounding);
assert!(!parsed.copilot.live_partials);
assert_eq!(parsed.copilot.partial_debounce_ms, 400);
assert_eq!(parsed.copilot.depth_refresh_secs, 75);
assert_eq!(parsed.copilot.grounding_refresh_secs, 20);
}
#[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 global_hotkey_and_notifications_defaults_match_legacy_startup() {
let config = Config::default();
assert!(!config.global_hotkey.shortcut_enabled);
assert_eq!(config.global_hotkey.shortcut, "CmdOrCtrl+Shift+M");
assert!(config.notifications.completion_enabled);
assert!(!config.notifications.copilot_critical_enabled);
}
#[test]
fn old_config_without_new_sections_still_loads() {
let parsed: Config = toml::from_str(
"[transcription]\nengine = \"whisper\"\n\n[palette]\nshortcut_enabled = false\nshortcut = \"CmdOrCtrl+Shift+K\"\n",
)
.expect("old config without new sections must load");
assert!(!parsed.global_hotkey.shortcut_enabled);
assert_eq!(parsed.global_hotkey.shortcut, "CmdOrCtrl+Shift+M");
assert!(parsed.notifications.completion_enabled);
assert!(!parsed.notifications.copilot_critical_enabled);
}
#[test]
fn new_sections_round_trip_through_toml() {
let mut config = Config::default();
config.global_hotkey.shortcut_enabled = true;
config.global_hotkey.shortcut = "CmdOrCtrl+Shift+J".into();
config.notifications.completion_enabled = false;
config.notifications.copilot_critical_enabled = true;
let out = toml::to_string(&config).unwrap();
let parsed: Config = toml::from_str(&out).unwrap();
assert!(parsed.global_hotkey.shortcut_enabled);
assert_eq!(parsed.global_hotkey.shortcut, "CmdOrCtrl+Shift+J");
assert!(!parsed.notifications.completion_enabled);
assert!(parsed.notifications.copilot_critical_enabled);
}
#[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 dictation_destination_tests {
use super::*;
#[test]
fn dictation_destination_defaults_to_insert() {
let config = Config::default();
assert_eq!(config.dictation.destination, "insert");
}
#[test]
fn explicit_clipboard_destination_overrides_insert_default() {
let config: Config = toml::from_str(
r#"
[dictation]
destination = "clipboard"
"#,
)
.unwrap();
assert_eq!(config.dictation.destination, "clipboard");
}
}
#[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");
}
}