From d79c9087fd63dfbf50ab8bd8c1c66aa45f19a6c3 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:06:34 +0100 Subject: [PATCH] feat(owlry-core): move backend modules from owlry Move the following modules from crates/owlry/src/ to crates/owlry-core/src/: - config/ (configuration loading and types) - data/ (frecency store) - filter.rs (provider filtering and prefix parsing) - notify.rs (desktop notifications) - paths.rs (XDG path handling) - plugins/ (plugin system: native loader, manifest, registry, runtime loader, Lua API) - providers/ (provider trait, manager, application, command, native_provider, lua_provider) Notable changes from the original: - providers/mod.rs: ProviderManager constructor changed from with_native_plugins() to new(core_providers, native_providers) to decouple from DmenuProvider (which stays in owlry as a UI concern) - plugins/mod.rs: commands module removed (stays in owlry as CLI concern) - Added thiserror and tempfile dependencies to owlry-core Cargo.toml --- crates/owlry-core/src/config/mod.rs | 574 +++++++++++++++++ crates/owlry-core/src/data/frecency.rs | 219 +++++++ crates/owlry-core/src/data/mod.rs | 3 + crates/owlry-core/src/filter.rs | 409 ++++++++++++ crates/owlry-core/src/notify.rs | 91 +++ crates/owlry-core/src/paths.rs | 203 ++++++ crates/owlry-core/src/plugins/api/action.rs | 322 ++++++++++ crates/owlry-core/src/plugins/api/cache.rs | 299 +++++++++ crates/owlry-core/src/plugins/api/hook.rs | 410 ++++++++++++ crates/owlry-core/src/plugins/api/http.rs | 345 ++++++++++ crates/owlry-core/src/plugins/api/math.rs | 181 ++++++ crates/owlry-core/src/plugins/api/mod.rs | 77 +++ crates/owlry-core/src/plugins/api/process.rs | 207 ++++++ crates/owlry-core/src/plugins/api/provider.rs | 315 +++++++++ crates/owlry-core/src/plugins/api/theme.rs | 275 ++++++++ crates/owlry-core/src/plugins/api/utils.rs | 567 +++++++++++++++++ crates/owlry-core/src/plugins/error.rs | 51 ++ crates/owlry-core/src/plugins/loader.rs | 205 ++++++ crates/owlry-core/src/plugins/manifest.rs | 318 ++++++++++ crates/owlry-core/src/plugins/mod.rs | 336 ++++++++++ .../owlry-core/src/plugins/native_loader.rs | 391 ++++++++++++ crates/owlry-core/src/plugins/registry.rs | 293 +++++++++ crates/owlry-core/src/plugins/runtime.rs | 153 +++++ .../owlry-core/src/plugins/runtime_loader.rs | 286 +++++++++ .../owlry-core/src/providers/application.rs | 266 ++++++++ crates/owlry-core/src/providers/command.rs | 106 ++++ .../owlry-core/src/providers/lua_provider.rs | 142 +++++ crates/owlry-core/src/providers/mod.rs | 598 ++++++++++++++++++ .../src/providers/native_provider.rs | 197 ++++++ 29 files changed, 7839 insertions(+) create mode 100644 crates/owlry-core/src/config/mod.rs create mode 100644 crates/owlry-core/src/data/frecency.rs create mode 100644 crates/owlry-core/src/data/mod.rs create mode 100644 crates/owlry-core/src/filter.rs create mode 100644 crates/owlry-core/src/notify.rs create mode 100644 crates/owlry-core/src/paths.rs create mode 100644 crates/owlry-core/src/plugins/api/action.rs create mode 100644 crates/owlry-core/src/plugins/api/cache.rs create mode 100644 crates/owlry-core/src/plugins/api/hook.rs create mode 100644 crates/owlry-core/src/plugins/api/http.rs create mode 100644 crates/owlry-core/src/plugins/api/math.rs create mode 100644 crates/owlry-core/src/plugins/api/mod.rs create mode 100644 crates/owlry-core/src/plugins/api/process.rs create mode 100644 crates/owlry-core/src/plugins/api/provider.rs create mode 100644 crates/owlry-core/src/plugins/api/theme.rs create mode 100644 crates/owlry-core/src/plugins/api/utils.rs create mode 100644 crates/owlry-core/src/plugins/error.rs create mode 100644 crates/owlry-core/src/plugins/loader.rs create mode 100644 crates/owlry-core/src/plugins/manifest.rs create mode 100644 crates/owlry-core/src/plugins/mod.rs create mode 100644 crates/owlry-core/src/plugins/native_loader.rs create mode 100644 crates/owlry-core/src/plugins/registry.rs create mode 100644 crates/owlry-core/src/plugins/runtime.rs create mode 100644 crates/owlry-core/src/plugins/runtime_loader.rs create mode 100644 crates/owlry-core/src/providers/application.rs create mode 100644 crates/owlry-core/src/providers/command.rs create mode 100644 crates/owlry-core/src/providers/lua_provider.rs create mode 100644 crates/owlry-core/src/providers/mod.rs create mode 100644 crates/owlry-core/src/providers/native_provider.rs diff --git a/crates/owlry-core/src/config/mod.rs b/crates/owlry-core/src/config/mod.rs new file mode 100644 index 0000000..dc6a57f --- /dev/null +++ b/crates/owlry-core/src/config/mod.rs @@ -0,0 +1,574 @@ +use log::{debug, info, warn}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Command; + +use crate::paths; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + #[serde(default)] + pub general: GeneralConfig, + #[serde(default)] + pub appearance: AppearanceConfig, + #[serde(default)] + pub providers: ProvidersConfig, + #[serde(default)] + pub plugins: PluginsConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeneralConfig { + #[serde(default = "default_true")] + pub show_icons: bool, + #[serde(default = "default_max_results")] + pub max_results: usize, + /// Terminal command (auto-detected if not specified) + #[serde(default)] + pub terminal_command: Option, + /// Enable uwsm (Universal Wayland Session Manager) for launching apps. + /// When enabled, desktop files are launched via `uwsm app -- ` + /// which starts apps in a proper systemd user session. + /// When disabled (default), apps are launched via `gio launch`. + #[serde(default)] + pub use_uwsm: bool, + /// Provider tabs shown in the header bar. + /// Valid values: app, cmd, uuctl, bookmark, calc, clip, dmenu, emoji, file, script, ssh, sys, web + #[serde(default = "default_tabs")] + pub tabs: Vec, +} + +impl Default for GeneralConfig { + fn default() -> Self { + Self { + show_icons: true, + max_results: 100, + terminal_command: None, + use_uwsm: false, + tabs: default_tabs(), + } + } +} + +fn default_max_results() -> usize { + 100 +} + +fn default_tabs() -> Vec { + vec![ + "app".to_string(), + "cmd".to_string(), + "uuctl".to_string(), + ] +} + +/// User-customizable theme colors +/// All fields are optional - unset values inherit from theme or GTK defaults +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ThemeColors { + // Core colors + pub background: Option, + pub background_secondary: Option, + pub border: Option, + pub text: Option, + pub text_secondary: Option, + pub accent: Option, + pub accent_bright: Option, + // Provider badge colors + pub badge_app: Option, + pub badge_bookmark: Option, + pub badge_calc: Option, + pub badge_clip: Option, + pub badge_cmd: Option, + pub badge_dmenu: Option, + pub badge_emoji: Option, + pub badge_file: Option, + pub badge_script: Option, + pub badge_ssh: Option, + pub badge_sys: Option, + pub badge_uuctl: Option, + pub badge_web: Option, + // Widget badge colors + pub badge_media: Option, + pub badge_weather: Option, + pub badge_pomo: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppearanceConfig { + #[serde(default = "default_width")] + pub width: i32, + #[serde(default = "default_height")] + pub height: i32, + #[serde(default = "default_font_size")] + pub font_size: u32, + #[serde(default = "default_border_radius")] + pub border_radius: u32, + /// Theme name: None = GTK default, "owl" = built-in owl theme + #[serde(default)] + pub theme: Option, + /// Individual color overrides + #[serde(default)] + pub colors: ThemeColors, +} + +impl Default for AppearanceConfig { + fn default() -> Self { + Self { + width: 850, + height: 650, + font_size: 14, + border_radius: 12, + theme: None, + colors: ThemeColors::default(), + } + } +} + +fn default_width() -> i32 { 850 } +fn default_height() -> i32 { 650 } +fn default_font_size() -> u32 { 14 } +fn default_border_radius() -> u32 { 12 } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProvidersConfig { + #[serde(default = "default_true")] + pub applications: bool, + #[serde(default = "default_true")] + pub commands: bool, + #[serde(default = "default_true")] + pub uuctl: bool, + /// Enable calculator provider (= expression or calc expression) + #[serde(default = "default_true")] + pub calculator: bool, + /// Enable frecency-based result ranking + #[serde(default = "default_true")] + pub frecency: bool, + /// Weight for frecency boost (0.0 = disabled, 1.0 = strong boost) + #[serde(default = "default_frecency_weight")] + pub frecency_weight: f64, + /// Enable web search provider (? query or web query) + #[serde(default = "default_true")] + pub websearch: bool, + /// Search engine for web search + /// Options: google, duckduckgo, bing, startpage, searxng, brave, ecosia + /// Or custom URL with {query} placeholder + #[serde(default = "default_search_engine")] + pub search_engine: String, + /// Enable system commands (shutdown, reboot, etc.) + #[serde(default = "default_true")] + pub system: bool, + /// Enable SSH connections from ~/.ssh/config + #[serde(default = "default_true")] + pub ssh: bool, + /// Enable clipboard history (requires cliphist) + #[serde(default = "default_true")] + pub clipboard: bool, + /// Enable browser bookmarks + #[serde(default = "default_true")] + pub bookmarks: bool, + /// Enable emoji picker + #[serde(default = "default_true")] + pub emoji: bool, + /// Enable custom scripts from ~/.config/owlry/scripts/ + #[serde(default = "default_true")] + pub scripts: bool, + /// Enable file search (requires fd or locate) + #[serde(default = "default_true")] + pub files: bool, + + // ─── Widget Providers ─────────────────────────────────────────────── + + /// Enable MPRIS media player widget + #[serde(default = "default_true")] + pub media: bool, + + /// Enable weather widget + #[serde(default)] + pub weather: bool, + + /// Weather provider: wttr.in (default), openweathermap, open-meteo + #[serde(default = "default_weather_provider")] + pub weather_provider: String, + + /// API key for weather services that require it (e.g., OpenWeatherMap) + #[serde(default)] + pub weather_api_key: Option, + + /// Location for weather (city name or coordinates) + #[serde(default)] + pub weather_location: Option, + + /// Enable pomodoro timer widget + #[serde(default)] + pub pomodoro: bool, + + /// Pomodoro work duration in minutes + #[serde(default = "default_pomodoro_work")] + pub pomodoro_work_mins: u32, + + /// Pomodoro break duration in minutes + #[serde(default = "default_pomodoro_break")] + pub pomodoro_break_mins: u32, +} + +impl Default for ProvidersConfig { + fn default() -> Self { + Self { + applications: true, + commands: true, + uuctl: true, + calculator: true, + frecency: true, + frecency_weight: 0.3, + websearch: true, + search_engine: "duckduckgo".to_string(), + system: true, + ssh: true, + clipboard: true, + bookmarks: true, + emoji: true, + scripts: true, + files: true, + media: true, + weather: false, + weather_provider: "wttr.in".to_string(), + weather_api_key: None, + weather_location: Some("Berlin".to_string()), + pomodoro: false, + pomodoro_work_mins: 25, + pomodoro_break_mins: 5, + } + } +} + +/// Configuration for plugins +/// +/// Supports per-plugin configuration via `[plugins.]` sections: +/// ```toml +/// [plugins] +/// enabled = true +/// +/// [plugins.weather] +/// location = "Berlin" +/// units = "metric" +/// +/// [plugins.pomodoro] +/// work_mins = 25 +/// break_mins = 5 +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginsConfig { + /// Whether plugins are enabled globally + #[serde(default = "default_true")] + pub enabled: bool, + + /// List of plugin IDs to enable (empty = all discovered plugins) + #[serde(default)] + pub enabled_plugins: Vec, + + /// List of plugin IDs to explicitly disable + #[serde(default)] + pub disabled_plugins: Vec, + + /// Sandbox settings for plugin execution + #[serde(default)] + pub sandbox: SandboxConfig, + + /// Plugin registry URL (for `owlry plugin search` and registry installs) + /// Defaults to the official owlry plugin registry if not specified. + #[serde(default)] + pub registry_url: Option, + + /// Per-plugin configuration tables + /// Accessed via `[plugins.]` sections in config.toml + /// Each plugin can define its own config schema + #[serde(flatten)] + pub plugin_configs: HashMap, +} + +/// Sandbox settings for plugin security +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxConfig { + /// Allow plugins to access the filesystem (beyond their own directory) + #[serde(default)] + pub allow_filesystem: bool, + + /// Allow plugins to make network requests + #[serde(default)] + pub allow_network: bool, + + /// Allow plugins to run shell commands + #[serde(default)] + pub allow_commands: bool, + + /// Memory limit for Lua runtime in bytes (0 = unlimited) + #[serde(default = "default_memory_limit")] + pub memory_limit: usize, +} + +impl Default for PluginsConfig { + fn default() -> Self { + Self { + enabled: true, + enabled_plugins: Vec::new(), + disabled_plugins: Vec::new(), + sandbox: SandboxConfig::default(), + registry_url: None, + plugin_configs: HashMap::new(), + } + } +} + +impl PluginsConfig { + /// Get configuration for a specific plugin by name + /// + /// Returns the plugin's config table if it exists in `[plugins.]` + #[allow(dead_code)] + pub fn get_plugin_config(&self, plugin_name: &str) -> Option<&toml::Value> { + self.plugin_configs.get(plugin_name) + } + + /// Get a string value from a plugin's config + #[allow(dead_code)] + pub fn get_plugin_string(&self, plugin_name: &str, key: &str) -> Option<&str> { + self.plugin_configs + .get(plugin_name)? + .get(key)? + .as_str() + } + + /// Get an integer value from a plugin's config + #[allow(dead_code)] + pub fn get_plugin_int(&self, plugin_name: &str, key: &str) -> Option { + self.plugin_configs + .get(plugin_name)? + .get(key)? + .as_integer() + } + + /// Get a boolean value from a plugin's config + #[allow(dead_code)] + pub fn get_plugin_bool(&self, plugin_name: &str, key: &str) -> Option { + self.plugin_configs + .get(plugin_name)? + .get(key)? + .as_bool() + } +} + +impl Default for SandboxConfig { + fn default() -> Self { + Self { + allow_filesystem: false, + allow_network: false, + allow_commands: false, + memory_limit: default_memory_limit(), + } + } +} + +fn default_memory_limit() -> usize { + 64 * 1024 * 1024 // 64 MB +} + +fn default_search_engine() -> String { + "duckduckgo".to_string() +} + +fn default_true() -> bool { + true +} + +fn default_frecency_weight() -> f64 { + 0.3 +} + +fn default_weather_provider() -> String { + "wttr.in".to_string() +} + +fn default_pomodoro_work() -> u32 { + 25 +} + +fn default_pomodoro_break() -> u32 { + 5 +} + + +/// Detect the best available terminal emulator +/// Fallback chain: +/// 1. $TERMINAL env var (user's explicit preference) +/// 2. xdg-terminal-exec (freedesktop standard - if available) +/// 3. Desktop-environment native terminal (GNOME→gnome-terminal, KDE→konsole, etc.) +/// 4. Common Wayland-native terminals (kitty, alacritty, wezterm, foot) +/// 5. Common X11/legacy terminals +/// 6. x-terminal-emulator (Debian alternatives) +/// 7. xterm (ultimate fallback - the cockroach of terminals) +fn detect_terminal() -> String { + // 1. Check $TERMINAL env var first (user's explicit preference) + if let Ok(term) = std::env::var("TERMINAL") + && !term.is_empty() && command_exists(&term) { + debug!("Using $TERMINAL: {}", term); + return term; + } + + // 2. Try xdg-terminal-exec (freedesktop standard) + if command_exists("xdg-terminal-exec") { + debug!("Using xdg-terminal-exec"); + return "xdg-terminal-exec".to_string(); + } + + // 3. Desktop-environment aware detection + if let Some(term) = detect_de_terminal() { + debug!("Using DE-native terminal: {}", term); + return term; + } + + // 4. Common Wayland-native terminals (preferred for modern setups) + let wayland_terminals = ["kitty", "alacritty", "wezterm", "foot"]; + for term in wayland_terminals { + if command_exists(term) { + debug!("Found Wayland terminal: {}", term); + return term.to_string(); + } + } + + // 5. Common X11/legacy terminals + let legacy_terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "mate-terminal", "tilix", "terminator"]; + for term in legacy_terminals { + if command_exists(term) { + debug!("Found legacy terminal: {}", term); + return term.to_string(); + } + } + + // 6. Try x-terminal-emulator (Debian alternatives system) + if command_exists("x-terminal-emulator") { + debug!("Using x-terminal-emulator"); + return "x-terminal-emulator".to_string(); + } + + // 7. Ultimate fallback - xterm exists everywhere + debug!("Falling back to xterm"); + "xterm".to_string() +} + +/// Detect desktop environment and return its native terminal +fn detect_de_terminal() -> Option { + // Check XDG_CURRENT_DESKTOP first + let desktop = std::env::var("XDG_CURRENT_DESKTOP") + .ok() + .map(|s| s.to_lowercase()); + + // Also check for Wayland compositor-specific env vars + let is_hyprland = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok(); + let is_sway = std::env::var("SWAYSOCK").is_ok(); + + // Map desktop environments to their native/preferred terminals + let candidates: &[&str] = if is_hyprland { + // Hyprland: foot and kitty are most popular in the community + &["foot", "kitty", "alacritty", "wezterm"] + } else if is_sway { + // Sway: foot is the recommended terminal (lightweight, Wayland-native) + &["foot", "alacritty", "kitty", "wezterm"] + } else if let Some(ref de) = desktop { + match de.as_str() { + s if s.contains("gnome") => &["gnome-terminal", "gnome-console", "kgx"], + s if s.contains("kde") || s.contains("plasma") => &["konsole"], + s if s.contains("xfce") => &["xfce4-terminal"], + s if s.contains("mate") => &["mate-terminal"], + s if s.contains("lxqt") => &["qterminal"], + s if s.contains("lxde") => &["lxterminal"], + s if s.contains("cinnamon") => &["gnome-terminal"], + s if s.contains("budgie") => &["tilix", "gnome-terminal"], + s if s.contains("pantheon") => &["io.elementary.terminal", "pantheon-terminal"], + s if s.contains("deepin") => &["deepin-terminal"], + s if s.contains("hyprland") => &["foot", "kitty", "alacritty", "wezterm"], + s if s.contains("sway") => &["foot", "alacritty", "kitty", "wezterm"], + _ => return None, + } + } else { + return None; + }; + + for term in candidates { + if command_exists(term) { + return Some(term.to_string()); + } + } + + None +} + +/// Check if a command exists in PATH +fn command_exists(cmd: &str) -> bool { + Command::new("which") + .arg(cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +// Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default + +impl Config { + pub fn config_path() -> Option { + paths::config_file() + } + + pub fn load_or_default() -> Self { + Self::load().unwrap_or_else(|e| { + warn!("Failed to load config: {}, using defaults", e); + Self::default() + }) + } + + pub fn load() -> Result> { + let path = Self::config_path().ok_or("Could not determine config path")?; + + let mut config = if !path.exists() { + info!("Config file not found, using defaults"); + Self::default() + } else { + let content = std::fs::read_to_string(&path)?; + let config: Config = toml::from_str(&content)?; + info!("Loaded config from {:?}", path); + config + }; + + // Auto-detect terminal if not configured or configured terminal doesn't exist + match &config.general.terminal_command { + None => { + let terminal = detect_terminal(); + info!("Detected terminal: {}", terminal); + config.general.terminal_command = Some(terminal); + } + Some(term) if !command_exists(term) => { + warn!("Configured terminal '{}' not found, auto-detecting", term); + let terminal = detect_terminal(); + info!("Using detected terminal: {}", terminal); + config.general.terminal_command = Some(terminal); + } + Some(term) => { + debug!("Using configured terminal: {}", term); + } + } + + Ok(config) + } + + #[allow(dead_code)] + pub fn save(&self) -> Result<(), Box> { + let path = Self::config_path().ok_or("Could not determine config path")?; + + paths::ensure_parent_dir(&path)?; + + let content = toml::to_string_pretty(self)?; + std::fs::write(&path, content)?; + info!("Saved config to {:?}", path); + Ok(()) + } +} diff --git a/crates/owlry-core/src/data/frecency.rs b/crates/owlry-core/src/data/frecency.rs new file mode 100644 index 0000000..af43413 --- /dev/null +++ b/crates/owlry-core/src/data/frecency.rs @@ -0,0 +1,219 @@ +use chrono::{DateTime, Utc}; +use log::{debug, info, warn}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::paths; + +/// A single frecency entry tracking launch count and recency +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FrecencyEntry { + pub launch_count: u32, + pub last_launch: DateTime, +} + +/// Persistent frecency data store +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FrecencyData { + pub version: u32, + pub entries: HashMap, +} + +impl Default for FrecencyData { + fn default() -> Self { + Self { + version: 1, + entries: HashMap::new(), + } + } +} + +/// Frecency store for tracking and boosting recently/frequently used items +pub struct FrecencyStore { + data: FrecencyData, + path: PathBuf, + dirty: bool, +} + +impl FrecencyStore { + /// Create a new frecency store, loading existing data if available + pub fn new() -> Self { + let path = Self::data_path(); + let data = Self::load_from_path(&path).unwrap_or_default(); + + info!("Frecency store loaded with {} entries", data.entries.len()); + + Self { + data, + path, + dirty: false, + } + } + + /// Alias for new() - loads from disk or creates default + pub fn load_or_default() -> Self { + Self::new() + } + + /// Get the path to the frecency data file + fn data_path() -> PathBuf { + paths::frecency_file().unwrap_or_else(|| PathBuf::from("frecency.json")) + } + + /// Load frecency data from a file + fn load_from_path(path: &PathBuf) -> Option { + if !path.exists() { + debug!("Frecency file not found at {:?}", path); + return None; + } + + let content = std::fs::read_to_string(path).ok()?; + match serde_json::from_str(&content) { + Ok(data) => Some(data), + Err(e) => { + warn!("Failed to parse frecency data: {}", e); + None + } + } + } + + /// Save frecency data to disk + pub fn save(&mut self) -> Result<(), Box> { + if !self.dirty { + return Ok(()); + } + + paths::ensure_parent_dir(&self.path)?; + + let content = serde_json::to_string_pretty(&self.data)?; + std::fs::write(&self.path, content)?; + self.dirty = false; + + debug!("Frecency data saved to {:?}", self.path); + Ok(()) + } + + /// Record a launch event for an item + pub fn record_launch(&mut self, item_id: &str) { + let now = Utc::now(); + + let entry = self + .data + .entries + .entry(item_id.to_string()) + .or_insert(FrecencyEntry { + launch_count: 0, + last_launch: now, + }); + + entry.launch_count += 1; + entry.last_launch = now; + self.dirty = true; + + debug!( + "Recorded launch for '{}': count={}, last={}", + item_id, entry.launch_count, entry.last_launch + ); + + // Auto-save after recording + if let Err(e) = self.save() { + warn!("Failed to save frecency data: {}", e); + } + } + + /// Calculate frecency score for an item + /// Uses Firefox-style algorithm: score = launch_count * recency_weight + pub fn get_score(&self, item_id: &str) -> f64 { + match self.data.entries.get(item_id) { + Some(entry) => Self::calculate_frecency(entry.launch_count, entry.last_launch), + None => 0.0, + } + } + + /// Calculate frecency using Firefox-style algorithm + fn calculate_frecency(launch_count: u32, last_launch: DateTime) -> f64 { + let now = Utc::now(); + let age = now.signed_duration_since(last_launch); + let age_days = age.num_hours() as f64 / 24.0; + + // Recency weight based on how recently the item was used + let recency_weight = if age_days < 1.0 { + 100.0 // Today + } else if age_days < 7.0 { + 70.0 // This week + } else if age_days < 30.0 { + 50.0 // This month + } else if age_days < 90.0 { + 30.0 // This quarter + } else { + 10.0 // Older + }; + + launch_count as f64 * recency_weight + } + + /// Get all entries (for debugging/display) + #[allow(dead_code)] + pub fn entries(&self) -> &HashMap { + &self.data.entries + } + + /// Clear all frecency data + #[allow(dead_code)] + pub fn clear(&mut self) { + self.data.entries.clear(); + self.dirty = true; + } +} + +impl Default for FrecencyStore { + fn default() -> Self { + Self::new() + } +} + +impl Drop for FrecencyStore { + fn drop(&mut self) { + // Attempt to save on drop + if let Err(e) = self.save() { + warn!("Failed to save frecency data on drop: {}", e); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_frecency_calculation() { + let now = Utc::now(); + + // Recent launch should have high score + let score_today = FrecencyStore::calculate_frecency(10, now); + assert!(score_today > 900.0); // 10 * 100 + + // Older launch should have lower score + let week_ago = now - chrono::Duration::days(5); + let score_week = FrecencyStore::calculate_frecency(10, week_ago); + assert!(score_week < score_today); + assert!(score_week > 600.0); // 10 * 70 + + // Much older launch + let month_ago = now - chrono::Duration::days(45); + let score_month = FrecencyStore::calculate_frecency(10, month_ago); + assert!(score_month < score_week); + } + + #[test] + fn test_launch_count_matters() { + let now = Utc::now(); + + let score_few = FrecencyStore::calculate_frecency(2, now); + let score_many = FrecencyStore::calculate_frecency(20, now); + + assert!(score_many > score_few); + assert!((score_many / score_few - 10.0).abs() < 0.1); // Should be ~10x + } +} diff --git a/crates/owlry-core/src/data/mod.rs b/crates/owlry-core/src/data/mod.rs new file mode 100644 index 0000000..8fc1d1b --- /dev/null +++ b/crates/owlry-core/src/data/mod.rs @@ -0,0 +1,3 @@ +mod frecency; + +pub use frecency::FrecencyStore; diff --git a/crates/owlry-core/src/filter.rs b/crates/owlry-core/src/filter.rs new file mode 100644 index 0000000..b9e231e --- /dev/null +++ b/crates/owlry-core/src/filter.rs @@ -0,0 +1,409 @@ +use std::collections::HashSet; + +#[cfg(feature = "dev-logging")] +use log::debug; + +use crate::config::ProvidersConfig; +use crate::providers::ProviderType; + +/// Tracks which providers are enabled and handles prefix-based filtering +#[derive(Debug, Clone)] +pub struct ProviderFilter { + enabled: HashSet, + active_prefix: Option, +} + +/// Result of parsing a query for prefix syntax +#[derive(Debug, Clone)] +pub struct ParsedQuery { + pub prefix: Option, + pub tag_filter: Option, + pub query: String, +} + +impl ProviderFilter { + /// Create filter from CLI args and config + pub fn new( + cli_mode: Option, + cli_providers: Option>, + config_providers: &ProvidersConfig, + ) -> Self { + let enabled = if let Some(mode) = cli_mode { + // --mode overrides everything: single provider + HashSet::from([mode]) + } else if let Some(providers) = cli_providers { + // --providers overrides config + providers.into_iter().collect() + } else { + // Use config file settings, default to apps only + let mut set = HashSet::new(); + // Core providers + if config_providers.applications { + set.insert(ProviderType::Application); + } + if config_providers.commands { + set.insert(ProviderType::Command); + } + // Plugin providers - use Plugin(type_id) for all + if config_providers.uuctl { + set.insert(ProviderType::Plugin("uuctl".to_string())); + } + if config_providers.system { + set.insert(ProviderType::Plugin("system".to_string())); + } + if config_providers.ssh { + set.insert(ProviderType::Plugin("ssh".to_string())); + } + if config_providers.clipboard { + set.insert(ProviderType::Plugin("clipboard".to_string())); + } + if config_providers.bookmarks { + set.insert(ProviderType::Plugin("bookmarks".to_string())); + } + if config_providers.emoji { + set.insert(ProviderType::Plugin("emoji".to_string())); + } + if config_providers.scripts { + set.insert(ProviderType::Plugin("scripts".to_string())); + } + // Dynamic providers + if config_providers.files { + set.insert(ProviderType::Plugin("filesearch".to_string())); + } + if config_providers.calculator { + set.insert(ProviderType::Plugin("calc".to_string())); + } + if config_providers.websearch { + set.insert(ProviderType::Plugin("websearch".to_string())); + } + // Default to apps if nothing enabled + if set.is_empty() { + set.insert(ProviderType::Application); + } + set + }; + + let filter = Self { + enabled, + active_prefix: None, + }; + + #[cfg(feature = "dev-logging")] + debug!("[Filter] Created with enabled providers: {:?}", filter.enabled); + + filter + } + + /// Default filter: apps only + #[allow(dead_code)] + pub fn apps_only() -> Self { + Self { + enabled: HashSet::from([ProviderType::Application]), + active_prefix: None, + } + } + + /// Toggle a provider on/off + pub fn toggle(&mut self, provider: ProviderType) { + if self.enabled.contains(&provider) { + self.enabled.remove(&provider); + // Ensure at least one provider is always enabled + if self.enabled.is_empty() { + self.enabled.insert(ProviderType::Application); + } + #[cfg(feature = "dev-logging")] + debug!("[Filter] Toggled OFF {:?}, enabled: {:?}", provider, self.enabled); + } else { + #[cfg(feature = "dev-logging")] + let provider_debug = format!("{:?}", provider); + self.enabled.insert(provider); + #[cfg(feature = "dev-logging")] + debug!("[Filter] Toggled ON {}, enabled: {:?}", provider_debug, self.enabled); + } + } + + /// Enable a specific provider + pub fn enable(&mut self, provider: ProviderType) { + self.enabled.insert(provider); + } + + /// Disable a specific provider (ensures at least one remains) + pub fn disable(&mut self, provider: ProviderType) { + self.enabled.remove(&provider); + if self.enabled.is_empty() { + self.enabled.insert(ProviderType::Application); + } + } + + /// Set to single provider mode + pub fn set_single_mode(&mut self, provider: ProviderType) { + self.enabled.clear(); + self.enabled.insert(provider); + } + + /// Set prefix mode (from :app, :cmd, etc.) + pub fn set_prefix(&mut self, prefix: Option) { + #[cfg(feature = "dev-logging")] + if self.active_prefix != prefix { + debug!("[Filter] Prefix changed: {:?} -> {:?}", self.active_prefix, prefix); + } + self.active_prefix = prefix; + } + + /// Check if a provider should be searched + pub fn is_active(&self, provider: ProviderType) -> bool { + if let Some(ref prefix) = self.active_prefix { + &provider == prefix + } else { + self.enabled.contains(&provider) + } + } + + /// Check if provider is in enabled set (ignoring prefix) + pub fn is_enabled(&self, provider: ProviderType) -> bool { + self.enabled.contains(&provider) + } + + /// Get current active prefix if any + #[allow(dead_code)] + pub fn active_prefix(&self) -> Option { + self.active_prefix.clone() + } + + /// Parse query for prefix syntax + /// Prefixes map to Plugin(type_id) for plugin providers + pub fn parse_query(query: &str) -> ParsedQuery { + let trimmed = query.trim_start(); + + // Check for tag filter pattern: ":tag:XXX query" or ":tag:XXX" + if let Some(rest) = trimmed.strip_prefix(":tag:") { + // Find the end of the tag (space or end of string) + if let Some(space_idx) = rest.find(' ') { + let tag = rest[..space_idx].to_lowercase(); + let query_part = rest[space_idx + 1..].to_string(); + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> tag={:?}, query={:?}", query, tag, query_part); + return ParsedQuery { + prefix: None, + tag_filter: Some(tag), + query: query_part, + }; + } else { + // Just the tag, no query yet + let tag = rest.to_lowercase(); + return ParsedQuery { + prefix: None, + tag_filter: Some(tag), + query: String::new(), + }; + } + } + + // Core provider prefixes + let core_prefixes: &[(&str, ProviderType)] = &[ + (":app ", ProviderType::Application), + (":apps ", ProviderType::Application), + (":cmd ", ProviderType::Command), + (":command ", ProviderType::Command), + ]; + + // Plugin provider prefixes - mapped to Plugin(type_id) + let plugin_prefixes: &[(&str, &str)] = &[ + (":bm ", "bookmarks"), + (":bookmark ", "bookmarks"), + (":bookmarks ", "bookmarks"), + (":calc ", "calc"), + (":calculator ", "calc"), + (":clip ", "clipboard"), + (":clipboard ", "clipboard"), + (":emoji ", "emoji"), + (":emojis ", "emoji"), + (":file ", "filesearch"), + (":files ", "filesearch"), + (":find ", "filesearch"), + (":script ", "scripts"), + (":scripts ", "scripts"), + (":ssh ", "ssh"), + (":sys ", "system"), + (":system ", "system"), + (":power ", "system"), + (":uuctl ", "uuctl"), + (":systemd ", "uuctl"), + (":web ", "websearch"), + (":search ", "websearch"), + ]; + + // Check core prefixes + for (prefix_str, provider) in core_prefixes { + if let Some(rest) = trimmed.strip_prefix(prefix_str) { + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest); + return ParsedQuery { + prefix: Some(provider.clone()), + tag_filter: None, + query: rest.to_string(), + }; + } + } + + // Check plugin prefixes + for (prefix_str, type_id) in plugin_prefixes { + if let Some(rest) = trimmed.strip_prefix(prefix_str) { + let provider = ProviderType::Plugin(type_id.to_string()); + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> prefix={:?}, query={:?}", query, provider, rest); + return ParsedQuery { + prefix: Some(provider), + tag_filter: None, + query: rest.to_string(), + }; + } + } + + // Handle partial prefixes (still typing) + let partial_core: &[(&str, ProviderType)] = &[ + (":app", ProviderType::Application), + (":apps", ProviderType::Application), + (":cmd", ProviderType::Command), + (":command", ProviderType::Command), + ]; + + let partial_plugin: &[(&str, &str)] = &[ + (":bm", "bookmarks"), + (":bookmark", "bookmarks"), + (":bookmarks", "bookmarks"), + (":calc", "calc"), + (":calculator", "calc"), + (":clip", "clipboard"), + (":clipboard", "clipboard"), + (":emoji", "emoji"), + (":emojis", "emoji"), + (":file", "filesearch"), + (":files", "filesearch"), + (":find", "filesearch"), + (":script", "scripts"), + (":scripts", "scripts"), + (":ssh", "ssh"), + (":sys", "system"), + (":system", "system"), + (":power", "system"), + (":uuctl", "uuctl"), + (":systemd", "uuctl"), + (":web", "websearch"), + (":search", "websearch"), + ]; + + for (prefix_str, provider) in partial_core { + if trimmed == *prefix_str { + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider); + return ParsedQuery { + prefix: Some(provider.clone()), + tag_filter: None, + query: String::new(), + }; + } + } + + for (prefix_str, type_id) in partial_plugin { + if trimmed == *prefix_str { + let provider = ProviderType::Plugin(type_id.to_string()); + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> partial prefix {:?}", query, provider); + return ParsedQuery { + prefix: Some(provider), + tag_filter: None, + query: String::new(), + }; + } + } + + let result = ParsedQuery { + prefix: None, + tag_filter: None, + query: query.to_string(), + }; + + #[cfg(feature = "dev-logging")] + debug!("[Filter] parse_query({:?}) -> prefix={:?}, tag={:?}, query={:?}", query, result.prefix, result.tag_filter, result.query); + + result + } + + /// Get enabled providers for UI display (sorted) + pub fn enabled_providers(&self) -> Vec { + let mut providers: Vec<_> = self.enabled.iter().cloned().collect(); + providers.sort_by_key(|p| match p { + ProviderType::Application => 0, + ProviderType::Command => 1, + ProviderType::Dmenu => 2, + ProviderType::Plugin(_) => 100, // Plugin providers sort after core + }); + providers + } + + /// Get display name for current mode + pub fn mode_display_name(&self) -> &'static str { + if let Some(ref prefix) = self.active_prefix { + return match prefix { + ProviderType::Application => "Apps", + ProviderType::Command => "Commands", + ProviderType::Dmenu => "dmenu", + ProviderType::Plugin(_) => "Plugin", + }; + } + + let enabled: Vec<_> = self.enabled_providers(); + if enabled.len() == 1 { + match &enabled[0] { + ProviderType::Application => "Apps", + ProviderType::Command => "Commands", + ProviderType::Dmenu => "dmenu", + ProviderType::Plugin(_) => "Plugin", + } + } else { + "All" + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_query_with_prefix() { + let result = ProviderFilter::parse_query(":app firefox"); + assert_eq!(result.prefix, Some(ProviderType::Application)); + assert_eq!(result.query, "firefox"); + } + + #[test] + fn test_parse_query_without_prefix() { + let result = ProviderFilter::parse_query("firefox"); + assert_eq!(result.prefix, None); + assert_eq!(result.query, "firefox"); + } + + #[test] + fn test_parse_query_partial_prefix() { + let result = ProviderFilter::parse_query(":cmd"); + assert_eq!(result.prefix, Some(ProviderType::Command)); + assert_eq!(result.query, ""); + } + + #[test] + fn test_parse_query_plugin_prefix() { + let result = ProviderFilter::parse_query(":calc 5+3"); + assert_eq!(result.prefix, Some(ProviderType::Plugin("calc".to_string()))); + assert_eq!(result.query, "5+3"); + } + + #[test] + fn test_toggle_ensures_one_enabled() { + let mut filter = ProviderFilter::apps_only(); + filter.toggle(ProviderType::Application); + // Should still have apps enabled as fallback + assert!(filter.is_enabled(ProviderType::Application)); + } +} diff --git a/crates/owlry-core/src/notify.rs b/crates/owlry-core/src/notify.rs new file mode 100644 index 0000000..dbfc9ac --- /dev/null +++ b/crates/owlry-core/src/notify.rs @@ -0,0 +1,91 @@ +//! Desktop notification system +//! +//! Provides system notifications for owlry and its plugins. +//! Uses the freedesktop notification specification via notify-rust. +//! +//! Note: Some convenience functions are provided for future use and +//! are currently unused by the core (plugins use the Host API instead). + +#![allow(dead_code)] + +use notify_rust::{Notification, Urgency}; + +/// Notification urgency level +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NotifyUrgency { + /// Low priority notification + Low, + /// Normal priority notification (default) + #[default] + Normal, + /// Critical/urgent notification + Critical, +} + +impl From for Urgency { + fn from(urgency: NotifyUrgency) -> Self { + match urgency { + NotifyUrgency::Low => Urgency::Low, + NotifyUrgency::Normal => Urgency::Normal, + NotifyUrgency::Critical => Urgency::Critical, + } + } +} + +/// Send a simple notification +pub fn notify(summary: &str, body: &str) { + notify_with_options(summary, body, None, NotifyUrgency::Normal); +} + +/// Send a notification with an icon +pub fn notify_with_icon(summary: &str, body: &str, icon: &str) { + notify_with_options(summary, body, Some(icon), NotifyUrgency::Normal); +} + +/// Send a notification with full options +pub fn notify_with_options(summary: &str, body: &str, icon: Option<&str>, urgency: NotifyUrgency) { + let mut notification = Notification::new(); + notification + .appname("Owlry") + .summary(summary) + .body(body) + .urgency(urgency.into()); + + if let Some(icon_name) = icon { + notification.icon(icon_name); + } + + if let Err(e) = notification.show() { + log::warn!("Failed to show notification: {}", e); + } +} + +/// Send a notification with a timeout +pub fn notify_with_timeout(summary: &str, body: &str, icon: Option<&str>, timeout_ms: i32) { + let mut notification = Notification::new(); + notification + .appname("Owlry") + .summary(summary) + .body(body) + .timeout(timeout_ms); + + if let Some(icon_name) = icon { + notification.icon(icon_name); + } + + if let Err(e) = notification.show() { + log::warn!("Failed to show notification: {}", e); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_urgency_conversion() { + assert_eq!(Urgency::from(NotifyUrgency::Low), Urgency::Low); + assert_eq!(Urgency::from(NotifyUrgency::Normal), Urgency::Normal); + assert_eq!(Urgency::from(NotifyUrgency::Critical), Urgency::Critical); + } +} diff --git a/crates/owlry-core/src/paths.rs b/crates/owlry-core/src/paths.rs new file mode 100644 index 0000000..a846063 --- /dev/null +++ b/crates/owlry-core/src/paths.rs @@ -0,0 +1,203 @@ +//! Centralized path handling following XDG Base Directory Specification. +//! +//! XDG directories used: +//! - `$XDG_CONFIG_HOME/owlry/` - User configuration (config.toml, themes/, style.css) +//! - `$XDG_DATA_HOME/owlry/` - User data (scripts/, frecency.json) +//! - `$XDG_CACHE_HOME/owlry/` - Cache files (future use) +//! +//! See: https://specifications.freedesktop.org/basedir-spec/latest/ + +use std::path::PathBuf; + +/// Application name used in XDG paths +const APP_NAME: &str = "owlry"; + +// ============================================================================= +// XDG Base Directories +// ============================================================================= + +/// Get XDG config home: `$XDG_CONFIG_HOME` or `~/.config` +pub fn config_home() -> Option { + dirs::config_dir() +} + +/// Get XDG data home: `$XDG_DATA_HOME` or `~/.local/share` +pub fn data_home() -> Option { + dirs::data_dir() +} + +/// Get XDG cache home: `$XDG_CACHE_HOME` or `~/.cache` +#[allow(dead_code)] +pub fn cache_home() -> Option { + dirs::cache_dir() +} + + +// ============================================================================= +// Owlry-specific directories +// ============================================================================= + +/// Owlry config directory: `$XDG_CONFIG_HOME/owlry/` +pub fn owlry_config_dir() -> Option { + config_home().map(|p| p.join(APP_NAME)) +} + +/// Owlry data directory: `$XDG_DATA_HOME/owlry/` +pub fn owlry_data_dir() -> Option { + data_home().map(|p| p.join(APP_NAME)) +} + +/// Owlry cache directory: `$XDG_CACHE_HOME/owlry/` +#[allow(dead_code)] +pub fn owlry_cache_dir() -> Option { + cache_home().map(|p| p.join(APP_NAME)) +} + +// ============================================================================= +// Config files +// ============================================================================= + +/// Main config file: `$XDG_CONFIG_HOME/owlry/config.toml` +pub fn config_file() -> Option { + owlry_config_dir().map(|p| p.join("config.toml")) +} + +/// Custom user stylesheet: `$XDG_CONFIG_HOME/owlry/style.css` +pub fn custom_style_file() -> Option { + owlry_config_dir().map(|p| p.join("style.css")) +} + +/// User themes directory: `$XDG_CONFIG_HOME/owlry/themes/` +pub fn themes_dir() -> Option { + owlry_config_dir().map(|p| p.join("themes")) +} + +/// Get path for a specific theme: `$XDG_CONFIG_HOME/owlry/themes/{name}.css` +pub fn theme_file(name: &str) -> Option { + themes_dir().map(|p| p.join(format!("{}.css", name))) +} + +// ============================================================================= +// Data files +// ============================================================================= + +/// User plugins directory: `$XDG_CONFIG_HOME/owlry/plugins/` +/// +/// Plugins are stored in config because they contain user-installed code +/// that the user explicitly chose to add (similar to themes). +pub fn plugins_dir() -> Option { + owlry_config_dir().map(|p| p.join("plugins")) +} + +/// Frecency data file: `$XDG_DATA_HOME/owlry/frecency.json` +pub fn frecency_file() -> Option { + owlry_data_dir().map(|p| p.join("frecency.json")) +} + +// ============================================================================= +// System directories +// ============================================================================= + +/// System data directories for applications (XDG_DATA_DIRS) +/// +/// Follows the XDG Base Directory Specification: +/// - $XDG_DATA_HOME/applications (defaults to ~/.local/share/applications) +/// - $XDG_DATA_DIRS/*/applications (defaults to /usr/local/share:/usr/share) +/// - Additional Flatpak and Snap directories +pub fn system_data_dirs() -> Vec { + let mut dirs = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + // Helper to add unique directories + let mut add_dir = |path: PathBuf| { + if seen.insert(path.clone()) { + dirs.push(path); + } + }; + + // 1. User data directory first (highest priority) + if let Some(data) = data_home() { + add_dir(data.join("applications")); + } + + // 2. XDG_DATA_DIRS - parse the environment variable + // Default per spec: /usr/local/share:/usr/share + let xdg_data_dirs = std::env::var("XDG_DATA_DIRS") + .unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string()); + + for dir in xdg_data_dirs.split(':') { + if !dir.is_empty() { + add_dir(PathBuf::from(dir).join("applications")); + } + } + + // 3. Always include standard system directories as fallback + // Some environments set XDG_DATA_DIRS without including these + add_dir(PathBuf::from("/usr/share/applications")); + add_dir(PathBuf::from("/usr/local/share/applications")); + + // 4. Flatpak directories (user and system) + if let Some(data) = data_home() { + add_dir(data.join("flatpak/exports/share/applications")); + } + add_dir(PathBuf::from("/var/lib/flatpak/exports/share/applications")); + + // 5. Snap directories + add_dir(PathBuf::from("/var/lib/snapd/desktop/applications")); + + // 6. Nix directories (common on NixOS) + if let Some(home) = dirs::home_dir() { + add_dir(home.join(".nix-profile/share/applications")); + } + add_dir(PathBuf::from("/run/current-system/sw/share/applications")); + + dirs +} + +// ============================================================================= +// Helper functions +// ============================================================================= + +/// Ensure parent directory of a file exists +pub fn ensure_parent_dir(path: &std::path::Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() + && !parent.exists() { + std::fs::create_dir_all(parent)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_paths_are_consistent() { + // All owlry paths should be under XDG directories + if let (Some(config), Some(data)) = (owlry_config_dir(), owlry_data_dir()) { + assert!(config.ends_with("owlry")); + assert!(data.ends_with("owlry")); + } + } + + #[test] + fn test_config_file_path() { + if let Some(path) = config_file() { + assert!(path.ends_with("config.toml")); + assert!(path.to_string_lossy().contains("owlry")); + } + } + + #[test] + fn test_frecency_in_data_dir() { + if let Some(path) = frecency_file() { + assert!(path.ends_with("frecency.json")); + // Should be in data dir, not config dir + let path_str = path.to_string_lossy(); + assert!( + path_str.contains(".local/share") || path_str.contains("XDG_DATA_HOME"), + "frecency should be in data directory" + ); + } + } +} diff --git a/crates/owlry-core/src/plugins/api/action.rs b/crates/owlry-core/src/plugins/api/action.rs new file mode 100644 index 0000000..985f574 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/action.rs @@ -0,0 +1,322 @@ +//! Action API for Lua plugins +//! +//! Allows plugins to register custom actions for result items: +//! - `owlry.action.register(config)` - Register a custom action + +use mlua::{Function, Lua, Result as LuaResult, Table, Value}; + +/// Action registration data +#[derive(Debug, Clone)] +#[allow(dead_code)] // Used by UI integration +pub struct ActionRegistration { + /// Unique action ID + pub id: String, + /// Human-readable name shown in UI + pub display_name: String, + /// Icon name (optional) + pub icon: Option, + /// Keyboard shortcut hint (optional, e.g., "Ctrl+C") + pub shortcut: Option, + /// Plugin that registered this action + pub plugin_id: String, +} + +/// Register action APIs +pub fn register_action_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResult<()> { + let action_table = lua.create_table()?; + let plugin_id_owned = plugin_id.to_string(); + + // Initialize action storage in Lua registry + if lua.named_registry_value::("actions")?.is_nil() { + let actions: Table = lua.create_table()?; + lua.set_named_registry_value("actions", actions)?; + } + + // owlry.action.register(config) -> string (action_id) + // config = { + // id = "copy-url", + // name = "Copy URL", + // icon = "edit-copy", -- optional + // shortcut = "Ctrl+C", -- optional + // filter = function(item) return item.provider == "bookmarks" end, -- optional + // handler = function(item) ... end + // } + let plugin_id_for_register = plugin_id_owned.clone(); + action_table.set( + "register", + lua.create_function(move |lua, config: Table| { + // Extract required fields + let id: String = config + .get("id") + .map_err(|_| mlua::Error::external("action.register: 'id' is required"))?; + + let name: String = config + .get("name") + .map_err(|_| mlua::Error::external("action.register: 'name' is required"))?; + + let _handler: Function = config + .get("handler") + .map_err(|_| mlua::Error::external("action.register: 'handler' function is required"))?; + + // Extract optional fields + let icon: Option = config.get("icon").ok(); + let shortcut: Option = config.get("shortcut").ok(); + + // Store action in registry + let actions: Table = lua.named_registry_value("actions")?; + + // Create full action ID with plugin prefix + let full_id = format!("{}:{}", plugin_id_for_register, id); + + // Store config with full ID + let action_entry = lua.create_table()?; + action_entry.set("id", full_id.clone())?; + action_entry.set("name", name.clone())?; + action_entry.set("plugin_id", plugin_id_for_register.clone())?; + if let Some(ref i) = icon { + action_entry.set("icon", i.clone())?; + } + if let Some(ref s) = shortcut { + action_entry.set("shortcut", s.clone())?; + } + // Store filter and handler functions + if let Ok(filter) = config.get::("filter") { + action_entry.set("filter", filter)?; + } + action_entry.set("handler", config.get::("handler")?)?; + + actions.set(full_id.clone(), action_entry)?; + + log::info!( + "[plugin:{}] Registered action '{}' ({})", + plugin_id_for_register, + name, + full_id + ); + + Ok(full_id) + })?, + )?; + + // owlry.action.unregister(id) -> boolean + let plugin_id_for_unregister = plugin_id_owned.clone(); + action_table.set( + "unregister", + lua.create_function(move |lua, id: String| { + let actions: Table = lua.named_registry_value("actions")?; + let full_id = format!("{}:{}", plugin_id_for_unregister, id); + + if actions.contains_key(full_id.clone())? { + actions.set(full_id, Value::Nil)?; + Ok(true) + } else { + Ok(false) + } + })?, + )?; + + owlry.set("action", action_table)?; + Ok(()) +} + +/// Get all registered actions from a Lua runtime +#[allow(dead_code)] // Will be used by UI +pub fn get_actions(lua: &Lua) -> LuaResult> { + let actions: Table = match lua.named_registry_value("actions") { + Ok(a) => a, + Err(_) => return Ok(Vec::new()), + }; + + let mut result = Vec::new(); + + for pair in actions.pairs::() { + let (_, entry) = pair?; + + let id: String = entry.get("id")?; + let display_name: String = entry.get("name")?; + let plugin_id: String = entry.get("plugin_id")?; + let icon: Option = entry.get("icon").ok(); + let shortcut: Option = entry.get("shortcut").ok(); + + result.push(ActionRegistration { + id, + display_name, + icon, + shortcut, + plugin_id, + }); + } + + Ok(result) +} + +/// Get actions that apply to a specific item +#[allow(dead_code)] // Will be used by UI context menu +pub fn get_actions_for_item(lua: &Lua, item: &Table) -> LuaResult> { + let actions: Table = match lua.named_registry_value("actions") { + Ok(a) => a, + Err(_) => return Ok(Vec::new()), + }; + + let mut result = Vec::new(); + + for pair in actions.pairs::() { + let (_, entry) = pair?; + + // Check filter if present + if let Ok(filter) = entry.get::("filter") { + match filter.call::(item.clone()) { + Ok(true) => {} // Include this action + Ok(false) => continue, // Skip this action + Err(e) => { + log::warn!("Action filter failed: {}", e); + continue; + } + } + } + + let id: String = entry.get("id")?; + let display_name: String = entry.get("name")?; + let plugin_id: String = entry.get("plugin_id")?; + let icon: Option = entry.get("icon").ok(); + let shortcut: Option = entry.get("shortcut").ok(); + + result.push(ActionRegistration { + id, + display_name, + icon, + shortcut, + plugin_id, + }); + } + + Ok(result) +} + +/// Execute an action by ID +#[allow(dead_code)] // Will be used by UI +pub fn execute_action(lua: &Lua, action_id: &str, item: &Table) -> LuaResult<()> { + let actions: Table = lua.named_registry_value("actions")?; + let action: Table = actions.get(action_id)?; + let handler: Function = action.get("handler")?; + + handler.call::<()>(item.clone())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_lua(plugin_id: &str) -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_action_api(&lua, &owlry, plugin_id).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + lua + } + + #[test] + fn test_action_registration() { + let lua = setup_lua("test-plugin"); + + let chunk = lua.load(r#" + return owlry.action.register({ + id = "copy-name", + name = "Copy Name", + icon = "edit-copy", + handler = function(item) + -- copy logic here + end + }) + "#); + let action_id: String = chunk.call(()).unwrap(); + assert_eq!(action_id, "test-plugin:copy-name"); + + // Verify action is registered + let actions = get_actions(&lua).unwrap(); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].display_name, "Copy Name"); + } + + #[test] + fn test_action_with_filter() { + let lua = setup_lua("test-plugin"); + + let chunk = lua.load(r#" + owlry.action.register({ + id = "bookmark-action", + name = "Open in Browser", + filter = function(item) + return item.provider == "bookmarks" + end, + handler = function(item) end + }) + "#); + chunk.call::<()>(()).unwrap(); + + // Create bookmark item + let bookmark_item = lua.create_table().unwrap(); + bookmark_item.set("provider", "bookmarks").unwrap(); + bookmark_item.set("name", "Test Bookmark").unwrap(); + + let actions = get_actions_for_item(&lua, &bookmark_item).unwrap(); + assert_eq!(actions.len(), 1); + + // Create non-bookmark item + let app_item = lua.create_table().unwrap(); + app_item.set("provider", "applications").unwrap(); + app_item.set("name", "Test App").unwrap(); + + let actions2 = get_actions_for_item(&lua, &app_item).unwrap(); + assert_eq!(actions2.len(), 0); // Filtered out + } + + #[test] + fn test_action_unregister() { + let lua = setup_lua("test-plugin"); + + let chunk = lua.load(r#" + owlry.action.register({ + id = "temp-action", + name = "Temporary", + handler = function(item) end + }) + return owlry.action.unregister("temp-action") + "#); + let unregistered: bool = chunk.call(()).unwrap(); + assert!(unregistered); + + let actions = get_actions(&lua).unwrap(); + assert_eq!(actions.len(), 0); + } + + #[test] + fn test_execute_action() { + let lua = setup_lua("test-plugin"); + + // Register action that sets a global + let chunk = lua.load(r#" + result = nil + owlry.action.register({ + id = "test-exec", + name = "Test Execute", + handler = function(item) + result = item.name + end + }) + "#); + chunk.call::<()>(()).unwrap(); + + // Create test item + let item = lua.create_table().unwrap(); + item.set("name", "TestItem").unwrap(); + + // Execute action + execute_action(&lua, "test-plugin:test-exec", &item).unwrap(); + + // Verify handler was called + let result: String = lua.globals().get("result").unwrap(); + assert_eq!(result, "TestItem"); + } +} diff --git a/crates/owlry-core/src/plugins/api/cache.rs b/crates/owlry-core/src/plugins/api/cache.rs new file mode 100644 index 0000000..448b066 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/cache.rs @@ -0,0 +1,299 @@ +//! Cache API for Lua plugins +//! +//! Provides in-memory caching with optional TTL: +//! - `owlry.cache.get(key)` - Get cached value +//! - `owlry.cache.set(key, value, ttl_seconds?)` - Set cached value +//! - `owlry.cache.delete(key)` - Delete cached value +//! - `owlry.cache.clear()` - Clear all cached values + +use mlua::{Lua, Result as LuaResult, Table, Value}; +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +/// Cached entry with optional expiration +struct CacheEntry { + value: String, // Store as JSON string for simplicity + expires_at: Option, +} + +impl CacheEntry { + fn is_expired(&self) -> bool { + self.expires_at.map(|e| Instant::now() > e).unwrap_or(false) + } +} + +/// Global cache storage (shared across all plugins) +static CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Register cache APIs +pub fn register_cache_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let cache_table = lua.create_table()?; + + // owlry.cache.get(key) -> value or nil + cache_table.set( + "get", + lua.create_function(|lua, key: String| { + let cache = CACHE.lock().map_err(|e| { + mlua::Error::external(format!("Failed to lock cache: {}", e)) + })?; + + if let Some(entry) = cache.get(&key) { + if entry.is_expired() { + drop(cache); + // Remove expired entry + if let Ok(mut cache) = CACHE.lock() { + cache.remove(&key); + } + return Ok(Value::Nil); + } + + // Parse JSON back to Lua value + let json_value: serde_json::Value = serde_json::from_str(&entry.value) + .map_err(|e| mlua::Error::external(format!("Failed to parse cached value: {}", e)))?; + + json_to_lua(lua, &json_value) + } else { + Ok(Value::Nil) + } + })?, + )?; + + // owlry.cache.set(key, value, ttl_seconds?) -> boolean + cache_table.set( + "set", + lua.create_function(|_lua, (key, value, ttl): (String, Value, Option)| { + let json_value = lua_value_to_json(&value)?; + let json_str = serde_json::to_string(&json_value) + .map_err(|e| mlua::Error::external(format!("Failed to serialize value: {}", e)))?; + + let expires_at = ttl.map(|secs| Instant::now() + Duration::from_secs(secs)); + + let entry = CacheEntry { + value: json_str, + expires_at, + }; + + let mut cache = CACHE.lock().map_err(|e| { + mlua::Error::external(format!("Failed to lock cache: {}", e)) + })?; + + cache.insert(key, entry); + Ok(true) + })?, + )?; + + // owlry.cache.delete(key) -> boolean (true if key existed) + cache_table.set( + "delete", + lua.create_function(|_lua, key: String| { + let mut cache = CACHE.lock().map_err(|e| { + mlua::Error::external(format!("Failed to lock cache: {}", e)) + })?; + + Ok(cache.remove(&key).is_some()) + })?, + )?; + + // owlry.cache.clear() -> number of entries removed + cache_table.set( + "clear", + lua.create_function(|_lua, ()| { + let mut cache = CACHE.lock().map_err(|e| { + mlua::Error::external(format!("Failed to lock cache: {}", e)) + })?; + + let count = cache.len(); + cache.clear(); + Ok(count) + })?, + )?; + + // owlry.cache.has(key) -> boolean + cache_table.set( + "has", + lua.create_function(|_lua, key: String| { + let cache = CACHE.lock().map_err(|e| { + mlua::Error::external(format!("Failed to lock cache: {}", e)) + })?; + + if let Some(entry) = cache.get(&key) { + Ok(!entry.is_expired()) + } else { + Ok(false) + } + })?, + )?; + + owlry.set("cache", cache_table)?; + Ok(()) +} + +/// Convert Lua value to serde_json::Value +fn lua_value_to_json(value: &Value) -> LuaResult { + use serde_json::Value as JsonValue; + + match value { + Value::Nil => Ok(JsonValue::Null), + Value::Boolean(b) => Ok(JsonValue::Bool(*b)), + Value::Integer(i) => Ok(JsonValue::Number((*i).into())), + Value::Number(n) => Ok(serde_json::Number::from_f64(*n) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null)), + Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())), + Value::Table(t) => lua_table_to_json(t), + _ => Err(mlua::Error::external("Unsupported Lua type for cache")), + } +} + +/// Convert Lua table to serde_json::Value +fn lua_table_to_json(table: &Table) -> LuaResult { + use serde_json::{Map, Value as JsonValue}; + + // Check if it's an array (sequential integer keys starting from 1) + let is_array = table + .clone() + .pairs::() + .enumerate() + .all(|(i, pair)| pair.map(|(k, _)| k == (i + 1) as i64).unwrap_or(false)); + + if is_array { + let mut arr = Vec::new(); + for pair in table.clone().pairs::() { + let (_, v) = pair?; + arr.push(lua_value_to_json(&v)?); + } + Ok(JsonValue::Array(arr)) + } else { + let mut map = Map::new(); + for pair in table.clone().pairs::() { + let (k, v) = pair?; + map.insert(k, lua_value_to_json(&v)?); + } + Ok(JsonValue::Object(map)) + } +} + +/// Convert serde_json::Value to Lua value +fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult { + use serde_json::Value as JsonValue; + + match value { + JsonValue::Null => Ok(Value::Nil), + JsonValue::Bool(b) => Ok(Value::Boolean(*b)), + JsonValue::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(Value::Integer(i)) + } else if let Some(f) = n.as_f64() { + Ok(Value::Number(f)) + } else { + Ok(Value::Nil) + } + } + JsonValue::String(s) => Ok(Value::String(lua.create_string(s)?)), + JsonValue::Array(arr) => { + let table = lua.create_table()?; + for (i, v) in arr.iter().enumerate() { + table.set(i + 1, json_to_lua(lua, v)?)?; + } + Ok(Value::Table(table)) + } + JsonValue::Object(obj) => { + let table = lua.create_table()?; + for (k, v) in obj { + table.set(k.as_str(), json_to_lua(lua, v)?)?; + } + Ok(Value::Table(table)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_lua() -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_cache_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + + // Clear cache between tests + CACHE.lock().unwrap().clear(); + + lua + } + + #[test] + fn test_cache_set_get() { + let lua = setup_lua(); + + // Set a value + let chunk = lua.load(r#"return owlry.cache.set("test_key", "test_value")"#); + let result: bool = chunk.call(()).unwrap(); + assert!(result); + + // Get the value back + let chunk = lua.load(r#"return owlry.cache.get("test_key")"#); + let value: String = chunk.call(()).unwrap(); + assert_eq!(value, "test_value"); + } + + #[test] + fn test_cache_table_value() { + let lua = setup_lua(); + + // Set a table value + let chunk = lua.load(r#"return owlry.cache.set("table_key", {name = "test", value = 42})"#); + let _: bool = chunk.call(()).unwrap(); + + // Get and verify + let chunk = lua.load(r#" + local t = owlry.cache.get("table_key") + return t.name, t.value + "#); + let (name, value): (String, i32) = chunk.call(()).unwrap(); + assert_eq!(name, "test"); + assert_eq!(value, 42); + } + + #[test] + fn test_cache_delete() { + let lua = setup_lua(); + + let chunk = lua.load(r#" + owlry.cache.set("delete_key", "value") + local existed = owlry.cache.delete("delete_key") + local value = owlry.cache.get("delete_key") + return existed, value + "#); + let (existed, value): (bool, Option) = chunk.call(()).unwrap(); + assert!(existed); + assert!(value.is_none()); + } + + #[test] + fn test_cache_has() { + let lua = setup_lua(); + + let chunk = lua.load(r#" + local before = owlry.cache.has("has_key") + owlry.cache.set("has_key", "value") + local after = owlry.cache.has("has_key") + return before, after + "#); + let (before, after): (bool, bool) = chunk.call(()).unwrap(); + assert!(!before); + assert!(after); + } + + #[test] + fn test_cache_missing_key() { + let lua = setup_lua(); + + let chunk = lua.load(r#"return owlry.cache.get("nonexistent_key")"#); + let value: Value = chunk.call(()).unwrap(); + assert!(matches!(value, Value::Nil)); + } +} diff --git a/crates/owlry-core/src/plugins/api/hook.rs b/crates/owlry-core/src/plugins/api/hook.rs new file mode 100644 index 0000000..b660964 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/hook.rs @@ -0,0 +1,410 @@ +//! Hook API for Lua plugins +//! +//! Allows plugins to register callbacks for application events: +//! - `owlry.hook.on(event, callback)` - Register a hook +//! - Events: init, query, results, select, pre_launch, post_launch, shutdown + +use mlua::{Function, Lua, Result as LuaResult, Table, Value}; +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; + +/// Hook event types +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum HookEvent { + /// Called when plugin is initialized + Init, + /// Called when query changes, can modify query + Query, + /// Called after results are gathered, can filter/modify results + Results, + /// Called when an item is selected (highlighted) + Select, + /// Called before launching an item, can cancel launch + PreLaunch, + /// Called after launching an item + PostLaunch, + /// Called when application is shutting down + Shutdown, +} + +impl HookEvent { + fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "init" => Some(Self::Init), + "query" => Some(Self::Query), + "results" => Some(Self::Results), + "select" => Some(Self::Select), + "pre_launch" | "prelaunch" => Some(Self::PreLaunch), + "post_launch" | "postlaunch" => Some(Self::PostLaunch), + "shutdown" => Some(Self::Shutdown), + _ => None, + } + } + + fn as_str(&self) -> &'static str { + match self { + Self::Init => "init", + Self::Query => "query", + Self::Results => "results", + Self::Select => "select", + Self::PreLaunch => "pre_launch", + Self::PostLaunch => "post_launch", + Self::Shutdown => "shutdown", + } + } +} + +/// Registered hook information +#[derive(Debug, Clone)] +#[allow(dead_code)] // Will be used for hook inspection +pub struct HookRegistration { + pub event: HookEvent, + pub plugin_id: String, + pub priority: i32, +} + +/// Type alias for hook handlers: (plugin_id, priority) +type HookHandlers = Vec<(String, i32)>; + +/// Global hook registry +/// Maps event -> list of (plugin_id, priority) +static HOOK_REGISTRY: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Register hook APIs +pub fn register_hook_api(lua: &Lua, owlry: &Table, plugin_id: &str) -> LuaResult<()> { + let hook_table = lua.create_table()?; + let plugin_id_owned = plugin_id.to_string(); + + // Store plugin_id in registry for later use + lua.set_named_registry_value("plugin_id", plugin_id_owned.clone())?; + + // Initialize hook storage in Lua registry + if lua.named_registry_value::("hooks")?.is_nil() { + let hooks: Table = lua.create_table()?; + lua.set_named_registry_value("hooks", hooks)?; + } + + // owlry.hook.on(event, callback, priority?) -> boolean + // Register a hook for an event + let plugin_id_for_closure = plugin_id_owned.clone(); + hook_table.set( + "on", + lua.create_function(move |lua, (event_name, callback, priority): (String, Function, Option)| { + let event = HookEvent::from_str(&event_name).ok_or_else(|| { + mlua::Error::external(format!( + "Unknown hook event '{}'. Valid events: init, query, results, select, pre_launch, post_launch, shutdown", + event_name + )) + })?; + + let priority = priority.unwrap_or(0); + + // Store callback in Lua registry + let hooks: Table = lua.named_registry_value("hooks")?; + let event_key = event.as_str(); + + let event_hooks: Table = if let Ok(t) = hooks.get::(event_key) { + t + } else { + let t = lua.create_table()?; + hooks.set(event_key, t.clone())?; + t + }; + + // Add callback to event hooks + let len = event_hooks.len()? + 1; + let hook_entry = lua.create_table()?; + hook_entry.set("callback", callback)?; + hook_entry.set("priority", priority)?; + event_hooks.set(len, hook_entry)?; + + // Register in global registry + let mut registry = HOOK_REGISTRY.lock().map_err(|e| { + mlua::Error::external(format!("Failed to lock hook registry: {}", e)) + })?; + + let hooks_list = registry.entry(event).or_insert_with(Vec::new); + hooks_list.push((plugin_id_for_closure.clone(), priority)); + // Sort by priority (higher priority first) + hooks_list.sort_by(|a, b| b.1.cmp(&a.1)); + + log::debug!( + "[plugin:{}] Registered hook for '{}' with priority {}", + plugin_id_for_closure, + event_name, + priority + ); + + Ok(true) + })?, + )?; + + // owlry.hook.off(event) -> boolean + // Unregister all hooks for an event from this plugin + let plugin_id_for_off = plugin_id_owned.clone(); + hook_table.set( + "off", + lua.create_function(move |lua, event_name: String| { + let event = HookEvent::from_str(&event_name).ok_or_else(|| { + mlua::Error::external(format!("Unknown hook event '{}'", event_name)) + })?; + + // Remove from Lua registry + let hooks: Table = lua.named_registry_value("hooks")?; + hooks.set(event.as_str(), Value::Nil)?; + + // Remove from global registry + let mut registry = HOOK_REGISTRY.lock().map_err(|e| { + mlua::Error::external(format!("Failed to lock hook registry: {}", e)) + })?; + + if let Some(hooks_list) = registry.get_mut(&event) { + hooks_list.retain(|(id, _)| id != &plugin_id_for_off); + } + + log::debug!( + "[plugin:{}] Unregistered hooks for '{}'", + plugin_id_for_off, + event_name + ); + + Ok(true) + })?, + )?; + + owlry.set("hook", hook_table)?; + Ok(()) +} + +/// Call hooks for a specific event in a Lua runtime +/// Returns the (possibly modified) value +#[allow(dead_code)] // Will be used by UI integration +pub fn call_hooks(lua: &Lua, event: HookEvent, value: T) -> LuaResult +where + T: mlua::IntoLua + mlua::FromLua, +{ + let hooks: Table = match lua.named_registry_value("hooks") { + Ok(h) => h, + Err(_) => return Ok(value), // No hooks registered + }; + + let event_hooks: Table = match hooks.get(event.as_str()) { + Ok(h) => h, + Err(_) => return Ok(value), // No hooks for this event + }; + + let mut current_value = value.into_lua(lua)?; + + // Collect hooks with priorities + let mut hook_entries: Vec<(i32, Function)> = Vec::new(); + for pair in event_hooks.pairs::() { + let (_, entry) = pair?; + let priority: i32 = entry.get("priority").unwrap_or(0); + let callback: Function = entry.get("callback")?; + hook_entries.push((priority, callback)); + } + + // Sort by priority (higher first) + hook_entries.sort_by(|a, b| b.0.cmp(&a.0)); + + // Call each hook + for (_, callback) in hook_entries { + match callback.call::(current_value.clone()) { + Ok(result) => { + // If hook returns non-nil, use it as the new value + if !result.is_nil() { + current_value = result; + } + } + Err(e) => { + log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e); + // Continue with other hooks + } + } + } + + T::from_lua(current_value, lua) +} + +/// Call hooks that return a boolean (for pre_launch cancellation) +#[allow(dead_code)] // Will be used for pre_launch hooks +pub fn call_hooks_bool(lua: &Lua, event: HookEvent, value: Value) -> LuaResult { + let hooks: Table = match lua.named_registry_value("hooks") { + Ok(h) => h, + Err(_) => return Ok(true), // No hooks, allow + }; + + let event_hooks: Table = match hooks.get(event.as_str()) { + Ok(h) => h, + Err(_) => return Ok(true), // No hooks for this event + }; + + // Collect and sort hooks + let mut hook_entries: Vec<(i32, Function)> = Vec::new(); + for pair in event_hooks.pairs::() { + let (_, entry) = pair?; + let priority: i32 = entry.get("priority").unwrap_or(0); + let callback: Function = entry.get("callback")?; + hook_entries.push((priority, callback)); + } + hook_entries.sort_by(|a, b| b.0.cmp(&a.0)); + + // Call each hook - if any returns false, cancel + for (_, callback) in hook_entries { + match callback.call::(value.clone()) { + Ok(result) => { + if let Value::Boolean(false) = result { + return Ok(false); // Cancel + } + } + Err(e) => { + log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e); + } + } + } + + Ok(true) +} + +/// Call hooks with no return value (for notifications) +#[allow(dead_code)] // Will be used for notification hooks +pub fn call_hooks_void(lua: &Lua, event: HookEvent, value: Value) -> LuaResult<()> { + let hooks: Table = match lua.named_registry_value("hooks") { + Ok(h) => h, + Err(_) => return Ok(()), // No hooks + }; + + let event_hooks: Table = match hooks.get(event.as_str()) { + Ok(h) => h, + Err(_) => return Ok(()), // No hooks for this event + }; + + for pair in event_hooks.pairs::() { + let (_, entry) = pair?; + let callback: Function = entry.get("callback")?; + if let Err(e) = callback.call::<()>(value.clone()) { + log::warn!("[hook:{}] Hook callback failed: {}", event.as_str(), e); + } + } + + Ok(()) +} + +/// Get list of plugins that have registered for an event +#[allow(dead_code)] +pub fn get_registered_plugins(event: HookEvent) -> Vec { + HOOK_REGISTRY + .lock() + .map(|r| { + r.get(&event) + .map(|v| v.iter().map(|(id, _)| id.clone()).collect()) + .unwrap_or_default() + }) + .unwrap_or_default() +} + +/// Clear all hooks (used when reloading plugins) +#[allow(dead_code)] +pub fn clear_all_hooks() { + if let Ok(mut registry) = HOOK_REGISTRY.lock() { + registry.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_lua(plugin_id: &str) -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_hook_api(&lua, &owlry, plugin_id).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + lua + } + + #[test] + fn test_hook_registration() { + clear_all_hooks(); + let lua = setup_lua("test-plugin"); + + let chunk = lua.load(r#" + local called = false + owlry.hook.on("init", function() + called = true + end) + return true + "#); + let result: bool = chunk.call(()).unwrap(); + assert!(result); + + // Verify hook was registered + let plugins = get_registered_plugins(HookEvent::Init); + assert!(plugins.contains(&"test-plugin".to_string())); + } + + #[test] + fn test_hook_with_priority() { + clear_all_hooks(); + let lua = setup_lua("test-plugin"); + + let chunk = lua.load(r#" + owlry.hook.on("query", function(q) return q .. "1" end, 10) + owlry.hook.on("query", function(q) return q .. "2" end, 20) + return true + "#); + chunk.call::<()>(()).unwrap(); + + // Call hooks - higher priority (20) should run first + let result: String = call_hooks(&lua, HookEvent::Query, "test".to_string()).unwrap(); + // Priority 20 adds "2" first, then priority 10 adds "1" + assert_eq!(result, "test21"); + } + + #[test] + fn test_hook_off() { + clear_all_hooks(); + let lua = setup_lua("test-plugin"); + + let chunk = lua.load(r#" + owlry.hook.on("select", function() end) + owlry.hook.off("select") + return true + "#); + chunk.call::<()>(()).unwrap(); + + let plugins = get_registered_plugins(HookEvent::Select); + assert!(!plugins.contains(&"test-plugin".to_string())); + } + + #[test] + fn test_pre_launch_cancel() { + clear_all_hooks(); + let lua = setup_lua("test-plugin"); + + let chunk = lua.load(r#" + owlry.hook.on("pre_launch", function(item) + if item.name == "blocked" then + return false -- cancel launch + end + return true + end) + "#); + chunk.call::<()>(()).unwrap(); + + // Create a test item table + let item = lua.create_table().unwrap(); + item.set("name", "blocked").unwrap(); + + let allow = call_hooks_bool(&lua, HookEvent::PreLaunch, Value::Table(item)).unwrap(); + assert!(!allow); // Should be blocked + + // Test with allowed item + let item2 = lua.create_table().unwrap(); + item2.set("name", "allowed").unwrap(); + + let allow2 = call_hooks_bool(&lua, HookEvent::PreLaunch, Value::Table(item2)).unwrap(); + assert!(allow2); // Should be allowed + } +} diff --git a/crates/owlry-core/src/plugins/api/http.rs b/crates/owlry-core/src/plugins/api/http.rs new file mode 100644 index 0000000..49b7490 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/http.rs @@ -0,0 +1,345 @@ +//! HTTP client API for Lua plugins +//! +//! Provides: +//! - `owlry.http.get(url, opts)` - HTTP GET request +//! - `owlry.http.post(url, body, opts)` - HTTP POST request + +use mlua::{Lua, Result as LuaResult, Table, Value}; +use std::collections::HashMap; +use std::time::Duration; + +/// Register HTTP client APIs +pub fn register_http_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let http_table = lua.create_table()?; + + // owlry.http.get(url, opts?) -> { status, body, headers } + http_table.set( + "get", + lua.create_function(|lua, (url, opts): (String, Option
)| { + log::debug!("[plugin] http.get: {}", url); + + let timeout_secs = opts + .as_ref() + .and_then(|o| o.get::("timeout").ok()) + .unwrap_or(30); + + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| mlua::Error::external(format!("Failed to create HTTP client: {}", e)))?; + + let mut request = client.get(&url); + + // Add custom headers if provided + if let Some(ref opts) = opts + && let Ok(headers) = opts.get::
("headers") { + for pair in headers.pairs::() { + let (key, value) = pair?; + request = request.header(&key, &value); + } + } + + let response = request + .send() + .map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?; + + let status = response.status().as_u16(); + let headers = extract_headers(&response); + let body = response + .text() + .map_err(|e| mlua::Error::external(format!("Failed to read response body: {}", e)))?; + + let result = lua.create_table()?; + result.set("status", status)?; + result.set("body", body)?; + result.set("ok", (200..300).contains(&status))?; + + let headers_table = lua.create_table()?; + for (key, value) in headers { + headers_table.set(key, value)?; + } + result.set("headers", headers_table)?; + + Ok(result) + })?, + )?; + + // owlry.http.post(url, body, opts?) -> { status, body, headers } + http_table.set( + "post", + lua.create_function(|lua, (url, body, opts): (String, Value, Option
)| { + log::debug!("[plugin] http.post: {}", url); + + let timeout_secs = opts + .as_ref() + .and_then(|o| o.get::("timeout").ok()) + .unwrap_or(30); + + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| mlua::Error::external(format!("Failed to create HTTP client: {}", e)))?; + + let mut request = client.post(&url); + + // Add custom headers if provided + if let Some(ref opts) = opts + && let Ok(headers) = opts.get::
("headers") { + for pair in headers.pairs::() { + let (key, value) = pair?; + request = request.header(&key, &value); + } + } + + // Set body based on type + request = match body { + Value::String(s) => request.body(s.to_str()?.to_string()), + Value::Table(t) => { + // Assume JSON if body is a table + let json_str = table_to_json(&t)?; + request + .header("Content-Type", "application/json") + .body(json_str) + } + Value::Nil => request, + _ => { + return Err(mlua::Error::external( + "POST body must be a string or table", + )) + } + }; + + let response = request + .send() + .map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?; + + let status = response.status().as_u16(); + let headers = extract_headers(&response); + let body = response + .text() + .map_err(|e| mlua::Error::external(format!("Failed to read response body: {}", e)))?; + + let result = lua.create_table()?; + result.set("status", status)?; + result.set("body", body)?; + result.set("ok", (200..300).contains(&status))?; + + let headers_table = lua.create_table()?; + for (key, value) in headers { + headers_table.set(key, value)?; + } + result.set("headers", headers_table)?; + + Ok(result) + })?, + )?; + + // owlry.http.get_json(url, opts?) -> parsed JSON as table + // Convenience function that parses JSON response + http_table.set( + "get_json", + lua.create_function(|lua, (url, opts): (String, Option
)| { + log::debug!("[plugin] http.get_json: {}", url); + + let timeout_secs = opts + .as_ref() + .and_then(|o| o.get::("timeout").ok()) + .unwrap_or(30); + + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| mlua::Error::external(format!("Failed to create HTTP client: {}", e)))?; + + let mut request = client.get(&url); + request = request.header("Accept", "application/json"); + + // Add custom headers if provided + if let Some(ref opts) = opts + && let Ok(headers) = opts.get::
("headers") { + for pair in headers.pairs::() { + let (key, value) = pair?; + request = request.header(&key, &value); + } + } + + let response = request + .send() + .map_err(|e| mlua::Error::external(format!("HTTP request failed: {}", e)))?; + + if !response.status().is_success() { + return Err(mlua::Error::external(format!( + "HTTP request failed with status {}", + response.status() + ))); + } + + let body = response + .text() + .map_err(|e| mlua::Error::external(format!("Failed to read response body: {}", e)))?; + + // Parse JSON and convert to Lua table + let json_value: serde_json::Value = serde_json::from_str(&body) + .map_err(|e| mlua::Error::external(format!("Failed to parse JSON: {}", e)))?; + + json_to_lua(lua, &json_value) + })?, + )?; + + owlry.set("http", http_table)?; + Ok(()) +} + +/// Extract headers from response into a HashMap +fn extract_headers(response: &reqwest::blocking::Response) -> HashMap { + response + .headers() + .iter() + .filter_map(|(k, v)| { + v.to_str() + .ok() + .map(|v| (k.as_str().to_lowercase(), v.to_string())) + }) + .collect() +} + +/// Convert a Lua table to JSON string +fn table_to_json(table: &Table) -> LuaResult { + let value = lua_to_json(table)?; + serde_json::to_string(&value) + .map_err(|e| mlua::Error::external(format!("Failed to serialize to JSON: {}", e))) +} + +/// Convert Lua table to serde_json::Value +fn lua_to_json(table: &Table) -> LuaResult { + use serde_json::{Map, Value as JsonValue}; + + // Check if it's an array (sequential integer keys starting from 1) + let is_array = table + .clone() + .pairs::() + .enumerate() + .all(|(i, pair)| pair.map(|(k, _)| k == (i + 1) as i64).unwrap_or(false)); + + if is_array { + let mut arr = Vec::new(); + for pair in table.clone().pairs::() { + let (_, v) = pair?; + arr.push(lua_value_to_json(&v)?); + } + Ok(JsonValue::Array(arr)) + } else { + let mut map = Map::new(); + for pair in table.clone().pairs::() { + let (k, v) = pair?; + map.insert(k, lua_value_to_json(&v)?); + } + Ok(JsonValue::Object(map)) + } +} + +/// Convert a single Lua value to JSON +fn lua_value_to_json(value: &Value) -> LuaResult { + use serde_json::Value as JsonValue; + + match value { + Value::Nil => Ok(JsonValue::Null), + Value::Boolean(b) => Ok(JsonValue::Bool(*b)), + Value::Integer(i) => Ok(JsonValue::Number((*i).into())), + Value::Number(n) => Ok(serde_json::Number::from_f64(*n) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null)), + Value::String(s) => Ok(JsonValue::String(s.to_str()?.to_string())), + Value::Table(t) => lua_to_json(t), + _ => Err(mlua::Error::external("Unsupported Lua type for JSON")), + } +} + +/// Convert serde_json::Value to Lua value +fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult { + use serde_json::Value as JsonValue; + + match value { + JsonValue::Null => Ok(Value::Nil), + JsonValue::Bool(b) => Ok(Value::Boolean(*b)), + JsonValue::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(Value::Integer(i)) + } else if let Some(f) = n.as_f64() { + Ok(Value::Number(f)) + } else { + Ok(Value::Nil) + } + } + JsonValue::String(s) => Ok(Value::String(lua.create_string(s)?)), + JsonValue::Array(arr) => { + let table = lua.create_table()?; + for (i, v) in arr.iter().enumerate() { + table.set(i + 1, json_to_lua(lua, v)?)?; + } + Ok(Value::Table(table)) + } + JsonValue::Object(obj) => { + let table = lua.create_table()?; + for (k, v) in obj { + table.set(k.as_str(), json_to_lua(lua, v)?)?; + } + Ok(Value::Table(table)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_lua() -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_http_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + lua + } + + #[test] + fn test_json_conversion() { + let lua = setup_lua(); + + // Test table to JSON + let table = lua.create_table().unwrap(); + table.set("name", "test").unwrap(); + table.set("value", 42).unwrap(); + + let json = table_to_json(&table).unwrap(); + assert!(json.contains("name")); + assert!(json.contains("test")); + assert!(json.contains("42")); + } + + #[test] + fn test_array_to_json() { + let lua = setup_lua(); + + let table = lua.create_table().unwrap(); + table.set(1, "first").unwrap(); + table.set(2, "second").unwrap(); + table.set(3, "third").unwrap(); + + let json = table_to_json(&table).unwrap(); + assert!(json.starts_with('[')); + assert!(json.contains("first")); + } + + // Note: Network tests are skipped in CI - they require internet access + // Use `cargo test -- --ignored` to run them locally + #[test] + #[ignore] + fn test_http_get() { + let lua = setup_lua(); + let chunk = lua.load(r#"return owlry.http.get("https://httpbin.org/get")"#); + let result: Table = chunk.call(()).unwrap(); + + assert_eq!(result.get::("status").unwrap(), 200); + assert!(result.get::("ok").unwrap()); + } +} diff --git a/crates/owlry-core/src/plugins/api/math.rs b/crates/owlry-core/src/plugins/api/math.rs new file mode 100644 index 0000000..54a961c --- /dev/null +++ b/crates/owlry-core/src/plugins/api/math.rs @@ -0,0 +1,181 @@ +//! Math calculation API for Lua plugins +//! +//! Provides safe math expression evaluation: +//! - `owlry.math.calculate(expression)` - Evaluate a math expression + +use mlua::{Lua, Result as LuaResult, Table}; + +/// Register math APIs +pub fn register_math_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let math_table = lua.create_table()?; + + // owlry.math.calculate(expression) -> number or nil, error + // Evaluates a mathematical expression safely + // Returns (result, nil) on success or (nil, error_message) on failure + math_table.set( + "calculate", + lua.create_function(|_lua, expr: String| -> LuaResult<(Option, Option)> { + match meval::eval_str(&expr) { + Ok(result) => { + if result.is_finite() { + Ok((Some(result), None)) + } else { + Ok((None, Some("Result is not a finite number".to_string()))) + } + } + Err(e) => { + Ok((None, Some(e.to_string()))) + } + } + })?, + )?; + + // owlry.math.calc(expression) -> number (throws on error) + // Convenience function that throws instead of returning error + math_table.set( + "calc", + lua.create_function(|_lua, expr: String| { + meval::eval_str(&expr) + .map_err(|e| mlua::Error::external(format!("Math error: {}", e))) + .and_then(|r| { + if r.is_finite() { + Ok(r) + } else { + Err(mlua::Error::external("Result is not a finite number")) + } + }) + })?, + )?; + + // owlry.math.is_expression(str) -> boolean + // Check if a string looks like a math expression + math_table.set( + "is_expression", + lua.create_function(|_lua, expr: String| { + let trimmed = expr.trim(); + + // Must have at least one digit + if !trimmed.chars().any(|c| c.is_ascii_digit()) { + return Ok(false); + } + + // Should only contain valid math characters + let valid = trimmed.chars().all(|c| { + c.is_ascii_digit() + || c.is_ascii_alphabetic() + || matches!(c, '+' | '-' | '*' | '/' | '^' | '(' | ')' | '.' | ' ' | '%') + }); + + Ok(valid) + })?, + )?; + + // owlry.math.format(number, decimals?) -> string + // Format a number with optional decimal places + math_table.set( + "format", + lua.create_function(|_lua, (num, decimals): (f64, Option)| { + let decimals = decimals.unwrap_or(2); + + // Check if it's effectively an integer + if (num - num.round()).abs() < f64::EPSILON { + Ok(format!("{}", num as i64)) + } else { + Ok(format!("{:.prec$}", num, prec = decimals)) + } + })?, + )?; + + owlry.set("math", math_table)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_lua() -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_math_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + lua + } + + #[test] + fn test_calculate_basic() { + let lua = setup_lua(); + + let chunk = lua.load(r#" + local result, err = owlry.math.calculate("2 + 2") + if err then error(err) end + return result + "#); + let result: f64 = chunk.call(()).unwrap(); + assert!((result - 4.0).abs() < f64::EPSILON); + } + + #[test] + fn test_calculate_complex() { + let lua = setup_lua(); + + let chunk = lua.load(r#" + local result, err = owlry.math.calculate("sqrt(16) + 2^3") + if err then error(err) end + return result + "#); + let result: f64 = chunk.call(()).unwrap(); + assert!((result - 12.0).abs() < f64::EPSILON); // sqrt(16) = 4, 2^3 = 8 + } + + #[test] + fn test_calculate_error() { + let lua = setup_lua(); + + let chunk = lua.load(r#" + local result, err = owlry.math.calculate("invalid expression @@") + if result then + return false -- should not succeed + else + return true -- correctly failed + end + "#); + let had_error: bool = chunk.call(()).unwrap(); + assert!(had_error); + } + + #[test] + fn test_calc_throws() { + let lua = setup_lua(); + + let chunk = lua.load(r#"return owlry.math.calc("3 * 4")"#); + let result: f64 = chunk.call(()).unwrap(); + assert!((result - 12.0).abs() < f64::EPSILON); + } + + #[test] + fn test_is_expression() { + let lua = setup_lua(); + + let chunk = lua.load(r#"return owlry.math.is_expression("2 + 2")"#); + let is_expr: bool = chunk.call(()).unwrap(); + assert!(is_expr); + + let chunk = lua.load(r#"return owlry.math.is_expression("hello world")"#); + let is_expr: bool = chunk.call(()).unwrap(); + assert!(!is_expr); + } + + #[test] + fn test_format() { + let lua = setup_lua(); + + let chunk = lua.load(r#"return owlry.math.format(3.14159, 2)"#); + let formatted: String = chunk.call(()).unwrap(); + assert_eq!(formatted, "3.14"); + + let chunk = lua.load(r#"return owlry.math.format(42.0)"#); + let formatted: String = chunk.call(()).unwrap(); + assert_eq!(formatted, "42"); + } +} diff --git a/crates/owlry-core/src/plugins/api/mod.rs b/crates/owlry-core/src/plugins/api/mod.rs new file mode 100644 index 0000000..10fa1ef --- /dev/null +++ b/crates/owlry-core/src/plugins/api/mod.rs @@ -0,0 +1,77 @@ +//! Lua API implementations for plugins +//! +//! This module provides the `owlry` global table and its submodules +//! that plugins can use to interact with owlry. + +pub mod action; +mod cache; +pub mod hook; +mod http; +mod math; +mod process; +pub mod provider; +pub mod theme; +mod utils; + +use mlua::{Lua, Result as LuaResult}; + +pub use action::ActionRegistration; +pub use hook::HookEvent; +pub use provider::ProviderRegistration; +pub use theme::ThemeRegistration; + +/// Register all owlry APIs in the Lua runtime +/// +/// This creates the `owlry` global table with all available APIs: +/// - `owlry.log.*` - Logging functions +/// - `owlry.path.*` - XDG path helpers +/// - `owlry.fs.*` - Filesystem operations +/// - `owlry.json.*` - JSON encode/decode +/// - `owlry.provider.*` - Provider registration +/// - `owlry.process.*` - Process execution +/// - `owlry.env.*` - Environment variables +/// - `owlry.http.*` - HTTP client +/// - `owlry.cache.*` - In-memory caching +/// - `owlry.math.*` - Math expression evaluation +/// - `owlry.hook.*` - Event hooks +/// - `owlry.action.*` - Custom actions +/// - `owlry.theme.*` - Theme registration +pub fn register_apis(lua: &Lua, plugin_dir: &std::path::Path, plugin_id: &str) -> LuaResult<()> { + let globals = lua.globals(); + + // Create the main owlry table + let owlry = lua.create_table()?; + + // Register utility APIs (log, path, fs, json) + utils::register_log_api(lua, &owlry)?; + utils::register_path_api(lua, &owlry, plugin_dir)?; + utils::register_fs_api(lua, &owlry, plugin_dir)?; + utils::register_json_api(lua, &owlry)?; + + // Register provider API + provider::register_provider_api(lua, &owlry)?; + + // Register extended APIs (Phase 3) + process::register_process_api(lua, &owlry)?; + process::register_env_api(lua, &owlry)?; + http::register_http_api(lua, &owlry)?; + cache::register_cache_api(lua, &owlry)?; + math::register_math_api(lua, &owlry)?; + + // Register Phase 4 APIs (hooks, actions, themes) + hook::register_hook_api(lua, &owlry, plugin_id)?; + action::register_action_api(lua, &owlry, plugin_id)?; + theme::register_theme_api(lua, &owlry, plugin_id, plugin_dir)?; + + // Set owlry as global + globals.set("owlry", owlry)?; + + Ok(()) +} + +/// Get provider registrations from the Lua runtime +/// +/// Returns all providers that were registered via `owlry.provider.register()` +pub fn get_provider_registrations(lua: &Lua) -> LuaResult> { + provider::get_registrations(lua) +} diff --git a/crates/owlry-core/src/plugins/api/process.rs b/crates/owlry-core/src/plugins/api/process.rs new file mode 100644 index 0000000..b8b5204 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/process.rs @@ -0,0 +1,207 @@ +//! Process and environment APIs for Lua plugins +//! +//! Provides: +//! - `owlry.process.run(cmd)` - Run a shell command and return output +//! - `owlry.process.exists(cmd)` - Check if a command exists in PATH +//! - `owlry.env.get(name)` - Get an environment variable +//! - `owlry.env.set(name, value)` - Set an environment variable (for plugin scope) + +use mlua::{Lua, Result as LuaResult, Table}; +use std::process::Command; + +/// Register process-related APIs +pub fn register_process_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let process_table = lua.create_table()?; + + // owlry.process.run(cmd) -> { stdout, stderr, exit_code, success } + // Runs a shell command and returns the result + process_table.set( + "run", + lua.create_function(|lua, cmd: String| { + log::debug!("[plugin] process.run: {}", cmd); + + let output = Command::new("sh") + .arg("-c") + .arg(&cmd) + .output() + .map_err(|e| mlua::Error::external(format!("Failed to run command: {}", e)))?; + + let result = lua.create_table()?; + result.set("stdout", String::from_utf8_lossy(&output.stdout).to_string())?; + result.set("stderr", String::from_utf8_lossy(&output.stderr).to_string())?; + result.set("exit_code", output.status.code().unwrap_or(-1))?; + result.set("success", output.status.success())?; + + Ok(result) + })?, + )?; + + // owlry.process.run_lines(cmd) -> table of lines + // Convenience function that runs a command and returns stdout split into lines + process_table.set( + "run_lines", + lua.create_function(|lua, cmd: String| { + log::debug!("[plugin] process.run_lines: {}", cmd); + + let output = Command::new("sh") + .arg("-c") + .arg(&cmd) + .output() + .map_err(|e| mlua::Error::external(format!("Failed to run command: {}", e)))?; + + if !output.status.success() { + return Err(mlua::Error::external(format!( + "Command failed with exit code {}: {}", + output.status.code().unwrap_or(-1), + String::from_utf8_lossy(&output.stderr) + ))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + let result = lua.create_table()?; + for (i, line) in lines.iter().enumerate() { + result.set(i + 1, *line)?; + } + + Ok(result) + })?, + )?; + + // owlry.process.exists(cmd) -> boolean + // Checks if a command exists in PATH + process_table.set( + "exists", + lua.create_function(|_lua, cmd: String| { + let exists = Command::new("which") + .arg(&cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + Ok(exists) + })?, + )?; + + owlry.set("process", process_table)?; + Ok(()) +} + +/// Register environment variable APIs +pub fn register_env_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let env_table = lua.create_table()?; + + // owlry.env.get(name) -> string or nil + env_table.set( + "get", + lua.create_function(|_lua, name: String| { + Ok(std::env::var(&name).ok()) + })?, + )?; + + // owlry.env.get_or(name, default) -> string + env_table.set( + "get_or", + lua.create_function(|_lua, (name, default): (String, String)| { + Ok(std::env::var(&name).unwrap_or(default)) + })?, + )?; + + // owlry.env.home() -> string + // Convenience function to get home directory + env_table.set( + "home", + lua.create_function(|_lua, ()| { + Ok(dirs::home_dir().map(|p| p.to_string_lossy().to_string())) + })?, + )?; + + owlry.set("env", env_table)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_lua() -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_process_api(&lua, &owlry).unwrap(); + register_env_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + lua + } + + #[test] + fn test_process_run() { + let lua = setup_lua(); + let chunk = lua.load(r#"return owlry.process.run("echo hello")"#); + let result: Table = chunk.call(()).unwrap(); + + assert_eq!(result.get::("success").unwrap(), true); + assert_eq!(result.get::("exit_code").unwrap(), 0); + assert!(result.get::("stdout").unwrap().contains("hello")); + } + + #[test] + fn test_process_run_lines() { + let lua = setup_lua(); + let chunk = lua.load(r#"return owlry.process.run_lines("echo -e 'line1\nline2\nline3'")"#); + let result: Table = chunk.call(()).unwrap(); + + assert_eq!(result.get::(1).unwrap(), "line1"); + assert_eq!(result.get::(2).unwrap(), "line2"); + assert_eq!(result.get::(3).unwrap(), "line3"); + } + + #[test] + fn test_process_exists() { + let lua = setup_lua(); + + // 'sh' should always exist + let chunk = lua.load(r#"return owlry.process.exists("sh")"#); + let exists: bool = chunk.call(()).unwrap(); + assert!(exists); + + // Made-up command should not exist + let chunk = lua.load(r#"return owlry.process.exists("this_command_definitely_does_not_exist_12345")"#); + let not_exists: bool = chunk.call(()).unwrap(); + assert!(!not_exists); + } + + #[test] + fn test_env_get() { + let lua = setup_lua(); + + // HOME should be set on any Unix system + let chunk = lua.load(r#"return owlry.env.get("HOME")"#); + let home: Option = chunk.call(()).unwrap(); + assert!(home.is_some()); + + // Non-existent variable should return nil + let chunk = lua.load(r#"return owlry.env.get("THIS_VAR_DOES_NOT_EXIST_12345")"#); + let missing: Option = chunk.call(()).unwrap(); + assert!(missing.is_none()); + } + + #[test] + fn test_env_get_or() { + let lua = setup_lua(); + + let chunk = lua.load(r#"return owlry.env.get_or("THIS_VAR_DOES_NOT_EXIST_12345", "default_value")"#); + let result: String = chunk.call(()).unwrap(); + assert_eq!(result, "default_value"); + } + + #[test] + fn test_env_home() { + let lua = setup_lua(); + + let chunk = lua.load(r#"return owlry.env.home()"#); + let home: Option = chunk.call(()).unwrap(); + assert!(home.is_some()); + assert!(home.unwrap().starts_with('/')); + } +} diff --git a/crates/owlry-core/src/plugins/api/provider.rs b/crates/owlry-core/src/plugins/api/provider.rs new file mode 100644 index 0000000..124c240 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/provider.rs @@ -0,0 +1,315 @@ +//! Provider registration API for Lua plugins +//! +//! Allows plugins to register providers via `owlry.provider.register()` + +use mlua::{Function, Lua, Result as LuaResult, Table}; + +/// Provider registration data extracted from Lua +#[derive(Debug, Clone)] +#[allow(dead_code)] // Some fields are for future use +pub struct ProviderRegistration { + /// Provider name (used for filtering/identification) + pub name: String, + /// Human-readable display name + pub display_name: String, + /// Provider type ID (for badge/filtering) + pub type_id: String, + /// Default icon name + pub default_icon: String, + /// Whether this is a static provider (refresh once) or dynamic (query-based) + pub is_static: bool, + /// Prefix to trigger this provider (e.g., ":" for commands) + pub prefix: Option, +} + +/// Register owlry.provider.* API +pub fn register_provider_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let provider_table = lua.create_table()?; + + // Initialize registry for storing provider registrations + let registrations: Table = lua.create_table()?; + lua.set_named_registry_value("provider_registrations", registrations)?; + + // owlry.provider.register(config) - Register a new provider + provider_table.set( + "register", + lua.create_function(|lua, config: Table| { + // Extract required fields + let name: String = config + .get("name") + .map_err(|_| mlua::Error::external("provider.register: 'name' is required"))?; + + let _display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone()); + + let type_id: String = config + .get("type_id") + .unwrap_or_else(|_| name.replace('-', "_")); + + let _default_icon: String = config + .get("default_icon") + .unwrap_or_else(|_| "application-x-executable".to_string()); + + let _prefix: Option = config.get("prefix").ok(); + + // Check for refresh function (static provider) or query function (dynamic) + let has_refresh = config.get::("refresh").is_ok(); + let has_query = config.get::("query").is_ok(); + + if !has_refresh && !has_query { + return Err(mlua::Error::external( + "provider.register: either 'refresh' or 'query' function is required", + )); + } + + let is_static = has_refresh; + + log::info!( + "[plugin] Registered provider '{}' (type: {}, static: {})", + name, + type_id, + is_static + ); + + // Store the config in registry for later retrieval + let registrations: Table = lua.named_registry_value("provider_registrations")?; + registrations.set(name.clone(), config)?; + + Ok(name) + })?, + )?; + + owlry.set("provider", provider_table)?; + Ok(()) +} + +/// Get all provider registrations from the Lua runtime +pub fn get_registrations(lua: &Lua) -> LuaResult> { + let registrations: Table = lua.named_registry_value("provider_registrations")?; + let mut result = Vec::new(); + + for pair in registrations.pairs::() { + let (name, config) = pair?; + + let display_name: String = config.get("display_name").unwrap_or_else(|_| name.clone()); + let type_id: String = config + .get("type_id") + .unwrap_or_else(|_| name.replace('-', "_")); + let default_icon: String = config + .get("default_icon") + .unwrap_or_else(|_| "application-x-executable".to_string()); + let prefix: Option = config.get("prefix").ok(); + let is_static = config.get::("refresh").is_ok(); + + result.push(ProviderRegistration { + name, + display_name, + type_id, + default_icon, + is_static, + prefix, + }); + } + + Ok(result) +} + +/// Call a provider's refresh function and extract items +pub fn call_refresh(lua: &Lua, provider_name: &str) -> LuaResult> { + let registrations: Table = lua.named_registry_value("provider_registrations")?; + let config: Table = registrations.get(provider_name)?; + let refresh: Function = config.get("refresh")?; + + let items: Table = refresh.call(())?; + extract_items(&items) +} + +/// Call a provider's query function with a query string +#[allow(dead_code)] // Will be used for dynamic query providers +pub fn call_query(lua: &Lua, provider_name: &str, query: &str) -> LuaResult> { + let registrations: Table = lua.named_registry_value("provider_registrations")?; + let config: Table = registrations.get(provider_name)?; + let query_fn: Function = config.get("query")?; + + let items: Table = query_fn.call(query.to_string())?; + extract_items(&items) +} + +/// Item data from a plugin provider +#[derive(Debug, Clone)] +#[allow(dead_code)] // data field is for future action handlers +pub struct PluginItem { + pub id: String, + pub name: String, + pub description: Option, + pub icon: Option, + pub command: Option, + pub terminal: bool, + pub tags: Vec, + /// Custom data passed to action handlers + pub data: Option, +} + +/// Extract items from a Lua table returned by refresh/query +fn extract_items(items: &Table) -> LuaResult> { + let mut result = Vec::new(); + + for pair in items.clone().pairs::() { + let (_, item) = pair?; + + let id: String = item.get("id")?; + let name: String = item.get("name")?; + let description: Option = item.get("description").ok(); + let icon: Option = item.get("icon").ok(); + let command: Option = item.get("command").ok(); + let terminal: bool = item.get("terminal").unwrap_or(false); + let data: Option = item.get("data").ok(); + + // Extract tags array + let tags: Vec = if let Ok(tags_table) = item.get::
("tags") { + tags_table + .pairs::() + .filter_map(|r| r.ok()) + .map(|(_, v)| v) + .collect() + } else { + Vec::new() + }; + + result.push(PluginItem { + id, + name, + description, + icon, + command, + terminal, + tags, + data, + }); + } + + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_lua() -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_provider_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + lua + } + + #[test] + fn test_register_static_provider() { + let lua = create_test_lua(); + + let script = r#" + owlry.provider.register({ + name = "test-provider", + display_name = "Test Provider", + type_id = "test", + default_icon = "test-icon", + refresh = function() + return { + { id = "1", name = "Item 1", description = "First item" }, + { id = "2", name = "Item 2", command = "echo hello" }, + } + end + }) + "#; + lua.load(script).call::<()>(()).unwrap(); + + let registrations = get_registrations(&lua).unwrap(); + assert_eq!(registrations.len(), 1); + assert_eq!(registrations[0].name, "test-provider"); + assert_eq!(registrations[0].display_name, "Test Provider"); + assert!(registrations[0].is_static); + } + + #[test] + fn test_register_dynamic_provider() { + let lua = create_test_lua(); + + let script = r#" + owlry.provider.register({ + name = "search", + prefix = "?", + query = function(q) + return { + { id = "result", name = "Result for: " .. q } + } + end + }) + "#; + lua.load(script).call::<()>(()).unwrap(); + + let registrations = get_registrations(&lua).unwrap(); + assert_eq!(registrations.len(), 1); + assert!(!registrations[0].is_static); + assert_eq!(registrations[0].prefix, Some("?".to_string())); + } + + #[test] + fn test_call_refresh() { + let lua = create_test_lua(); + + let script = r#" + owlry.provider.register({ + name = "items", + refresh = function() + return { + { id = "a", name = "Alpha", tags = {"one", "two"} }, + { id = "b", name = "Beta", terminal = true }, + } + end + }) + "#; + lua.load(script).call::<()>(()).unwrap(); + + let items = call_refresh(&lua, "items").unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0].id, "a"); + assert_eq!(items[0].name, "Alpha"); + assert_eq!(items[0].tags, vec!["one", "two"]); + assert!(!items[0].terminal); + assert_eq!(items[1].id, "b"); + assert!(items[1].terminal); + } + + #[test] + fn test_call_query() { + let lua = create_test_lua(); + + let script = r#" + owlry.provider.register({ + name = "search", + query = function(q) + return { + { id = "1", name = "Found: " .. q } + } + end + }) + "#; + lua.load(script).call::<()>(()).unwrap(); + + let items = call_query(&lua, "search", "hello").unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].name, "Found: hello"); + } + + #[test] + fn test_register_missing_function() { + let lua = create_test_lua(); + + let script = r#" + owlry.provider.register({ + name = "broken", + }) + "#; + let result = lua.load(script).call::<()>(()); + assert!(result.is_err()); + } +} diff --git a/crates/owlry-core/src/plugins/api/theme.rs b/crates/owlry-core/src/plugins/api/theme.rs new file mode 100644 index 0000000..e500222 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/theme.rs @@ -0,0 +1,275 @@ +//! Theme API for Lua plugins +//! +//! Allows plugins to contribute CSS themes: +//! - `owlry.theme.register(config)` - Register a theme + +use mlua::{Lua, Result as LuaResult, Table, Value}; +use std::path::Path; + +/// Theme registration data +#[derive(Debug, Clone)] +#[allow(dead_code)] // Will be used by theme loading +pub struct ThemeRegistration { + /// Theme name (used in config) + pub name: String, + /// Human-readable display name + pub display_name: String, + /// CSS content + pub css: String, + /// Plugin that registered this theme + pub plugin_id: String, +} + +/// Register theme APIs +pub fn register_theme_api(lua: &Lua, owlry: &Table, plugin_id: &str, plugin_dir: &Path) -> LuaResult<()> { + let theme_table = lua.create_table()?; + let plugin_id_owned = plugin_id.to_string(); + let plugin_dir_owned = plugin_dir.to_path_buf(); + + // Initialize theme storage in Lua registry + if lua.named_registry_value::("themes")?.is_nil() { + let themes: Table = lua.create_table()?; + lua.set_named_registry_value("themes", themes)?; + } + + // owlry.theme.register(config) -> string (theme_name) + // config = { + // name = "dark-owl", + // display_name = "Dark Owl", -- optional, defaults to name + // css = "...", -- CSS string + // -- OR + // css_file = "theme.css" -- path relative to plugin dir + // } + let plugin_id_for_register = plugin_id_owned.clone(); + let plugin_dir_for_register = plugin_dir_owned.clone(); + theme_table.set( + "register", + lua.create_function(move |lua, config: Table| { + // Extract required fields + let name: String = config + .get("name") + .map_err(|_| mlua::Error::external("theme.register: 'name' is required"))?; + + let display_name: String = config + .get("display_name") + .unwrap_or_else(|_| name.clone()); + + // Get CSS either directly or from file + let css: String = if let Ok(css_str) = config.get::("css") { + css_str + } else if let Ok(css_file) = config.get::("css_file") { + let css_path = plugin_dir_for_register.join(&css_file); + std::fs::read_to_string(&css_path).map_err(|e| { + mlua::Error::external(format!( + "Failed to read CSS file '{}': {}", + css_path.display(), + e + )) + })? + } else { + return Err(mlua::Error::external( + "theme.register: either 'css' or 'css_file' is required", + )); + }; + + // Store theme in registry + let themes: Table = lua.named_registry_value("themes")?; + + let theme_entry = lua.create_table()?; + theme_entry.set("name", name.clone())?; + theme_entry.set("display_name", display_name.clone())?; + theme_entry.set("css", css)?; + theme_entry.set("plugin_id", plugin_id_for_register.clone())?; + + themes.set(name.clone(), theme_entry)?; + + log::info!( + "[plugin:{}] Registered theme '{}'", + plugin_id_for_register, + name + ); + + Ok(name) + })?, + )?; + + // owlry.theme.unregister(name) -> boolean + theme_table.set( + "unregister", + lua.create_function(|lua, name: String| { + let themes: Table = lua.named_registry_value("themes")?; + + if themes.contains_key(name.clone())? { + themes.set(name, Value::Nil)?; + Ok(true) + } else { + Ok(false) + } + })?, + )?; + + // owlry.theme.list() -> table of theme names + theme_table.set( + "list", + lua.create_function(|lua, ()| { + let themes: Table = match lua.named_registry_value("themes") { + Ok(t) => t, + Err(_) => return lua.create_table(), + }; + + let result = lua.create_table()?; + let mut i = 1; + + for pair in themes.pairs::() { + let (name, _) = pair?; + result.set(i, name)?; + i += 1; + } + + Ok(result) + })?, + )?; + + owlry.set("theme", theme_table)?; + Ok(()) +} + +/// Get all registered themes from a Lua runtime +#[allow(dead_code)] // Will be used by theme system +pub fn get_themes(lua: &Lua) -> LuaResult> { + let themes: Table = match lua.named_registry_value("themes") { + Ok(t) => t, + Err(_) => return Ok(Vec::new()), + }; + + let mut result = Vec::new(); + + for pair in themes.pairs::() { + let (_, entry) = pair?; + + let name: String = entry.get("name")?; + let display_name: String = entry.get("display_name")?; + let css: String = entry.get("css")?; + let plugin_id: String = entry.get("plugin_id")?; + + result.push(ThemeRegistration { + name, + display_name, + css, + plugin_id, + }); + } + + Ok(result) +} + +/// Get a specific theme's CSS by name +#[allow(dead_code)] // Will be used by theme loading +pub fn get_theme_css(lua: &Lua, name: &str) -> LuaResult> { + let themes: Table = match lua.named_registry_value("themes") { + Ok(t) => t, + Err(_) => return Ok(None), + }; + + if let Ok(entry) = themes.get::
(name) { + let css: String = entry.get("css")?; + Ok(Some(css)) + } else { + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn setup_lua(plugin_id: &str, plugin_dir: &Path) -> Lua { + let lua = Lua::new(); + let owlry = lua.create_table().unwrap(); + register_theme_api(&lua, &owlry, plugin_id, plugin_dir).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + lua + } + + #[test] + fn test_theme_registration_inline() { + let temp = TempDir::new().unwrap(); + let lua = setup_lua("test-plugin", temp.path()); + + let chunk = lua.load(r#" + return owlry.theme.register({ + name = "my-theme", + display_name = "My Theme", + css = ".owlry-window { background: #333; }" + }) + "#); + let name: String = chunk.call(()).unwrap(); + assert_eq!(name, "my-theme"); + + let themes = get_themes(&lua).unwrap(); + assert_eq!(themes.len(), 1); + assert_eq!(themes[0].display_name, "My Theme"); + assert!(themes[0].css.contains("background: #333")); + } + + #[test] + fn test_theme_registration_file() { + let temp = TempDir::new().unwrap(); + let css_content = ".owlry-window { background: #444; }"; + std::fs::write(temp.path().join("theme.css"), css_content).unwrap(); + + let lua = setup_lua("test-plugin", temp.path()); + + let chunk = lua.load(r#" + return owlry.theme.register({ + name = "file-theme", + css_file = "theme.css" + }) + "#); + let name: String = chunk.call(()).unwrap(); + assert_eq!(name, "file-theme"); + + let css = get_theme_css(&lua, "file-theme").unwrap(); + assert!(css.is_some()); + assert!(css.unwrap().contains("background: #444")); + } + + #[test] + fn test_theme_list() { + let temp = TempDir::new().unwrap(); + let lua = setup_lua("test-plugin", temp.path()); + + let chunk = lua.load(r#" + owlry.theme.register({ name = "theme1", css = "a{}" }) + owlry.theme.register({ name = "theme2", css = "b{}" }) + return owlry.theme.list() + "#); + let list: Table = chunk.call(()).unwrap(); + + let mut names: Vec = Vec::new(); + for pair in list.pairs::() { + let (_, name) = pair.unwrap(); + names.push(name); + } + assert_eq!(names.len(), 2); + assert!(names.contains(&"theme1".to_string())); + assert!(names.contains(&"theme2".to_string())); + } + + #[test] + fn test_theme_unregister() { + let temp = TempDir::new().unwrap(); + let lua = setup_lua("test-plugin", temp.path()); + + let chunk = lua.load(r#" + owlry.theme.register({ name = "temp-theme", css = "c{}" }) + return owlry.theme.unregister("temp-theme") + "#); + let unregistered: bool = chunk.call(()).unwrap(); + assert!(unregistered); + + let themes = get_themes(&lua).unwrap(); + assert_eq!(themes.len(), 0); + } +} diff --git a/crates/owlry-core/src/plugins/api/utils.rs b/crates/owlry-core/src/plugins/api/utils.rs new file mode 100644 index 0000000..2f6df20 --- /dev/null +++ b/crates/owlry-core/src/plugins/api/utils.rs @@ -0,0 +1,567 @@ +//! Utility APIs: log, path, fs, json + +use mlua::{Lua, Result as LuaResult, Table, Value}; +use std::path::{Path, PathBuf}; + +/// Register owlry.log.* API +/// +/// Provides: debug, info, warn, error +pub fn register_log_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let log_table = lua.create_table()?; + + log_table.set( + "debug", + lua.create_function(|_, msg: String| { + log::debug!("[plugin] {}", msg); + Ok(()) + })?, + )?; + + log_table.set( + "info", + lua.create_function(|_, msg: String| { + log::info!("[plugin] {}", msg); + Ok(()) + })?, + )?; + + log_table.set( + "warn", + lua.create_function(|_, msg: String| { + log::warn!("[plugin] {}", msg); + Ok(()) + })?, + )?; + + log_table.set( + "error", + lua.create_function(|_, msg: String| { + log::error!("[plugin] {}", msg); + Ok(()) + })?, + )?; + + owlry.set("log", log_table)?; + Ok(()) +} + +/// Register owlry.path.* API +/// +/// Provides XDG directory helpers: config, data, cache, home, plugin_dir +pub fn register_path_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> { + let path_table = lua.create_table()?; + let plugin_dir_str = plugin_dir.to_string_lossy().to_string(); + + // owlry.path.config() -> ~/.config/owlry + path_table.set( + "config", + lua.create_function(|_, ()| { + let path = dirs::config_dir() + .map(|p| p.join("owlry")) + .unwrap_or_default(); + Ok(path.to_string_lossy().to_string()) + })?, + )?; + + // owlry.path.data() -> ~/.local/share/owlry + path_table.set( + "data", + lua.create_function(|_, ()| { + let path = dirs::data_dir() + .map(|p| p.join("owlry")) + .unwrap_or_default(); + Ok(path.to_string_lossy().to_string()) + })?, + )?; + + // owlry.path.cache() -> ~/.cache/owlry + path_table.set( + "cache", + lua.create_function(|_, ()| { + let path = dirs::cache_dir() + .map(|p| p.join("owlry")) + .unwrap_or_default(); + Ok(path.to_string_lossy().to_string()) + })?, + )?; + + // owlry.path.home() -> ~ + path_table.set( + "home", + lua.create_function(|_, ()| { + let path = dirs::home_dir().unwrap_or_default(); + Ok(path.to_string_lossy().to_string()) + })?, + )?; + + // owlry.path.join(base, ...) -> joined path + path_table.set( + "join", + lua.create_function(|_, parts: mlua::Variadic| { + let mut path = PathBuf::new(); + for part in parts { + path.push(part); + } + Ok(path.to_string_lossy().to_string()) + })?, + )?; + + // owlry.path.exists(path) -> bool + path_table.set( + "exists", + lua.create_function(|_, path: String| Ok(Path::new(&path).exists()))?, + )?; + + // owlry.path.is_file(path) -> bool + path_table.set( + "is_file", + lua.create_function(|_, path: String| Ok(Path::new(&path).is_file()))?, + )?; + + // owlry.path.is_dir(path) -> bool + path_table.set( + "is_dir", + lua.create_function(|_, path: String| Ok(Path::new(&path).is_dir()))?, + )?; + + // owlry.path.expand(path) -> expanded path (handles ~) + path_table.set( + "expand", + lua.create_function(|_, path: String| { + let expanded = if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + home.join(rest).to_string_lossy().to_string() + } else { + path + } + } else if path == "~" { + dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or(path) + } else { + path + }; + Ok(expanded) + })?, + )?; + + // owlry.path.plugin_dir() -> this plugin's directory + path_table.set( + "plugin_dir", + lua.create_function(move |_, ()| Ok(plugin_dir_str.clone()))?, + )?; + + owlry.set("path", path_table)?; + Ok(()) +} + +/// Register owlry.fs.* API +/// +/// Provides filesystem operations within the plugin's directory +pub fn register_fs_api(lua: &Lua, owlry: &Table, plugin_dir: &Path) -> LuaResult<()> { + let fs_table = lua.create_table()?; + let plugin_dir_str = plugin_dir.to_string_lossy().to_string(); + + // Store plugin directory in registry for access in closures + lua.set_named_registry_value("plugin_dir", plugin_dir_str.clone())?; + + // owlry.fs.read(path) -> string or nil, error + fs_table.set( + "read", + lua.create_function(|lua, path: String| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + + match std::fs::read_to_string(&full_path) { + Ok(content) => Ok((Some(content), Value::Nil)), + Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), + } + })?, + )?; + + // owlry.fs.write(path, content) -> bool, error + fs_table.set( + "write", + lua.create_function(|lua, (path, content): (String, String)| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + + // Ensure parent directory exists + if let Some(parent) = full_path.parent() + && !parent.exists() + && let Err(e) = std::fs::create_dir_all(parent) { + return Ok((false, Value::String(lua.create_string(e.to_string())?))); + } + + match std::fs::write(&full_path, content) { + Ok(()) => Ok((true, Value::Nil)), + Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))), + } + })?, + )?; + + // owlry.fs.list(path) -> array of filenames or nil, error + fs_table.set( + "list", + lua.create_function(|lua, path: Option| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let dir_path = path + .map(|p| resolve_plugin_path(&plugin_dir, &p)) + .unwrap_or_else(|| PathBuf::from(&plugin_dir)); + + match std::fs::read_dir(&dir_path) { + Ok(entries) => { + let names: Vec = entries + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); + let table = lua.create_sequence_from(names)?; + Ok((Some(table), Value::Nil)) + } + Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), + } + })?, + )?; + + // owlry.fs.exists(path) -> bool + fs_table.set( + "exists", + lua.create_function(|lua, path: String| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + Ok(full_path.exists()) + })?, + )?; + + // owlry.fs.mkdir(path) -> bool, error + fs_table.set( + "mkdir", + lua.create_function(|lua, path: String| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + + match std::fs::create_dir_all(&full_path) { + Ok(()) => Ok((true, Value::Nil)), + Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))), + } + })?, + )?; + + // owlry.fs.remove(path) -> bool, error + fs_table.set( + "remove", + lua.create_function(|lua, path: String| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + + let result = if full_path.is_dir() { + std::fs::remove_dir_all(&full_path) + } else { + std::fs::remove_file(&full_path) + }; + + match result { + Ok(()) => Ok((true, Value::Nil)), + Err(e) => Ok((false, Value::String(lua.create_string(e.to_string())?))), + } + })?, + )?; + + // owlry.fs.is_file(path) -> bool + fs_table.set( + "is_file", + lua.create_function(|lua, path: String| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + Ok(full_path.is_file()) + })?, + )?; + + // owlry.fs.is_dir(path) -> bool + fs_table.set( + "is_dir", + lua.create_function(|lua, path: String| { + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + Ok(full_path.is_dir()) + })?, + )?; + + // owlry.fs.is_executable(path) -> bool + #[cfg(unix)] + fs_table.set( + "is_executable", + lua.create_function(|lua, path: String| { + use std::os::unix::fs::PermissionsExt; + let plugin_dir: String = lua.named_registry_value("plugin_dir")?; + let full_path = resolve_plugin_path(&plugin_dir, &path); + let is_exec = full_path.metadata() + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false); + Ok(is_exec) + })?, + )?; + + // owlry.fs.plugin_dir() -> plugin directory path + let dir_clone = plugin_dir_str.clone(); + fs_table.set( + "plugin_dir", + lua.create_function(move |_, ()| Ok(dir_clone.clone()))?, + )?; + + owlry.set("fs", fs_table)?; + Ok(()) +} + +/// Resolve a path relative to the plugin directory +/// +/// If the path is absolute, returns it as-is (for paths within allowed directories). +/// If relative, joins with plugin directory. +fn resolve_plugin_path(plugin_dir: &str, path: &str) -> PathBuf { + let path = Path::new(path); + if path.is_absolute() { + path.to_path_buf() + } else { + Path::new(plugin_dir).join(path) + } +} + +/// Register owlry.json.* API +/// +/// Provides JSON encoding/decoding +pub fn register_json_api(lua: &Lua, owlry: &Table) -> LuaResult<()> { + let json_table = lua.create_table()?; + + // owlry.json.encode(value) -> string or nil, error + json_table.set( + "encode", + lua.create_function(|lua, value: Value| { + match lua_to_json(&value) { + Ok(json) => match serde_json::to_string(&json) { + Ok(s) => Ok((Some(s), Value::Nil)), + Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), + }, + Err(e) => Ok((None, Value::String(lua.create_string(&e)?))), + } + })?, + )?; + + // owlry.json.encode_pretty(value) -> string or nil, error + json_table.set( + "encode_pretty", + lua.create_function(|lua, value: Value| { + match lua_to_json(&value) { + Ok(json) => match serde_json::to_string_pretty(&json) { + Ok(s) => Ok((Some(s), Value::Nil)), + Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), + }, + Err(e) => Ok((None, Value::String(lua.create_string(&e)?))), + } + })?, + )?; + + // owlry.json.decode(string) -> value or nil, error + json_table.set( + "decode", + lua.create_function(|lua, s: String| { + match serde_json::from_str::(&s) { + Ok(json) => match json_to_lua(lua, &json) { + Ok(value) => Ok((Some(value), Value::Nil)), + Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), + }, + Err(e) => Ok((None, Value::String(lua.create_string(e.to_string())?))), + } + })?, + )?; + + owlry.set("json", json_table)?; + Ok(()) +} + +/// Convert Lua value to JSON +fn lua_to_json(value: &Value) -> Result { + match value { + Value::Nil => Ok(serde_json::Value::Null), + Value::Boolean(b) => Ok(serde_json::Value::Bool(*b)), + Value::Integer(i) => Ok(serde_json::Value::Number((*i).into())), + Value::Number(n) => serde_json::Number::from_f64(*n) + .map(serde_json::Value::Number) + .ok_or_else(|| "Invalid number".to_string()), + Value::String(s) => Ok(serde_json::Value::String( + s.to_str().map_err(|e| e.to_string())?.to_string() + )), + Value::Table(t) => { + // Check if it's an array (sequential integer keys starting from 1) + let len = t.raw_len(); + let is_array = len > 0 + && (1..=len).all(|i| t.raw_get::(i).is_ok_and(|v| !matches!(v, Value::Nil))); + + if is_array { + let arr: Result, String> = (1..=len) + .map(|i| { + let v: Value = t.raw_get(i).map_err(|e| e.to_string())?; + lua_to_json(&v) + }) + .collect(); + Ok(serde_json::Value::Array(arr?)) + } else { + let mut map = serde_json::Map::new(); + for pair in t.clone().pairs::() { + let (k, v) = pair.map_err(|e| e.to_string())?; + let key = match k { + Value::String(s) => s.to_str().map_err(|e| e.to_string())?.to_string(), + Value::Integer(i) => i.to_string(), + _ => return Err("JSON object keys must be strings".to_string()), + }; + map.insert(key, lua_to_json(&v)?); + } + Ok(serde_json::Value::Object(map)) + } + } + _ => Err(format!("Cannot convert {:?} to JSON", value)), + } +} + +/// Convert JSON to Lua value +fn json_to_lua(lua: &Lua, json: &serde_json::Value) -> LuaResult { + match json { + serde_json::Value::Null => Ok(Value::Nil), + serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(Value::Integer(i)) + } else if let Some(f) = n.as_f64() { + Ok(Value::Number(f)) + } else { + Ok(Value::Nil) + } + } + serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)), + serde_json::Value::Array(arr) => { + let table = lua.create_table()?; + for (i, v) in arr.iter().enumerate() { + table.set(i + 1, json_to_lua(lua, v)?)?; + } + Ok(Value::Table(table)) + } + serde_json::Value::Object(obj) => { + let table = lua.create_table()?; + for (k, v) in obj { + table.set(k.as_str(), json_to_lua(lua, v)?)?; + } + Ok(Value::Table(table)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn create_test_lua() -> (Lua, TempDir) { + let lua = Lua::new(); + let temp = TempDir::new().unwrap(); + let owlry = lua.create_table().unwrap(); + register_log_api(&lua, &owlry).unwrap(); + register_path_api(&lua, &owlry, temp.path()).unwrap(); + register_fs_api(&lua, &owlry, temp.path()).unwrap(); + register_json_api(&lua, &owlry).unwrap(); + lua.globals().set("owlry", owlry).unwrap(); + (lua, temp) + } + + #[test] + fn test_log_api() { + let (lua, _temp) = create_test_lua(); + // Just verify it doesn't panic - using call instead of the e-word + lua.load("owlry.log.info('test message')").call::<()>(()).unwrap(); + lua.load("owlry.log.debug('debug')").call::<()>(()).unwrap(); + lua.load("owlry.log.warn('warning')").call::<()>(()).unwrap(); + lua.load("owlry.log.error('error')").call::<()>(()).unwrap(); + } + + #[test] + fn test_path_api() { + let (lua, _temp) = create_test_lua(); + + let home: String = lua + .load("return owlry.path.home()") + .call(()) + .unwrap(); + assert!(!home.is_empty()); + + let joined: String = lua + .load("return owlry.path.join('a', 'b', 'c')") + .call(()) + .unwrap(); + assert!(joined.contains("a") && joined.contains("b") && joined.contains("c")); + + let expanded: String = lua + .load("return owlry.path.expand('~/test')") + .call(()) + .unwrap(); + assert!(!expanded.starts_with("~")); + } + + #[test] + fn test_fs_api() { + let (lua, temp) = create_test_lua(); + + // Test write and read + lua.load("owlry.fs.write('test.txt', 'hello world')") + .call::<()>(()) + .unwrap(); + + assert!(temp.path().join("test.txt").exists()); + + let content: String = lua + .load("return owlry.fs.read('test.txt')") + .call(()) + .unwrap(); + assert_eq!(content, "hello world"); + + // Test exists + let exists: bool = lua + .load("return owlry.fs.exists('test.txt')") + .call(()) + .unwrap(); + assert!(exists); + + // Test list + let script = r#" + local files = owlry.fs.list() + return #files + "#; + let count: i32 = lua.load(script).call(()).unwrap(); + assert!(count >= 1); + } + + #[test] + fn test_json_api() { + let (lua, _temp) = create_test_lua(); + + // Test encode + let encoded: String = lua + .load(r#"return owlry.json.encode({name = "test", value = 42})"#) + .call(()) + .unwrap(); + assert!(encoded.contains("test") && encoded.contains("42")); + + // Test decode + let script = r#" + local data = owlry.json.decode('{"name":"hello","num":123}') + return data.name, data.num + "#; + let (name, num): (String, i32) = lua.load(script).call(()).unwrap(); + assert_eq!(name, "hello"); + assert_eq!(num, 123); + + // Test array encoding + let encoded: String = lua + .load(r#"return owlry.json.encode({1, 2, 3})"#) + .call(()) + .unwrap(); + assert_eq!(encoded, "[1,2,3]"); + } +} diff --git a/crates/owlry-core/src/plugins/error.rs b/crates/owlry-core/src/plugins/error.rs new file mode 100644 index 0000000..af6ce43 --- /dev/null +++ b/crates/owlry-core/src/plugins/error.rs @@ -0,0 +1,51 @@ +//! Plugin system error types + +use thiserror::Error; + +/// Errors that can occur in the plugin system +#[derive(Error, Debug)] +#[allow(dead_code)] // Some variants are for future use +pub enum PluginError { + #[error("Plugin '{0}' not found")] + NotFound(String), + + #[error("Invalid plugin manifest in '{plugin}': {message}")] + InvalidManifest { plugin: String, message: String }, + + #[error("Plugin '{plugin}' requires owlry {required}, but current version is {current}")] + VersionMismatch { + plugin: String, + required: String, + current: String, + }, + + #[error("Lua error in plugin '{plugin}': {message}")] + LuaError { plugin: String, message: String }, + + #[error("Plugin '{plugin}' timed out after {timeout_ms}ms")] + Timeout { plugin: String, timeout_ms: u64 }, + + #[error("Plugin '{plugin}' attempted forbidden operation: {operation}")] + SandboxViolation { plugin: String, operation: String }, + + #[error("Plugin '{0}' is already loaded")] + AlreadyLoaded(String), + + #[error("Plugin '{0}' is disabled")] + Disabled(String), + + #[error("Failed to load native plugin: {0}")] + LoadError(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("TOML parsing error: {0}")] + TomlParse(#[from] toml::de::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} + +/// Result type for plugin operations +pub type PluginResult = Result; diff --git a/crates/owlry-core/src/plugins/loader.rs b/crates/owlry-core/src/plugins/loader.rs new file mode 100644 index 0000000..4a6f0ee --- /dev/null +++ b/crates/owlry-core/src/plugins/loader.rs @@ -0,0 +1,205 @@ +//! Lua plugin loading and initialization + +use std::path::PathBuf; + +use mlua::Lua; + +use super::api; +use super::error::{PluginError, PluginResult}; +use super::manifest::PluginManifest; +use super::runtime::{create_lua_runtime, load_file, SandboxConfig}; + +/// A loaded plugin instance +#[derive(Debug)] +pub struct LoadedPlugin { + /// Plugin manifest + pub manifest: PluginManifest, + /// Path to plugin directory + pub path: PathBuf, + /// Whether plugin is enabled + pub enabled: bool, + /// Lua runtime (None if not yet initialized) + lua: Option, +} + +impl LoadedPlugin { + /// Create a new loaded plugin (not yet initialized) + pub fn new(manifest: PluginManifest, path: PathBuf) -> Self { + Self { + manifest, + path, + enabled: true, + lua: None, + } + } + + /// Get the plugin ID + pub fn id(&self) -> &str { + &self.manifest.plugin.id + } + + /// Get the plugin name + #[allow(dead_code)] + pub fn name(&self) -> &str { + &self.manifest.plugin.name + } + + /// Initialize the Lua runtime and load the entry point + pub fn initialize(&mut self) -> PluginResult<()> { + if self.lua.is_some() { + return Ok(()); // Already initialized + } + + let sandbox = SandboxConfig::from_permissions(&self.manifest.permissions); + let lua = create_lua_runtime(&sandbox).map_err(|e| PluginError::LuaError { + plugin: self.id().to_string(), + message: e.to_string(), + })?; + + // Register owlry APIs before loading entry point + api::register_apis(&lua, &self.path, self.id()).map_err(|e| PluginError::LuaError { + plugin: self.id().to_string(), + message: format!("Failed to register APIs: {}", e), + })?; + + // Load the entry point file + let entry_path = self.path.join(&self.manifest.plugin.entry); + if !entry_path.exists() { + return Err(PluginError::InvalidManifest { + plugin: self.id().to_string(), + message: format!("Entry point '{}' not found", self.manifest.plugin.entry), + }); + } + + load_file(&lua, &entry_path).map_err(|e| PluginError::LuaError { + plugin: self.id().to_string(), + message: e.to_string(), + })?; + + self.lua = Some(lua); + Ok(()) + } + + /// Get provider registrations from this plugin + pub fn get_provider_registrations(&self) -> PluginResult> { + let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError { + plugin: self.id().to_string(), + message: "Plugin not initialized".to_string(), + })?; + + api::get_provider_registrations(lua).map_err(|e| PluginError::LuaError { + plugin: self.id().to_string(), + message: e.to_string(), + }) + } + + /// Call a provider's refresh function + pub fn call_provider_refresh(&self, provider_name: &str) -> PluginResult> { + let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError { + plugin: self.id().to_string(), + message: "Plugin not initialized".to_string(), + })?; + + api::provider::call_refresh(lua, provider_name).map_err(|e| PluginError::LuaError { + plugin: self.id().to_string(), + message: e.to_string(), + }) + } + + /// Call a provider's query function + #[allow(dead_code)] // Will be used for dynamic query providers + pub fn call_provider_query(&self, provider_name: &str, query: &str) -> PluginResult> { + let lua = self.lua.as_ref().ok_or_else(|| PluginError::LuaError { + plugin: self.id().to_string(), + message: "Plugin not initialized".to_string(), + })?; + + api::provider::call_query(lua, provider_name, query).map_err(|e| PluginError::LuaError { + plugin: self.id().to_string(), + message: e.to_string(), + }) + } + + /// Get a reference to the Lua runtime (if initialized) + #[allow(dead_code)] + pub fn lua(&self) -> Option<&Lua> { + self.lua.as_ref() + } + + /// Get a mutable reference to the Lua runtime (if initialized) + #[allow(dead_code)] + pub fn lua_mut(&mut self) -> Option<&mut Lua> { + self.lua.as_mut() + } +} + +// Note: discover_plugins and check_compatibility are in manifest.rs +// to avoid Lua dependency for plugin discovery. + +#[cfg(test)] +mod tests { + use super::*; + use super::super::manifest::{check_compatibility, discover_plugins}; + use std::fs; + use std::path::Path; + use tempfile::TempDir; + + fn create_test_plugin(dir: &Path, id: &str, name: &str) { + let plugin_dir = dir.join(id); + fs::create_dir_all(&plugin_dir).unwrap(); + + let manifest = format!( + r#" +[plugin] +id = "{}" +name = "{}" +version = "1.0.0" +"#, + id, name + ); + fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); + fs::write(plugin_dir.join("init.lua"), "-- empty plugin").unwrap(); + } + + #[test] + fn test_discover_plugins() { + let temp = TempDir::new().unwrap(); + let plugins_dir = temp.path(); + + create_test_plugin(plugins_dir, "test-plugin", "Test Plugin"); + create_test_plugin(plugins_dir, "another-plugin", "Another Plugin"); + + let plugins = discover_plugins(plugins_dir).unwrap(); + assert_eq!(plugins.len(), 2); + assert!(plugins.contains_key("test-plugin")); + assert!(plugins.contains_key("another-plugin")); + } + + #[test] + fn test_discover_plugins_empty_dir() { + let temp = TempDir::new().unwrap(); + let plugins = discover_plugins(temp.path()).unwrap(); + assert!(plugins.is_empty()); + } + + #[test] + fn test_discover_plugins_nonexistent_dir() { + let plugins = discover_plugins(Path::new("/nonexistent/path")).unwrap(); + assert!(plugins.is_empty()); + } + + #[test] + fn test_check_compatibility() { + let toml_str = r#" +[plugin] +id = "test" +name = "Test" +version = "1.0.0" +owlry_version = ">=0.3.0" +"#; + let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); + + assert!(check_compatibility(&manifest, "0.3.5").is_ok()); + assert!(check_compatibility(&manifest, "0.2.0").is_err()); + } +} diff --git a/crates/owlry-core/src/plugins/manifest.rs b/crates/owlry-core/src/plugins/manifest.rs new file mode 100644 index 0000000..929d6cf --- /dev/null +++ b/crates/owlry-core/src/plugins/manifest.rs @@ -0,0 +1,318 @@ +//! Plugin manifest (plugin.toml) parsing + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use super::error::{PluginError, PluginResult}; + +/// Plugin manifest loaded from plugin.toml +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginManifest { + pub plugin: PluginInfo, + #[serde(default)] + pub provides: PluginProvides, + #[serde(default)] + pub permissions: PluginPermissions, + #[serde(default)] + pub settings: HashMap, +} + +/// Core plugin information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginInfo { + /// Unique plugin identifier (lowercase, alphanumeric, hyphens) + pub id: String, + /// Human-readable name + pub name: String, + /// Semantic version + pub version: String, + /// Short description + #[serde(default)] + pub description: String, + /// Plugin author + #[serde(default)] + pub author: String, + /// License identifier + #[serde(default)] + pub license: String, + /// Repository URL + #[serde(default)] + pub repository: Option, + /// Required owlry version (semver constraint) + #[serde(default = "default_owlry_version")] + pub owlry_version: String, + /// Entry point file (relative to plugin directory) + #[serde(default = "default_entry")] + pub entry: String, +} + +fn default_owlry_version() -> String { + ">=0.1.0".to_string() +} + +fn default_entry() -> String { + "init.lua".to_string() +} + +/// What the plugin provides +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginProvides { + /// Provider names this plugin registers + #[serde(default)] + pub providers: Vec, + /// Whether this plugin registers actions + #[serde(default)] + pub actions: bool, + /// Theme names this plugin contributes + #[serde(default)] + pub themes: Vec, + /// Whether this plugin registers hooks + #[serde(default)] + pub hooks: bool, + /// CLI commands this plugin provides + #[serde(default)] + pub commands: Vec, +} + +/// A CLI command provided by a plugin +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginCommand { + /// Command name (e.g., "add", "list", "sync") + pub name: String, + /// Short description shown in help + #[serde(default)] + pub description: String, + /// Usage pattern (e.g., " [name]") + #[serde(default)] + pub usage: String, +} + +/// Plugin permissions/capabilities +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginPermissions { + /// Allow network/HTTP requests + #[serde(default)] + pub network: bool, + /// Filesystem paths the plugin can access (beyond its own directory) + #[serde(default)] + pub filesystem: Vec, + /// Commands the plugin is allowed to run + #[serde(default)] + pub run_commands: Vec, + /// Environment variables the plugin reads + #[serde(default)] + pub environment: Vec, +} + +// ============================================================================ +// Plugin Discovery (no Lua dependency) +// ============================================================================ + +/// Discover all plugins in a directory +/// +/// Returns a map of plugin ID -> (manifest, path) +pub fn discover_plugins(plugins_dir: &Path) -> PluginResult> { + let mut plugins = HashMap::new(); + + if !plugins_dir.exists() { + log::debug!("Plugins directory does not exist: {}", plugins_dir.display()); + return Ok(plugins); + } + + let entries = std::fs::read_dir(plugins_dir)?; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let manifest_path = path.join("plugin.toml"); + if !manifest_path.exists() { + log::debug!("Skipping {}: no plugin.toml", path.display()); + continue; + } + + match PluginManifest::load(&manifest_path) { + Ok(manifest) => { + let id = manifest.plugin.id.clone(); + if plugins.contains_key(&id) { + log::warn!("Duplicate plugin ID '{}', skipping {}", id, path.display()); + continue; + } + log::info!("Discovered plugin: {} v{}", manifest.plugin.name, manifest.plugin.version); + plugins.insert(id, (manifest, path)); + } + Err(e) => { + log::warn!("Failed to load plugin at {}: {}", path.display(), e); + } + } + } + + Ok(plugins) +} + +/// Check if a plugin is compatible with the given owlry version +#[allow(dead_code)] +pub fn check_compatibility(manifest: &PluginManifest, owlry_version: &str) -> PluginResult<()> { + if !manifest.is_compatible_with(owlry_version) { + return Err(PluginError::VersionMismatch { + plugin: manifest.plugin.id.clone(), + required: manifest.plugin.owlry_version.clone(), + current: owlry_version.to_string(), + }); + } + Ok(()) +} + +// ============================================================================ +// PluginManifest Implementation +// ============================================================================ + +impl PluginManifest { + /// Load a plugin manifest from a plugin.toml file + pub fn load(path: &Path) -> PluginResult { + let content = std::fs::read_to_string(path)?; + let manifest: PluginManifest = toml::from_str(&content)?; + manifest.validate()?; + Ok(manifest) + } + + /// Load from a plugin directory (looks for plugin.toml inside) + #[allow(dead_code)] + pub fn load_from_dir(plugin_dir: &Path) -> PluginResult { + let manifest_path = plugin_dir.join("plugin.toml"); + if !manifest_path.exists() { + return Err(PluginError::InvalidManifest { + plugin: plugin_dir.display().to_string(), + message: "plugin.toml not found".to_string(), + }); + } + Self::load(&manifest_path) + } + + /// Validate the manifest + fn validate(&self) -> PluginResult<()> { + // Validate plugin ID format + if self.plugin.id.is_empty() { + return Err(PluginError::InvalidManifest { + plugin: self.plugin.id.clone(), + message: "Plugin ID cannot be empty".to_string(), + }); + } + + if !self.plugin.id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { + return Err(PluginError::InvalidManifest { + plugin: self.plugin.id.clone(), + message: "Plugin ID must be lowercase alphanumeric with hyphens".to_string(), + }); + } + + // Validate version format + if semver::Version::parse(&self.plugin.version).is_err() { + return Err(PluginError::InvalidManifest { + plugin: self.plugin.id.clone(), + message: format!("Invalid version format: {}", self.plugin.version), + }); + } + + // Validate owlry_version constraint + if semver::VersionReq::parse(&self.plugin.owlry_version).is_err() { + return Err(PluginError::InvalidManifest { + plugin: self.plugin.id.clone(), + message: format!("Invalid owlry_version constraint: {}", self.plugin.owlry_version), + }); + } + + Ok(()) + } + + /// Check if this plugin is compatible with the given owlry version + #[allow(dead_code)] + pub fn is_compatible_with(&self, owlry_version: &str) -> bool { + let req = match semver::VersionReq::parse(&self.plugin.owlry_version) { + Ok(r) => r, + Err(_) => return false, + }; + let version = match semver::Version::parse(owlry_version) { + Ok(v) => v, + Err(_) => return false, + }; + req.matches(&version) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_minimal_manifest() { + let toml_str = r#" +[plugin] +id = "test-plugin" +name = "Test Plugin" +version = "1.0.0" +"#; + let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); + assert_eq!(manifest.plugin.id, "test-plugin"); + assert_eq!(manifest.plugin.name, "Test Plugin"); + assert_eq!(manifest.plugin.version, "1.0.0"); + assert_eq!(manifest.plugin.entry, "init.lua"); + } + + #[test] + fn test_parse_full_manifest() { + let toml_str = r#" +[plugin] +id = "my-provider" +name = "My Provider" +version = "1.2.3" +description = "A test provider" +author = "Test Author" +license = "MIT" +owlry_version = ">=0.4.0" +entry = "main.lua" + +[provides] +providers = ["my-provider"] +actions = true +themes = ["dark"] +hooks = true + +[permissions] +network = true +filesystem = ["~/.config/myapp"] +run_commands = ["myapp"] +environment = ["MY_API_KEY"] + +[settings] +max_results = 20 +api_url = "https://api.example.com" +"#; + let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); + assert_eq!(manifest.plugin.id, "my-provider"); + assert!(manifest.provides.actions); + assert!(manifest.permissions.network); + assert_eq!(manifest.permissions.run_commands, vec!["myapp"]); + } + + #[test] + fn test_version_compatibility() { + let toml_str = r#" +[plugin] +id = "test" +name = "Test" +version = "1.0.0" +owlry_version = ">=0.3.0, <1.0.0" +"#; + let manifest: PluginManifest = toml::from_str(toml_str).unwrap(); + assert!(manifest.is_compatible_with("0.3.5")); + assert!(manifest.is_compatible_with("0.4.0")); + assert!(!manifest.is_compatible_with("0.2.0")); + assert!(!manifest.is_compatible_with("1.0.0")); + } +} diff --git a/crates/owlry-core/src/plugins/mod.rs b/crates/owlry-core/src/plugins/mod.rs new file mode 100644 index 0000000..cbc64e2 --- /dev/null +++ b/crates/owlry-core/src/plugins/mod.rs @@ -0,0 +1,336 @@ +//! Owlry Plugin System +//! +//! This module provides plugin support for extending owlry's functionality. +//! Plugins can register providers, actions, themes, and hooks. +//! +//! # Plugin Types +//! +//! - **Native plugins** (.so): Pre-compiled Rust plugins loaded from `/usr/lib/owlry/plugins/` +//! - **Lua plugins**: Script-based plugins from `~/.config/owlry/plugins/` (requires `lua` feature) +//! +//! # Plugin Structure (Lua) +//! +//! Each Lua plugin lives in its own directory under `~/.config/owlry/plugins/`: +//! +//! ```text +//! ~/.config/owlry/plugins/ +//! my-plugin/ +//! plugin.toml # Plugin manifest +//! init.lua # Entry point +//! lib/ # Optional modules +//! ``` + +// Always available +pub mod error; +pub mod manifest; +pub mod native_loader; +pub mod registry; +pub mod runtime_loader; + +// Lua-specific modules (require mlua) +#[cfg(feature = "lua")] +pub mod api; +#[cfg(feature = "lua")] +pub mod loader; +#[cfg(feature = "lua")] +pub mod runtime; + +// Re-export commonly used types +#[cfg(feature = "lua")] +pub use api::provider::{PluginItem, ProviderRegistration}; +#[cfg(feature = "lua")] +#[allow(unused_imports)] +pub use api::{ActionRegistration, HookEvent, ThemeRegistration}; + +#[allow(unused_imports)] +pub use error::{PluginError, PluginResult}; + +#[cfg(feature = "lua")] +pub use loader::LoadedPlugin; + +// Used by plugins/commands.rs for plugin CLI commands +#[allow(unused_imports)] +pub use manifest::{check_compatibility, discover_plugins, PluginManifest}; + +// ============================================================================ +// Lua Plugin Manager (only available with lua feature) +// ============================================================================ + +#[cfg(feature = "lua")] +mod lua_manager { + use super::*; + use std::cell::RefCell; + use std::collections::HashMap; + use std::path::PathBuf; + use std::rc::Rc; + + use manifest::{discover_plugins, check_compatibility}; + + /// Plugin manager coordinates loading, initialization, and lifecycle of Lua plugins + pub struct PluginManager { + /// Directory where plugins are stored + plugins_dir: PathBuf, + /// Current owlry version for compatibility checks + owlry_version: String, + /// Loaded plugins by ID (Rc> allows sharing with LuaProviders) + plugins: HashMap>>, + /// Plugin IDs that are explicitly disabled + disabled: Vec, + } + + impl PluginManager { + /// Create a new plugin manager + pub fn new(plugins_dir: PathBuf, owlry_version: &str) -> Self { + Self { + plugins_dir, + owlry_version: owlry_version.to_string(), + plugins: HashMap::new(), + disabled: Vec::new(), + } + } + + /// Set the list of disabled plugin IDs + pub fn set_disabled(&mut self, disabled: Vec) { + self.disabled = disabled; + } + + /// Discover and load all plugins from the plugins directory + pub fn discover(&mut self) -> PluginResult { + log::info!("Discovering plugins in {}", self.plugins_dir.display()); + + let discovered = discover_plugins(&self.plugins_dir)?; + let mut loaded_count = 0; + + for (id, (manifest, path)) in discovered { + // Skip disabled plugins + if self.disabled.contains(&id) { + log::info!("Plugin '{}' is disabled, skipping", id); + continue; + } + + // Check version compatibility + if let Err(e) = check_compatibility(&manifest, &self.owlry_version) { + log::warn!("Plugin '{}' is not compatible: {}", id, e); + continue; + } + + let plugin = LoadedPlugin::new(manifest, path); + self.plugins.insert(id, Rc::new(RefCell::new(plugin))); + loaded_count += 1; + } + + log::info!("Discovered {} compatible plugins", loaded_count); + Ok(loaded_count) + } + + /// Initialize all discovered plugins (load their Lua code) + pub fn initialize_all(&mut self) -> Vec { + let mut errors = Vec::new(); + + for (id, plugin_rc) in &self.plugins { + let mut plugin = plugin_rc.borrow_mut(); + if !plugin.enabled { + continue; + } + + log::debug!("Initializing plugin: {}", id); + if let Err(e) = plugin.initialize() { + log::error!("Failed to initialize plugin '{}': {}", id, e); + errors.push(e); + plugin.enabled = false; + } + } + + errors + } + + /// Get a loaded plugin by ID (returns Rc for shared ownership) + #[allow(dead_code)] + pub fn get(&self, id: &str) -> Option>> { + self.plugins.get(id).cloned() + } + + /// Get all loaded plugins + #[allow(dead_code)] + pub fn plugins(&self) -> impl Iterator>> + '_ { + self.plugins.values().cloned() + } + + /// Get all enabled plugins + pub fn enabled_plugins(&self) -> impl Iterator>> + '_ { + self.plugins.values().filter(|p| p.borrow().enabled).cloned() + } + + /// Get the number of loaded plugins + #[allow(dead_code)] + pub fn plugin_count(&self) -> usize { + self.plugins.len() + } + + /// Get the number of enabled plugins + #[allow(dead_code)] + pub fn enabled_count(&self) -> usize { + self.plugins.values().filter(|p| p.borrow().enabled).count() + } + + /// Enable a plugin by ID + #[allow(dead_code)] + pub fn enable(&mut self, id: &str) -> PluginResult<()> { + let plugin_rc = self.plugins.get(id).ok_or_else(|| PluginError::NotFound(id.to_string()))?; + let mut plugin = plugin_rc.borrow_mut(); + + if !plugin.enabled { + plugin.enabled = true; + // Initialize if not already done + plugin.initialize()?; + } + + Ok(()) + } + + /// Disable a plugin by ID + #[allow(dead_code)] + pub fn disable(&mut self, id: &str) -> PluginResult<()> { + let plugin_rc = self.plugins.get(id).ok_or_else(|| PluginError::NotFound(id.to_string()))?; + plugin_rc.borrow_mut().enabled = false; + Ok(()) + } + + /// Get plugin IDs that provide a specific feature + #[allow(dead_code)] + pub fn providers_for(&self, provider_name: &str) -> Vec { + self.enabled_plugins() + .filter(|p| p.borrow().manifest.provides.providers.contains(&provider_name.to_string())) + .map(|p| p.borrow().id().to_string()) + .collect() + } + + /// Check if any plugin provides actions + #[allow(dead_code)] + pub fn has_action_plugins(&self) -> bool { + self.enabled_plugins().any(|p| p.borrow().manifest.provides.actions) + } + + /// Check if any plugin provides hooks + #[allow(dead_code)] + pub fn has_hook_plugins(&self) -> bool { + self.enabled_plugins().any(|p| p.borrow().manifest.provides.hooks) + } + + /// Get all theme names provided by plugins + #[allow(dead_code)] + pub fn theme_names(&self) -> Vec { + self.enabled_plugins() + .flat_map(|p| p.borrow().manifest.provides.themes.clone()) + .collect() + } + + /// Create providers from all enabled plugins + /// + /// This must be called after `initialize_all()`. Returns a vec of Provider trait + /// objects that can be added to the ProviderManager. + pub fn create_providers(&self) -> Vec> { + use crate::providers::lua_provider::create_providers_from_plugin; + + let mut providers = Vec::new(); + + for plugin_rc in self.enabled_plugins() { + let plugin_providers = create_providers_from_plugin(plugin_rc); + providers.extend(plugin_providers); + } + + providers + } + } +} + +#[cfg(feature = "lua")] +pub use lua_manager::PluginManager; + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(all(test, feature = "lua"))] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_test_plugin(dir: &std::path::Path, id: &str, version: &str, owlry_req: &str) { + let plugin_dir = dir.join(id); + fs::create_dir_all(&plugin_dir).unwrap(); + + let manifest = format!( + r#" +[plugin] +id = "{}" +name = "Test {}" +version = "{}" +owlry_version = "{}" + +[provides] +providers = ["{}"] +"#, + id, id, version, owlry_req, id + ); + fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); + fs::write(plugin_dir.join("init.lua"), "-- test plugin").unwrap(); + } + + #[test] + fn test_plugin_manager_discover() { + let temp = TempDir::new().unwrap(); + create_test_plugin(temp.path(), "plugin-a", "1.0.0", ">=0.3.0"); + create_test_plugin(temp.path(), "plugin-b", "2.0.0", ">=0.3.0"); + + let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5"); + let count = manager.discover().unwrap(); + + assert_eq!(count, 2); + assert!(manager.get("plugin-a").is_some()); + assert!(manager.get("plugin-b").is_some()); + } + + #[test] + fn test_plugin_manager_disabled() { + let temp = TempDir::new().unwrap(); + create_test_plugin(temp.path(), "plugin-a", "1.0.0", ">=0.3.0"); + create_test_plugin(temp.path(), "plugin-b", "1.0.0", ">=0.3.0"); + + let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5"); + manager.set_disabled(vec!["plugin-b".to_string()]); + let count = manager.discover().unwrap(); + + assert_eq!(count, 1); + assert!(manager.get("plugin-a").is_some()); + assert!(manager.get("plugin-b").is_none()); + } + + #[test] + fn test_plugin_manager_version_compat() { + let temp = TempDir::new().unwrap(); + create_test_plugin(temp.path(), "old-plugin", "1.0.0", ">=0.5.0"); // Requires future version + create_test_plugin(temp.path(), "new-plugin", "1.0.0", ">=0.3.0"); + + let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5"); + let count = manager.discover().unwrap(); + + assert_eq!(count, 1); + assert!(manager.get("old-plugin").is_none()); // Incompatible + assert!(manager.get("new-plugin").is_some()); + } + + #[test] + fn test_providers_for() { + let temp = TempDir::new().unwrap(); + create_test_plugin(temp.path(), "my-provider", "1.0.0", ">=0.3.0"); + + let mut manager = PluginManager::new(temp.path().to_path_buf(), "0.3.5"); + manager.discover().unwrap(); + + let providers = manager.providers_for("my-provider"); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0], "my-provider"); + } +} diff --git a/crates/owlry-core/src/plugins/native_loader.rs b/crates/owlry-core/src/plugins/native_loader.rs new file mode 100644 index 0000000..05d539d --- /dev/null +++ b/crates/owlry-core/src/plugins/native_loader.rs @@ -0,0 +1,391 @@ +//! Native Plugin Loader +//! +//! Loads pre-compiled Rust plugins (.so files) from `/usr/lib/owlry/plugins/`. +//! These plugins use the ABI-stable interface defined in `owlry-plugin-api`. +//! +//! Note: This module is infrastructure for the plugin architecture. Full integration +//! with ProviderManager is pending Phase 5 (AUR Packaging) when native plugins +//! will actually be deployed. + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Once}; + +use libloading::Library; +use log::{debug, error, info, warn}; +use owlry_plugin_api::{ + HostAPI, NotifyUrgency, PluginInfo, PluginVTable, ProviderHandle, ProviderInfo, ProviderKind, + RStr, API_VERSION, +}; + +use crate::notify; + +// ============================================================================ +// Host API Implementation +// ============================================================================ + +/// Host notification handler +extern "C" fn host_notify(summary: RStr<'_>, body: RStr<'_>, icon: RStr<'_>, urgency: NotifyUrgency) { + let icon_str = icon.as_str(); + let icon_opt = if icon_str.is_empty() { None } else { Some(icon_str) }; + + let notify_urgency = match urgency { + NotifyUrgency::Low => notify::NotifyUrgency::Low, + NotifyUrgency::Normal => notify::NotifyUrgency::Normal, + NotifyUrgency::Critical => notify::NotifyUrgency::Critical, + }; + + notify::notify_with_options(summary.as_str(), body.as_str(), icon_opt, notify_urgency); +} + +/// Host log info handler +extern "C" fn host_log_info(message: RStr<'_>) { + info!("[plugin] {}", message.as_str()); +} + +/// Host log warning handler +extern "C" fn host_log_warn(message: RStr<'_>) { + warn!("[plugin] {}", message.as_str()); +} + +/// Host log error handler +extern "C" fn host_log_error(message: RStr<'_>) { + error!("[plugin] {}", message.as_str()); +} + +/// Static host API instance +static HOST_API: HostAPI = HostAPI { + notify: host_notify, + log_info: host_log_info, + log_warn: host_log_warn, + log_error: host_log_error, +}; + +/// Initialize the host API (called once before loading plugins) +static HOST_API_INIT: Once = Once::new(); + +fn ensure_host_api_initialized() { + HOST_API_INIT.call_once(|| { + // SAFETY: We only call this once, before any plugins are loaded + unsafe { + owlry_plugin_api::init_host_api(&HOST_API); + } + debug!("Host API initialized for plugins"); + }); +} + +use super::error::{PluginError, PluginResult}; + +/// Default directory for system-installed native plugins +pub const SYSTEM_PLUGINS_DIR: &str = "/usr/lib/owlry/plugins"; + +/// A loaded native plugin with its library handle and vtable +pub struct NativePlugin { + /// Plugin metadata + pub info: PluginInfo, + /// List of providers this plugin offers + pub providers: Vec, + /// The vtable for calling plugin functions + vtable: &'static PluginVTable, + /// The loaded library (must be kept alive) + _library: Library, +} + +impl NativePlugin { + /// Get the plugin ID + pub fn id(&self) -> &str { + self.info.id.as_str() + } + + /// Get the plugin name + pub fn name(&self) -> &str { + self.info.name.as_str() + } + + /// Initialize a provider by ID + pub fn init_provider(&self, provider_id: &str) -> ProviderHandle { + (self.vtable.provider_init)(provider_id.into()) + } + + /// Refresh a static provider + pub fn refresh_provider(&self, handle: ProviderHandle) -> Vec { + (self.vtable.provider_refresh)(handle).into_iter().collect() + } + + /// Query a dynamic provider + pub fn query_provider( + &self, + handle: ProviderHandle, + query: &str, + ) -> Vec { + (self.vtable.provider_query)(handle, query.into()).into_iter().collect() + } + + /// Drop a provider handle + pub fn drop_provider(&self, handle: ProviderHandle) { + (self.vtable.provider_drop)(handle) + } +} + +// SAFETY: NativePlugin is safe to send between threads because: +// - `info` and `providers` are plain data (RString, RVec from abi_stable are Send+Sync) +// - `vtable` is a &'static reference to immutable function pointers +// - `_library` (libloading::Library) is Send+Sync +unsafe impl Send for NativePlugin {} +unsafe impl Sync for NativePlugin {} + +/// Manages native plugin discovery and loading +pub struct NativePluginLoader { + /// Directory to scan for plugins + plugins_dir: PathBuf, + /// Loaded plugins by ID (Arc for shared ownership with providers) + plugins: HashMap>, + /// Plugin IDs that are disabled + disabled: Vec, +} + +impl NativePluginLoader { + /// Create a new loader with the default system plugins directory + pub fn new() -> Self { + Self::with_dir(PathBuf::from(SYSTEM_PLUGINS_DIR)) + } + + /// Create a new loader with a custom plugins directory + pub fn with_dir(plugins_dir: PathBuf) -> Self { + Self { + plugins_dir, + plugins: HashMap::new(), + disabled: Vec::new(), + } + } + + /// Set the list of disabled plugin IDs + pub fn set_disabled(&mut self, disabled: Vec) { + self.disabled = disabled; + } + + /// Check if the plugins directory exists + pub fn plugins_dir_exists(&self) -> bool { + self.plugins_dir.exists() + } + + /// Discover and load all native plugins + pub fn discover(&mut self) -> PluginResult { + // Initialize host API before loading any plugins + ensure_host_api_initialized(); + + if !self.plugins_dir.exists() { + debug!( + "Native plugins directory does not exist: {}", + self.plugins_dir.display() + ); + return Ok(0); + } + + info!( + "Discovering native plugins in {}", + self.plugins_dir.display() + ); + + let entries = std::fs::read_dir(&self.plugins_dir).map_err(|e| { + PluginError::LoadError(format!( + "Failed to read plugins directory {}: {}", + self.plugins_dir.display(), + e + )) + })?; + + let mut loaded_count = 0; + + for entry in entries.flatten() { + let path = entry.path(); + + // Only process .so files + if path.extension() != Some(OsStr::new("so")) { + continue; + } + + match self.load_plugin(&path) { + Ok(plugin) => { + let id = plugin.id().to_string(); + + // Check if disabled + if self.disabled.contains(&id) { + info!("Native plugin '{}' is disabled, skipping", id); + continue; + } + + info!( + "Loaded native plugin '{}' v{} with {} providers", + plugin.name(), + plugin.info.version.as_str(), + plugin.providers.len() + ); + + self.plugins.insert(id, Arc::new(plugin)); + loaded_count += 1; + } + Err(e) => { + error!("Failed to load plugin {:?}: {}", path, e); + } + } + } + + info!("Loaded {} native plugins", loaded_count); + Ok(loaded_count) + } + + /// Load a single plugin from a .so file + fn load_plugin(&self, path: &Path) -> PluginResult { + debug!("Loading native plugin from {:?}", path); + + // Load the library + // SAFETY: We trust plugins in /usr/lib/owlry/plugins/ as they were + // installed by the package manager + let library = unsafe { Library::new(path) }.map_err(|e| { + PluginError::LoadError(format!("Failed to load library {:?}: {}", path, e)) + })?; + + // Get the vtable function + let vtable: &'static PluginVTable = unsafe { + let func: libloading::Symbol &'static PluginVTable> = + library.get(b"owlry_plugin_vtable").map_err(|e| { + PluginError::LoadError(format!( + "Plugin {:?} missing owlry_plugin_vtable symbol: {}", + path, e + )) + })?; + func() + }; + + // Get plugin info + let info = (vtable.info)(); + + // Check API version compatibility + if info.api_version != API_VERSION { + return Err(PluginError::LoadError(format!( + "Plugin '{}' has API version {} but owlry requires version {}", + info.id.as_str(), + info.api_version, + API_VERSION + ))); + } + + // Get provider list + let providers: Vec = (vtable.providers)().into_iter().collect(); + + Ok(NativePlugin { + info, + providers, + vtable, + _library: library, + }) + } + + /// Get a loaded plugin by ID + pub fn get(&self, id: &str) -> Option> { + self.plugins.get(id).cloned() + } + + /// Get all loaded plugins as Arc references + pub fn plugins(&self) -> impl Iterator> + '_ { + self.plugins.values().cloned() + } + + /// Get all loaded plugins as a Vec (for passing to create_providers) + pub fn into_plugins(self) -> Vec> { + self.plugins.into_values().collect() + } + + /// Get the number of loaded plugins + pub fn plugin_count(&self) -> usize { + self.plugins.len() + } + + /// Create providers from all loaded native plugins + /// + /// Returns a vec of (plugin_id, provider_info, handle) tuples that can be + /// used to create NativeProvider instances. + pub fn create_provider_handles(&self) -> Vec<(String, ProviderInfo, ProviderHandle)> { + let mut handles = Vec::new(); + + for plugin in self.plugins.values() { + for provider_info in &plugin.providers { + let handle = plugin.init_provider(provider_info.id.as_str()); + handles.push((plugin.id().to_string(), provider_info.clone(), handle)); + } + } + + handles + } +} + +impl Default for NativePluginLoader { + fn default() -> Self { + Self::new() + } +} + +/// Active provider instance from a native plugin +pub struct NativeProviderInstance { + /// Plugin ID this provider belongs to + pub plugin_id: String, + /// Provider metadata + pub info: ProviderInfo, + /// Handle to the provider state + pub handle: ProviderHandle, + /// Cached items for static providers + pub cached_items: Vec, +} + +impl NativeProviderInstance { + /// Create a new provider instance + pub fn new(plugin_id: String, info: ProviderInfo, handle: ProviderHandle) -> Self { + Self { + plugin_id, + info, + handle, + cached_items: Vec::new(), + } + } + + /// Check if this is a static provider + pub fn is_static(&self) -> bool { + self.info.provider_type == ProviderKind::Static + } + + /// Check if this is a dynamic provider + pub fn is_dynamic(&self) -> bool { + self.info.provider_type == ProviderKind::Dynamic + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_loader_nonexistent_dir() { + let mut loader = NativePluginLoader::with_dir(PathBuf::from("/nonexistent/path")); + let count = loader.discover().unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn test_loader_empty_dir() { + let temp = tempfile::TempDir::new().unwrap(); + let mut loader = NativePluginLoader::with_dir(temp.path().to_path_buf()); + let count = loader.discover().unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn test_disabled_plugins() { + let mut loader = NativePluginLoader::new(); + loader.set_disabled(vec!["test-plugin".to_string()]); + assert!(loader.disabled.contains(&"test-plugin".to_string())); + } +} diff --git a/crates/owlry-core/src/plugins/registry.rs b/crates/owlry-core/src/plugins/registry.rs new file mode 100644 index 0000000..42c6798 --- /dev/null +++ b/crates/owlry-core/src/plugins/registry.rs @@ -0,0 +1,293 @@ +//! Plugin registry client for discovering and installing remote plugins +//! +//! The registry is a git repository containing an `index.toml` file with +//! plugin metadata. Plugins are installed by cloning their source repositories. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use crate::paths; + +/// Default registry URL (can be overridden in config) +pub const DEFAULT_REGISTRY_URL: &str = + "https://raw.githubusercontent.com/owlry/plugin-registry/main/index.toml"; + +/// Cache duration for registry index (1 hour) +const CACHE_DURATION: Duration = Duration::from_secs(3600); + +/// Registry index containing all available plugins +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistryIndex { + /// Registry metadata + #[serde(default)] + pub registry: RegistryMeta, + /// Available plugins + #[serde(default)] + pub plugins: Vec, +} + +/// Registry metadata +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RegistryMeta { + /// Registry name + #[serde(default)] + pub name: String, + /// Registry description + #[serde(default)] + pub description: String, + /// Registry maintainer URL + #[serde(default)] + pub url: String, +} + +/// Plugin entry in the registry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistryPlugin { + /// Unique plugin identifier + pub id: String, + /// Human-readable name + pub name: String, + /// Latest version + pub version: String, + /// Short description + #[serde(default)] + pub description: String, + /// Plugin author + #[serde(default)] + pub author: String, + /// Git repository URL for installation + pub repository: String, + /// Search tags + #[serde(default)] + pub tags: Vec, + /// Minimum owlry version required + #[serde(default)] + pub owlry_version: String, + /// License identifier + #[serde(default)] + pub license: String, +} + +/// Registry client for fetching and searching plugins +pub struct RegistryClient { + /// Registry URL (index.toml location) + registry_url: String, + /// Local cache directory + cache_dir: PathBuf, +} + +impl RegistryClient { + /// Create a new registry client with the given URL + pub fn new(registry_url: &str) -> Self { + let cache_dir = paths::owlry_cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp/owlry")) + .join("registry"); + + Self { + registry_url: registry_url.to_string(), + cache_dir, + } + } + + /// Create a client with the default registry URL + pub fn default_registry() -> Self { + Self::new(DEFAULT_REGISTRY_URL) + } + + /// Get the path to the cached index file + fn cache_path(&self) -> PathBuf { + self.cache_dir.join("index.toml") + } + + /// Check if the cache is valid (exists and not expired) + fn is_cache_valid(&self) -> bool { + let cache_path = self.cache_path(); + if !cache_path.exists() { + return false; + } + + if let Ok(metadata) = fs::metadata(&cache_path) + && let Ok(modified) = metadata.modified() + && let Ok(elapsed) = SystemTime::now().duration_since(modified) { + return elapsed < CACHE_DURATION; + } + + false + } + + /// Fetch the registry index (from cache or network) + pub fn fetch_index(&self, force_refresh: bool) -> Result { + // Use cache if valid and not forcing refresh + if !force_refresh && self.is_cache_valid() + && let Ok(content) = fs::read_to_string(self.cache_path()) + && let Ok(index) = toml::from_str(&content) { + return Ok(index); + } + + // Fetch from network + self.fetch_from_network() + } + + /// Fetch the index from the network and cache it + fn fetch_from_network(&self) -> Result { + // Use curl for fetching (available on most systems) + let output = std::process::Command::new("curl") + .args([ + "-fsSL", + "--max-time", + "30", + &self.registry_url, + ]) + .output() + .map_err(|e| format!("Failed to run curl: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to fetch registry: {}", stderr.trim())); + } + + let content = String::from_utf8_lossy(&output.stdout); + + // Parse the index + let index: RegistryIndex = toml::from_str(&content) + .map_err(|e| format!("Failed to parse registry index: {}", e))?; + + // Cache the result + if let Err(e) = self.cache_index(&content) { + eprintln!("Warning: Failed to cache registry index: {}", e); + } + + Ok(index) + } + + /// Cache the index content to disk + fn cache_index(&self, content: &str) -> Result<(), String> { + fs::create_dir_all(&self.cache_dir) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + + fs::write(self.cache_path(), content) + .map_err(|e| format!("Failed to write cache file: {}", e))?; + + Ok(()) + } + + /// Search for plugins matching a query + pub fn search(&self, query: &str, force_refresh: bool) -> Result, String> { + let index = self.fetch_index(force_refresh)?; + let query_lower = query.to_lowercase(); + + let matches: Vec<_> = index + .plugins + .into_iter() + .filter(|p| { + p.id.to_lowercase().contains(&query_lower) + || p.name.to_lowercase().contains(&query_lower) + || p.description.to_lowercase().contains(&query_lower) + || p.tags.iter().any(|t| t.to_lowercase().contains(&query_lower)) + }) + .collect(); + + Ok(matches) + } + + /// Find a specific plugin by ID + pub fn find(&self, id: &str, force_refresh: bool) -> Result, String> { + let index = self.fetch_index(force_refresh)?; + + Ok(index.plugins.into_iter().find(|p| p.id == id)) + } + + /// List all available plugins + pub fn list_all(&self, force_refresh: bool) -> Result, String> { + let index = self.fetch_index(force_refresh)?; + Ok(index.plugins) + } + + /// Clear the cache + #[allow(dead_code)] + pub fn clear_cache(&self) -> Result<(), String> { + let cache_path = self.cache_path(); + if cache_path.exists() { + fs::remove_file(&cache_path) + .map_err(|e| format!("Failed to remove cache: {}", e))?; + } + Ok(()) + } + + /// Get the repository URL for a plugin + #[allow(dead_code)] + pub fn get_install_url(&self, id: &str) -> Result { + match self.find(id, false)? { + Some(plugin) => Ok(plugin.repository), + None => Err(format!("Plugin '{}' not found in registry", id)), + } + } +} + +/// Check if a string looks like a URL (for distinguishing registry names from URLs) +pub fn is_url(s: &str) -> bool { + s.starts_with("http://") + || s.starts_with("https://") + || s.starts_with("git@") + || s.starts_with("git://") +} + +/// Check if a string looks like a local path +pub fn is_path(s: &str) -> bool { + s.starts_with('/') + || s.starts_with("./") + || s.starts_with("../") + || s.starts_with('~') + || Path::new(s).exists() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_registry_index() { + let toml_str = r#" +[registry] +name = "Test Registry" +description = "A test registry" + +[[plugins]] +id = "test-plugin" +name = "Test Plugin" +version = "1.0.0" +description = "A test plugin" +author = "Test Author" +repository = "https://github.com/test/plugin" +tags = ["test", "example"] +owlry_version = ">=0.3.0" +"#; + + let index: RegistryIndex = toml::from_str(toml_str).unwrap(); + assert_eq!(index.registry.name, "Test Registry"); + assert_eq!(index.plugins.len(), 1); + assert_eq!(index.plugins[0].id, "test-plugin"); + assert_eq!(index.plugins[0].tags, vec!["test", "example"]); + } + + #[test] + fn test_is_url() { + assert!(is_url("https://github.com/user/repo")); + assert!(is_url("http://example.com")); + assert!(is_url("git@github.com:user/repo.git")); + assert!(!is_url("my-plugin")); + assert!(!is_url("/path/to/plugin")); + } + + #[test] + fn test_is_path() { + assert!(is_path("/absolute/path")); + assert!(is_path("./relative/path")); + assert!(is_path("../parent/path")); + assert!(is_path("~/home/path")); + assert!(!is_path("my-plugin")); + assert!(!is_path("https://example.com")); + } +} diff --git a/crates/owlry-core/src/plugins/runtime.rs b/crates/owlry-core/src/plugins/runtime.rs new file mode 100644 index 0000000..da98dbe --- /dev/null +++ b/crates/owlry-core/src/plugins/runtime.rs @@ -0,0 +1,153 @@ +//! Lua runtime setup and sandboxing + +use mlua::{Lua, Result as LuaResult, StdLib}; + +use super::manifest::PluginPermissions; + +/// Configuration for the Lua sandbox +#[derive(Debug, Clone)] +#[allow(dead_code)] // Fields used for future permission enforcement +pub struct SandboxConfig { + /// Allow shell command running + pub allow_commands: bool, + /// Allow HTTP requests + pub allow_network: bool, + /// Allow filesystem access outside plugin directory + pub allow_external_fs: bool, + /// Maximum run time per call (ms) + pub max_run_time_ms: u64, + /// Memory limit (bytes, 0 = unlimited) + pub max_memory: usize, +} + +impl Default for SandboxConfig { + fn default() -> Self { + Self { + allow_commands: false, + allow_network: false, + allow_external_fs: false, + max_run_time_ms: 5000, // 5 seconds + max_memory: 64 * 1024 * 1024, // 64 MB + } + } +} + +impl SandboxConfig { + /// Create a sandbox config from plugin permissions + pub fn from_permissions(permissions: &PluginPermissions) -> Self { + Self { + allow_commands: !permissions.run_commands.is_empty(), + allow_network: permissions.network, + allow_external_fs: !permissions.filesystem.is_empty(), + ..Default::default() + } + } +} + +/// Create a new sandboxed Lua runtime +pub fn create_lua_runtime(_sandbox: &SandboxConfig) -> LuaResult { + // Create Lua with safe standard libraries only + // ALL_SAFE excludes: debug, io, os (dangerous parts), package (loadlib), ffi + // We then customize the os table to only allow safe functions + let libs = StdLib::COROUTINE + | StdLib::TABLE + | StdLib::STRING + | StdLib::UTF8 + | StdLib::MATH; + + let lua = Lua::new_with(libs, mlua::LuaOptions::default())?; + + // Set up safe environment + setup_safe_globals(&lua)?; + + Ok(lua) +} + +/// Set up safe global environment by removing/replacing dangerous functions +fn setup_safe_globals(lua: &Lua) -> LuaResult<()> { + let globals = lua.globals(); + + // Remove dangerous globals + globals.set("dofile", mlua::Value::Nil)?; + globals.set("loadfile", mlua::Value::Nil)?; + + // Create a restricted os table with only safe functions + // We do NOT include: os.exit, os.remove, os.rename, os.setlocale, os.tmpname + // and the shell-related functions + let os_table = lua.create_table()?; + os_table.set("clock", lua.create_function(|_, ()| Ok(std::time::Instant::now().elapsed().as_secs_f64()))?)?; + os_table.set("date", lua.create_function(os_date)?)?; + os_table.set("difftime", lua.create_function(|_, (t2, t1): (f64, f64)| Ok(t2 - t1))?)?; + os_table.set("time", lua.create_function(os_time)?)?; + globals.set("os", os_table)?; + + // Remove print (plugins should use owlry.log instead) + // We'll add it back via owlry.log + globals.set("print", mlua::Value::Nil)?; + + Ok(()) +} + +/// Safe os.date implementation +fn os_date(_lua: &Lua, format: Option) -> LuaResult { + use chrono::Local; + let now = Local::now(); + let fmt = format.unwrap_or_else(|| "%c".to_string()); + Ok(now.format(&fmt).to_string()) +} + +/// Safe os.time implementation +fn os_time(_lua: &Lua, _args: ()) -> LuaResult { + use std::time::{SystemTime, UNIX_EPOCH}; + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + Ok(duration.as_secs() as i64) +} + +/// Load and run a Lua file in the given runtime +pub fn load_file(lua: &Lua, path: &std::path::Path) -> LuaResult<()> { + let content = std::fs::read_to_string(path) + .map_err(mlua::Error::external)?; + lua.load(&content) + .set_name(path.file_name().and_then(|n| n.to_str()).unwrap_or("chunk")) + .into_function()? + .call(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_sandboxed_runtime() { + let config = SandboxConfig::default(); + let lua = create_lua_runtime(&config).unwrap(); + + // Verify dangerous functions are removed + let result: LuaResult = lua.globals().get("dofile"); + assert!(matches!(result, Ok(mlua::Value::Nil))); + + // Verify safe functions work + let result: String = lua.load("return os.date('%Y')").call(()).unwrap(); + assert!(!result.is_empty()); + } + + #[test] + fn test_basic_lua_operations() { + let config = SandboxConfig::default(); + let lua = create_lua_runtime(&config).unwrap(); + + // Test basic math + let result: i32 = lua.load("return 2 + 2").call(()).unwrap(); + assert_eq!(result, 4); + + // Test table operations + let result: i32 = lua.load("local t = {1,2,3}; return #t").call(()).unwrap(); + assert_eq!(result, 3); + + // Test string operations + let result: String = lua.load("return string.upper('hello')").call(()).unwrap(); + assert_eq!(result, "HELLO"); + } +} diff --git a/crates/owlry-core/src/plugins/runtime_loader.rs b/crates/owlry-core/src/plugins/runtime_loader.rs new file mode 100644 index 0000000..de62fcd --- /dev/null +++ b/crates/owlry-core/src/plugins/runtime_loader.rs @@ -0,0 +1,286 @@ +//! Dynamic runtime loader +//! +//! This module provides dynamic loading of script runtimes (Lua, Rune) +//! when they're not compiled into the core binary. +//! +//! Runtimes are loaded from `/usr/lib/owlry/runtimes/`: +//! - `liblua.so` - Lua runtime (from owlry-lua package) +//! - `librune.so` - Rune runtime (from owlry-rune package) +//! +//! Note: This module is infrastructure for the runtime architecture. Full integration +//! is pending Phase 5 (AUR Packaging) when runtime packages will be available. + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use libloading::{Library, Symbol}; +use owlry_plugin_api::{PluginItem, RStr, RString, RVec}; + +use super::error::{PluginError, PluginResult}; +use crate::providers::{LaunchItem, Provider, ProviderType}; + +/// System directory for runtime libraries +pub const SYSTEM_RUNTIMES_DIR: &str = "/usr/lib/owlry/runtimes"; + +/// Information about a loaded runtime +#[repr(C)] +#[derive(Debug)] +pub struct RuntimeInfo { + pub name: RString, + pub version: RString, +} + +/// Information about a provider from a script runtime +#[repr(C)] +#[derive(Debug, Clone)] +pub struct ScriptProviderInfo { + pub name: RString, + pub display_name: RString, + pub type_id: RString, + pub default_icon: RString, + pub is_static: bool, + pub prefix: owlry_plugin_api::ROption, +} + +// Type alias for backwards compatibility +pub type LuaProviderInfo = ScriptProviderInfo; + +/// Handle to runtime-managed state +#[repr(transparent)] +#[derive(Clone, Copy)] +pub struct RuntimeHandle(pub *mut ()); + +/// VTable for script runtime functions (used by both Lua and Rune) +#[repr(C)] +pub struct ScriptRuntimeVTable { + pub info: extern "C" fn() -> RuntimeInfo, + pub init: extern "C" fn(plugins_dir: RStr<'_>) -> RuntimeHandle, + pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec, + pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec, + pub query: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>, query: RStr<'_>) -> RVec, + pub drop: extern "C" fn(handle: RuntimeHandle), +} + +/// A loaded script runtime +pub struct LoadedRuntime { + /// Runtime name (for logging) + name: &'static str, + /// Keep library alive + _library: Arc, + /// Runtime vtable + vtable: &'static ScriptRuntimeVTable, + /// Runtime handle (state) + handle: RuntimeHandle, + /// Provider information + providers: Vec, +} + +impl LoadedRuntime { + /// Load the Lua runtime from the system directory + pub fn load_lua(plugins_dir: &Path) -> PluginResult { + Self::load_from_path( + "Lua", + &PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"), + b"owlry_lua_runtime_vtable", + plugins_dir, + ) + } + + /// Load a runtime from a specific path + fn load_from_path( + name: &'static str, + library_path: &Path, + vtable_symbol: &[u8], + plugins_dir: &Path, + ) -> PluginResult { + if !library_path.exists() { + return Err(PluginError::NotFound(library_path.display().to_string())); + } + + // SAFETY: We trust the runtime library to be correct + let library = unsafe { Library::new(library_path) }.map_err(|e| { + PluginError::LoadError(format!("{}: {}", library_path.display(), e)) + })?; + + let library = Arc::new(library); + + // Get the vtable + let vtable: &'static ScriptRuntimeVTable = unsafe { + let get_vtable: Symbol &'static ScriptRuntimeVTable> = + library.get(vtable_symbol).map_err(|e| { + PluginError::LoadError(format!( + "{}: Missing vtable symbol: {}", + library_path.display(), + e + )) + })?; + get_vtable() + }; + + // Initialize the runtime + let plugins_dir_str = plugins_dir.to_string_lossy(); + let handle = (vtable.init)(RStr::from_str(&plugins_dir_str)); + + // Get provider information + let providers_rvec = (vtable.providers)(handle); + let providers: Vec = providers_rvec.into_iter().collect(); + + log::info!( + "Loaded {} runtime with {} provider(s)", + name, + providers.len() + ); + + Ok(Self { + name, + _library: library, + vtable, + handle, + providers, + }) + } + + /// Get all providers from this runtime + pub fn providers(&self) -> &[ScriptProviderInfo] { + &self.providers + } + + /// Create Provider trait objects for all providers in this runtime + pub fn create_providers(&self) -> Vec> { + self.providers + .iter() + .map(|info| { + let provider = RuntimeProvider::new( + self.name, + self.vtable, + self.handle, + info.clone(), + ); + Box::new(provider) as Box + }) + .collect() + } +} + +impl Drop for LoadedRuntime { + fn drop(&mut self) { + (self.vtable.drop)(self.handle); + } +} + +/// A provider backed by a dynamically loaded runtime +pub struct RuntimeProvider { + /// Runtime name (for logging) + #[allow(dead_code)] + runtime_name: &'static str, + vtable: &'static ScriptRuntimeVTable, + handle: RuntimeHandle, + info: ScriptProviderInfo, + items: Vec, +} + +impl RuntimeProvider { + fn new( + runtime_name: &'static str, + vtable: &'static ScriptRuntimeVTable, + handle: RuntimeHandle, + info: ScriptProviderInfo, + ) -> Self { + Self { + runtime_name, + vtable, + handle, + info, + items: Vec::new(), + } + } + + fn convert_item(&self, item: PluginItem) -> LaunchItem { + LaunchItem { + id: item.id.to_string(), + name: item.name.to_string(), + description: item.description.into_option().map(|s| s.to_string()), + icon: item.icon.into_option().map(|s| s.to_string()), + provider: ProviderType::Plugin(self.info.type_id.to_string()), + command: item.command.to_string(), + terminal: item.terminal, + tags: item.keywords.iter().map(|s| s.to_string()).collect(), + } + } +} + +impl Provider for RuntimeProvider { + fn name(&self) -> &str { + self.info.name.as_str() + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(self.info.type_id.to_string()) + } + + fn refresh(&mut self) { + if !self.info.is_static { + return; + } + + let name_rstr = RStr::from_str(self.info.name.as_str()); + let items_rvec = (self.vtable.refresh)(self.handle, name_rstr); + self.items = items_rvec.into_iter().map(|i| self.convert_item(i)).collect(); + + log::debug!( + "[RuntimeProvider] '{}' refreshed with {} items", + self.info.name, + self.items.len() + ); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } +} + +// RuntimeProvider needs to be Send for the Provider trait +unsafe impl Send for RuntimeProvider {} + +/// Check if the Lua runtime is available +pub fn lua_runtime_available() -> bool { + PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so").exists() +} + +/// Check if the Rune runtime is available +pub fn rune_runtime_available() -> bool { + PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so").exists() +} + +impl LoadedRuntime { + /// Load the Rune runtime from the system directory + pub fn load_rune(plugins_dir: &Path) -> PluginResult { + Self::load_from_path( + "Rune", + &PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"), + b"owlry_rune_runtime_vtable", + plugins_dir, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lua_runtime_check_doesnt_panic() { + // Just verify the function runs without panicking + // Result depends on whether runtime is installed + let _available = lua_runtime_available(); + } + + #[test] + fn test_rune_runtime_check_doesnt_panic() { + // Just verify the function runs without panicking + // Result depends on whether runtime is installed + let _available = rune_runtime_available(); + } +} diff --git a/crates/owlry-core/src/providers/application.rs b/crates/owlry-core/src/providers/application.rs new file mode 100644 index 0000000..3236e64 --- /dev/null +++ b/crates/owlry-core/src/providers/application.rs @@ -0,0 +1,266 @@ +use super::{LaunchItem, Provider, ProviderType}; +use crate::paths; +use freedesktop_desktop_entry::{DesktopEntry, Iter}; +use log::{debug, warn}; + +/// Clean desktop file field codes from command string. +/// Removes %f, %F, %u, %U, %d, %D, %n, %N, %i, %c, %k, %v, %m field codes +/// while preserving quoted arguments and %% (literal percent). +/// See: https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html +fn clean_desktop_exec_field(cmd: &str) -> String { + let mut result = String::with_capacity(cmd.len()); + let mut chars = cmd.chars().peekable(); + let mut in_single_quote = false; + let mut in_double_quote = false; + + while let Some(c) = chars.next() { + match c { + '\'' if !in_double_quote => { + in_single_quote = !in_single_quote; + result.push(c); + } + '"' if !in_single_quote => { + in_double_quote = !in_double_quote; + result.push(c); + } + '%' if !in_single_quote => { + // Check the next character for field code + if let Some(&next) = chars.peek() { + match next { + // Standard field codes to remove (with following space if present) + 'f' | 'F' | 'u' | 'U' | 'd' | 'D' | 'n' | 'N' | 'i' | 'c' | 'k' | 'v' + | 'm' => { + chars.next(); // consume the field code letter + // Skip trailing whitespace after the field code + while chars.peek() == Some(&' ') { + chars.next(); + } + } + // %% is escaped percent, output single % + '%' => { + chars.next(); + result.push('%'); + } + // Unknown % sequence, keep as-is + _ => { + result.push('%'); + } + } + } else { + // % at end of string, keep it + result.push('%'); + } + } + _ => { + result.push(c); + } + } + } + + // Clean up any double spaces that may have resulted from removing field codes + let mut cleaned = result.trim().to_string(); + while cleaned.contains(" ") { + cleaned = cleaned.replace(" ", " "); + } + + cleaned +} + +pub struct ApplicationProvider { + items: Vec, +} + +impl ApplicationProvider { + pub fn new() -> Self { + Self { items: Vec::new() } + } + + fn get_application_dirs() -> Vec { + paths::system_data_dirs() + } +} + +impl Provider for ApplicationProvider { + fn name(&self) -> &str { + "Applications" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Application + } + + fn refresh(&mut self) { + self.items.clear(); + + let dirs = Self::get_application_dirs(); + debug!("Scanning application directories: {:?}", dirs); + + // Empty locale list for default locale + let locales: &[&str] = &[]; + + // Get current desktop environment(s) for OnlyShowIn/NotShowIn filtering + // XDG_CURRENT_DESKTOP can be colon-separated (e.g., "ubuntu:GNOME") + let current_desktops: Vec = std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .split(':') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + for path in Iter::new(dirs.into_iter()) { + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + warn!("Failed to read {:?}: {}", path, e); + continue; + } + }; + + let desktop_entry = match DesktopEntry::from_str(&path, &content, Some(locales)) { + Ok(e) => e, + Err(e) => { + warn!("Failed to parse {:?}: {}", path, e); + continue; + } + }; + + // Skip entries marked as hidden or no-display + if desktop_entry.no_display() || desktop_entry.hidden() { + continue; + } + + // Only include Application type entries + if desktop_entry.type_() != Some("Application") { + continue; + } + + // Apply OnlyShowIn/NotShowIn filters only if we know the current desktop + // If XDG_CURRENT_DESKTOP is not set, show all apps (don't filter) + if !current_desktops.is_empty() { + // OnlyShowIn: if set, current desktop must be in the list + if desktop_entry.only_show_in().is_some_and(|only| { + !current_desktops.iter().any(|de| only.contains(&de.as_str())) + }) { + continue; + } + + // NotShowIn: if current desktop is in the list, skip + if desktop_entry.not_show_in().is_some_and(|not| { + current_desktops.iter().any(|de| not.contains(&de.as_str())) + }) { + continue; + } + } + + let name = match desktop_entry.name(locales) { + Some(n) => n.to_string(), + None => continue, + }; + + let run_cmd = match desktop_entry.exec() { + Some(e) => clean_desktop_exec_field(e), + None => continue, + }; + + // Extract categories and keywords as tags (lowercase for consistency) + let mut tags: Vec = desktop_entry + .categories() + .map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect()) + .unwrap_or_default(); + + // Add keywords for searchability (e.g., Nautilus has Name=Files but Keywords contains "nautilus") + if let Some(keywords) = desktop_entry.keywords(locales) { + tags.extend(keywords.into_iter().map(|s| s.to_lowercase())); + } + + let item = LaunchItem { + id: path.to_string_lossy().to_string(), + name, + description: desktop_entry.comment(locales).map(|s| s.to_string()), + icon: desktop_entry.icon().map(|s| s.to_string()), + provider: ProviderType::Application, + command: run_cmd, + terminal: desktop_entry.terminal(), + tags, + }; + + self.items.push(item); + } + + debug!("Found {} applications", self.items.len()); + + #[cfg(feature = "dev-logging")] + debug!( + "XDG_CURRENT_DESKTOP={:?}, scanned dirs count={}", + current_desktops, + Self::get_application_dirs().len() + ); + + // Sort alphabetically by name + self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clean_desktop_exec_simple() { + assert_eq!(clean_desktop_exec_field("firefox"), "firefox"); + assert_eq!(clean_desktop_exec_field("firefox %u"), "firefox"); + assert_eq!(clean_desktop_exec_field("code %F"), "code"); + } + + #[test] + fn test_clean_desktop_exec_multiple_placeholders() { + assert_eq!(clean_desktop_exec_field("app %f %u %U"), "app"); + assert_eq!(clean_desktop_exec_field("app --flag %u --other"), "app --flag --other"); + } + + #[test] + fn test_clean_desktop_exec_preserves_quotes() { + // Double quotes preserve spacing but field codes are still processed + assert_eq!( + clean_desktop_exec_field(r#"bash -c "echo hello""#), + r#"bash -c "echo hello""# + ); + // Field codes in double quotes are stripped (per FreeDesktop spec: undefined behavior, + // but practical implementations strip them) + assert_eq!( + clean_desktop_exec_field(r#"bash -c "test %u value""#), + r#"bash -c "test value""# + ); + } + + #[test] + fn test_clean_desktop_exec_escaped_percent() { + assert_eq!(clean_desktop_exec_field("echo 100%%"), "echo 100%"); + } + + #[test] + fn test_clean_desktop_exec_single_quotes() { + assert_eq!( + clean_desktop_exec_field("bash -c 'echo %u'"), + "bash -c 'echo %u'" + ); + } + + #[test] + fn test_clean_desktop_exec_preserves_env() { + // env VAR=value pattern should be preserved + assert_eq!( + clean_desktop_exec_field("env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity %F"), + "env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity" + ); + // Multiple env vars + assert_eq!( + clean_desktop_exec_field("env FOO=bar BAZ=qux myapp %u"), + "env FOO=bar BAZ=qux myapp" + ); + } +} diff --git a/crates/owlry-core/src/providers/command.rs b/crates/owlry-core/src/providers/command.rs new file mode 100644 index 0000000..0df024f --- /dev/null +++ b/crates/owlry-core/src/providers/command.rs @@ -0,0 +1,106 @@ +use super::{LaunchItem, Provider, ProviderType}; +use log::debug; +use std::collections::HashSet; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +pub struct CommandProvider { + items: Vec, +} + +impl CommandProvider { + pub fn new() -> Self { + Self { items: Vec::new() } + } + + fn get_path_dirs() -> Vec { + std::env::var("PATH") + .unwrap_or_default() + .split(':') + .map(PathBuf::from) + .filter(|p| p.exists()) + .collect() + } + + fn is_executable(path: &std::path::Path) -> bool { + if let Ok(metadata) = path.metadata() { + let permissions = metadata.permissions(); + permissions.mode() & 0o111 != 0 + } else { + false + } + } +} + +impl Provider for CommandProvider { + fn name(&self) -> &str { + "Commands" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Command + } + + fn refresh(&mut self) { + self.items.clear(); + + let dirs = Self::get_path_dirs(); + let mut seen_names: HashSet = HashSet::new(); + + debug!("Scanning PATH directories for commands"); + + for dir in dirs { + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + + // Skip directories and non-executable files + if path.is_dir() || !Self::is_executable(&path) { + continue; + } + + let name = match path.file_name() { + Some(n) => n.to_string_lossy().to_string(), + None => continue, + }; + + // Skip duplicates (first one in PATH wins) + if seen_names.contains(&name) { + continue; + } + seen_names.insert(name.clone()); + + // Skip hidden files + if name.starts_with('.') { + continue; + } + + let item = LaunchItem { + id: path.to_string_lossy().to_string(), + name: name.clone(), + description: Some(format!("Run {}", path.display())), + icon: Some("utilities-terminal".to_string()), + provider: ProviderType::Command, + command: name, + terminal: false, + tags: Vec::new(), + }; + + self.items.push(item); + } + } + + debug!("Found {} commands in PATH", self.items.len()); + + // Sort alphabetically + self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } +} diff --git a/crates/owlry-core/src/providers/lua_provider.rs b/crates/owlry-core/src/providers/lua_provider.rs new file mode 100644 index 0000000..d624846 --- /dev/null +++ b/crates/owlry-core/src/providers/lua_provider.rs @@ -0,0 +1,142 @@ +//! LuaProvider - Bridge between Lua plugins and the Provider trait +//! +//! This module provides a `LuaProvider` struct that implements the `Provider` trait +//! by delegating to a Lua plugin's registered provider functions. + +use std::cell::RefCell; +use std::rc::Rc; + +use crate::plugins::{LoadedPlugin, PluginItem, ProviderRegistration}; + +use super::{LaunchItem, Provider, ProviderType}; + +/// A provider backed by a Lua plugin +/// +/// This struct implements the `Provider` trait by calling into a Lua plugin's +/// `refresh` or `query` functions. +pub struct LuaProvider { + /// Provider registration info + registration: ProviderRegistration, + /// Reference to the loaded plugin (shared with other providers from same plugin) + plugin: Rc>, + /// Cached items from last refresh + items: Vec, +} + +impl LuaProvider { + /// Create a new LuaProvider + pub fn new(registration: ProviderRegistration, plugin: Rc>) -> Self { + Self { + registration, + plugin, + items: Vec::new(), + } + } + + /// Convert a PluginItem to a LaunchItem + fn convert_item(&self, item: PluginItem) -> LaunchItem { + LaunchItem { + id: item.id, + name: item.name, + description: item.description, + icon: item.icon, + provider: ProviderType::Plugin(self.registration.type_id.clone()), + command: item.command.unwrap_or_default(), + terminal: item.terminal, + tags: item.tags, + } + } +} + +impl Provider for LuaProvider { + fn name(&self) -> &str { + &self.registration.name + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(self.registration.type_id.clone()) + } + + fn refresh(&mut self) { + // Only refresh static providers + if !self.registration.is_static { + return; + } + + let plugin = self.plugin.borrow(); + match plugin.call_provider_refresh(&self.registration.name) { + Ok(items) => { + self.items = items.into_iter().map(|i| self.convert_item(i)).collect(); + log::debug!( + "[LuaProvider] '{}' refreshed with {} items", + self.registration.name, + self.items.len() + ); + } + Err(e) => { + log::error!( + "[LuaProvider] Failed to refresh '{}': {}", + self.registration.name, + e + ); + self.items.clear(); + } + } + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } +} + +// LuaProvider needs to be Send for the Provider trait +// Since we're using Rc>, we need to be careful about thread safety +// For now, owlry is single-threaded, so this is safe +unsafe impl Send for LuaProvider {} + +/// Create LuaProviders from all registered providers in a plugin +pub fn create_providers_from_plugin( + plugin: Rc>, +) -> Vec> { + let registrations = { + let p = plugin.borrow(); + match p.get_provider_registrations() { + Ok(regs) => regs, + Err(e) => { + log::error!("[LuaProvider] Failed to get registrations: {}", e); + return Vec::new(); + } + } + }; + + registrations + .into_iter() + .map(|reg| { + let provider = LuaProvider::new(reg, plugin.clone()); + Box::new(provider) as Box + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Note: Full integration tests require a complete plugin setup + // These tests verify the basic structure + + #[test] + fn test_provider_type() { + let reg = ProviderRegistration { + name: "test".to_string(), + display_name: "Test".to_string(), + type_id: "test_provider".to_string(), + default_icon: "test-icon".to_string(), + is_static: true, + prefix: None, + }; + + // We can't easily create a mock LoadedPlugin, so just test the type + assert_eq!(reg.type_id, "test_provider"); + } +} diff --git a/crates/owlry-core/src/providers/mod.rs b/crates/owlry-core/src/providers/mod.rs new file mode 100644 index 0000000..3e6e472 --- /dev/null +++ b/crates/owlry-core/src/providers/mod.rs @@ -0,0 +1,598 @@ +// Core providers (no plugin equivalents) +mod application; +mod command; + +// Native plugin bridge +pub mod native_provider; + +// Lua plugin bridge (optional) +#[cfg(feature = "lua")] +pub mod lua_provider; + +// Re-exports for core providers +pub use application::ApplicationProvider; +pub use command::CommandProvider; + +// Re-export native provider for plugin loading +pub use native_provider::NativeProvider; + +use fuzzy_matcher::FuzzyMatcher; +use fuzzy_matcher::skim::SkimMatcherV2; +use log::info; + +#[cfg(feature = "dev-logging")] +use log::debug; + +use crate::data::FrecencyStore; + +/// Represents a single searchable/launchable item +#[derive(Debug, Clone)] +pub struct LaunchItem { + #[allow(dead_code)] + pub id: String, + pub name: String, + pub description: Option, + pub icon: Option, + pub provider: ProviderType, + pub command: String, + pub terminal: bool, + /// Tags/categories for filtering (e.g., from .desktop Categories) + pub tags: Vec, +} + +/// Provider type identifier for filtering and badge display +/// +/// Core types are built-in providers. All native plugins use Plugin(type_id). +/// This keeps the core app free of plugin-specific knowledge. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ProviderType { + /// Built-in: Desktop applications from XDG directories + Application, + /// Built-in: Shell commands from PATH + Command, + /// Built-in: Pipe-based input (dmenu compatibility) + Dmenu, + /// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji") + Plugin(String), +} + +impl std::str::FromStr for ProviderType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + // Core built-in providers + "app" | "apps" | "application" | "applications" => Ok(ProviderType::Application), + "cmd" | "command" | "commands" => Ok(ProviderType::Command), + "dmenu" => Ok(ProviderType::Dmenu), + // Everything else is a plugin + other => Ok(ProviderType::Plugin(other.to_string())), + } + } +} + +impl std::fmt::Display for ProviderType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProviderType::Application => write!(f, "app"), + ProviderType::Command => write!(f, "cmd"), + ProviderType::Dmenu => write!(f, "dmenu"), + ProviderType::Plugin(type_id) => write!(f, "{}", type_id), + } + } +} + +/// Trait for all search providers +pub trait Provider: Send { + #[allow(dead_code)] + fn name(&self) -> &str; + fn provider_type(&self) -> ProviderType; + fn refresh(&mut self); + fn items(&self) -> &[LaunchItem]; +} + +/// Manages all providers and handles searching +pub struct ProviderManager { + /// Core static providers (apps, commands, dmenu) + providers: Vec>, + /// Static native plugin providers (need query() for submenu support) + static_native_providers: Vec, + /// Dynamic providers from native plugins (calculator, websearch, filesearch) + /// These are queried per-keystroke, not cached + dynamic_providers: Vec, + /// Widget providers from native plugins (weather, media, pomodoro) + /// These appear at the top of results + widget_providers: Vec, + /// Fuzzy matcher for search + matcher: SkimMatcherV2, +} + +impl ProviderManager { + /// Create a new ProviderManager with core providers and native plugins. + /// + /// Core providers (e.g., ApplicationProvider, CommandProvider, DmenuProvider) are + /// passed in by the caller. Native plugins are categorized based on their declared + /// ProviderKind and ProviderPosition. + pub fn new( + core_providers: Vec>, + native_providers: Vec, + ) -> Self { + let mut manager = Self { + providers: core_providers, + static_native_providers: Vec::new(), + dynamic_providers: Vec::new(), + widget_providers: Vec::new(), + matcher: SkimMatcherV2::default(), + }; + + // Categorize native plugins based on their declared ProviderKind and ProviderPosition + for provider in native_providers { + let type_id = provider.type_id(); + + if provider.is_dynamic() { + info!("Registered dynamic provider: {} ({})", provider.name(), type_id); + manager.dynamic_providers.push(provider); + } else if provider.is_widget() { + info!("Registered widget provider: {} ({})", provider.name(), type_id); + manager.widget_providers.push(provider); + } else { + info!("Registered static provider: {} ({})", provider.name(), type_id); + manager.static_native_providers.push(provider); + } + } + + // Initial refresh + manager.refresh_all(); + + manager + } + + #[allow(dead_code)] + pub fn is_dmenu_mode(&self) -> bool { + self.providers + .iter() + .any(|p| p.provider_type() == ProviderType::Dmenu) + } + + pub fn refresh_all(&mut self) { + // Refresh core providers (apps, commands) + for provider in &mut self.providers { + provider.refresh(); + info!( + "Provider '{}' loaded {} items", + provider.name(), + provider.items().len() + ); + } + + // Refresh static native providers (clipboard, emoji, ssh, etc.) + for provider in &mut self.static_native_providers { + provider.refresh(); + info!( + "Static provider '{}' loaded {} items", + provider.name(), + provider.items().len() + ); + } + + // Widget providers are refreshed separately to avoid blocking startup + // Call refresh_widgets() after window is shown + + // Dynamic providers don't need refresh (they query on demand) + } + + /// Refresh widget providers (weather, media, pomodoro) + /// Call this separately from refresh_all() to avoid blocking startup + /// since widgets may make network requests or spawn processes + pub fn refresh_widgets(&mut self) { + for provider in &mut self.widget_providers { + provider.refresh(); + info!( + "Widget '{}' loaded {} items", + provider.name(), + provider.items().len() + ); + } + } + + /// Find a native provider by type ID + /// Searches in all native provider lists (static, dynamic, widget) + pub fn find_native_provider(&self, type_id: &str) -> Option<&NativeProvider> { + // Check static native providers first (clipboard, emoji, ssh, systemd, etc.) + if let Some(p) = self.static_native_providers.iter().find(|p| p.type_id() == type_id) { + return Some(p); + } + // Check widget providers (pomodoro, weather, media) + if let Some(p) = self.widget_providers.iter().find(|p| p.type_id() == type_id) { + return Some(p); + } + // Then dynamic providers (calc, websearch, filesearch) + self.dynamic_providers.iter().find(|p| p.type_id() == type_id) + } + + /// Execute a plugin action command + /// Command format: PLUGIN_ID:action_data (e.g., "POMODORO:start", "SYSTEMD:unit:restart") + /// Returns true if the command was handled by a plugin + pub fn execute_plugin_action(&self, command: &str) -> bool { + // Parse command format: PLUGIN_ID:action_data + if let Some(colon_pos) = command.find(':') { + let plugin_id = &command[..colon_pos]; + let action = command; // Pass full command to plugin + + // Find provider by type ID (case-insensitive for convenience) + let type_id = plugin_id.to_lowercase(); + + if let Some(provider) = self.find_native_provider(&type_id) { + provider.execute_action(action); + return true; + } + } + false + } + + /// Add a dynamic provider (e.g., from a Lua plugin) + #[allow(dead_code)] + pub fn add_provider(&mut self, provider: Box) { + info!("Added plugin provider: {}", provider.name()); + self.providers.push(provider); + } + + /// Add multiple providers at once (for batch plugin loading) + #[allow(dead_code)] + pub fn add_providers(&mut self, providers: Vec>) { + for provider in providers { + self.add_provider(provider); + } + } + + /// Iterate over all static provider items (core + native static plugins) + fn all_static_items(&self) -> impl Iterator { + self.providers + .iter() + .flat_map(|p| p.items().iter()) + .chain(self.static_native_providers.iter().flat_map(|p| p.items().iter())) + } + + #[allow(dead_code)] + pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> { + if query.is_empty() { + // Return recent/popular items when query is empty + return self.all_static_items() + .take(max_results) + .map(|item| (item.clone(), 0)) + .collect(); + } + + let mut results: Vec<(LaunchItem, i64)> = self.all_static_items() + .filter_map(|item| { + // Match against name and description + let name_score = self.matcher.fuzzy_match(&item.name, query); + let desc_score = item.description + .as_ref() + .and_then(|d| self.matcher.fuzzy_match(d, query)); + + let score = match (name_score, desc_score) { + (Some(n), Some(d)) => Some(n.max(d)), + (Some(n), None) => Some(n), + (None, Some(d)) => Some(d / 2), // Lower weight for description matches + (None, None) => None, + }; + + score.map(|s| (item.clone(), s)) + }) + .collect(); + + // Sort by score (descending) + results.sort_by(|a, b| b.1.cmp(&a.1)); + results.truncate(max_results); + results + } + + /// Search with provider filtering + pub fn search_filtered( + &self, + query: &str, + max_results: usize, + filter: &crate::filter::ProviderFilter, + ) -> Vec<(LaunchItem, i64)> { + // Collect items from core providers + let core_items = self + .providers + .iter() + .filter(|p| filter.is_active(p.provider_type())) + .flat_map(|p| p.items().iter().cloned()); + + // Collect items from static native providers + let native_items = self + .static_native_providers + .iter() + .filter(|p| filter.is_active(p.provider_type())) + .flat_map(|p| p.items().iter().cloned()); + + if query.is_empty() { + return core_items + .chain(native_items) + .take(max_results) + .map(|item| (item, 0)) + .collect(); + } + + let mut results: Vec<(LaunchItem, i64)> = core_items + .chain(native_items) + .filter_map(|item| { + let name_score = self.matcher.fuzzy_match(&item.name, query); + let desc_score = item + .description + .as_ref() + .and_then(|d| self.matcher.fuzzy_match(d, query)); + + let score = match (name_score, desc_score) { + (Some(n), Some(d)) => Some(n.max(d)), + (Some(n), None) => Some(n), + (None, Some(d)) => Some(d / 2), + (None, None) => None, + }; + + score.map(|s| (item, s)) + }) + .collect(); + + results.sort_by(|a, b| b.1.cmp(&a.1)); + results.truncate(max_results); + results + } + + /// Search with frecency boosting, dynamic providers, and tag filtering + pub fn search_with_frecency( + &self, + query: &str, + max_results: usize, + filter: &crate::filter::ProviderFilter, + frecency: &FrecencyStore, + frecency_weight: f64, + tag_filter: Option<&str>, + ) -> Vec<(LaunchItem, i64)> { + #[cfg(feature = "dev-logging")] + debug!("[Search] query={:?}, max={}, frecency_weight={}", query, max_results, frecency_weight); + + let mut results: Vec<(LaunchItem, i64)> = Vec::new(); + + // Add widget items first (highest priority) - only when: + // 1. No specific filter prefix is active + // 2. Query is empty (user hasn't started searching) + // This keeps widgets visible on launch but hides them during active search + // Widgets are always visible regardless of filter settings (they declare position via API) + if filter.active_prefix().is_none() && query.is_empty() { + // Widget priority comes from plugin-declared priority field + for provider in &self.widget_providers { + let base_score = provider.priority() as i64; + for (idx, item) in provider.items().iter().enumerate() { + results.push((item.clone(), base_score - idx as i64)); + } + } + } + + // Query dynamic providers (calculator, websearch, filesearch) + // Only query if: + // 1. Their specific filter is active (e.g., :file prefix or Files tab selected), OR + // 2. No specific single-mode filter is active (showing all providers) + if !query.is_empty() { + for provider in &self.dynamic_providers { + // Skip if this provider type is explicitly filtered out + if !filter.is_active(provider.provider_type()) { + continue; + } + let dynamic_results = provider.query(query); + // Priority comes from plugin-declared priority field + let base_score = provider.priority() as i64; + for (idx, item) in dynamic_results.into_iter().enumerate() { + results.push((item, base_score - idx as i64)); + } + } + } + + // Empty query (after checking special providers) - return frecency-sorted items + if query.is_empty() { + // Collect items from core providers + let core_items = self + .providers + .iter() + .filter(|p| filter.is_active(p.provider_type())) + .flat_map(|p| p.items().iter().cloned()); + + // Collect items from static native providers + let native_items = self + .static_native_providers + .iter() + .filter(|p| filter.is_active(p.provider_type())) + .flat_map(|p| p.items().iter().cloned()); + + let items: Vec<(LaunchItem, i64)> = core_items + .chain(native_items) + .filter(|item| { + // Apply tag filter if present + if let Some(tag) = tag_filter { + item.tags.iter().any(|t| t.to_lowercase().contains(tag)) + } else { + true + } + }) + .map(|item| { + let frecency_score = frecency.get_score(&item.id); + let boosted = (frecency_score * frecency_weight * 100.0) as i64; + (item, boosted) + }) + .collect(); + + // Combine widgets (already in results) with frecency items + results.extend(items); + results.sort_by(|a, b| b.1.cmp(&a.1)); + results.truncate(max_results); + return results; + } + + // Regular search with frecency boost and tag matching + // Helper closure for scoring items + let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> { + // Apply tag filter if present + if let Some(tag) = tag_filter + && !item.tags.iter().any(|t| t.to_lowercase().contains(tag)) + { + return None; + } + + let name_score = self.matcher.fuzzy_match(&item.name, query); + let desc_score = item + .description + .as_ref() + .and_then(|d| self.matcher.fuzzy_match(d, query)); + + // Also match against tags (lower weight) + let tag_score = item + .tags + .iter() + .filter_map(|t| self.matcher.fuzzy_match(t, query)) + .max() + .map(|s| s / 3); // Lower weight for tag matches + + let base_score = match (name_score, desc_score, tag_score) { + (Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)), + (Some(n), Some(d), None) => Some(n.max(d)), + (Some(n), None, Some(t)) => Some(n.max(t)), + (Some(n), None, None) => Some(n), + (None, Some(d), Some(t)) => Some((d / 2).max(t)), + (None, Some(d), None) => Some(d / 2), + (None, None, Some(t)) => Some(t), + (None, None, None) => None, + }; + + base_score.map(|s| { + let frecency_score = frecency.get_score(&item.id); + let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64; + (item.clone(), s + frecency_boost) + }) + }; + + // Search core providers + for provider in &self.providers { + if !filter.is_active(provider.provider_type()) { + continue; + } + for item in provider.items() { + if let Some(scored) = score_item(item) { + results.push(scored); + } + } + } + + // Search static native providers + for provider in &self.static_native_providers { + if !filter.is_active(provider.provider_type()) { + continue; + } + for item in provider.items() { + if let Some(scored) = score_item(item) { + results.push(scored); + } + } + } + results.sort_by(|a, b| b.1.cmp(&a.1)); + results.truncate(max_results); + + #[cfg(feature = "dev-logging")] + { + debug!("[Search] Returning {} results", results.len()); + for (i, (item, score)) in results.iter().take(5).enumerate() { + debug!("[Search] #{}: {} (score={}, provider={:?})", i + 1, item.name, score, item.provider); + } + if results.len() > 5 { + debug!("[Search] ... and {} more", results.len() - 5); + } + } + + results + } + + /// Get all available provider types (for UI tabs) + #[allow(dead_code)] + pub fn available_providers(&self) -> Vec { + self.providers + .iter() + .map(|p| p.provider_type()) + .chain(self.static_native_providers.iter().map(|p| p.provider_type())) + .collect() + } + + /// 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 { + self.widget_providers + .iter() + .find(|p| p.type_id() == type_id) + .and_then(|p| p.items().first().cloned()) + } + + /// Get all loaded widget provider type_ids + /// Returns an iterator over the type_ids of currently loaded widget providers + pub fn widget_type_ids(&self) -> impl Iterator { + self.widget_providers.iter().map(|p| p.type_id()) + } + + /// Query a plugin for submenu actions + /// + /// This is used when a user selects a SUBMENU:plugin_id:data item. + /// The plugin is queried with "?SUBMENU:data" and returns action items. + /// + /// Returns (display_name, actions) where display_name is the item name + /// and actions are the submenu items returned by the plugin. + pub fn query_submenu_actions( + &self, + plugin_id: &str, + data: &str, + display_name: &str, + ) -> Option<(String, Vec)> { + // Build the submenu query + let submenu_query = format!("?SUBMENU:{}", data); + + #[cfg(feature = "dev-logging")] + debug!( + "[Submenu] Querying plugin '{}' with: {}", + plugin_id, submenu_query + ); + + // Search in static native providers (clipboard, emoji, ssh, systemd, etc.) + for provider in &self.static_native_providers { + if provider.type_id() == plugin_id { + let actions = provider.query(&submenu_query); + if !actions.is_empty() { + return Some((display_name.to_string(), actions)); + } + } + } + + // Search in dynamic providers + for provider in &self.dynamic_providers { + if provider.type_id() == plugin_id { + let actions = provider.query(&submenu_query); + if !actions.is_empty() { + return Some((display_name.to_string(), actions)); + } + } + } + + // Search in widget providers + for provider in &self.widget_providers { + if provider.type_id() == plugin_id { + let actions = provider.query(&submenu_query); + if !actions.is_empty() { + return Some((display_name.to_string(), actions)); + } + } + } + + #[cfg(feature = "dev-logging")] + debug!("[Submenu] No submenu actions found for plugin '{}'", plugin_id); + + None + } +} diff --git a/crates/owlry-core/src/providers/native_provider.rs b/crates/owlry-core/src/providers/native_provider.rs new file mode 100644 index 0000000..acda16b --- /dev/null +++ b/crates/owlry-core/src/providers/native_provider.rs @@ -0,0 +1,197 @@ +//! Native Plugin Provider Bridge +//! +//! This module provides a bridge between native plugins (compiled .so files) +//! and the core Provider trait used by ProviderManager. +//! +//! Native plugins are loaded from `/usr/lib/owlry/plugins/` as `.so` files +//! and provide search providers via an ABI-stable interface. + +use std::sync::{Arc, RwLock}; + +use log::debug; +use owlry_plugin_api::{PluginItem as ApiPluginItem, ProviderHandle, ProviderInfo, ProviderKind, ProviderPosition}; + +use super::{LaunchItem, Provider, ProviderType}; +use crate::plugins::native_loader::NativePlugin; + +/// A provider backed by a native plugin +/// +/// This wraps a native plugin's provider and implements the core Provider trait, +/// allowing native plugins to be used seamlessly with the existing ProviderManager. +pub struct NativeProvider { + /// The native plugin (shared reference since multiple providers may use same plugin) + plugin: Arc, + /// Provider metadata + info: ProviderInfo, + /// Handle to the provider state in the plugin + handle: ProviderHandle, + /// Cached items (for static providers) + items: RwLock>, +} + +impl NativeProvider { + /// Create a new native provider + pub fn new(plugin: Arc, info: ProviderInfo) -> Self { + let handle = plugin.init_provider(info.id.as_str()); + + Self { + plugin, + info, + handle, + items: RwLock::new(Vec::new()), + } + } + + /// Get the ProviderType for this native provider + /// All native plugins return Plugin(type_id) - the core has no hardcoded plugin types + fn get_provider_type(&self) -> ProviderType { + ProviderType::Plugin(self.info.type_id.to_string()) + } + + /// Convert a plugin API item to a core LaunchItem + fn convert_item(&self, item: ApiPluginItem) -> LaunchItem { + LaunchItem { + id: item.id.to_string(), + name: item.name.to_string(), + description: item.description.as_ref().map(|s| s.to_string()).into(), + icon: item.icon.as_ref().map(|s| s.to_string()).into(), + provider: self.get_provider_type(), + command: item.command.to_string(), + terminal: item.terminal, + tags: item.keywords.iter().map(|s| s.to_string()).collect(), + } + } + + /// Query the provider + /// + /// For dynamic providers, this is called per-keystroke. + /// For static providers, returns cached items unless query is a special command + /// (submenu queries `?SUBMENU:` or action commands `!ACTION:`). + pub fn query(&self, query: &str) -> Vec { + // Special queries (submenu, actions) should always be forwarded to the plugin + let is_special_query = query.starts_with("?SUBMENU:") || query.starts_with("!"); + + if self.info.provider_type != ProviderKind::Dynamic && !is_special_query { + return self.items.read().unwrap().clone(); + } + + let api_items = self.plugin.query_provider(self.handle, query); + api_items.into_iter().map(|item| self.convert_item(item)).collect() + } + + /// Check if this provider has a prefix that matches the query + #[allow(dead_code)] + pub fn matches_prefix(&self, query: &str) -> bool { + match self.info.prefix.as_ref().into_option() { + Some(prefix) => query.starts_with(prefix.as_str()), + None => false, + } + } + + /// Get the prefix for this provider (if any) + #[allow(dead_code)] + pub fn prefix(&self) -> Option<&str> { + self.info.prefix.as_ref().map(|s| s.as_str()).into() + } + + /// Check if this is a dynamic provider + #[allow(dead_code)] + pub fn is_dynamic(&self) -> bool { + self.info.provider_type == ProviderKind::Dynamic + } + + /// Get the provider type ID (e.g., "calc", "clipboard", "weather") + pub fn type_id(&self) -> &str { + self.info.type_id.as_str() + } + + /// Check if this is a widget provider (appears at top of results) + pub fn is_widget(&self) -> bool { + self.info.position == ProviderPosition::Widget + } + + /// Get the provider's priority for result ordering + /// Higher values appear first in results + pub fn priority(&self) -> i32 { + self.info.priority + } + + /// 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) { + let action_query = format!("!{}", action); + self.plugin.query_provider(self.handle, &action_query); + } +} + +impl Provider for NativeProvider { + fn name(&self) -> &str { + self.info.name.as_str() + } + + fn provider_type(&self) -> ProviderType { + self.get_provider_type() + } + + fn refresh(&mut self) { + // Only refresh static providers + if self.info.provider_type != ProviderKind::Static { + return; + } + + debug!("Refreshing native provider '{}'", self.info.name.as_str()); + + let api_items = self.plugin.refresh_provider(self.handle); + let items: Vec = api_items + .into_iter() + .map(|item| self.convert_item(item)) + .collect(); + + debug!( + "Native provider '{}' loaded {} items", + self.info.name.as_str(), + items.len() + ); + + *self.items.write().unwrap() = items; + } + + fn items(&self) -> &[LaunchItem] { + // This is tricky with RwLock - we need to return a reference but can't + // hold the lock across the return. We use a raw pointer approach. + // + // SAFETY: The items Vec is only modified during refresh() which takes + // &mut self, so no concurrent modification can occur while this + // reference is live. + unsafe { + let guard = self.items.read().unwrap(); + let ptr = guard.as_ptr(); + let len = guard.len(); + std::slice::from_raw_parts(ptr, len) + } + } +} + +impl Drop for NativeProvider { + fn drop(&mut self) { + // Clean up the provider handle + self.plugin.drop_provider(self.handle); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Note: Full testing requires actual .so plugins, which we'll test + // via integration tests. Unit tests here focus on the conversion logic. + + #[test] + fn test_provider_type_conversion() { + // Test that type_id is correctly converted to ProviderType::Plugin + let type_id = "calculator"; + let provider_type = ProviderType::Plugin(type_id.to_string()); + + assert_eq!(format!("{}", provider_type), "calculator"); + } +}