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
This commit is contained in:
2026-03-26 12:07:03 +01:00
parent d79c9087fd
commit 182a500596
41 changed files with 92 additions and 7946 deletions

19
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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<Box<dyn Provider>> = 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<Arc<crate::plugins::native_loader::NativePlugin>> =
let plugins: Vec<Arc<owlry_core::plugins::native_loader::NativePlugin>> =
loader.into_plugins();
// Create NativeProvider instances from loaded plugins

View File

@@ -4,7 +4,7 @@
use clap::{Parser, Subcommand};
use crate::providers::ProviderType;
use owlry_core::providers::ProviderType;
#[derive(Parser, Debug, Clone)]
#[command(

View File

@@ -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<String>,
/// Enable uwsm (Universal Wayland Session Manager) for launching apps.
/// When enabled, desktop files are launched via `uwsm app -- <file>`
/// 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<String>,
}
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<String> {
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<String>,
pub background_secondary: Option<String>,
pub border: Option<String>,
pub text: Option<String>,
pub text_secondary: Option<String>,
pub accent: Option<String>,
pub accent_bright: Option<String>,
// Provider badge colors
pub badge_app: Option<String>,
pub badge_bookmark: Option<String>,
pub badge_calc: Option<String>,
pub badge_clip: Option<String>,
pub badge_cmd: Option<String>,
pub badge_dmenu: Option<String>,
pub badge_emoji: Option<String>,
pub badge_file: Option<String>,
pub badge_script: Option<String>,
pub badge_ssh: Option<String>,
pub badge_sys: Option<String>,
pub badge_uuctl: Option<String>,
pub badge_web: Option<String>,
// Widget badge colors
pub badge_media: Option<String>,
pub badge_weather: Option<String>,
pub badge_pomo: Option<String>,
}
#[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<String>,
/// 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<String>,
/// Location for weather (city name or coordinates)
#[serde(default)]
pub weather_location: Option<String>,
/// 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.<name>]` 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<String>,
/// List of plugin IDs to explicitly disable
#[serde(default)]
pub disabled_plugins: Vec<String>,
/// 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<String>,
/// Per-plugin configuration tables
/// Accessed via `[plugins.<plugin_name>]` sections in config.toml
/// Each plugin can define its own config schema
#[serde(flatten)]
pub plugin_configs: HashMap<String, toml::Value>,
}
/// 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.<name>]`
#[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<i64> {
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<bool> {
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<String> {
// 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<PathBuf> {
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<Self, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}
}

View File

@@ -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<Utc>,
}
/// Persistent frecency data store
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrecencyData {
pub version: u32,
pub entries: HashMap<String, FrecencyEntry>,
}
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<FrecencyData> {
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<dyn std::error::Error>> {
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<Utc>) -> 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<String, FrecencyEntry> {
&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
}
}

View File

@@ -1,3 +0,0 @@
mod frecency;
pub use frecency::FrecencyStore;

View File

@@ -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<ProviderType>,
active_prefix: Option<ProviderType>,
}
/// Result of parsing a query for prefix syntax
#[derive(Debug, Clone)]
pub struct ParsedQuery {
pub prefix: Option<ProviderType>,
pub tag_filter: Option<String>,
pub query: String,
}
impl ProviderFilter {
/// Create filter from CLI args and config
pub fn new(
cli_mode: Option<ProviderType>,
cli_providers: Option<Vec<ProviderType>>,
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<ProviderType>) {
#[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<ProviderType> {
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<ProviderType> {
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));
}
}

View File

@@ -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);
}

View File

@@ -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<NotifyUrgency> 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);
}
}

View File

@@ -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<PathBuf> {
dirs::config_dir()
}
/// Get XDG data home: `$XDG_DATA_HOME` or `~/.local/share`
pub fn data_home() -> Option<PathBuf> {
dirs::data_dir()
}
/// Get XDG cache home: `$XDG_CACHE_HOME` or `~/.cache`
#[allow(dead_code)]
pub fn cache_home() -> Option<PathBuf> {
dirs::cache_dir()
}
// =============================================================================
// Owlry-specific directories
// =============================================================================
/// Owlry config directory: `$XDG_CONFIG_HOME/owlry/`
pub fn owlry_config_dir() -> Option<PathBuf> {
config_home().map(|p| p.join(APP_NAME))
}
/// Owlry data directory: `$XDG_DATA_HOME/owlry/`
pub fn owlry_data_dir() -> Option<PathBuf> {
data_home().map(|p| p.join(APP_NAME))
}
/// Owlry cache directory: `$XDG_CACHE_HOME/owlry/`
#[allow(dead_code)]
pub fn owlry_cache_dir() -> Option<PathBuf> {
cache_home().map(|p| p.join(APP_NAME))
}
// =============================================================================
// Config files
// =============================================================================
/// Main config file: `$XDG_CONFIG_HOME/owlry/config.toml`
pub fn config_file() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("config.toml"))
}
/// Custom user stylesheet: `$XDG_CONFIG_HOME/owlry/style.css`
pub fn custom_style_file() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("style.css"))
}
/// User themes directory: `$XDG_CONFIG_HOME/owlry/themes/`
pub fn themes_dir() -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
owlry_config_dir().map(|p| p.join("plugins"))
}
/// Frecency data file: `$XDG_DATA_HOME/owlry/frecency.json`
pub fn frecency_file() -> Option<PathBuf> {
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<PathBuf> {
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"
);
}
}
}

View File

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

View File

@@ -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<String>,
/// Keyboard shortcut hint (optional, e.g., "Ctrl+C")
pub shortcut: Option<String>,
/// 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::<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<String> = config.get("icon").ok();
let shortcut: Option<String> = 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::<Function>("filter") {
action_entry.set("filter", filter)?;
}
action_entry.set("handler", config.get::<Function>("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<Vec<ActionRegistration>> {
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::<String, Table>() {
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<String> = entry.get("icon").ok();
let shortcut: Option<String> = 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<Vec<ActionRegistration>> {
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::<String, Table>() {
let (_, entry) = pair?;
// Check filter if present
if let Ok(filter) = entry.get::<Function>("filter") {
match filter.call::<bool>(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<String> = entry.get("icon").ok();
let shortcut: Option<String> = 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");
}
}

View File

@@ -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<Instant>,
}
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<Mutex<HashMap<String, CacheEntry>>> =
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<u64>)| {
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<serde_json::Value> {
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<serde_json::Value> {
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::<i64, Value>()
.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::<i64, Value>() {
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::<String, Value>() {
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<Value> {
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<String>) = 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));
}
}

View File

@@ -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<Self> {
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<Mutex<HashMap<HookEvent, HookHandlers>>> =
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::<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<i32>)| {
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::<Table>(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<T>(lua: &Lua, event: HookEvent, value: T) -> LuaResult<T>
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::<i64, Table>() {
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::<Value>(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<bool> {
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::<i64, Table>() {
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>(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::<i64, Table>() {
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<String> {
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
}
}

View File

@@ -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<Table>)| {
log::debug!("[plugin] http.get: {}", url);
let timeout_secs = opts
.as_ref()
.and_then(|o| o.get::<u64>("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::<Table>("headers") {
for pair in headers.pairs::<String, String>() {
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<Table>)| {
log::debug!("[plugin] http.post: {}", url);
let timeout_secs = opts
.as_ref()
.and_then(|o| o.get::<u64>("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::<Table>("headers") {
for pair in headers.pairs::<String, String>() {
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<Table>)| {
log::debug!("[plugin] http.get_json: {}", url);
let timeout_secs = opts
.as_ref()
.and_then(|o| o.get::<u64>("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::<Table>("headers") {
for pair in headers.pairs::<String, String>() {
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<String, String> {
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<String> {
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<serde_json::Value> {
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::<i64, Value>()
.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::<i64, Value>() {
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::<String, Value>() {
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<serde_json::Value> {
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<Value> {
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::<u16>("status").unwrap(), 200);
assert!(result.get::<bool>("ok").unwrap());
}
}

View File

@@ -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<f64>, Option<String>)> {
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<usize>)| {
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");
}
}

View File

@@ -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<Vec<ProviderRegistration>> {
provider::get_registrations(lua)
}

View File

@@ -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::<bool>("success").unwrap(), true);
assert_eq!(result.get::<i32>("exit_code").unwrap(), 0);
assert!(result.get::<String>("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::<String>(1).unwrap(), "line1");
assert_eq!(result.get::<String>(2).unwrap(), "line2");
assert_eq!(result.get::<String>(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<String> = 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<String> = 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<String> = chunk.call(()).unwrap();
assert!(home.is_some());
assert!(home.unwrap().starts_with('/'));
}
}

View File

@@ -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<String>,
}
/// 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<String> = config.get("prefix").ok();
// Check for refresh function (static provider) or query function (dynamic)
let has_refresh = config.get::<Function>("refresh").is_ok();
let has_query = config.get::<Function>("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<Vec<ProviderRegistration>> {
let registrations: Table = lua.named_registry_value("provider_registrations")?;
let mut result = Vec::new();
for pair in registrations.pairs::<String, Table>() {
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<String> = config.get("prefix").ok();
let is_static = config.get::<Function>("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<Vec<PluginItem>> {
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<Vec<PluginItem>> {
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<String>,
pub icon: Option<String>,
pub command: Option<String>,
pub terminal: bool,
pub tags: Vec<String>,
/// Custom data passed to action handlers
pub data: Option<String>,
}
/// Extract items from a Lua table returned by refresh/query
fn extract_items(items: &Table) -> LuaResult<Vec<PluginItem>> {
let mut result = Vec::new();
for pair in items.clone().pairs::<i64, Table>() {
let (_, item) = pair?;
let id: String = item.get("id")?;
let name: String = item.get("name")?;
let description: Option<String> = item.get("description").ok();
let icon: Option<String> = item.get("icon").ok();
let command: Option<String> = item.get("command").ok();
let terminal: bool = item.get("terminal").unwrap_or(false);
let data: Option<String> = item.get("data").ok();
// Extract tags array
let tags: Vec<String> = if let Ok(tags_table) = item.get::<Table>("tags") {
tags_table
.pairs::<i64, String>()
.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());
}
}

View File

@@ -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::<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::<String>("css") {
css_str
} else if let Ok(css_file) = config.get::<String>("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::<String, Table>() {
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<Vec<ThemeRegistration>> {
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::<String, Table>() {
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<Option<String>> {
let themes: Table = match lua.named_registry_value("themes") {
Ok(t) => t,
Err(_) => return Ok(None),
};
if let Ok(entry) = themes.get::<Table>(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<String> = Vec::new();
for pair in list.pairs::<i64, String>() {
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);
}
}

View File

@@ -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<String>| {
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<String>| {
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<String> = 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::<serde_json::Value>(&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<serde_json::Value, String> {
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::<Value>(i).is_ok_and(|v| !matches!(v, Value::Nil)));
if is_array {
let arr: Result<Vec<serde_json::Value>, 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::<Value, Value>() {
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<Value> {
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]");
}
}

View File

@@ -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<T> = Result<T, PluginError>;

View File

@@ -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<Lua>,
}
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<Vec<super::ProviderRegistration>> {
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<Vec<super::PluginItem>> {
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<Vec<super::PluginItem>> {
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());
}
}

View File

@@ -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<String, toml::Value>,
}
/// 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<String>,
/// 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<String>,
/// Whether this plugin registers actions
#[serde(default)]
pub actions: bool,
/// Theme names this plugin contributes
#[serde(default)]
pub themes: Vec<String>,
/// Whether this plugin registers hooks
#[serde(default)]
pub hooks: bool,
/// CLI commands this plugin provides
#[serde(default)]
pub commands: Vec<PluginCommand>,
}
/// 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., "<url> [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<String>,
/// Commands the plugin is allowed to run
#[serde(default)]
pub run_commands: Vec<String>,
/// Environment variables the plugin reads
#[serde(default)]
pub environment: Vec<String>,
}
// ============================================================================
// 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<HashMap<String, (PluginManifest, PathBuf)>> {
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<Self> {
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<Self> {
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"));
}
}

View File

@@ -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<RefCell<>> allows sharing with LuaProviders)
plugins: HashMap<String, Rc<RefCell<LoadedPlugin>>>,
/// Plugin IDs that are explicitly disabled
disabled: Vec<String>,
}
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<String>) {
self.disabled = disabled;
}
/// Discover and load all plugins from the plugins directory
pub fn discover(&mut self) -> PluginResult<usize> {
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<PluginError> {
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<Rc<RefCell<LoadedPlugin>>> {
self.plugins.get(id).cloned()
}
/// Get all loaded plugins
#[allow(dead_code)]
pub fn plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
self.plugins.values().cloned()
}
/// Get all enabled plugins
pub fn enabled_plugins(&self) -> impl Iterator<Item = Rc<RefCell<LoadedPlugin>>> + '_ {
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<String> {
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<String> {
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<Box<dyn crate::providers::Provider>> {
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");
}
}

View File

@@ -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<ProviderInfo>,
/// 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<owlry_plugin_api::PluginItem> {
(self.vtable.provider_refresh)(handle).into_iter().collect()
}
/// Query a dynamic provider
pub fn query_provider(
&self,
handle: ProviderHandle,
query: &str,
) -> Vec<owlry_plugin_api::PluginItem> {
(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<String, Arc<NativePlugin>>,
/// Plugin IDs that are disabled
disabled: Vec<String>,
}
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<String>) {
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<usize> {
// 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<NativePlugin> {
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<extern "C" fn() -> &'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<ProviderInfo> = (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<Arc<NativePlugin>> {
self.plugins.get(id).cloned()
}
/// Get all loaded plugins as Arc references
pub fn plugins(&self) -> impl Iterator<Item = Arc<NativePlugin>> + '_ {
self.plugins.values().cloned()
}
/// Get all loaded plugins as a Vec (for passing to create_providers)
pub fn into_plugins(self) -> Vec<Arc<NativePlugin>> {
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<owlry_plugin_api::PluginItem>,
}
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()));
}
}

View File

@@ -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<RegistryPlugin>,
}
/// 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<String>,
/// 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<RegistryIndex, String> {
// 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<RegistryIndex, String> {
// 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<Vec<RegistryPlugin>, 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<Option<RegistryPlugin>, 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<Vec<RegistryPlugin>, 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<String, String> {
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"));
}
}

View File

@@ -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<Lua> {
// 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<String>) -> LuaResult<String> {
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<i64> {
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<mlua::Value> = 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");
}
}

View File

@@ -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<RString>,
}
// 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<ScriptProviderInfo>,
pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
pub query: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>, query: RStr<'_>) -> RVec<PluginItem>,
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<Library>,
/// Runtime vtable
vtable: &'static ScriptRuntimeVTable,
/// Runtime handle (state)
handle: RuntimeHandle,
/// Provider information
providers: Vec<ScriptProviderInfo>,
}
impl LoadedRuntime {
/// Load the Lua runtime from the system directory
pub fn load_lua(plugins_dir: &Path) -> PluginResult<Self> {
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<Self> {
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<extern "C" fn() -> &'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<ScriptProviderInfo> = 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<Box<dyn Provider>> {
self.providers
.iter()
.map(|info| {
let provider = RuntimeProvider::new(
self.name,
self.vtable,
self.handle,
info.clone(),
);
Box::new(provider) as Box<dyn Provider>
})
.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<LaunchItem>,
}
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> {
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();
}
}

View File

@@ -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<LaunchItem>,
}
impl ApplicationProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn get_application_dirs() -> Vec<std::path::PathBuf> {
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<String> = 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<String> = 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"
);
}
}

View File

@@ -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<LaunchItem>,
}
impl CommandProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn get_path_dirs() -> Vec<PathBuf> {
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<String> = 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
}
}

View File

@@ -1,4 +1,4 @@
use super::{LaunchItem, Provider, ProviderType};
use owlry_core::providers::{LaunchItem, Provider, ProviderType};
use log::debug;
use std::io::{self, BufRead};

View File

@@ -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<RefCell<LoadedPlugin>>,
/// Cached items from last refresh
items: Vec<LaunchItem>,
}
impl LuaProvider {
/// Create a new LuaProvider
pub fn new(registration: ProviderRegistration, plugin: Rc<RefCell<LoadedPlugin>>) -> 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<RefCell<>>, 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<RefCell<LoadedPlugin>>,
) -> Vec<Box<dyn Provider>> {
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<dyn Provider>
})
.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");
}
}

View File

@@ -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<String>,
pub icon: Option<String>,
pub provider: ProviderType,
pub command: String,
pub terminal: bool,
/// Tags/categories for filtering (e.g., from .desktop Categories)
pub tags: Vec<String>,
}
/// 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<Self, Self::Err> {
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<Box<dyn Provider>>,
/// Static native plugin providers (need query() for submenu support)
static_native_providers: Vec<NativeProvider>,
/// Dynamic providers from native plugins (calculator, websearch, filesearch)
/// These are queried per-keystroke, not cached
dynamic_providers: Vec<NativeProvider>,
/// Widget providers from native plugins (weather, media, pomodoro)
/// These appear at the top of results
widget_providers: Vec<NativeProvider>,
/// 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<NativeProvider>) -> 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<dyn Provider>) {
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<Box<dyn Provider>>) {
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<Item = &LaunchItem> {
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<ProviderType> {
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<LaunchItem> {
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<Item = &str> {
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<LaunchItem>)> {
// 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
}
}

View File

@@ -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<NativePlugin>,
/// Provider metadata
info: ProviderInfo,
/// Handle to the provider state in the plugin
handle: ProviderHandle,
/// Cached items (for static providers)
items: RwLock<Vec<LaunchItem>>,
}
impl NativeProvider {
/// Create a new native provider
pub fn new(plugin: Arc<NativePlugin>, 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<LaunchItem> {
// 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<LaunchItem> = 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");
}
}

View File

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

View File

@@ -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<String> = 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));
}

View File

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

View File

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