Skip to main content

murmur_core/
config.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5/// Top-level application mode.
6///
7/// **Dictation** — transcription is pasted at the cursor via hotkey.
8/// **Notes** — transcription is shown in an overlay and saved to note files,
9/// triggered by wake word.
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum AppMode {
13    #[default]
14    Dictation,
15    Notes,
16}
17
18impl std::fmt::Display for AppMode {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            AppMode::Dictation => write!(f, "Dictation"),
22            AppMode::Notes => write!(f, "Notes"),
23        }
24    }
25}
26
27#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum InputMode {
30    #[default]
31    PushToTalk,
32    OpenMic,
33}
34
35impl std::fmt::Display for InputMode {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            InputMode::PushToTalk => write!(f, "Push to Talk"),
39            InputMode::OpenMic => write!(f, "Open Mic"),
40        }
41    }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
45#[serde(rename_all = "snake_case")]
46pub enum DictationMode {
47    #[default]
48    Prose,
49    Code,
50    Command,
51    List,
52}
53
54impl std::fmt::Display for DictationMode {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            DictationMode::Prose => write!(f, "Prose"),
58            DictationMode::Code => write!(f, "Code"),
59            DictationMode::Command => write!(f, "Command"),
60            DictationMode::List => write!(f, "List"),
61        }
62    }
63}
64
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct AppContextConfig {
67    /// Vocabulary terms to bias Whisper toward when this app is focused
68    #[serde(default)]
69    pub vocabulary: Vec<String>,
70    /// Dictation mode to use when this app is focused
71    #[serde(default)]
72    pub mode: Option<DictationMode>,
73}
74
75/// ASR backend engine.
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum AsrBackend {
79    #[default]
80    Whisper,
81    Qwen3Asr,
82    Parakeet,
83}
84
85impl std::fmt::Display for AsrBackend {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        match self {
88            AsrBackend::Whisper => write!(f, "Whisper"),
89            AsrBackend::Qwen3Asr => write!(f, "Qwen3-ASR"),
90            AsrBackend::Parakeet => write!(f, "Parakeet"),
91        }
92    }
93}
94
95/// ONNX model quantization level.
96#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum AsrQuantization {
99    Fp32,
100    #[default]
101    Int4,
102    Int8,
103}
104
105impl std::fmt::Display for AsrQuantization {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        match self {
108            AsrQuantization::Fp32 => write!(f, "FP32"),
109            AsrQuantization::Int4 => write!(f, "INT4"),
110            AsrQuantization::Int8 => write!(f, "INT8"),
111        }
112    }
113}
114
115pub const WHISPER_MODELS: &[&str] = &[
116    "tiny.en",
117    "tiny",
118    "base.en",
119    "base",
120    "small.en",
121    "small",
122    "medium.en",
123    "medium",
124    "large-v3-turbo",
125    "large",
126    "distil-large-v3",
127];
128
129pub const QWEN3_ASR_MODELS: &[&str] = &["0.6b", "1.7b"];
130
131pub const PARAKEET_MODELS: &[&str] = &["0.6b-v2"];
132
133/// All supported models for a given backend.
134pub fn supported_models(backend: AsrBackend) -> &'static [&'static str] {
135    match backend {
136        AsrBackend::Whisper => WHISPER_MODELS,
137        AsrBackend::Qwen3Asr => QWEN3_ASR_MODELS,
138        AsrBackend::Parakeet => PARAKEET_MODELS,
139    }
140}
141
142/// Deprecated: use `WHISPER_MODELS` or `supported_models()` instead.
143pub const SUPPORTED_MODELS: &[&str] = WHISPER_MODELS;
144
145/// Returns true for models that only support English (`.en` suffix or `distil-*`).
146pub fn is_english_only_model(model: &str) -> bool {
147    model.ends_with(".en") || model.starts_with("distil-")
148}
149
150pub const SUPPORTED_LANGUAGES: &[(&str, &str)] = &[
151    ("auto", "Auto-Detect"),
152    ("en", "English"),
153    ("zh", "Chinese"),
154    ("de", "German"),
155    ("es", "Spanish"),
156    ("ru", "Russian"),
157    ("ko", "Korean"),
158    ("fr", "French"),
159    ("ja", "Japanese"),
160    ("pt", "Portuguese"),
161    ("tr", "Turkish"),
162    ("pl", "Polish"),
163    ("nl", "Dutch"),
164    ("ar", "Arabic"),
165    ("sv", "Swedish"),
166    ("it", "Italian"),
167    ("id", "Indonesian"),
168    ("hi", "Hindi"),
169    ("fi", "Finnish"),
170    ("vi", "Vietnamese"),
171    ("he", "Hebrew"),
172    ("uk", "Ukrainian"),
173    ("el", "Greek"),
174    ("cs", "Czech"),
175    ("ro", "Romanian"),
176    ("da", "Danish"),
177    ("hu", "Hungarian"),
178    ("no", "Norwegian"),
179    ("th", "Thai"),
180    ("ca", "Catalan"),
181    ("sk", "Slovak"),
182    ("hr", "Croatian"),
183    ("bg", "Bulgarian"),
184    ("lt", "Lithuanian"),
185    ("sl", "Slovenian"),
186    ("et", "Estonian"),
187    ("lv", "Latvian"),
188    ("sr", "Serbian"),
189    ("mk", "Macedonian"),
190    ("ta", "Tamil"),
191    ("te", "Telugu"),
192    ("ml", "Malayalam"),
193    ("kn", "Kannada"),
194    ("bn", "Bengali"),
195    ("mr", "Marathi"),
196    ("gu", "Gujarati"),
197    ("pa", "Punjabi"),
198    ("ur", "Urdu"),
199    ("fa", "Persian"),
200    ("sw", "Swahili"),
201    ("af", "Afrikaans"),
202    ("ms", "Malay"),
203    ("az", "Azerbaijani"),
204    ("sq", "Albanian"),
205    ("hy", "Armenian"),
206    ("ka", "Georgian"),
207    ("ne", "Nepali"),
208    ("mn", "Mongolian"),
209    ("bs", "Bosnian"),
210    ("kk", "Kazakh"),
211    ("gl", "Galician"),
212    ("eu", "Basque"),
213    ("is", "Icelandic"),
214    ("cy", "Welsh"),
215    ("la", "Latin"),
216    ("haw", "Hawaiian"),
217    ("jw", "Javanese"),
218];
219
220pub fn is_valid_language(code: &str) -> bool {
221    SUPPORTED_LANGUAGES.iter().any(|(c, _)| *c == code)
222}
223
224pub fn language_name(code: &str) -> Option<&str> {
225    SUPPORTED_LANGUAGES
226        .iter()
227        .find(|(c, _)| *c == code)
228        .map(|(_, name)| *name)
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct Config {
233    pub hotkey: String,
234    pub model_size: String,
235    /// ASR backend engine (default: whisper)
236    #[serde(default)]
237    pub asr_backend: AsrBackend,
238    /// ONNX model quantization level (default: int4, only used for ONNX backends)
239    #[serde(default)]
240    pub asr_quantization: AsrQuantization,
241    pub language: String,
242    #[serde(default)]
243    pub spoken_punctuation: bool,
244    #[serde(default)]
245    pub filler_word_removal: bool,
246    #[serde(default)]
247    pub max_recordings: u32,
248    #[serde(default)]
249    pub mode: InputMode,
250    #[serde(default)]
251    pub streaming: bool,
252    #[serde(default)]
253    pub translate_to_english: bool,
254    /// Enable noise suppression via nnnoiseless (default: true)
255    #[serde(default = "default_true")]
256    pub noise_suppression: bool,
257    /// Global vocabulary terms to bias Whisper toward
258    #[serde(default)]
259    pub vocabulary: Vec<String>,
260    /// Per-application context configurations, keyed by bundle ID or process name
261    #[serde(default)]
262    pub app_contexts: std::collections::HashMap<String, AppContextConfig>,
263    /// App identifiers to exclude from context capture (password managers, banking apps)
264    #[serde(default)]
265    pub excluded_apps: Vec<String>,
266    /// Default dictation mode
267    #[serde(default)]
268    pub dictation_mode: DictationMode,
269    /// Application mode: `dictation` (paste at cursor) or `notes` (overlay + wake word)
270    #[serde(default)]
271    pub app_mode: AppMode,
272    /// Phrase that triggers dictation when spoken (default: "murmur start dictation")
273    #[serde(default = "default_wake_word")]
274    pub wake_word: String,
275    /// Phrase that stops dictation when spoken (default: "murmur stop dictation")
276    #[serde(default = "default_stop_phrase")]
277    pub stop_phrase: String,
278    /// Directory for saving dictation notes (default: data_dir/murmur/notes)
279    #[serde(default)]
280    pub notes_dir: Option<std::path::PathBuf>,
281    /// Input device name for system audio capture (e.g. "BlackHole 2ch").
282    /// When set, meeting sessions capture both mic and system audio.
283    #[serde(default)]
284    pub system_audio_device: Option<String>,
285    /// Hide the overlay window from screen capture and screen sharing.
286    #[serde(default)]
287    pub stealth_mode: bool,
288    /// LLM model name for Ollama (default: "phi3")
289    #[serde(default = "default_llm_model")]
290    pub llm_model: String,
291    /// Ollama API base URL
292    #[serde(default = "default_ollama_url")]
293    pub ollama_url: String,
294    /// Directory for storing meeting sessions
295    #[serde(default)]
296    pub sessions_dir: Option<String>,
297    /// Auto-generate summary when meeting ends
298    #[serde(default)]
299    pub auto_summary: bool,
300    /// Automatically check for and apply updates on startup (default: false)
301    #[serde(default)]
302    pub auto_update: bool,
303}
304
305fn default_true() -> bool {
306    true
307}
308
309fn default_wake_word() -> String {
310    "murmur start dictation".to_string()
311}
312
313fn default_stop_phrase() -> String {
314    "murmur stop dictation".to_string()
315}
316
317fn default_llm_model() -> String {
318    "phi3".to_string()
319}
320
321fn default_ollama_url() -> String {
322    "http://localhost:11434".to_string()
323}
324
325impl Default for Config {
326    fn default() -> Self {
327        Self {
328            hotkey: default_hotkey().to_string(),
329            model_size: "base.en".to_string(),
330            asr_backend: AsrBackend::default(),
331            asr_quantization: AsrQuantization::default(),
332            language: "en".to_string(),
333            spoken_punctuation: false,
334            filler_word_removal: false,
335            max_recordings: 0,
336            mode: InputMode::PushToTalk,
337            streaming: false,
338            translate_to_english: false,
339            noise_suppression: true,
340            vocabulary: Vec::new(),
341            app_contexts: std::collections::HashMap::new(),
342            excluded_apps: Vec::new(),
343            dictation_mode: DictationMode::default(),
344            app_mode: AppMode::default(),
345            wake_word: default_wake_word(),
346            stop_phrase: default_stop_phrase(),
347            notes_dir: None,
348            system_audio_device: None,
349            stealth_mode: false,
350            llm_model: default_llm_model(),
351            ollama_url: default_ollama_url(),
352            sessions_dir: None,
353            auto_summary: false,
354            auto_update: false,
355        }
356    }
357}
358
359fn default_hotkey() -> &'static str {
360    #[cfg(target_os = "macos")]
361    {
362        "rightoption"
363    }
364    #[cfg(not(target_os = "macos"))]
365    {
366        "rightalt"
367    }
368}
369
370impl Config {
371    pub fn dir() -> PathBuf {
372        dirs::config_dir()
373            .unwrap_or_else(|| PathBuf::from("."))
374            .join("murmur")
375    }
376
377    pub fn file_path() -> PathBuf {
378        Self::dir().join("config.json")
379    }
380
381    /// Whether the app is in Notes mode.
382    pub fn is_notes_mode(&self) -> bool {
383        self.app_mode == AppMode::Notes
384    }
385
386    /// Default model size for the current ASR backend.
387    pub fn default_model_for_backend(&self) -> &'static str {
388        match self.asr_backend {
389            AsrBackend::Whisper => "base.en",
390            AsrBackend::Qwen3Asr => "0.6b",
391            AsrBackend::Parakeet => "0.6b-v2",
392        }
393    }
394
395    /// Whether the current backend produces pre-formatted output (punctuation, capitalization).
396    pub fn backend_has_native_formatting(&self) -> bool {
397        matches!(self.asr_backend, AsrBackend::Parakeet)
398    }
399
400    /// Resolved notes directory, falling back to data_dir/murmur/notes.
401    pub fn notes_dir(&self) -> PathBuf {
402        self.notes_dir.clone().unwrap_or_else(|| {
403            dirs::data_dir()
404                .unwrap_or_else(|| PathBuf::from("."))
405                .join("murmur")
406                .join("notes")
407        })
408    }
409
410    pub fn load() -> Self {
411        Self::load_from(&Self::file_path())
412    }
413
414    pub fn load_from(path: &std::path::Path) -> Self {
415        match std::fs::read_to_string(path) {
416            Ok(contents) => Self::parse(&contents, path),
417            Err(_) => {
418                let config = Self::default();
419                let _ = config.save_to(path);
420                config
421            }
422        }
423    }
424
425    pub fn parse(contents: &str, source: &std::path::Path) -> Self {
426        match serde_json::from_str::<Config>(contents) {
427            Ok(config) => config,
428            Err(e) => {
429                eprintln!("Warning: unable to parse {}: {e}", source.display());
430                Self::default()
431            }
432        }
433    }
434
435    pub fn save(&self) -> Result<()> {
436        self.save_to(&Self::file_path())
437    }
438
439    pub fn save_to(&self, path: &std::path::Path) -> Result<()> {
440        if let Some(dir) = path.parent() {
441            std::fs::create_dir_all(dir)?;
442        }
443        let json = serde_json::to_string_pretty(self)?;
444        std::fs::write(path, json)?;
445        Ok(())
446    }
447
448    pub fn effective_max_recordings(value: u32) -> u32 {
449        if value == 0 {
450            0
451        } else {
452            value.clamp(1, 100)
453        }
454    }
455
456    /// Load vocabulary terms from a `.murmur-vocab` file if it exists in the given directory.
457    /// The file contains one term per line. Empty lines and lines starting with # are ignored.
458    pub fn load_vocab_file(dir: &std::path::Path) -> Vec<String> {
459        let path = dir.join(".murmur-vocab");
460        match std::fs::read_to_string(&path) {
461            Ok(contents) => contents
462                .lines()
463                .map(|l| l.trim())
464                .filter(|l| !l.is_empty() && !l.starts_with('#'))
465                .map(String::from)
466                .collect(),
467            Err(_) => Vec::new(),
468        }
469    }
470
471    /// Collect all effective vocabulary: global config + app-specific + vocab file.
472    pub fn effective_vocabulary(
473        &self,
474        app_id: Option<&str>,
475        project_dir: Option<&std::path::Path>,
476    ) -> Vec<String> {
477        let mut vocab: Vec<String> = self.vocabulary.clone();
478
479        if let Some(id) = app_id {
480            if let Some(app_ctx) = self.app_contexts.get(id) {
481                vocab.extend(app_ctx.vocabulary.iter().cloned());
482            }
483        }
484
485        if let Some(dir) = project_dir {
486            vocab.extend(Self::load_vocab_file(dir));
487        }
488
489        // Deduplicate while preserving order
490        let mut seen = std::collections::HashSet::new();
491        vocab.retain(|v| seen.insert(v.clone()));
492        vocab
493    }
494
495    /// Check if an app is excluded from context capture.
496    pub fn is_app_excluded(&self, app_id: &str) -> bool {
497        self.excluded_apps.iter().any(|e| e == app_id)
498    }
499
500    /// Get the effective dictation mode for a given app.
501    pub fn effective_dictation_mode(&self, app_id: Option<&str>) -> DictationMode {
502        if let Some(id) = app_id {
503            if let Some(ctx) = self.app_contexts.get(id) {
504                if let Some(mode) = ctx.mode {
505                    return mode;
506                }
507            }
508        }
509        self.dictation_mode
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    #[test]
518    fn test_default_config() {
519        let cfg = Config::default();
520        assert_eq!(cfg.model_size, "base.en");
521        assert_eq!(cfg.language, "en");
522        assert!(!cfg.spoken_punctuation);
523        assert_eq!(cfg.max_recordings, 0);
524        assert_eq!(cfg.mode, InputMode::PushToTalk);
525        assert!(!cfg.streaming);
526        assert!(!cfg.translate_to_english);
527    }
528
529    #[test]
530    fn test_effective_max_recordings() {
531        assert_eq!(Config::effective_max_recordings(0), 0);
532        assert_eq!(Config::effective_max_recordings(1), 1);
533        assert_eq!(Config::effective_max_recordings(50), 50);
534        assert_eq!(Config::effective_max_recordings(100), 100);
535        assert_eq!(Config::effective_max_recordings(200), 100);
536    }
537
538    #[test]
539    fn test_is_valid_language() {
540        assert!(is_valid_language("en"));
541        assert!(is_valid_language("auto"));
542        assert!(is_valid_language("fr"));
543        assert!(!is_valid_language("xx"));
544        assert!(!is_valid_language(""));
545    }
546
547    #[test]
548    fn test_language_name() {
549        assert_eq!(language_name("en"), Some("English"));
550        assert_eq!(language_name("auto"), Some("Auto-Detect"));
551        assert_eq!(language_name("xx"), None);
552    }
553
554    #[test]
555    fn test_config_roundtrip() {
556        let cfg = Config {
557            hotkey: "f9".to_string(),
558            model_size: "small.en".to_string(),
559            language: "fr".to_string(),
560            spoken_punctuation: true,
561            filler_word_removal: true,
562            max_recordings: 10,
563            mode: InputMode::OpenMic,
564            streaming: true,
565            translate_to_english: true,
566            vocabulary: vec!["murmur".to_string()],
567            app_contexts: std::collections::HashMap::new(),
568            excluded_apps: Vec::new(),
569            dictation_mode: DictationMode::Code,
570            noise_suppression: true,
571            ..Config::default()
572        };
573
574        let json = serde_json::to_string(&cfg).unwrap();
575        let parsed: Config = serde_json::from_str(&json).unwrap();
576
577        assert_eq!(parsed.hotkey, "f9");
578        assert_eq!(parsed.model_size, "small.en");
579        assert_eq!(parsed.language, "fr");
580        assert!(parsed.spoken_punctuation);
581        assert_eq!(parsed.max_recordings, 10);
582        assert_eq!(parsed.mode, InputMode::OpenMic);
583        assert!(parsed.streaming);
584        assert!(parsed.translate_to_english);
585        assert_eq!(parsed.vocabulary, vec!["murmur".to_string()]);
586        assert!(parsed.app_contexts.is_empty());
587        assert!(parsed.excluded_apps.is_empty());
588        assert_eq!(parsed.dictation_mode, DictationMode::Code);
589    }
590
591    #[test]
592    fn test_config_dir_and_file_path() {
593        let dir = Config::dir();
594        assert!(dir.to_string_lossy().contains("murmur"));
595        let fp = Config::file_path();
596        assert!(fp.to_string_lossy().contains("config.json"));
597        assert!(fp.starts_with(&dir));
598    }
599
600    #[test]
601    fn test_save_to_and_load_from() {
602        let tmp = tempfile::TempDir::new().unwrap();
603        let path = tmp.path().join("test_config.json");
604
605        let cfg = Config {
606            hotkey: "ctrl+shift+a".to_string(),
607            model_size: "medium.en".to_string(),
608            language: "de".to_string(),
609            spoken_punctuation: true,
610            filler_word_removal: true,
611            max_recordings: 5,
612            mode: InputMode::OpenMic,
613            streaming: false,
614            translate_to_english: false,
615            vocabulary: vec!["test".to_string()],
616            app_contexts: std::collections::HashMap::new(),
617            excluded_apps: vec!["com.bank.app".to_string()],
618            dictation_mode: DictationMode::Prose,
619            noise_suppression: true,
620            ..Config::default()
621        };
622        cfg.save_to(&path).unwrap();
623
624        let loaded = Config::load_from(&path);
625        assert_eq!(loaded.hotkey, "ctrl+shift+a");
626        assert_eq!(loaded.model_size, "medium.en");
627        assert_eq!(loaded.language, "de");
628        assert!(loaded.spoken_punctuation);
629        assert_eq!(loaded.max_recordings, 5);
630        assert_eq!(loaded.mode, InputMode::OpenMic);
631        assert!(!loaded.translate_to_english);
632        assert_eq!(loaded.vocabulary, vec!["test".to_string()]);
633        assert!(loaded.app_contexts.is_empty());
634        assert_eq!(loaded.excluded_apps, vec!["com.bank.app".to_string()]);
635        assert_eq!(loaded.dictation_mode, DictationMode::Prose);
636    }
637
638    #[test]
639    fn test_load_from_nonexistent_creates_default() {
640        let tmp = tempfile::TempDir::new().unwrap();
641        let path = tmp.path().join("nonexistent.json");
642        let loaded = Config::load_from(&path);
643        assert_eq!(loaded.model_size, "base.en");
644        // Should have created the default config file
645        assert!(path.exists());
646    }
647
648    #[test]
649    fn test_parse_invalid_json_returns_default() {
650        let path = std::path::Path::new("/tmp/test_invalid.json");
651        let cfg = Config::parse("not valid json", path);
652        assert_eq!(cfg.model_size, "base.en");
653    }
654
655    #[test]
656    fn test_parse_valid_json() {
657        let json = r#"{"hotkey":"f5","model_size":"tiny","language":"ja","spoken_punctuation":false,"max_recordings":0,"mode":"push_to_talk","streaming":false,"translate_to_english":false}"#;
658        let path = std::path::Path::new("/tmp/test.json");
659        let cfg = Config::parse(json, path);
660        assert_eq!(cfg.hotkey, "f5");
661        assert_eq!(cfg.model_size, "tiny");
662        assert_eq!(cfg.language, "ja");
663    }
664
665    #[test]
666    fn test_serde_defaults() {
667        // JSON without optional fields should use defaults
668        let json = r#"{"hotkey":"f9","model_size":"base.en","language":"en"}"#;
669        let cfg: Config = serde_json::from_str(json).unwrap();
670        assert!(!cfg.spoken_punctuation);
671        assert_eq!(cfg.max_recordings, 0);
672        assert_eq!(cfg.mode, InputMode::PushToTalk);
673        assert!(!cfg.streaming);
674        assert!(!cfg.translate_to_english);
675    }
676
677    #[test]
678    fn test_supported_models_contains_expected() {
679        assert!(SUPPORTED_MODELS.contains(&"tiny.en"));
680        assert!(SUPPORTED_MODELS.contains(&"base.en"));
681        assert!(SUPPORTED_MODELS.contains(&"small.en"));
682        assert!(SUPPORTED_MODELS.contains(&"medium.en"));
683        assert!(SUPPORTED_MODELS.contains(&"large-v3-turbo"));
684        assert!(SUPPORTED_MODELS.contains(&"large"));
685        assert!(SUPPORTED_MODELS.contains(&"distil-large-v3"));
686        assert!(!SUPPORTED_MODELS.contains(&"nonexistent"));
687    }
688
689    #[test]
690    fn test_supported_languages_coverage() {
691        // Test a variety of languages
692        for &(code, name) in SUPPORTED_LANGUAGES {
693            assert!(is_valid_language(code));
694            assert_eq!(language_name(code), Some(name));
695        }
696    }
697
698    #[test]
699    fn test_save_to_creates_parent_dirs() {
700        let tmp = tempfile::TempDir::new().unwrap();
701        let path = tmp.path().join("a").join("b").join("config.json");
702        let cfg = Config::default();
703        cfg.save_to(&path).unwrap();
704        assert!(path.exists());
705    }
706
707    #[test]
708    fn test_effective_max_recordings_boundary() {
709        assert_eq!(Config::effective_max_recordings(0), 0);
710        assert_eq!(Config::effective_max_recordings(1), 1);
711        assert_eq!(Config::effective_max_recordings(100), 100);
712        assert_eq!(Config::effective_max_recordings(101), 100);
713        assert_eq!(Config::effective_max_recordings(u32::MAX), 100);
714    }
715
716    #[test]
717    fn test_input_mode_display() {
718        assert_eq!(InputMode::PushToTalk.to_string(), "Push to Talk");
719        assert_eq!(InputMode::OpenMic.to_string(), "Open Mic");
720    }
721
722    #[test]
723    fn test_input_mode_default() {
724        let mode: InputMode = Default::default();
725        assert_eq!(mode, InputMode::PushToTalk);
726    }
727
728    #[test]
729    fn test_input_mode_serde_round_trip() {
730        let push = InputMode::PushToTalk;
731        let json = serde_json::to_string(&push).unwrap();
732        assert_eq!(json, "\"push_to_talk\"");
733        let parsed: InputMode = serde_json::from_str(&json).unwrap();
734        assert_eq!(parsed, InputMode::PushToTalk);
735
736        let open = InputMode::OpenMic;
737        let json = serde_json::to_string(&open).unwrap();
738        assert_eq!(json, "\"open_mic\"");
739        let parsed: InputMode = serde_json::from_str(&json).unwrap();
740        assert_eq!(parsed, InputMode::OpenMic);
741    }
742
743    #[test]
744    fn test_default_config_hotkey_platform() {
745        let cfg = Config::default();
746        #[cfg(target_os = "macos")]
747        assert_eq!(cfg.hotkey, "rightoption");
748        #[cfg(not(target_os = "macos"))]
749        assert_eq!(cfg.hotkey, "rightalt");
750    }
751
752    #[test]
753    fn test_config_all_fields_serialize() {
754        let cfg = Config {
755            hotkey: "f5".to_string(),
756            model_size: "large".to_string(),
757            language: "auto".to_string(),
758            spoken_punctuation: true,
759            filler_word_removal: true,
760            max_recordings: 50,
761            mode: InputMode::OpenMic,
762            streaming: true,
763            translate_to_english: true,
764            vocabulary: vec!["Kubernetes".to_string()],
765            app_contexts: std::collections::HashMap::new(),
766            excluded_apps: Vec::new(),
767            dictation_mode: DictationMode::Command,
768            noise_suppression: true,
769            ..Config::default()
770        };
771        let json = serde_json::to_string_pretty(&cfg).unwrap();
772        assert!(json.contains("\"hotkey\": \"f5\""));
773        assert!(json.contains("\"streaming\": true"));
774        assert!(json.contains("\"translate_to_english\": true"));
775        assert!(json.contains("\"mode\": \"open_mic\""));
776        assert!(json.contains("\"Kubernetes\""));
777        assert!(json.contains("\"dictation_mode\": \"command\""));
778    }
779
780    #[test]
781    fn test_supported_languages_has_auto() {
782        assert!(is_valid_language("auto"));
783        assert_eq!(language_name("auto"), Some("Auto-Detect"));
784    }
785
786    #[test]
787    fn test_supported_languages_no_duplicates() {
788        let mut seen = std::collections::HashSet::new();
789        for (code, _) in SUPPORTED_LANGUAGES {
790            assert!(seen.insert(*code), "duplicate language code: {code}");
791        }
792    }
793
794    #[test]
795    fn test_supported_models_no_duplicates() {
796        let mut seen = std::collections::HashSet::new();
797        for model in SUPPORTED_MODELS {
798            assert!(seen.insert(*model), "duplicate model: {model}");
799        }
800    }
801
802    #[test]
803    fn test_dictation_mode_serde_round_trip() {
804        for (mode, expected_json) in [
805            (DictationMode::Prose, "\"prose\""),
806            (DictationMode::Code, "\"code\""),
807            (DictationMode::Command, "\"command\""),
808            (DictationMode::List, "\"list\""),
809        ] {
810            let json = serde_json::to_string(&mode).unwrap();
811            assert_eq!(json, expected_json);
812            let parsed: DictationMode = serde_json::from_str(&json).unwrap();
813            assert_eq!(parsed, mode);
814        }
815    }
816
817    #[test]
818    fn test_dictation_mode_display() {
819        assert_eq!(DictationMode::Prose.to_string(), "Prose");
820        assert_eq!(DictationMode::Code.to_string(), "Code");
821        assert_eq!(DictationMode::Command.to_string(), "Command");
822        assert_eq!(DictationMode::List.to_string(), "List");
823    }
824
825    #[test]
826    fn test_dictation_mode_default() {
827        let mode: DictationMode = Default::default();
828        assert_eq!(mode, DictationMode::Prose);
829    }
830
831    #[test]
832    fn test_app_context_config_serde() {
833        let ctx = AppContextConfig {
834            vocabulary: vec!["kubectl".to_string(), "nginx".to_string()],
835            mode: Some(DictationMode::Command),
836        };
837        let json = serde_json::to_string(&ctx).unwrap();
838        let parsed: AppContextConfig = serde_json::from_str(&json).unwrap();
839        assert_eq!(parsed.vocabulary, vec!["kubectl", "nginx"]);
840        assert_eq!(parsed.mode, Some(DictationMode::Command));
841    }
842
843    #[test]
844    fn test_serde_defaults_new_fields() {
845        // Old JSON without the new fields should still deserialize with defaults
846        let json = r#"{"hotkey":"f9","model_size":"base.en","language":"en"}"#;
847        let cfg: Config = serde_json::from_str(json).unwrap();
848        assert!(cfg.vocabulary.is_empty());
849        assert!(cfg.app_contexts.is_empty());
850        assert!(cfg.excluded_apps.is_empty());
851        assert_eq!(cfg.dictation_mode, DictationMode::Prose);
852    }
853
854    #[test]
855    fn test_effective_vocabulary_global_only() {
856        let cfg = Config {
857            vocabulary: vec!["alpha".to_string(), "beta".to_string()],
858            ..Config::default()
859        };
860        let vocab = cfg.effective_vocabulary(None, None);
861        assert_eq!(vocab, vec!["alpha", "beta"]);
862    }
863
864    #[test]
865    fn test_effective_vocabulary_with_app() {
866        let mut app_contexts = std::collections::HashMap::new();
867        app_contexts.insert(
868            "com.editor.code".to_string(),
869            AppContextConfig {
870                vocabulary: vec!["rustfmt".to_string()],
871                mode: None,
872            },
873        );
874        let cfg = Config {
875            vocabulary: vec!["global".to_string()],
876            app_contexts,
877            ..Config::default()
878        };
879        let vocab = cfg.effective_vocabulary(Some("com.editor.code"), None);
880        assert_eq!(vocab, vec!["global", "rustfmt"]);
881    }
882
883    #[test]
884    fn test_effective_vocabulary_dedup() {
885        let mut app_contexts = std::collections::HashMap::new();
886        app_contexts.insert(
887            "app".to_string(),
888            AppContextConfig {
889                vocabulary: vec!["dup".to_string(), "unique".to_string()],
890                mode: None,
891            },
892        );
893        let cfg = Config {
894            vocabulary: vec!["dup".to_string(), "other".to_string()],
895            app_contexts,
896            ..Config::default()
897        };
898        let vocab = cfg.effective_vocabulary(Some("app"), None);
899        assert_eq!(vocab, vec!["dup", "other", "unique"]);
900    }
901
902    #[test]
903    fn test_effective_vocabulary_with_vocab_file() {
904        let tmp = tempfile::TempDir::new().unwrap();
905        std::fs::write(tmp.path().join(".murmur-vocab"), "file_term\nanother\n").unwrap();
906        let cfg = Config {
907            vocabulary: vec!["global".to_string()],
908            ..Config::default()
909        };
910        let vocab = cfg.effective_vocabulary(None, Some(tmp.path()));
911        assert_eq!(vocab, vec!["global", "file_term", "another"]);
912    }
913
914    #[test]
915    fn test_load_vocab_file_missing() {
916        let tmp = tempfile::TempDir::new().unwrap();
917        let result = Config::load_vocab_file(tmp.path());
918        assert!(result.is_empty());
919    }
920
921    #[test]
922    fn test_load_vocab_file_with_comments() {
923        let tmp = tempfile::TempDir::new().unwrap();
924        let content = "# This is a comment\nterm1\n\n# Another comment\nterm2\n  \n";
925        std::fs::write(tmp.path().join(".murmur-vocab"), content).unwrap();
926        let result = Config::load_vocab_file(tmp.path());
927        assert_eq!(result, vec!["term1", "term2"]);
928    }
929
930    #[test]
931    fn test_is_app_excluded() {
932        let cfg = Config {
933            excluded_apps: vec!["com.1password".to_string(), "com.bank.app".to_string()],
934            ..Config::default()
935        };
936        assert!(cfg.is_app_excluded("com.1password"));
937        assert!(cfg.is_app_excluded("com.bank.app"));
938        assert!(!cfg.is_app_excluded("com.editor.code"));
939    }
940
941    #[test]
942    fn test_effective_dictation_mode_default() {
943        let cfg = Config::default();
944        assert_eq!(cfg.effective_dictation_mode(None), DictationMode::Prose);
945    }
946
947    #[test]
948    fn test_effective_dictation_mode_app_override() {
949        let mut app_contexts = std::collections::HashMap::new();
950        app_contexts.insert(
951            "com.terminal".to_string(),
952            AppContextConfig {
953                vocabulary: Vec::new(),
954                mode: Some(DictationMode::Command),
955            },
956        );
957        let cfg = Config {
958            app_contexts,
959            ..Config::default()
960        };
961        assert_eq!(
962            cfg.effective_dictation_mode(Some("com.terminal")),
963            DictationMode::Command
964        );
965    }
966
967    #[test]
968    fn test_effective_dictation_mode_app_without_mode() {
969        let mut app_contexts = std::collections::HashMap::new();
970        app_contexts.insert(
971            "com.notes".to_string(),
972            AppContextConfig {
973                vocabulary: vec!["note".to_string()],
974                mode: None,
975            },
976        );
977        let cfg = Config {
978            dictation_mode: DictationMode::List,
979            app_contexts,
980            ..Config::default()
981        };
982        assert_eq!(
983            cfg.effective_dictation_mode(Some("com.notes")),
984            DictationMode::List
985        );
986    }
987
988    #[test]
989    fn test_config_with_app_contexts_roundtrip() {
990        let tmp = tempfile::TempDir::new().unwrap();
991        let path = tmp.path().join("config.json");
992
993        let mut app_contexts = std::collections::HashMap::new();
994        app_contexts.insert(
995            "com.vscode".to_string(),
996            AppContextConfig {
997                vocabulary: vec!["rustfmt".to_string(), "clippy".to_string()],
998                mode: Some(DictationMode::Code),
999            },
1000        );
1001
1002        let cfg = Config {
1003            vocabulary: vec!["murmur".to_string()],
1004            app_contexts,
1005            excluded_apps: vec!["com.1password".to_string()],
1006            dictation_mode: DictationMode::Prose,
1007            ..Config::default()
1008        };
1009        cfg.save_to(&path).unwrap();
1010
1011        let loaded = Config::load_from(&path);
1012        assert_eq!(loaded.vocabulary, vec!["murmur"]);
1013        assert_eq!(loaded.excluded_apps, vec!["com.1password"]);
1014        assert_eq!(loaded.dictation_mode, DictationMode::Prose);
1015        let vscode_ctx = loaded.app_contexts.get("com.vscode").unwrap();
1016        assert_eq!(vscode_ctx.vocabulary, vec!["rustfmt", "clippy"]);
1017        assert_eq!(vscode_ctx.mode, Some(DictationMode::Code));
1018    }
1019
1020    #[test]
1021    fn is_english_only_model_detects_en_suffix() {
1022        assert!(is_english_only_model("base.en"));
1023        assert!(is_english_only_model("tiny.en"));
1024        assert!(is_english_only_model("medium.en"));
1025    }
1026
1027    #[test]
1028    fn is_english_only_model_detects_distil_prefix() {
1029        assert!(is_english_only_model("distil-large-v3"));
1030    }
1031
1032    #[test]
1033    fn is_english_only_model_rejects_multilingual() {
1034        assert!(!is_english_only_model("base"));
1035        assert!(!is_english_only_model("large"));
1036        assert!(!is_english_only_model("large-v3-turbo"));
1037        assert!(!is_english_only_model("tiny"));
1038    }
1039}