murmur_core/context/
provider.rs1pub use crate::config::DictationMode;
2
3#[derive(Debug, Clone, Default)]
6pub struct Context {
7 pub app_id: Option<String>,
9 pub app_name: Option<String>,
11 pub window_title: Option<String>,
13 pub surrounding_text: Option<String>,
15 pub clipboard_text: Option<String>,
17 pub file_language: Option<String>,
19 pub vocabulary_hints: Vec<String>,
21 pub suggested_mode: Option<DictationMode>,
23}
24
25pub trait ContextProvider: Send + Sync {
27 fn name(&self) -> &str;
29 fn get_context(&self) -> Context;
31}
32
33pub struct ContextManager {
35 providers: Vec<Box<dyn ContextProvider>>,
36}
37
38impl ContextManager {
39 pub fn new() -> Self {
41 Self {
42 providers: Vec::new(),
43 }
44 }
45
46 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 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 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}