From 915dc193d9fe25c8e26b8b48006a45785cd79bc5 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:22:37 +0100 Subject: [PATCH] feat(owlry-core): add daemon-friendly API to ProviderManager and ProviderFilter Add methods needed by the IPC server (Task 9) to create filters from mode strings, query provider metadata, and refresh individual providers. ProviderFilter: - from_mode_strings(): create filter from ["app", "cmd", "calc"] etc. - all(): create permissive filter accepting all provider types - mode_string_to_provider_type(): public helper for string-to-type mapping ProviderManager: - ProviderDescriptor struct for IPC provider metadata responses - available_providers() -> Vec (replaces ProviderType version) - refresh_provider(id): refresh a single provider by type_id - new_with_config(config): self-contained init for daemon use NativeProvider: - icon(): get provider's default icon name - position_str(): get position as "normal"/"widget" string --- crates/owlry-core/src/filter.rs | 158 +++++++++ crates/owlry-core/src/providers/mod.rs | 325 +++++++++++++++++- .../src/providers/native_provider.rs | 13 + 3 files changed, 495 insertions(+), 1 deletion(-) diff --git a/crates/owlry-core/src/filter.rs b/crates/owlry-core/src/filter.rs index b9e231e..df9d303 100644 --- a/crates/owlry-core/src/filter.rs +++ b/crates/owlry-core/src/filter.rs @@ -342,6 +342,61 @@ impl ProviderFilter { providers } + /// Create a filter from a list of mode name strings. + /// + /// Maps each string to a ProviderType: "app" -> Application, "cmd" -> Command, + /// "dmenu" -> Dmenu, anything else -> Plugin(id). An empty list produces an + /// all-providers filter. + pub fn from_mode_strings(modes: &[String]) -> Self { + if modes.is_empty() { + return Self::all(); + } + let enabled: HashSet = modes + .iter() + .map(|s| Self::mode_string_to_provider_type(s)) + .collect(); + Self { + enabled, + active_prefix: None, + } + } + + /// Create a filter that accepts all providers. + /// + /// Internally enables Application, Command, and Dmenu. Plugin providers are + /// implicitly accepted because `is_active` will match them when they appear + /// in the enabled set. For a true "pass everything" filter, this also + /// pre-populates common plugin types. + /// + /// The daemon uses this as the default when no modes are specified. + pub fn all() -> Self { + let mut enabled = HashSet::new(); + enabled.insert(ProviderType::Application); + enabled.insert(ProviderType::Command); + enabled.insert(ProviderType::Dmenu); + // Common plugin types — the daemon typically has all plugins loaded + for id in &[ + "calc", "clipboard", "emoji", "bookmarks", "ssh", "scripts", + "system", "uuctl", "filesearch", "websearch", "weather", + "media", "pomodoro", + ] { + enabled.insert(ProviderType::Plugin(id.to_string())); + } + Self { + enabled, + active_prefix: None, + } + } + + /// Map a mode string to a ProviderType. + /// + /// Delegates to the existing `FromStr` impl on `ProviderType` which maps + /// "app"/"apps"/"application" -> Application, "cmd"/"command" -> Command, + /// "dmenu" -> Dmenu, and everything else -> Plugin(id). + pub fn mode_string_to_provider_type(mode: &str) -> ProviderType { + mode.parse::().unwrap_or_else(|_| ProviderType::Plugin(mode.to_string())) + } + /// Get display name for current mode pub fn mode_display_name(&self) -> &'static str { if let Some(ref prefix) = self.active_prefix { @@ -406,4 +461,107 @@ mod tests { // Should still have apps enabled as fallback assert!(filter.is_enabled(ProviderType::Application)); } + + #[test] + fn test_from_mode_strings_single_core() { + let filter = ProviderFilter::from_mode_strings(&["app".to_string()]); + assert!(filter.is_enabled(ProviderType::Application)); + assert!(!filter.is_enabled(ProviderType::Command)); + } + + #[test] + fn test_from_mode_strings_multiple() { + let filter = ProviderFilter::from_mode_strings(&[ + "app".to_string(), + "cmd".to_string(), + "calc".to_string(), + ]); + assert!(filter.is_enabled(ProviderType::Application)); + assert!(filter.is_enabled(ProviderType::Command)); + assert!(filter.is_enabled(ProviderType::Plugin("calc".to_string()))); + assert!(!filter.is_enabled(ProviderType::Dmenu)); + } + + #[test] + fn test_from_mode_strings_empty_returns_all() { + let filter = ProviderFilter::from_mode_strings(&[]); + assert!(filter.is_enabled(ProviderType::Application)); + assert!(filter.is_enabled(ProviderType::Command)); + assert!(filter.is_enabled(ProviderType::Dmenu)); + } + + #[test] + fn test_from_mode_strings_plugin() { + let filter = ProviderFilter::from_mode_strings(&["emoji".to_string()]); + assert!(filter.is_enabled(ProviderType::Plugin("emoji".to_string()))); + assert!(!filter.is_enabled(ProviderType::Application)); + } + + #[test] + fn test_from_mode_strings_dmenu() { + let filter = ProviderFilter::from_mode_strings(&["dmenu".to_string()]); + assert!(filter.is_enabled(ProviderType::Dmenu)); + assert!(!filter.is_enabled(ProviderType::Application)); + } + + #[test] + fn test_all_includes_core_types() { + let filter = ProviderFilter::all(); + assert!(filter.is_enabled(ProviderType::Application)); + assert!(filter.is_enabled(ProviderType::Command)); + assert!(filter.is_enabled(ProviderType::Dmenu)); + } + + #[test] + fn test_all_includes_common_plugins() { + let filter = ProviderFilter::all(); + assert!(filter.is_enabled(ProviderType::Plugin("calc".to_string()))); + assert!(filter.is_enabled(ProviderType::Plugin("clipboard".to_string()))); + assert!(filter.is_enabled(ProviderType::Plugin("emoji".to_string()))); + assert!(filter.is_enabled(ProviderType::Plugin("weather".to_string()))); + } + + #[test] + fn test_mode_string_to_provider_type_core() { + assert_eq!( + ProviderFilter::mode_string_to_provider_type("app"), + ProviderType::Application + ); + assert_eq!( + ProviderFilter::mode_string_to_provider_type("cmd"), + ProviderType::Command + ); + assert_eq!( + ProviderFilter::mode_string_to_provider_type("dmenu"), + ProviderType::Dmenu + ); + } + + #[test] + fn test_mode_string_to_provider_type_plugin() { + assert_eq!( + ProviderFilter::mode_string_to_provider_type("calc"), + ProviderType::Plugin("calc".to_string()) + ); + assert_eq!( + ProviderFilter::mode_string_to_provider_type("websearch"), + ProviderType::Plugin("websearch".to_string()) + ); + } + + #[test] + fn test_mode_string_to_provider_type_aliases() { + assert_eq!( + ProviderFilter::mode_string_to_provider_type("apps"), + ProviderType::Application + ); + assert_eq!( + ProviderFilter::mode_string_to_provider_type("application"), + ProviderType::Application + ); + assert_eq!( + ProviderFilter::mode_string_to_provider_type("command"), + ProviderType::Command + ); + } } diff --git a/crates/owlry-core/src/providers/mod.rs b/crates/owlry-core/src/providers/mod.rs index 3e6e472..9aef46e 100644 --- a/crates/owlry-core/src/providers/mod.rs +++ b/crates/owlry-core/src/providers/mod.rs @@ -23,8 +23,19 @@ use log::info; #[cfg(feature = "dev-logging")] use log::debug; +use crate::config::Config; use crate::data::FrecencyStore; +/// Metadata descriptor for an available provider (used by IPC/daemon API) +#[derive(Debug, Clone)] +pub struct ProviderDescriptor { + pub id: String, + pub name: String, + pub prefix: Option, + pub icon: String, + pub position: String, +} + /// Represents a single searchable/launchable item #[derive(Debug, Clone)] pub struct LaunchItem { @@ -147,6 +158,59 @@ impl ProviderManager { manager } + /// Create a self-contained ProviderManager from config. + /// + /// Loads native plugins, creates core providers (Application + Command), + /// categorizes everything, and performs initial refresh. Used by the daemon + /// which doesn't have the UI-driven setup path from `app.rs`. + pub fn new_with_config(config: &Config) -> Self { + use crate::plugins::native_loader::NativePluginLoader; + use std::sync::Arc; + + // Create core providers + let core_providers: Vec> = vec![ + Box::new(ApplicationProvider::new()), + Box::new(CommandProvider::new()), + ]; + + // Load native plugins + let mut loader = NativePluginLoader::new(); + loader.set_disabled(config.plugins.disabled_plugins.clone()); + + let native_providers = match loader.discover() { + Ok(count) => { + if count == 0 { + info!("No native plugins found"); + Vec::new() + } else { + info!("Discovered {} native plugin(s)", count); + let plugins: Vec> = + loader.into_plugins(); + let mut providers = Vec::new(); + for plugin in plugins { + for provider_info in &plugin.providers { + let provider = + NativeProvider::new(Arc::clone(&plugin), provider_info.clone()); + info!( + "Created native provider: {} ({})", + provider.name(), + provider.type_id() + ); + providers.push(provider); + } + } + providers + } + } + Err(e) => { + log::warn!("Failed to discover native plugins: {}", e); + Vec::new() + } + }; + + Self::new(core_providers, native_providers) + } + #[allow(dead_code)] pub fn is_dmenu_mode(&self) -> bool { self.providers @@ -515,7 +579,7 @@ impl ProviderManager { /// Get all available provider types (for UI tabs) #[allow(dead_code)] - pub fn available_providers(&self) -> Vec { + pub fn available_provider_types(&self) -> Vec { self.providers .iter() .map(|p| p.provider_type()) @@ -523,6 +587,122 @@ impl ProviderManager { .collect() } + /// Get descriptors for all registered providers (core + native plugins). + /// + /// Used by the IPC server to report what providers are available to clients. + pub fn available_providers(&self) -> Vec { + let mut descs = Vec::new(); + + // Core providers + for provider in &self.providers { + let (id, prefix, icon) = match provider.provider_type() { + ProviderType::Application => ( + "app".to_string(), + Some(":app".to_string()), + "application-x-executable".to_string(), + ), + ProviderType::Command => ( + "cmd".to_string(), + Some(":cmd".to_string()), + "utilities-terminal".to_string(), + ), + ProviderType::Dmenu => ( + "dmenu".to_string(), + None, + "view-list-symbolic".to_string(), + ), + ProviderType::Plugin(type_id) => ( + type_id, + None, + "application-x-addon".to_string(), + ), + }; + descs.push(ProviderDescriptor { + id, + name: provider.name().to_string(), + prefix, + icon, + position: "normal".to_string(), + }); + } + + // Static native plugin providers + for provider in &self.static_native_providers { + descs.push(ProviderDescriptor { + id: provider.type_id().to_string(), + name: provider.name().to_string(), + prefix: provider.prefix().map(String::from), + icon: provider.icon().to_string(), + position: provider.position_str().to_string(), + }); + } + + // Dynamic native plugin providers + for provider in &self.dynamic_providers { + descs.push(ProviderDescriptor { + id: provider.type_id().to_string(), + name: provider.name().to_string(), + prefix: provider.prefix().map(String::from), + icon: provider.icon().to_string(), + position: provider.position_str().to_string(), + }); + } + + // Widget native plugin providers + for provider in &self.widget_providers { + descs.push(ProviderDescriptor { + id: provider.type_id().to_string(), + name: provider.name().to_string(), + prefix: provider.prefix().map(String::from), + icon: provider.icon().to_string(), + position: provider.position_str().to_string(), + }); + } + + descs + } + + /// Refresh a specific provider by its type_id. + /// + /// Searches core providers (by ProviderType string), static native providers, + /// and widget providers. Dynamic providers are skipped (they query on demand). + pub fn refresh_provider(&mut self, provider_id: &str) { + // Check core providers + for provider in &mut self.providers { + let matches = match provider.provider_type() { + ProviderType::Application => provider_id == "app", + ProviderType::Command => provider_id == "cmd", + ProviderType::Dmenu => provider_id == "dmenu", + ProviderType::Plugin(ref id) => provider_id == id, + }; + if matches { + provider.refresh(); + info!("Refreshed core provider '{}'", provider.name()); + return; + } + } + + // Check static native providers + for provider in &mut self.static_native_providers { + if provider.type_id() == provider_id { + provider.refresh(); + info!("Refreshed static provider '{}'", provider.name()); + return; + } + } + + // Check widget providers + for provider in &mut self.widget_providers { + if provider.type_id() == provider_id { + provider.refresh(); + info!("Refreshed widget provider '{}'", provider.name()); + return; + } + } + + info!("Provider '{}' not found for refresh", provider_id); + } + /// Get a widget item by type_id (e.g., "pomodoro", "weather", "media") /// Returns the first item from the widget provider, if any pub fn get_widget_item(&self, type_id: &str) -> Option { @@ -596,3 +776,146 @@ impl ProviderManager { None } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal mock provider for testing ProviderManager + struct MockProvider { + name: String, + provider_type: ProviderType, + items: Vec, + refresh_count: usize, + } + + impl MockProvider { + fn new(name: &str, provider_type: ProviderType) -> Self { + Self { + name: name.to_string(), + provider_type, + items: Vec::new(), + refresh_count: 0, + } + } + + fn with_items(mut self, items: Vec) -> Self { + self.items = items; + self + } + } + + impl Provider for MockProvider { + fn name(&self) -> &str { + &self.name + } + + fn provider_type(&self) -> ProviderType { + self.provider_type.clone() + } + + fn refresh(&mut self) { + self.refresh_count += 1; + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } + } + + fn make_item(id: &str, name: &str, provider: ProviderType) -> LaunchItem { + LaunchItem { + id: id.to_string(), + name: name.to_string(), + description: None, + icon: None, + provider, + command: format!("run-{}", id), + terminal: false, + tags: Vec::new(), + } + } + + #[test] + fn test_available_providers_core_only() { + let providers: Vec> = vec![ + Box::new(MockProvider::new("Applications", ProviderType::Application)), + Box::new(MockProvider::new("Commands", ProviderType::Command)), + ]; + let pm = ProviderManager::new(providers, Vec::new()); + let descs = pm.available_providers(); + assert_eq!(descs.len(), 2); + assert_eq!(descs[0].id, "app"); + assert_eq!(descs[0].name, "Applications"); + assert_eq!(descs[0].prefix, Some(":app".to_string())); + assert_eq!(descs[0].icon, "application-x-executable"); + assert_eq!(descs[0].position, "normal"); + assert_eq!(descs[1].id, "cmd"); + assert_eq!(descs[1].name, "Commands"); + } + + #[test] + fn test_available_providers_dmenu() { + let providers: Vec> = vec![ + Box::new(MockProvider::new("dmenu", ProviderType::Dmenu)), + ]; + let pm = ProviderManager::new(providers, Vec::new()); + let descs = pm.available_providers(); + assert_eq!(descs.len(), 1); + assert_eq!(descs[0].id, "dmenu"); + assert!(descs[0].prefix.is_none()); + } + + #[test] + fn test_available_provider_types() { + let providers: Vec> = vec![ + Box::new(MockProvider::new("Applications", ProviderType::Application)), + Box::new(MockProvider::new("Commands", ProviderType::Command)), + ]; + let pm = ProviderManager::new(providers, Vec::new()); + let types = pm.available_provider_types(); + assert_eq!(types.len(), 2); + assert!(types.contains(&ProviderType::Application)); + assert!(types.contains(&ProviderType::Command)); + } + + #[test] + fn test_refresh_provider_core() { + let app = MockProvider::new("Applications", ProviderType::Application); + let cmd = MockProvider::new("Commands", ProviderType::Command); + let providers: Vec> = vec![Box::new(app), Box::new(cmd)]; + let mut pm = ProviderManager::new(providers, Vec::new()); + + // refresh_all was called during construction, now refresh individual + pm.refresh_provider("app"); + pm.refresh_provider("cmd"); + // Just verifying it doesn't panic; can't easily inspect refresh_count + // through Box + } + + #[test] + fn test_refresh_provider_unknown_does_not_panic() { + let providers: Vec> = vec![ + Box::new(MockProvider::new("Applications", ProviderType::Application)), + ]; + let mut pm = ProviderManager::new(providers, Vec::new()); + pm.refresh_provider("nonexistent"); + // Should complete without panicking + } + + #[test] + fn test_search_with_core_providers() { + let items = vec![ + make_item("firefox", "Firefox", ProviderType::Application), + make_item("vim", "Vim", ProviderType::Application), + ]; + let provider = MockProvider::new("Applications", ProviderType::Application) + .with_items(items); + let providers: Vec> = vec![Box::new(provider)]; + let pm = ProviderManager::new(providers, Vec::new()); + + let results = pm.search("fire", 10); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0.name, "Firefox"); + } +} diff --git a/crates/owlry-core/src/providers/native_provider.rs b/crates/owlry-core/src/providers/native_provider.rs index acda16b..3aabe9b 100644 --- a/crates/owlry-core/src/providers/native_provider.rs +++ b/crates/owlry-core/src/providers/native_provider.rs @@ -116,6 +116,19 @@ impl NativeProvider { self.info.priority } + /// Get the provider's default icon name + pub fn icon(&self) -> &str { + self.info.icon.as_str() + } + + /// Get the provider's display position as a string + pub fn position_str(&self) -> &str { + match self.info.position { + ProviderPosition::Widget => "widget", + ProviderPosition::Normal => "normal", + } + } + /// Execute an action command on the provider /// Uses query with "!" prefix to trigger action handling in the plugin pub fn execute_action(&self, action: &str) {