diff --git a/src/plugin.rs b/src/plugin.rs index 02dbf123..71bd252e 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -73,6 +73,20 @@ pub trait Plugin: Send + Sync { Vec::new() } + /// Optional query head prefixes that should route to this plugin. + /// + /// Prefix matching is case-insensitive and uses the first token of the + /// query. Plugins that return an empty slice are considered global and run + /// for all queries. + fn query_prefixes(&self) -> &[&str] { + &[] + } + + /// Opt-out of prefix routing and always run this plugin for searches. + fn always_search(&self) -> bool { + false + } + /// Return default settings for this plugin if any. fn default_settings(&self) -> Option { None @@ -295,6 +309,10 @@ impl PluginManager { enabled_caps: Option<&std::collections::HashMap>>, ) -> Vec { let (filtered_query, filters) = split_action_filters(query); + let query_head = filtered_query + .split_whitespace() + .next() + .map(str::to_ascii_lowercase); let mut actions = Vec::new(); for p in &self.plugins { let name = p.name(); @@ -310,6 +328,22 @@ impl PluginManager { } } } + + if !p.always_search() { + let prefixes = p.query_prefixes(); + if !prefixes.is_empty() { + let Some(head) = query_head.as_deref() else { + continue; + }; + if !prefixes + .iter() + .any(|prefix| prefix.eq_ignore_ascii_case(head)) + { + continue; + } + } + } + actions.extend(p.search(&filtered_query)); } if filters.include_kinds.is_empty() diff --git a/src/plugins/bookmarks.rs b/src/plugins/bookmarks.rs index b9b5ba3a..eecd6983 100644 --- a/src/plugins/bookmarks.rs +++ b/src/plugins/bookmarks.rs @@ -353,4 +353,8 @@ impl Plugin for BookmarksPlugin { }, ] } + + fn query_prefixes(&self) -> &[&str] { + &["bm"] + } } diff --git a/src/plugins/folders.rs b/src/plugins/folders.rs index ed7ff542..950f762a 100644 --- a/src/plugins/folders.rs +++ b/src/plugins/folders.rs @@ -293,4 +293,8 @@ impl Plugin for FoldersPlugin { }, ] } + + fn query_prefixes(&self) -> &[&str] { + &["f"] + } } diff --git a/src/plugins/note.rs b/src/plugins/note.rs index 865b6d18..c91602de 100644 --- a/src/plugins/note.rs +++ b/src/plugins/note.rs @@ -1440,6 +1440,10 @@ impl Plugin for NotePlugin { } self.external_open = cfg.external_open; } + + fn query_prefixes(&self) -> &[&str] { + &["note", "notes"] + } } #[cfg(test)] diff --git a/src/plugins/omni_search.rs b/src/plugins/omni_search.rs index 3498e440..2f1adfcf 100644 --- a/src/plugins/omni_search.rs +++ b/src/plugins/omni_search.rs @@ -110,6 +110,10 @@ impl Plugin for OmniSearchPlugin { }, ] } + + fn query_prefixes(&self) -> &[&str] { + &["o"] + } } impl OmniSearchPlugin { diff --git a/src/plugins/timer.rs b/src/plugins/timer.rs index 182c9a95..d27aa03e 100644 --- a/src/plugins/timer.rs +++ b/src/plugins/timer.rs @@ -869,4 +869,8 @@ impl Plugin for TimerPlugin { }, ] } + + fn query_prefixes(&self) -> &[&str] { + &["timer", "alarm"] + } } diff --git a/src/plugins/todo.rs b/src/plugins/todo.rs index d6d7f5d7..1004c380 100644 --- a/src/plugins/todo.rs +++ b/src/plugins/todo.rs @@ -1036,6 +1036,10 @@ impl Plugin for TodoPlugin { }, ] } + + fn query_prefixes(&self) -> &[&str] { + &["todo"] + } } #[cfg(test)] diff --git a/tests/plugin_routing.rs b/tests/plugin_routing.rs new file mode 100644 index 00000000..81c8ee0b --- /dev/null +++ b/tests/plugin_routing.rs @@ -0,0 +1,164 @@ +use multi_launcher::actions::Action; +use multi_launcher::plugin::{Plugin, PluginManager}; +use multi_launcher::plugins::todo::TodoPlugin; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; + +struct CountingPlugin { + name: &'static str, + prefixes: &'static [&'static str], + always_search: bool, + calls: Arc, +} + +impl CountingPlugin { + fn new( + name: &'static str, + prefixes: &'static [&'static str], + always_search: bool, + calls: Arc, + ) -> Self { + Self { + name, + prefixes, + always_search, + calls, + } + } +} + +impl Plugin for CountingPlugin { + fn search(&self, query: &str) -> Vec { + self.calls.fetch_add(1, Ordering::SeqCst); + vec![Action { + label: format!("{}:{query}", self.name), + desc: "test".into(), + action: self.name.into(), + args: None, + }] + } + + fn name(&self) -> &str { + self.name + } + + fn description(&self) -> &str { + "test" + } + + fn capabilities(&self) -> &[&str] { + &["search"] + } + + fn query_prefixes(&self) -> &[&str] { + self.prefixes + } + + fn always_search(&self) -> bool { + self.always_search + } +} + +#[test] +fn routing_selects_expected_plugins() { + let todo_calls = Arc::new(AtomicUsize::new(0)); + let timer_calls = Arc::new(AtomicUsize::new(0)); + let global_calls = Arc::new(AtomicUsize::new(0)); + + let mut pm = PluginManager::new(); + pm.register(Box::new(CountingPlugin::new( + "todo_plugin", + &["todo"], + false, + todo_calls.clone(), + ))); + pm.register(Box::new(CountingPlugin::new( + "timer_plugin", + &["timer"], + false, + timer_calls.clone(), + ))); + pm.register(Box::new(CountingPlugin::new( + "global_plugin", + &[], + false, + global_calls.clone(), + ))); + + let out = pm.search_filtered("todo list", None, None); + assert_eq!(todo_calls.load(Ordering::SeqCst), 1); + assert_eq!(timer_calls.load(Ordering::SeqCst), 0); + assert_eq!(global_calls.load(Ordering::SeqCst), 1); + assert!(out.iter().any(|a| a.action == "todo_plugin")); + assert!(out.iter().any(|a| a.action == "global_plugin")); + assert!(!out.iter().any(|a| a.action == "timer_plugin")); +} + +#[test] +fn global_plugins_and_opt_out_plugins_still_run() { + let global_calls = Arc::new(AtomicUsize::new(0)); + let opt_out_calls = Arc::new(AtomicUsize::new(0)); + let prefixed_calls = Arc::new(AtomicUsize::new(0)); + + let mut pm = PluginManager::new(); + pm.register(Box::new(CountingPlugin::new( + "global", + &[], + false, + global_calls.clone(), + ))); + pm.register(Box::new(CountingPlugin::new( + "always", + &["timer"], + true, + opt_out_calls.clone(), + ))); + pm.register(Box::new(CountingPlugin::new( + "prefixed", + &["todo"], + false, + prefixed_calls.clone(), + ))); + + pm.search_filtered("plain query", None, None); + assert_eq!(global_calls.load(Ordering::SeqCst), 1); + assert_eq!(opt_out_calls.load(Ordering::SeqCst), 1); + assert_eq!(prefixed_calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn existing_prefix_commands_remain_equivalent() { + let plugin = TodoPlugin::default(); + let direct = plugin.search("todo list"); + + let mut pm = PluginManager::new(); + pm.register(Box::new(TodoPlugin::default())); + let routed = pm.search_filtered("todo list", None, None); + + let routed_view: Vec<_> = routed + .iter() + .map(|a| { + ( + a.label.as_str(), + a.desc.as_str(), + a.action.as_str(), + a.args.as_ref(), + ) + }) + .collect(); + let direct_view: Vec<_> = direct + .iter() + .map(|a| { + ( + a.label.as_str(), + a.desc.as_str(), + a.action.as_str(), + a.args.as_ref(), + ) + }) + .collect(); + + assert_eq!(routed_view, direct_view); +}