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<ProviderDescriptor> (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
This commit is contained in:
2026-03-26 12:22:37 +01:00
parent 71d78ce7df
commit 915dc193d9
3 changed files with 495 additions and 1 deletions

View File

@@ -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<ProviderType> = 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::<ProviderType>().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
);
}
}

View File

@@ -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<String>,
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<Box<dyn Provider>> = 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<Arc<crate::plugins::native_loader::NativePlugin>> =
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<ProviderType> {
pub fn available_provider_types(&self) -> Vec<ProviderType> {
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<ProviderDescriptor> {
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<LaunchItem> {
@@ -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<LaunchItem>,
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<LaunchItem>) -> 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<Box<dyn Provider>> = 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<Box<dyn Provider>> = 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<Box<dyn Provider>> = 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<Box<dyn Provider>> = 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<dyn Provider>
}
#[test]
fn test_refresh_provider_unknown_does_not_panic() {
let providers: Vec<Box<dyn Provider>> = 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<Box<dyn Provider>> = 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");
}
}

View File

@@ -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) {