pub use crate::config::DictationMode;
#[derive(Debug, Clone, Default)]
pub struct Context {
pub app_id: Option<String>,
pub app_name: Option<String>,
pub window_title: Option<String>,
pub surrounding_text: Option<String>,
pub clipboard_text: Option<String>,
pub file_language: Option<String>,
pub vocabulary_hints: Vec<String>,
pub suggested_mode: Option<DictationMode>,
}
pub trait ContextProvider: Send + Sync {
fn name(&self) -> &str;
fn get_context(&self) -> Context;
}
pub struct ContextManager {
providers: Vec<Box<dyn ContextProvider>>,
}
impl ContextManager {
pub fn new() -> Self {
Self {
providers: Vec::new(),
}
}
pub fn add_provider(&mut self, provider: Box<dyn ContextProvider>) {
log::info!("Registered context provider: {}", provider.name());
self.providers.push(provider);
}
pub fn gather(&self) -> Context {
let mut merged = Context::default();
for provider in &self.providers {
let ctx = provider.get_context();
log::debug!("Context from provider '{}': {:?}", provider.name(), ctx);
if ctx.app_id.is_some() {
merged.app_id = ctx.app_id;
}
if ctx.app_name.is_some() {
merged.app_name = ctx.app_name;
}
if ctx.window_title.is_some() {
merged.window_title = ctx.window_title;
}
if ctx.surrounding_text.is_some() {
merged.surrounding_text = ctx.surrounding_text;
}
if ctx.clipboard_text.is_some() {
merged.clipboard_text = ctx.clipboard_text;
}
if ctx.file_language.is_some() {
merged.file_language = ctx.file_language;
}
if ctx.suggested_mode.is_some() {
merged.suggested_mode = ctx.suggested_mode;
}
for hint in ctx.vocabulary_hints {
if !merged.vocabulary_hints.contains(&hint) {
merged.vocabulary_hints.push(hint);
}
}
}
merged
}
}
impl Default for ContextManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_default_has_none_fields() {
let ctx = Context::default();
assert!(ctx.app_id.is_none());
assert!(ctx.app_name.is_none());
assert!(ctx.window_title.is_none());
assert!(ctx.surrounding_text.is_none());
assert!(ctx.clipboard_text.is_none());
assert!(ctx.file_language.is_none());
assert!(ctx.suggested_mode.is_none());
assert!(ctx.vocabulary_hints.is_empty());
}
#[test]
fn context_manager_no_providers_returns_default() {
let manager = ContextManager::new();
let ctx = manager.gather();
assert!(ctx.app_id.is_none());
assert!(ctx.app_name.is_none());
assert!(ctx.vocabulary_hints.is_empty());
}
struct StubProvider {
name: &'static str,
context: Context,
}
impl ContextProvider for StubProvider {
fn name(&self) -> &str {
self.name
}
fn get_context(&self) -> Context {
self.context.clone()
}
}
#[test]
fn context_manager_merges_later_overrides_earlier() {
let mut manager = ContextManager::new();
manager.add_provider(Box::new(StubProvider {
name: "first",
context: Context {
app_id: Some("com.first.App".to_string()),
app_name: Some("First App".to_string()),
window_title: Some("First Window".to_string()),
..Default::default()
},
}));
manager.add_provider(Box::new(StubProvider {
name: "second",
context: Context {
app_id: Some("com.second.App".to_string()),
file_language: Some("rust".to_string()),
..Default::default()
},
}));
let ctx = manager.gather();
assert_eq!(ctx.app_id.as_deref(), Some("com.second.App"));
assert_eq!(ctx.app_name.as_deref(), Some("First App"));
assert_eq!(ctx.window_title.as_deref(), Some("First Window"));
assert_eq!(ctx.file_language.as_deref(), Some("rust"));
}
#[test]
fn context_manager_deduplicates_vocabulary_hints() {
let mut manager = ContextManager::new();
manager.add_provider(Box::new(StubProvider {
name: "a",
context: Context {
vocabulary_hints: vec!["murmur".to_string(), "whisper".to_string()],
..Default::default()
},
}));
manager.add_provider(Box::new(StubProvider {
name: "b",
context: Context {
vocabulary_hints: vec!["whisper".to_string(), "dictation".to_string()],
..Default::default()
},
}));
let ctx = manager.gather();
assert_eq!(ctx.vocabulary_hints, vec!["murmur", "whisper", "dictation"]);
}
#[test]
fn dictation_mode_serde_roundtrip() {
let modes = [
DictationMode::Prose,
DictationMode::Code,
DictationMode::Command,
DictationMode::List,
];
for mode in &modes {
let json = serde_json::to_string(mode).unwrap();
let deserialized: DictationMode = serde_json::from_str(&json).unwrap();
assert_eq!(*mode, deserialized);
}
}
#[test]
fn dictation_mode_serde_snake_case() {
assert_eq!(
serde_json::to_string(&DictationMode::Prose).unwrap(),
"\"prose\""
);
assert_eq!(
serde_json::to_string(&DictationMode::Code).unwrap(),
"\"code\""
);
assert_eq!(
serde_json::to_string(&DictationMode::Command).unwrap(),
"\"command\""
);
assert_eq!(
serde_json::to_string(&DictationMode::List).unwrap(),
"\"list\""
);
}
#[test]
fn dictation_mode_display() {
assert_eq!(DictationMode::Prose.to_string(), "Prose");
assert_eq!(DictationMode::Code.to_string(), "Code");
assert_eq!(DictationMode::Command.to_string(), "Command");
assert_eq!(DictationMode::List.to_string(), "List");
}
#[test]
fn dictation_mode_default_is_prose() {
assert_eq!(DictationMode::default(), DictationMode::Prose);
}
}