Skip to main content

murmur_core/context/
provider.rs

1pub use crate::config::DictationMode;
2
3/// Runtime context gathered from the user's environment.
4/// Fields are all optional — providers fill in what they can.
5#[derive(Debug, Clone, Default)]
6pub struct Context {
7    /// Bundle ID or process identifier of the focused application (e.g. "com.microsoft.VSCode")
8    pub app_id: Option<String>,
9    /// Human-readable name of the focused application (e.g. "Visual Studio Code")
10    pub app_name: Option<String>,
11    /// Title of the focused window
12    pub window_title: Option<String>,
13    /// Text surrounding the cursor in the active text field (~50 chars before cursor)
14    pub surrounding_text: Option<String>,
15    /// Current clipboard text content
16    pub clipboard_text: Option<String>,
17    /// Programming language of the current file (if detectable)
18    pub file_language: Option<String>,
19    /// Vocabulary terms to bias Whisper toward
20    pub vocabulary_hints: Vec<String>,
21    /// Suggested dictation mode based on context
22    pub suggested_mode: Option<DictationMode>,
23}
24
25/// Trait for components that contribute to the runtime context.
26pub trait ContextProvider: Send + Sync {
27    /// Human-readable name for logging/debugging
28    fn name(&self) -> &str;
29    /// Gather context. Should be fast and non-blocking.
30    fn get_context(&self) -> Context;
31}
32
33/// Aggregates multiple context providers and merges their results.
34pub struct ContextManager {
35    providers: Vec<Box<dyn ContextProvider>>,
36}
37
38impl ContextManager {
39    /// Create a new empty context manager.
40    pub fn new() -> Self {
41        Self {
42            providers: Vec::new(),
43        }
44    }
45
46    /// Register a context provider.
47    pub fn add_provider(&mut self, provider: Box<dyn ContextProvider>) {
48        log::info!("Registered context provider: {}", provider.name());
49        self.providers.push(provider);
50    }
51
52    /// Call all providers and merge their results.
53    ///
54    /// Later providers override earlier ones for `Option` fields.
55    /// `vocabulary_hints` are concatenated and deduplicated.
56    pub fn gather(&self) -> Context {
57        let mut merged = Context::default();
58
59        for provider in &self.providers {
60            let ctx = provider.get_context();
61            log::debug!("Context from provider '{}': {:?}", provider.name(), ctx);
62
63            if ctx.app_id.is_some() {
64                merged.app_id = ctx.app_id;
65            }
66            if ctx.app_name.is_some() {
67                merged.app_name = ctx.app_name;
68            }
69            if ctx.window_title.is_some() {
70                merged.window_title = ctx.window_title;
71            }
72            if ctx.surrounding_text.is_some() {
73                merged.surrounding_text = ctx.surrounding_text;
74            }
75            if ctx.clipboard_text.is_some() {
76                merged.clipboard_text = ctx.clipboard_text;
77            }
78            if ctx.file_language.is_some() {
79                merged.file_language = ctx.file_language;
80            }
81            if ctx.suggested_mode.is_some() {
82                merged.suggested_mode = ctx.suggested_mode;
83            }
84
85            for hint in ctx.vocabulary_hints {
86                if !merged.vocabulary_hints.contains(&hint) {
87                    merged.vocabulary_hints.push(hint);
88                }
89            }
90        }
91
92        merged
93    }
94}
95
96impl Default for ContextManager {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn context_default_has_none_fields() {
108        let ctx = Context::default();
109        assert!(ctx.app_id.is_none());
110        assert!(ctx.app_name.is_none());
111        assert!(ctx.window_title.is_none());
112        assert!(ctx.surrounding_text.is_none());
113        assert!(ctx.clipboard_text.is_none());
114        assert!(ctx.file_language.is_none());
115        assert!(ctx.suggested_mode.is_none());
116        assert!(ctx.vocabulary_hints.is_empty());
117    }
118
119    #[test]
120    fn context_manager_no_providers_returns_default() {
121        let manager = ContextManager::new();
122        let ctx = manager.gather();
123        assert!(ctx.app_id.is_none());
124        assert!(ctx.app_name.is_none());
125        assert!(ctx.vocabulary_hints.is_empty());
126    }
127
128    struct StubProvider {
129        name: &'static str,
130        context: Context,
131    }
132
133    impl ContextProvider for StubProvider {
134        fn name(&self) -> &str {
135            self.name
136        }
137        fn get_context(&self) -> Context {
138            self.context.clone()
139        }
140    }
141
142    #[test]
143    fn context_manager_merges_later_overrides_earlier() {
144        let mut manager = ContextManager::new();
145
146        manager.add_provider(Box::new(StubProvider {
147            name: "first",
148            context: Context {
149                app_id: Some("com.first.App".to_string()),
150                app_name: Some("First App".to_string()),
151                window_title: Some("First Window".to_string()),
152                ..Default::default()
153            },
154        }));
155
156        manager.add_provider(Box::new(StubProvider {
157            name: "second",
158            context: Context {
159                app_id: Some("com.second.App".to_string()),
160                // app_name is None — should keep first provider's value
161                file_language: Some("rust".to_string()),
162                ..Default::default()
163            },
164        }));
165
166        let ctx = manager.gather();
167        assert_eq!(ctx.app_id.as_deref(), Some("com.second.App"));
168        assert_eq!(ctx.app_name.as_deref(), Some("First App"));
169        assert_eq!(ctx.window_title.as_deref(), Some("First Window"));
170        assert_eq!(ctx.file_language.as_deref(), Some("rust"));
171    }
172
173    #[test]
174    fn context_manager_deduplicates_vocabulary_hints() {
175        let mut manager = ContextManager::new();
176
177        manager.add_provider(Box::new(StubProvider {
178            name: "a",
179            context: Context {
180                vocabulary_hints: vec!["murmur".to_string(), "whisper".to_string()],
181                ..Default::default()
182            },
183        }));
184
185        manager.add_provider(Box::new(StubProvider {
186            name: "b",
187            context: Context {
188                vocabulary_hints: vec!["whisper".to_string(), "dictation".to_string()],
189                ..Default::default()
190            },
191        }));
192
193        let ctx = manager.gather();
194        assert_eq!(ctx.vocabulary_hints, vec!["murmur", "whisper", "dictation"]);
195    }
196
197    #[test]
198    fn dictation_mode_serde_roundtrip() {
199        let modes = [
200            DictationMode::Prose,
201            DictationMode::Code,
202            DictationMode::Command,
203            DictationMode::List,
204        ];
205
206        for mode in &modes {
207            let json = serde_json::to_string(mode).unwrap();
208            let deserialized: DictationMode = serde_json::from_str(&json).unwrap();
209            assert_eq!(*mode, deserialized);
210        }
211    }
212
213    #[test]
214    fn dictation_mode_serde_snake_case() {
215        assert_eq!(
216            serde_json::to_string(&DictationMode::Prose).unwrap(),
217            "\"prose\""
218        );
219        assert_eq!(
220            serde_json::to_string(&DictationMode::Code).unwrap(),
221            "\"code\""
222        );
223        assert_eq!(
224            serde_json::to_string(&DictationMode::Command).unwrap(),
225            "\"command\""
226        );
227        assert_eq!(
228            serde_json::to_string(&DictationMode::List).unwrap(),
229            "\"list\""
230        );
231    }
232
233    #[test]
234    fn dictation_mode_display() {
235        assert_eq!(DictationMode::Prose.to_string(), "Prose");
236        assert_eq!(DictationMode::Code.to_string(), "Code");
237        assert_eq!(DictationMode::Command.to_string(), "Command");
238        assert_eq!(DictationMode::List.to_string(), "List");
239    }
240
241    #[test]
242    fn dictation_mode_default_is_prose() {
243        assert_eq!(DictationMode::default(), DictationMode::Prose);
244    }
245}