From 182a50059600c86f8d22bf66792b71376966f1f0 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 26 Mar 2026 12:07:03 +0100 Subject: [PATCH] refactor: wire owlry to use owlry-core as library dependency - Add owlry-core dependency to owlry Cargo.toml - Remove dependencies from owlry that moved to owlry-core: fuzzy-matcher, freedesktop-desktop-entry, libloading, notify-rust, thiserror, mlua, meval, reqwest - Forward feature flags (dev-logging, lua) to owlry-core - Update all imports in owlry source files to use owlry_core:: for moved modules (config, data, filter, providers, plugins, notify, paths) - Delete original source files from owlry that were moved - Create minimal providers/mod.rs that only re-exports DmenuProvider - Move plugins/commands.rs to plugin_commands.rs (stays in owlry since it depends on CLI types from clap) - Restructure app.rs to build core providers externally and pass them to ProviderManager::new() instead of using the old with_native_plugins() constructor --- Cargo.lock | 19 +- crates/owlry-core/Cargo.toml | 6 + crates/owlry/Cargo.toml | 51 +- crates/owlry/src/app.rs | 45 +- crates/owlry/src/cli.rs | 2 +- crates/owlry/src/config/mod.rs | 574 ---------------- crates/owlry/src/data/frecency.rs | 219 ------- crates/owlry/src/data/mod.rs | 3 - crates/owlry/src/filter.rs | 409 ------------ crates/owlry/src/main.rs | 9 +- crates/owlry/src/notify.rs | 91 --- crates/owlry/src/paths.rs | 203 ------ .../commands.rs => plugin_commands.rs} | 14 +- crates/owlry/src/plugins/api/action.rs | 322 --------- crates/owlry/src/plugins/api/cache.rs | 299 --------- crates/owlry/src/plugins/api/hook.rs | 410 ------------ crates/owlry/src/plugins/api/http.rs | 345 ---------- crates/owlry/src/plugins/api/math.rs | 181 ----- crates/owlry/src/plugins/api/mod.rs | 77 --- crates/owlry/src/plugins/api/process.rs | 207 ------ crates/owlry/src/plugins/api/provider.rs | 315 --------- crates/owlry/src/plugins/api/theme.rs | 275 -------- crates/owlry/src/plugins/api/utils.rs | 567 ---------------- crates/owlry/src/plugins/error.rs | 51 -- crates/owlry/src/plugins/loader.rs | 205 ------ crates/owlry/src/plugins/manifest.rs | 318 --------- crates/owlry/src/plugins/mod.rs | 337 ---------- crates/owlry/src/plugins/native_loader.rs | 391 ----------- crates/owlry/src/plugins/registry.rs | 293 --------- crates/owlry/src/plugins/runtime.rs | 153 ----- crates/owlry/src/plugins/runtime_loader.rs | 286 -------- crates/owlry/src/providers/application.rs | 266 -------- crates/owlry/src/providers/command.rs | 106 --- crates/owlry/src/providers/dmenu.rs | 2 +- crates/owlry/src/providers/lua_provider.rs | 142 ---- crates/owlry/src/providers/mod.rs | 616 +----------------- crates/owlry/src/providers/native_provider.rs | 197 ------ crates/owlry/src/theme.rs | 2 +- crates/owlry/src/ui/main_window.rs | 16 +- crates/owlry/src/ui/result_row.rs | 10 +- crates/owlry/src/ui/submenu.rs | 4 +- 41 files changed, 92 insertions(+), 7946 deletions(-) delete mode 100644 crates/owlry/src/config/mod.rs delete mode 100644 crates/owlry/src/data/frecency.rs delete mode 100644 crates/owlry/src/data/mod.rs delete mode 100644 crates/owlry/src/filter.rs delete mode 100644 crates/owlry/src/notify.rs delete mode 100644 crates/owlry/src/paths.rs rename crates/owlry/src/{plugins/commands.rs => plugin_commands.rs} (98%) delete mode 100644 crates/owlry/src/plugins/api/action.rs delete mode 100644 crates/owlry/src/plugins/api/cache.rs delete mode 100644 crates/owlry/src/plugins/api/hook.rs delete mode 100644 crates/owlry/src/plugins/api/http.rs delete mode 100644 crates/owlry/src/plugins/api/math.rs delete mode 100644 crates/owlry/src/plugins/api/mod.rs delete mode 100644 crates/owlry/src/plugins/api/process.rs delete mode 100644 crates/owlry/src/plugins/api/provider.rs delete mode 100644 crates/owlry/src/plugins/api/theme.rs delete mode 100644 crates/owlry/src/plugins/api/utils.rs delete mode 100644 crates/owlry/src/plugins/error.rs delete mode 100644 crates/owlry/src/plugins/loader.rs delete mode 100644 crates/owlry/src/plugins/manifest.rs delete mode 100644 crates/owlry/src/plugins/mod.rs delete mode 100644 crates/owlry/src/plugins/native_loader.rs delete mode 100644 crates/owlry/src/plugins/registry.rs delete mode 100644 crates/owlry/src/plugins/runtime.rs delete mode 100644 crates/owlry/src/plugins/runtime_loader.rs delete mode 100644 crates/owlry/src/providers/application.rs delete mode 100644 crates/owlry/src/providers/command.rs delete mode 100644 crates/owlry/src/providers/lua_provider.rs delete mode 100644 crates/owlry/src/providers/native_provider.rs diff --git a/Cargo.lock b/Cargo.lock index ed4b218..130d0ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2439,12 +2439,27 @@ dependencies = [ "clap", "dirs", "env_logger", - "freedesktop-desktop-entry", - "fuzzy-matcher", "glib-build-tools", "gtk4", "gtk4-layer-shell", "libc", + "log", + "owlry-core", + "semver", + "serde", + "serde_json", + "toml 0.8.23", +] + +[[package]] +name = "owlry-core" +version = "0.5.0" +dependencies = [ + "chrono", + "dirs", + "env_logger", + "freedesktop-desktop-entry", + "fuzzy-matcher", "libloading 0.8.9", "log", "meval", diff --git a/crates/owlry-core/Cargo.toml b/crates/owlry-core/Cargo.toml index 0865c10..a6e5b40 100644 --- a/crates/owlry-core/Cargo.toml +++ b/crates/owlry-core/Cargo.toml @@ -29,6 +29,9 @@ toml = "0.8" chrono = { version = "0.4", features = ["serde"] } dirs = "5" +# Error handling +thiserror = "2" + # Logging & notifications log = "0.4" env_logger = "0.11" @@ -39,6 +42,9 @@ mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"] meval = { version = "0.2", optional = true } reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"], optional = true } +[dev-dependencies] +tempfile = "3" + [features] default = [] lua = ["dep:mlua", "dep:meval", "dep:reqwest"] diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml index 7774f5a..35b89ca 100644 --- a/crates/owlry/Cargo.toml +++ b/crates/owlry/Cargo.toml @@ -11,8 +11,8 @@ keywords = ["launcher", "wayland", "gtk4", "linux"] categories = ["gui"] [dependencies] -# Shared plugin API -owlry-plugin-api = { path = "../owlry-plugin-api" } +# Core backend library +owlry-core = { path = "../owlry-core" } # GTK4 for the UI gtk4 = { version = "0.10", features = ["v4_12"] } @@ -20,60 +20,32 @@ gtk4 = { version = "0.10", features = ["v4_12"] } # Layer shell support for Wayland overlay behavior gtk4-layer-shell = "0.7" -# Fuzzy matching for search -fuzzy-matcher = "0.3" - -# XDG desktop entry parsing -freedesktop-desktop-entry = "0.8" - -# Directory utilities -dirs = "5" - -# Low-level syscalls for stdin detection +# Low-level syscalls for stdin detection (dmenu mode) libc = "0.2" # Logging log = "0.4" env_logger = "0.11" -# Error handling -thiserror = "2" - -# Configuration +# Configuration (needed for config types used in app.rs/theme.rs) serde = { version = "1", features = ["derive"] } toml = "0.8" # CLI argument parsing clap = { version = "4", features = ["derive"] } -# Math expression evaluation (for Lua plugins) -meval = { version = "0.2", optional = true } - -# JSON serialization for data persistence +# JSON serialization (needed by plugin commands in CLI) serde_json = "1" -# Date/time for frecency calculations +# Date/time (needed by plugin commands in CLI) chrono = { version = "0.4", features = ["serde"] } -# HTTP client (for Lua plugins) -reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "blocking"], optional = true } +# Directory utilities (needed by plugin commands) +dirs = "5" -# Lua runtime for plugin system (optional - can be loaded dynamically via owlry-lua) -mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"], optional = true } - -# Semantic versioning for plugin compatibility +# Semantic versioning (needed by plugin commands) semver = "1" -# Dynamic library loading for native plugins -libloading = "0.8" - -# Desktop notifications (freedesktop notification spec) -notify-rust = "4" - -[dev-dependencies] -# Temporary directories for tests -tempfile = "3" - [build-dependencies] # GResource compilation for bundled icons glib-build-tools = "0.20" @@ -81,7 +53,6 @@ glib-build-tools = "0.20" [features] default = [] # Enable verbose debug logging (for development/testing builds) -dev-logging = [] +dev-logging = ["owlry-core/dev-logging"] # Enable built-in Lua runtime (disable to use external owlry-lua package) -# Includes: mlua, meval (math), reqwest (http) -lua = ["dep:mlua", "dep:meval", "dep:reqwest"] +lua = ["owlry-core/lua"] diff --git a/crates/owlry/src/app.rs b/crates/owlry/src/app.rs index 8679a5a..47836a2 100644 --- a/crates/owlry/src/app.rs +++ b/crates/owlry/src/app.rs @@ -1,16 +1,17 @@ use crate::cli::CliArgs; -use crate::config::Config; -use crate::data::FrecencyStore; -use crate::filter::ProviderFilter; -use crate::paths; -use crate::plugins::native_loader::NativePluginLoader; -#[cfg(feature = "lua")] -use crate::plugins::PluginManager; -use crate::providers::native_provider::NativeProvider; -use crate::providers::Provider; // For name() method -use crate::providers::ProviderManager; +use crate::providers::DmenuProvider; use crate::theme; use crate::ui::MainWindow; +use owlry_core::config::Config; +use owlry_core::data::FrecencyStore; +use owlry_core::filter::ProviderFilter; +use owlry_core::paths; +use owlry_core::plugins::native_loader::NativePluginLoader; +#[cfg(feature = "lua")] +use owlry_core::plugins::PluginManager; +use owlry_core::providers::native_provider::NativeProvider; +use owlry_core::providers::Provider; // For name() method +use owlry_core::providers::{ApplicationProvider, CommandProvider, ProviderManager}; use gtk4::prelude::*; use gtk4::{gio, Application, CssProvider}; use gtk4_layer_shell::{Edge, Layer, LayerShell}; @@ -55,11 +56,25 @@ impl OwlryApp { // Load native plugins from /usr/lib/owlry/plugins/ let native_providers = Self::load_native_plugins(&config.borrow()); - // Create provider manager with native plugins + // Build core providers based on mode + let dmenu_mode = DmenuProvider::has_stdin_data(); + let core_providers: Vec> = if dmenu_mode { + let mut dmenu = DmenuProvider::new(); + dmenu.enable(); + vec![Box::new(dmenu)] + } else { + vec![ + Box::new(ApplicationProvider::new()), + Box::new(CommandProvider::new()), + ] + }; + + // Create provider manager with core providers and native plugins + let native_for_manager = if dmenu_mode { Vec::new() } else { native_providers }; #[cfg(feature = "lua")] - let mut provider_manager = ProviderManager::with_native_plugins(native_providers); + let mut provider_manager = ProviderManager::new(core_providers, native_for_manager); #[cfg(not(feature = "lua"))] - let provider_manager = ProviderManager::with_native_plugins(native_providers); + let provider_manager = ProviderManager::new(core_providers, native_for_manager); // Load Lua plugins if enabled (requires lua feature) #[cfg(feature = "lua")] @@ -117,7 +132,7 @@ impl OwlryApp { Ok(count) => { if count == 0 { debug!("No native plugins found in {}", - crate::plugins::native_loader::SYSTEM_PLUGINS_DIR); + owlry_core::plugins::native_loader::SYSTEM_PLUGINS_DIR); return Vec::new(); } info!("Discovered {} native plugin(s)", count); @@ -129,7 +144,7 @@ impl OwlryApp { } // Get all plugins and create providers - let plugins: Vec> = + let plugins: Vec> = loader.into_plugins(); // Create NativeProvider instances from loaded plugins diff --git a/crates/owlry/src/cli.rs b/crates/owlry/src/cli.rs index 2090fe9..1b345da 100644 --- a/crates/owlry/src/cli.rs +++ b/crates/owlry/src/cli.rs @@ -4,7 +4,7 @@ use clap::{Parser, Subcommand}; -use crate::providers::ProviderType; +use owlry_core::providers::ProviderType; #[derive(Parser, Debug, Clone)] #[command( diff --git a/crates/owlry/src/config/mod.rs b/crates/owlry/src/config/mod.rs deleted file mode 100644 index dc6a57f..0000000 --- a/crates/owlry/src/config/mod.rs +++ /dev/null @@ -1,574 +0,0 @@ -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/src/data/frecency.rs b/crates/owlry/src/data/frecency.rs deleted file mode 100644 index af43413..0000000 --- a/crates/owlry/src/data/frecency.rs +++ /dev/null @@ -1,219 +0,0 @@ -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/src/data/mod.rs b/crates/owlry/src/data/mod.rs deleted file mode 100644 index 8fc1d1b..0000000 --- a/crates/owlry/src/data/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod frecency; - -pub use frecency::FrecencyStore; diff --git a/crates/owlry/src/filter.rs b/crates/owlry/src/filter.rs deleted file mode 100644 index b9e231e..0000000 --- a/crates/owlry/src/filter.rs +++ /dev/null @@ -1,409 +0,0 @@ -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/src/main.rs b/crates/owlry/src/main.rs index 5ac83a0..ec99458 100644 --- a/crates/owlry/src/main.rs +++ b/crates/owlry/src/main.rs @@ -1,11 +1,6 @@ mod app; mod cli; -mod config; -mod data; -mod filter; -mod notify; -mod paths; -mod plugins; +mod plugin_commands; mod providers; mod theme; mod ui; @@ -25,7 +20,7 @@ fn main() { // CLI commands don't need full logging match command { Command::Plugin(plugin_cmd) => { - if let Err(e) = plugins::commands::execute(plugin_cmd.clone()) { + if let Err(e) = plugin_commands::execute(plugin_cmd.clone()) { eprintln!("Error: {}", e); std::process::exit(1); } diff --git a/crates/owlry/src/notify.rs b/crates/owlry/src/notify.rs deleted file mode 100644 index dbfc9ac..0000000 --- a/crates/owlry/src/notify.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! 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/src/paths.rs b/crates/owlry/src/paths.rs deleted file mode 100644 index a846063..0000000 --- a/crates/owlry/src/paths.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! 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/src/plugins/commands.rs b/crates/owlry/src/plugin_commands.rs similarity index 98% rename from crates/owlry/src/plugins/commands.rs rename to crates/owlry/src/plugin_commands.rs index 4117a71..731f47c 100644 --- a/crates/owlry/src/plugins/commands.rs +++ b/crates/owlry/src/plugin_commands.rs @@ -7,11 +7,11 @@ use std::io::{self, Write}; use std::path::{Path, PathBuf}; use crate::cli::{PluginCommand as CliPluginCommand, PluginRuntime}; -use crate::config::Config; -use crate::paths; -use crate::plugins::manifest::{discover_plugins, PluginManifest}; -use crate::plugins::registry::{self, RegistryClient}; -use crate::plugins::runtime_loader::{lua_runtime_available, rune_runtime_available}; +use owlry_core::config::Config; +use owlry_core::paths; +use owlry_core::plugins::manifest::{discover_plugins, PluginManifest}; +use owlry_core::plugins::registry::{self, RegistryClient}; +use owlry_core::plugins::runtime_loader::{lua_runtime_available, rune_runtime_available}; /// Result type for plugin commands pub type CommandResult = Result<(), String>; @@ -932,7 +932,7 @@ fn cmd_validate(path: Option<&str>) -> CommandResult { /// Show available script runtimes fn cmd_runtimes() -> CommandResult { - use crate::plugins::runtime_loader::SYSTEM_RUNTIMES_DIR; + use owlry_core::plugins::runtime_loader::SYSTEM_RUNTIMES_DIR; println!("Script Runtimes:\n"); @@ -1024,7 +1024,7 @@ fn execute_plugin_command( command: &str, args: &[String], ) -> CommandResult { - use crate::plugins::runtime_loader::{LoadedRuntime, SYSTEM_RUNTIMES_DIR}; + use owlry_core::plugins::runtime_loader::{LoadedRuntime, SYSTEM_RUNTIMES_DIR}; let runtime = detect_runtime(manifest); diff --git a/crates/owlry/src/plugins/api/action.rs b/crates/owlry/src/plugins/api/action.rs deleted file mode 100644 index 985f574..0000000 --- a/crates/owlry/src/plugins/api/action.rs +++ /dev/null @@ -1,322 +0,0 @@ -//! 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/src/plugins/api/cache.rs b/crates/owlry/src/plugins/api/cache.rs deleted file mode 100644 index 448b066..0000000 --- a/crates/owlry/src/plugins/api/cache.rs +++ /dev/null @@ -1,299 +0,0 @@ -//! 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/src/plugins/api/hook.rs b/crates/owlry/src/plugins/api/hook.rs deleted file mode 100644 index b660964..0000000 --- a/crates/owlry/src/plugins/api/hook.rs +++ /dev/null @@ -1,410 +0,0 @@ -//! 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/src/plugins/api/http.rs b/crates/owlry/src/plugins/api/http.rs deleted file mode 100644 index 49b7490..0000000 --- a/crates/owlry/src/plugins/api/http.rs +++ /dev/null @@ -1,345 +0,0 @@ -//! 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/src/plugins/api/math.rs b/crates/owlry/src/plugins/api/math.rs deleted file mode 100644 index 54a961c..0000000 --- a/crates/owlry/src/plugins/api/math.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! 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/src/plugins/api/mod.rs b/crates/owlry/src/plugins/api/mod.rs deleted file mode 100644 index 10fa1ef..0000000 --- a/crates/owlry/src/plugins/api/mod.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! 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/src/plugins/api/process.rs b/crates/owlry/src/plugins/api/process.rs deleted file mode 100644 index b8b5204..0000000 --- a/crates/owlry/src/plugins/api/process.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! 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/src/plugins/api/provider.rs b/crates/owlry/src/plugins/api/provider.rs deleted file mode 100644 index 124c240..0000000 --- a/crates/owlry/src/plugins/api/provider.rs +++ /dev/null @@ -1,315 +0,0 @@ -//! 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/src/plugins/api/theme.rs b/crates/owlry/src/plugins/api/theme.rs deleted file mode 100644 index e500222..0000000 --- a/crates/owlry/src/plugins/api/theme.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! 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/src/plugins/api/utils.rs b/crates/owlry/src/plugins/api/utils.rs deleted file mode 100644 index 2f6df20..0000000 --- a/crates/owlry/src/plugins/api/utils.rs +++ /dev/null @@ -1,567 +0,0 @@ -//! 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/src/plugins/error.rs b/crates/owlry/src/plugins/error.rs deleted file mode 100644 index af6ce43..0000000 --- a/crates/owlry/src/plugins/error.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! 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/src/plugins/loader.rs b/crates/owlry/src/plugins/loader.rs deleted file mode 100644 index 4a6f0ee..0000000 --- a/crates/owlry/src/plugins/loader.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! 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/src/plugins/manifest.rs b/crates/owlry/src/plugins/manifest.rs deleted file mode 100644 index 929d6cf..0000000 --- a/crates/owlry/src/plugins/manifest.rs +++ /dev/null @@ -1,318 +0,0 @@ -//! 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/src/plugins/mod.rs b/crates/owlry/src/plugins/mod.rs deleted file mode 100644 index 403b6c0..0000000 --- a/crates/owlry/src/plugins/mod.rs +++ /dev/null @@ -1,337 +0,0 @@ -//! 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 commands; -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/src/plugins/native_loader.rs b/crates/owlry/src/plugins/native_loader.rs deleted file mode 100644 index 05d539d..0000000 --- a/crates/owlry/src/plugins/native_loader.rs +++ /dev/null @@ -1,391 +0,0 @@ -//! 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/src/plugins/registry.rs b/crates/owlry/src/plugins/registry.rs deleted file mode 100644 index 42c6798..0000000 --- a/crates/owlry/src/plugins/registry.rs +++ /dev/null @@ -1,293 +0,0 @@ -//! 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/src/plugins/runtime.rs b/crates/owlry/src/plugins/runtime.rs deleted file mode 100644 index da98dbe..0000000 --- a/crates/owlry/src/plugins/runtime.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! 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/src/plugins/runtime_loader.rs b/crates/owlry/src/plugins/runtime_loader.rs deleted file mode 100644 index de62fcd..0000000 --- a/crates/owlry/src/plugins/runtime_loader.rs +++ /dev/null @@ -1,286 +0,0 @@ -//! 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/src/providers/application.rs b/crates/owlry/src/providers/application.rs deleted file mode 100644 index 3236e64..0000000 --- a/crates/owlry/src/providers/application.rs +++ /dev/null @@ -1,266 +0,0 @@ -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/src/providers/command.rs b/crates/owlry/src/providers/command.rs deleted file mode 100644 index 0df024f..0000000 --- a/crates/owlry/src/providers/command.rs +++ /dev/null @@ -1,106 +0,0 @@ -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/src/providers/dmenu.rs b/crates/owlry/src/providers/dmenu.rs index 84a1022..1a6b8b3 100644 --- a/crates/owlry/src/providers/dmenu.rs +++ b/crates/owlry/src/providers/dmenu.rs @@ -1,4 +1,4 @@ -use super::{LaunchItem, Provider, ProviderType}; +use owlry_core::providers::{LaunchItem, Provider, ProviderType}; use log::debug; use std::io::{self, BufRead}; diff --git a/crates/owlry/src/providers/lua_provider.rs b/crates/owlry/src/providers/lua_provider.rs deleted file mode 100644 index d624846..0000000 --- a/crates/owlry/src/providers/lua_provider.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! 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/src/providers/mod.rs b/crates/owlry/src/providers/mod.rs index bc1b9ee..bbb7ad5 100644 --- a/crates/owlry/src/providers/mod.rs +++ b/crates/owlry/src/providers/mod.rs @@ -1,616 +1,2 @@ -// Core providers (no plugin equivalents) -mod application; -mod command; -mod dmenu; - -// 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; +pub mod dmenu; pub use dmenu::DmenuProvider; - -// 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 native plugins - /// - /// Native plugins are loaded from /usr/lib/owlry/plugins/ and categorized based on - /// their declared ProviderKind and ProviderPosition: - /// - Static providers with Normal position (added to providers vec) - /// - Dynamic providers (queried per-keystroke, declared via ProviderKind::Dynamic) - /// - Widget providers (shown at top, declared via ProviderPosition::Widget) - pub fn with_native_plugins(native_providers: Vec) -> Self { - let mut manager = Self { - providers: Vec::new(), - static_native_providers: Vec::new(), - dynamic_providers: Vec::new(), - widget_providers: Vec::new(), - matcher: SkimMatcherV2::default(), - }; - - // Check if running in dmenu mode (stdin has data) - let dmenu_mode = DmenuProvider::has_stdin_data(); - - if dmenu_mode { - // In dmenu mode, only use dmenu provider - let mut dmenu = DmenuProvider::new(); - dmenu.enable(); - manager.providers.push(Box::new(dmenu)); - } else { - // Core providers (no plugin equivalents) - manager.providers.push(Box::new(ApplicationProvider::new())); - manager.providers.push(Box::new(CommandProvider::new())); - - // 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() { - // Dynamic providers declare ProviderKind::Dynamic - info!("Registered dynamic provider: {} ({})", provider.name(), type_id); - manager.dynamic_providers.push(provider); - } else if provider.is_widget() { - // Widgets declare ProviderPosition::Widget - info!("Registered widget provider: {} ({})", provider.name(), type_id); - manager.widget_providers.push(provider); - } else { - // Static native providers (keep as NativeProvider for query/submenu support) - 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/src/providers/native_provider.rs b/crates/owlry/src/providers/native_provider.rs deleted file mode 100644 index acda16b..0000000 --- a/crates/owlry/src/providers/native_provider.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! 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"); - } -} diff --git a/crates/owlry/src/theme.rs b/crates/owlry/src/theme.rs index a2a6502..fd3b01b 100644 --- a/crates/owlry/src/theme.rs +++ b/crates/owlry/src/theme.rs @@ -1,4 +1,4 @@ -use crate::config::AppearanceConfig; +use owlry_core::config::AppearanceConfig; /// Generate CSS with :root variables from config settings pub fn generate_variables_css(config: &AppearanceConfig) -> String { diff --git a/crates/owlry/src/ui/main_window.rs b/crates/owlry/src/ui/main_window.rs index e41380a..792250a 100644 --- a/crates/owlry/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -1,7 +1,7 @@ -use crate::config::Config; -use crate::data::FrecencyStore; -use crate::filter::ProviderFilter; -use crate::providers::{LaunchItem, ProviderManager, ProviderType}; +use owlry_core::config::Config; +use owlry_core::data::FrecencyStore; +use owlry_core::filter::ProviderFilter; +use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType}; use crate::ui::submenu; use crate::ui::ResultRow; use gtk4::gdk::Key; @@ -408,7 +408,7 @@ impl MainWindow { } /// Build dynamic hints based on enabled providers - fn build_hints(config: &crate::config::ProvidersConfig) -> String { + fn build_hints(config: &owlry_core::config::ProvidersConfig) -> String { let mut parts: Vec = vec![ "Tab: cycle".to_string(), "↑↓: nav".to_string(), @@ -1337,7 +1337,7 @@ impl MainWindow { if let Err(e) = result { let msg = format!("Failed to launch '{}': {}", item.name, e); log::error!("{}", msg); - crate::notify::notify("Launch failed", &msg); + owlry_core::notify::notify("Launch failed", &msg); } } @@ -1355,7 +1355,7 @@ impl MainWindow { if !Path::new(desktop_path).exists() { let msg = format!("Desktop file not found: {}", desktop_path); log::error!("{}", msg); - crate::notify::notify("Launch failed", &msg); + owlry_core::notify::notify("Launch failed", &msg); return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg)); } @@ -1372,7 +1372,7 @@ impl MainWindow { if !uwsm_available { let msg = "uwsm is enabled in config but not installed"; log::error!("{}", msg); - crate::notify::notify("Launch failed", msg); + owlry_core::notify::notify("Launch failed", msg); return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg)); } diff --git a/crates/owlry/src/ui/result_row.rs b/crates/owlry/src/ui/result_row.rs index 8d5abec..175bba8 100644 --- a/crates/owlry/src/ui/result_row.rs +++ b/crates/owlry/src/ui/result_row.rs @@ -1,4 +1,4 @@ -use crate::providers::LaunchItem; +use owlry_core::providers::LaunchItem; use gtk4::prelude::*; use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation, Widget}; @@ -81,11 +81,11 @@ impl ResultRow { } else { // Default icon based on provider type (only core types, plugins should provide icons) let default_icon = match &item.provider { - crate::providers::ProviderType::Application => "application-x-executable-symbolic", - crate::providers::ProviderType::Command => "utilities-terminal-symbolic", - crate::providers::ProviderType::Dmenu => "view-list-symbolic", + owlry_core::providers::ProviderType::Application => "application-x-executable-symbolic", + owlry_core::providers::ProviderType::Command => "utilities-terminal-symbolic", + owlry_core::providers::ProviderType::Dmenu => "view-list-symbolic", // Plugins should provide their own icon; fallback to generic addon icon - crate::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic", + owlry_core::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic", }; let img = Image::from_icon_name(default_icon); img.set_pixel_size(32); diff --git a/crates/owlry/src/ui/submenu.rs b/crates/owlry/src/ui/submenu.rs index 6075428..b760733 100644 --- a/crates/owlry/src/ui/submenu.rs +++ b/crates/owlry/src/ui/submenu.rs @@ -46,7 +46,7 @@ //! } //! ``` -use crate::providers::LaunchItem; +use owlry_core::providers::LaunchItem; /// Parse a submenu command and extract plugin_id and data /// Returns (plugin_id, data) if command matches SUBMENU: format @@ -66,7 +66,7 @@ pub fn is_submenu_item(item: &LaunchItem) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::providers::ProviderType; + use owlry_core::providers::ProviderType; #[test] fn test_parse_submenu_command() {