1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5#[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 #[serde(default)]
69 pub vocabulary: Vec<String>,
70 #[serde(default)]
72 pub mode: Option<DictationMode>,
73}
74
75#[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#[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
133pub 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
142pub const SUPPORTED_MODELS: &[&str] = WHISPER_MODELS;
144
145pub 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 #[serde(default)]
237 pub asr_backend: AsrBackend,
238 #[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 #[serde(default = "default_true")]
256 pub noise_suppression: bool,
257 #[serde(default)]
259 pub vocabulary: Vec<String>,
260 #[serde(default)]
262 pub app_contexts: std::collections::HashMap<String, AppContextConfig>,
263 #[serde(default)]
265 pub excluded_apps: Vec<String>,
266 #[serde(default)]
268 pub dictation_mode: DictationMode,
269 #[serde(default)]
271 pub app_mode: AppMode,
272 #[serde(default = "default_wake_word")]
274 pub wake_word: String,
275 #[serde(default = "default_stop_phrase")]
277 pub stop_phrase: String,
278 #[serde(default)]
280 pub notes_dir: Option<std::path::PathBuf>,
281 #[serde(default)]
284 pub system_audio_device: Option<String>,
285 #[serde(default)]
287 pub stealth_mode: bool,
288 #[serde(default = "default_llm_model")]
290 pub llm_model: String,
291 #[serde(default = "default_ollama_url")]
293 pub ollama_url: String,
294 #[serde(default)]
296 pub sessions_dir: Option<String>,
297 #[serde(default)]
299 pub auto_summary: bool,
300 #[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 pub fn is_notes_mode(&self) -> bool {
383 self.app_mode == AppMode::Notes
384 }
385
386 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 pub fn backend_has_native_formatting(&self) -> bool {
397 matches!(self.asr_backend, AsrBackend::Parakeet)
398 }
399
400 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 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 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 let mut seen = std::collections::HashSet::new();
491 vocab.retain(|v| seen.insert(v.clone()));
492 vocab
493 }
494
495 pub fn is_app_excluded(&self, app_id: &str) -> bool {
497 self.excluded_apps.iter().any(|e| e == app_id)
498 }
499
500 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 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 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 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 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}