diff --git a/docs/superpowers/plans/2026-03-28-builtin-providers.md b/docs/superpowers/plans/2026-03-28-builtin-providers.md new file mode 100644 index 0000000..f101ed5 --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-builtin-providers.md @@ -0,0 +1,1225 @@ +# Built-in Providers Migration Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move calculator, converter, and system from external `.so` plugins to native providers compiled into `owlry-core`. Retire 3 plugin AUR packages (transitional) and 4 meta AUR packages (already deleted). Update READMEs. + +**Architecture:** Add a `DynamicProvider` trait to owlry-core for built-in providers that produce results per-keystroke (calculator, converter). System uses the existing `Provider` trait (static list). All 3 are registered in `ProviderManager::new_with_config()` gated by config toggles. Conflict detection skips native `.so` plugins when a built-in provider with the same type_id exists. + +**Tech Stack:** Rust 1.90+, owlry-core, meval (math), reqwest (currency HTTP), serde + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `crates/owlry-core/Cargo.toml` | Modify | Add/move `meval`, `reqwest` to required deps | +| `crates/owlry-core/src/providers/mod.rs` | Modify | Add `DynamicProvider` trait, `builtin_dynamic` field, iterate in search, register providers, conflict detection | +| `crates/owlry-core/src/providers/calculator.rs` | Create | Calculator provider (port from plugin) | +| `crates/owlry-core/src/providers/system.rs` | Create | System provider (port from plugin) | +| `crates/owlry-core/src/providers/converter/mod.rs` | Create | Converter provider entry (port from plugin) | +| `crates/owlry-core/src/providers/converter/parser.rs` | Create | Query parsing | +| `crates/owlry-core/src/providers/converter/units.rs` | Create | Unit definitions + conversion | +| `crates/owlry-core/src/providers/converter/currency.rs` | Create | ECB rate fetching + currency conversion | +| `crates/owlry-core/src/config/mod.rs` | Modify | Add `converter` toggle | +| `crates/owlry-core/src/lib.rs` | Modify | Re-export new provider modules if needed | +| `README.md` | Modify | Update package tables, remove meta section | + +**Plugins repo (separate commits):** + +| File | Action | Responsibility | +|------|--------|----------------| +| `crates/owlry-plugin-calculator/` | Remove | Retired — built into core | +| `crates/owlry-plugin-converter/` | Remove | Retired — built into core | +| `crates/owlry-plugin-system/` | Remove | Retired — built into core | +| `aur/owlry-plugin-calculator/` | Modify | Transitional PKGBUILD | +| `aur/owlry-plugin-converter/` | Modify | Transitional PKGBUILD | +| `aur/owlry-plugin-system/` | Modify | Transitional PKGBUILD | +| `README.md` | Modify | Remove retired plugins | + +--- + +### Task 1: Add dependencies and DynamicProvider trait + +**Files:** +- Modify: `crates/owlry-core/Cargo.toml` +- Modify: `crates/owlry-core/src/providers/mod.rs` + +- [ ] **Step 1: Update Cargo.toml — move meval and reqwest to required deps** + +In `crates/owlry-core/Cargo.toml`, add to the `[dependencies]` section (NOT in optional): + +```toml +# Built-in provider deps +meval = "0.2" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "blocking"] } +``` + +Remove `meval` and `reqwest` from the `[features]` section's `lua` feature list. The `lua` feature should become: + +```toml +[features] +default = [] +lua = ["dep:mlua"] +dev-logging = [] +``` + +Remove the `meval = { ... optional = true }` and `reqwest = { ... optional = true }` lines from `[dependencies]` since we're replacing them with required versions. + +- [ ] **Step 2: Add DynamicProvider trait to providers/mod.rs** + +In `crates/owlry-core/src/providers/mod.rs`, add after the `Provider` trait definition (after line 105): + +```rust +/// Trait for built-in providers that produce results per-keystroke. +/// Unlike static `Provider`s which cache items via `refresh()`/`items()`, +/// dynamic providers generate results on every query. +pub(crate) trait DynamicProvider: Send + Sync { + fn name(&self) -> &str; + fn provider_type(&self) -> ProviderType; + fn query(&self, query: &str) -> Vec; + fn priority(&self) -> u32; +} +``` + +- [ ] **Step 3: Add builtin_dynamic field to ProviderManager** + +In the `ProviderManager` struct definition, add after the `providers` field: + +```rust + /// Built-in dynamic providers (calculator, converter) + /// These are queried per-keystroke, like native dynamic plugins + builtin_dynamic: Vec>, +``` + +- [ ] **Step 4: Initialize the new field in ProviderManager::new()** + +In `ProviderManager::new()`, add `builtin_dynamic: Vec::new(),` to the struct initialization. + +- [ ] **Step 5: Iterate builtin_dynamic in search_with_frecency** + +In the `search_with_frecency` method, inside the `if !query.is_empty()` block, after the existing `for provider in &self.dynamic_providers` loop, add: + +```rust + // Built-in dynamic providers (calculator, converter) + for provider in &self.builtin_dynamic { + if !filter.is_active(provider.provider_type()) { + continue; + } + let dynamic_results = provider.query(query); + let base_score = provider.priority() as i64; + + let grouping_bonus: i64 = match provider.provider_type() { + ProviderType::Plugin(ref id) + if matches!(id.as_str(), "calc" | "conv") => + { + 10_000 + } + _ => 0, + }; + + for (idx, item) in dynamic_results.into_iter().enumerate() { + results.push((item, base_score + grouping_bonus - idx as i64)); + } + } +``` + +- [ ] **Step 6: Verify it compiles** + +Run: `cargo check -p owlry-core` + +Expected: Compiles (with warnings about unused fields/imports — that's fine, providers aren't registered yet). + +- [ ] **Step 7: Commit** + +```bash +git add crates/owlry-core/Cargo.toml crates/owlry-core/src/providers/mod.rs +git commit -m "feat(core): add DynamicProvider trait and builtin_dynamic support + +Foundation for built-in calculator, converter, and system providers. +DynamicProvider trait for per-keystroke providers. ProviderManager +iterates builtin_dynamic alongside native dynamic plugins in search." +``` + +--- + +### Task 2: Port calculator provider + +**Files:** +- Create: `crates/owlry-core/src/providers/calculator.rs` +- Modify: `crates/owlry-core/src/providers/mod.rs` (add `mod calculator;`) + +The calculator plugin (`owlry-plugins/crates/owlry-plugin-calculator/src/lib.rs`) evaluates math expressions. It supports `= expr` and `calc expr` prefix triggers, plus auto-detection of math-like input. Port it to implement `DynamicProvider`. + +- [ ] **Step 1: Add module declaration** + +In `crates/owlry-core/src/providers/mod.rs`, add with the other module declarations at the top: + +```rust +pub(crate) mod calculator; +``` + +- [ ] **Step 2: Write the calculator provider with tests** + +Create `crates/owlry-core/src/providers/calculator.rs`: + +```rust +//! Built-in calculator provider. +//! +//! Evaluates math expressions and returns the result. +//! Supports `= expr` and `calc expr` prefix triggers, plus auto-detection. + +use super::{DynamicProvider, LaunchItem, ProviderType}; + +const PROVIDER_TYPE_ID: &str = "calc"; + +pub struct CalculatorProvider; + +impl CalculatorProvider { + pub fn new() -> Self { + Self + } +} + +impl DynamicProvider for CalculatorProvider { + fn name(&self) -> &str { + "Calculator" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(PROVIDER_TYPE_ID.into()) + } + + fn query(&self, query: &str) -> Vec { + let input = query.trim(); + + // Strip trigger prefixes + let expr = if let Some(rest) = input.strip_prefix('=') { + rest.trim() + } else if let Some(rest) = input.strip_prefix("calc ") { + rest.trim() + } else if let Some(rest) = input.strip_prefix("calc\t") { + rest.trim() + } else if looks_like_math(input) { + input + } else { + return Vec::new(); + }; + + if expr.is_empty() { + return Vec::new(); + } + + match meval::eval_str(expr) { + Ok(result) if result.is_finite() => { + let display = format_result(result); + let description = format!("= {}", expr); + let copy_cmd = format!("printf '%s' '{}' | wl-copy", display); + + vec![LaunchItem { + id: format!("calc:{}", expr), + name: display, + description: Some(description), + icon: Some("accessories-calculator".into()), + provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), + command: copy_cmd, + terminal: false, + tags: vec!["math".into(), "calculator".into()], + }] + } + _ => Vec::new(), + } + } + + fn priority(&self) -> u32 { + 10_000 + } +} + +/// Check if input looks like a math expression (heuristic for auto-detect) +fn looks_like_math(input: &str) -> bool { + if input.is_empty() || input.len() < 2 { + return false; + } + // Must contain at least one operator + let has_operator = input.chars().any(|c| matches!(c, '+' | '-' | '*' | '/' | '^' | '%')); + if !has_operator && !input.contains("sqrt") && !input.contains("sin") && !input.contains("cos") && !input.contains("tan") && !input.contains("log") && !input.contains("ln") && !input.contains("abs") { + return false; + } + // First char should be a digit, minus, open-paren, or function name + let first = input.chars().next().unwrap(); + first.is_ascii_digit() || first == '-' || first == '(' || first == '.' + || input.starts_with("sqrt") || input.starts_with("sin") + || input.starts_with("cos") || input.starts_with("tan") + || input.starts_with("log") || input.starts_with("ln") + || input.starts_with("abs") || input.starts_with("pi") + || input.starts_with("e ") +} + +fn format_result(n: f64) -> String { + if n.fract() == 0.0 && n.abs() < 1e15 { + let i = n as i64; + if i.abs() >= 1000 { + format_with_separators(i) + } else { + format!("{}", i) + } + } else { + format!("{:.10}", n) + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } +} + +fn format_with_separators(n: i64) -> String { + let s = n.abs().to_string(); + let mut result = String::new(); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(c); + } + if n < 0 { + result.push('-'); + } + result.chars().rev().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn query(input: &str) -> Vec { + CalculatorProvider::new().query(input) + } + + #[test] + fn test_prefix_equals() { + let r = query("= 5+3"); + assert_eq!(r.len(), 1); + assert_eq!(r[0].name, "8"); + } + + #[test] + fn test_prefix_calc() { + let r = query("calc 10*2"); + assert_eq!(r.len(), 1); + assert_eq!(r[0].name, "20"); + } + + #[test] + fn test_auto_detect() { + let r = query("5+3"); + assert_eq!(r.len(), 1); + assert_eq!(r[0].name, "8"); + } + + #[test] + fn test_complex_expression() { + let r = query("= sqrt(16) + 2^3"); + assert_eq!(r.len(), 1); + assert_eq!(r[0].name, "12"); + } + + #[test] + fn test_decimal_result() { + let r = query("= 10/3"); + assert_eq!(r.len(), 1); + assert!(r[0].name.starts_with("3.333")); + } + + #[test] + fn test_large_number_separators() { + let r = query("= 1000000"); + assert_eq!(r.len(), 1); + assert_eq!(r[0].name, "1,000,000"); + } + + #[test] + fn test_invalid_expression_returns_empty() { + assert!(query("= hello world").is_empty()); + } + + #[test] + fn test_plain_text_returns_empty() { + assert!(query("firefox").is_empty()); + } + + #[test] + fn test_provider_type() { + let p = CalculatorProvider::new(); + assert_eq!(p.provider_type(), ProviderType::Plugin("calc".into())); + } + + #[test] + fn test_description_shows_expression() { + let r = query("= 2+2"); + assert_eq!(r[0].description.as_deref(), Some("= 2+2")); + } + + #[test] + fn test_copy_command() { + let r = query("= 42"); + assert!(r[0].command.contains("42")); + assert!(r[0].command.contains("wl-copy")); + } +} +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p owlry-core calculator` + +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/owlry-core/src/providers/calculator.rs crates/owlry-core/src/providers/mod.rs +git commit -m "feat(core): add built-in calculator provider + +Port from owlry-plugin-calculator. Implements DynamicProvider trait. +Supports = prefix, calc prefix, and auto-detection of math expressions. +Uses meval for expression evaluation." +``` + +--- + +### Task 3: Port system provider + +**Files:** +- Create: `crates/owlry-core/src/providers/system.rs` +- Modify: `crates/owlry-core/src/providers/mod.rs` (add `mod system;`) + +System is a static provider — it returns a fixed list of actions (shutdown, reboot, etc.) via `refresh()`/`items()`. Implements the existing `Provider` trait. + +- [ ] **Step 1: Add module declaration** + +In `crates/owlry-core/src/providers/mod.rs`: + +```rust +pub(crate) mod system; +``` + +- [ ] **Step 2: Write the system provider with tests** + +Create `crates/owlry-core/src/providers/system.rs`: + +```rust +//! Built-in system provider. +//! +//! Provides power and session management actions: shutdown, reboot, suspend, +//! hibernate, lock screen, and log out. + +use super::{LaunchItem, Provider, ProviderType}; + +const PROVIDER_TYPE_ID: &str = "sys"; +const PROVIDER_ICON: &str = "system-shutdown"; + +struct SystemAction { + id: &'static str, + name: &'static str, + description: &'static str, + icon: &'static str, + command: &'static str, +} + +const ACTIONS: &[SystemAction] = &[ + SystemAction { + id: "shutdown", + name: "Shutdown", + description: "Power off the system", + icon: "system-shutdown", + command: "systemctl poweroff", + }, + SystemAction { + id: "reboot", + name: "Reboot", + description: "Restart the system", + icon: "system-reboot", + command: "systemctl reboot", + }, + SystemAction { + id: "reboot-bios", + name: "Reboot to BIOS", + description: "Restart into firmware setup", + icon: "system-reboot", + command: "systemctl reboot --firmware-setup", + }, + SystemAction { + id: "suspend", + name: "Suspend", + description: "Suspend the system to RAM", + icon: "system-suspend", + command: "systemctl suspend", + }, + SystemAction { + id: "hibernate", + name: "Hibernate", + description: "Hibernate the system to disk", + icon: "system-hibernate", + command: "systemctl hibernate", + }, + SystemAction { + id: "lock", + name: "Lock Screen", + description: "Lock the current session", + icon: "system-lock-screen", + command: "loginctl lock-session", + }, + SystemAction { + id: "logout", + name: "Log Out", + description: "End the current session", + icon: "system-log-out", + command: "loginctl terminate-session self", + }, +]; + +pub struct SystemProvider { + items: Vec, +} + +impl SystemProvider { + pub fn new() -> Self { + let items = ACTIONS + .iter() + .map(|a| LaunchItem { + id: format!("sys:{}", a.id), + name: a.name.into(), + description: Some(a.description.into()), + icon: Some(a.icon.into()), + provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), + command: a.command.into(), + terminal: false, + tags: vec!["system".into()], + }) + .collect(); + + Self { items } + } +} + +impl Provider for SystemProvider { + fn name(&self) -> &str { + "System" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(PROVIDER_TYPE_ID.into()) + } + + fn refresh(&mut self) { + // Static list — nothing to refresh + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_has_all_actions() { + let p = SystemProvider::new(); + assert_eq!(p.items().len(), 7); + } + + #[test] + fn test_action_names() { + let p = SystemProvider::new(); + let names: Vec<&str> = p.items().iter().map(|i| i.name.as_str()).collect(); + assert!(names.contains(&"Shutdown")); + assert!(names.contains(&"Reboot")); + assert!(names.contains(&"Lock Screen")); + assert!(names.contains(&"Log Out")); + } + + #[test] + fn test_provider_type() { + let p = SystemProvider::new(); + assert_eq!(p.provider_type(), ProviderType::Plugin("sys".into())); + } + + #[test] + fn test_shutdown_command() { + let p = SystemProvider::new(); + let shutdown = p.items().iter().find(|i| i.id == "sys:shutdown").unwrap(); + assert_eq!(shutdown.command, "systemctl poweroff"); + } + + #[test] + fn test_all_items_have_system_tag() { + let p = SystemProvider::new(); + for item in p.items() { + assert!(item.tags.contains(&"system".into())); + } + } +} +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p owlry-core system` + +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/owlry-core/src/providers/system.rs crates/owlry-core/src/providers/mod.rs +git commit -m "feat(core): add built-in system provider + +Port from owlry-plugin-system. Static provider with 7 actions: +shutdown, reboot, reboot-to-BIOS, suspend, hibernate, lock, logout." +``` + +--- + +### Task 4: Port converter provider + +**Files:** +- Create: `crates/owlry-core/src/providers/converter/mod.rs` +- Create: `crates/owlry-core/src/providers/converter/parser.rs` +- Create: `crates/owlry-core/src/providers/converter/units.rs` +- Create: `crates/owlry-core/src/providers/converter/currency.rs` +- Modify: `crates/owlry-core/src/providers/mod.rs` (add `mod converter;`) + +This is the largest port — 4 files, ~1700 lines. The logic is identical to the plugin; the port removes FFI types (`RString`, `RVec`, `PluginItem`) and uses `LaunchItem` directly. + +- [ ] **Step 1: Add module declaration** + +In `crates/owlry-core/src/providers/mod.rs`: + +```rust +pub(crate) mod converter; +``` + +- [ ] **Step 2: Create converter/parser.rs** + +Create `crates/owlry-core/src/providers/converter/parser.rs`. This is a direct copy from `owlry-plugins/crates/owlry-plugin-converter/src/parser.rs` with `crate::` import paths changed to `super::`: + +Copy the complete file from `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins/crates/owlry-plugin-converter/src/parser.rs`, changing: +- `use crate::units;` → `use super::units;` + +No other changes — the parser has no FFI types. + +- [ ] **Step 3: Create converter/units.rs** + +Copy the complete file from `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins/crates/owlry-plugin-converter/src/units.rs`, changing: +- `use crate::currency;` → `use super::currency;` +- `crate::format_with_separators` → `super::format_with_separators` + +No other changes — units.rs has no FFI types. + +- [ ] **Step 4: Create converter/currency.rs** + +Copy the complete file from `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins/crates/owlry-plugin-converter/src/currency.rs`. + +No import changes needed — currency.rs uses only `std`, `serde`, `reqwest`, and `dirs`. + +- [ ] **Step 5: Create converter/mod.rs** + +Create `crates/owlry-core/src/providers/converter/mod.rs`. This replaces the plugin's `lib.rs`, removing all FFI types and implementing `DynamicProvider`: + +```rust +//! Built-in converter provider. +//! +//! Converts between units and currencies. +//! Supports `> expr` prefix or auto-detection. + +pub(crate) mod currency; +pub(crate) mod parser; +pub(crate) mod units; + +use super::{DynamicProvider, LaunchItem, ProviderType}; + +const PROVIDER_TYPE_ID: &str = "conv"; +const PROVIDER_ICON: &str = "edit-find-replace-symbolic"; + +pub struct ConverterProvider; + +impl ConverterProvider { + pub fn new() -> Self { + Self + } +} + +impl DynamicProvider for ConverterProvider { + fn name(&self) -> &str { + "Converter" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(PROVIDER_TYPE_ID.into()) + } + + fn query(&self, query: &str) -> Vec { + let query_str = query.trim(); + let input = if let Some(rest) = query_str.strip_prefix('>') { + rest.trim() + } else { + query_str + }; + + let parsed = match parser::parse_conversion(input) { + Some(p) => p, + None => return Vec::new(), + }; + + let results = if let Some(ref target) = parsed.target_unit { + units::convert_to(&parsed.value, &parsed.from_unit, target) + .into_iter() + .collect() + } else { + units::convert_common(&parsed.value, &parsed.from_unit) + }; + + results + .into_iter() + .map(|r| { + LaunchItem { + id: format!("conv:{}:{}:{}", parsed.from_unit, r.target_symbol, r.value), + name: r.display_value.clone(), + description: Some(format!( + "{} {} = {}", + format_number(parsed.value), + parsed.from_symbol, + r.display_value, + )), + icon: Some(PROVIDER_ICON.into()), + provider: ProviderType::Plugin(PROVIDER_TYPE_ID.into()), + command: format!("printf '%s' '{}' | wl-copy", r.raw_value.replace('\'', "'\\''")), + terminal: false, + tags: vec![], + } + }) + .collect() + } + + fn priority(&self) -> u32 { + 9_000 + } +} + +fn format_number(n: f64) -> String { + if n.fract() == 0.0 && n.abs() < 1e15 { + let i = n as i64; + if i.abs() >= 1000 { + format_with_separators(i) + } else { + format!("{}", i) + } + } else { + format!("{:.4}", n) + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } +} + +pub(crate) fn format_with_separators(n: i64) -> String { + let s = n.abs().to_string(); + let mut result = String::new(); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(c); + } + if n < 0 { + result.push('-'); + } + result.chars().rev().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn query(input: &str) -> Vec { + ConverterProvider::new().query(input) + } + + #[test] + fn test_prefix_trigger() { + let r = query("> 100 km to mi"); + assert!(!r.is_empty()); + } + + #[test] + fn test_auto_detect() { + let r = query("100 km to mi"); + assert!(!r.is_empty()); + } + + #[test] + fn test_no_target_returns_common() { + let r = query("> 100 km"); + assert!(r.len() > 1); + } + + #[test] + fn test_temperature() { + let r = query("102F to C"); + assert!(!r.is_empty()); + } + + #[test] + fn test_nonsense_returns_empty() { + assert!(query("hello world").is_empty()); + } + + #[test] + fn test_provider_type() { + let p = ConverterProvider::new(); + assert_eq!(p.provider_type(), ProviderType::Plugin("conv".into())); + } + + #[test] + fn test_description_no_double_unit() { + let r = query("100 km to mi"); + if let Some(item) = r.first() { + let desc = item.description.as_deref().unwrap(); + // Should be "100 km = 62.1371 mi", not "100 km = 62.1371 mi mi" + assert!(!desc.ends_with(" mi mi"), "double unit in: {}", desc); + } + } + + #[test] + fn test_copy_command() { + let r = query("= 100 km to mi"); + if let Some(item) = r.first() { + assert!(item.command.contains("wl-copy")); + } + } +} +``` + +- [ ] **Step 6: Run tests** + +Run: `cargo test -p owlry-core converter` + +Expected: All converter tests pass (currency tests may skip if network unavailable). + +- [ ] **Step 7: Run all tests** + +Run: `cargo test -p owlry-core --lib` + +Expected: All tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add crates/owlry-core/src/providers/converter/ +git add crates/owlry-core/src/providers/mod.rs +git commit -m "feat(core): add built-in converter provider + +Port from owlry-plugin-converter. Implements DynamicProvider trait. +Supports unit conversion (10 categories, 75+ units) and currency +conversion via ECB API with 24-hour file cache." +``` + +--- + +### Task 5: Register providers and add config toggle + +**Files:** +- Modify: `crates/owlry-core/src/config/mod.rs` +- Modify: `crates/owlry-core/src/providers/mod.rs` + +- [ ] **Step 1: Add converter config toggle** + +In `crates/owlry-core/src/config/mod.rs`, add to the `ProvidersConfig` struct after the `calculator` field: + +```rust + /// Enable converter provider (> expression or auto-detect) + #[serde(default = "default_true")] + pub converter: bool, +``` + +Add `converter: true,` to the `Default` impl for `ProvidersConfig`. + +- [ ] **Step 2: Register built-in providers in new_with_config** + +In `crates/owlry-core/src/providers/mod.rs`, in `new_with_config()`, after creating the `core_providers` vec and before the native plugin loader section, add: + +```rust + // Built-in dynamic providers + let mut builtin_dynamic: Vec> = Vec::new(); + + if config.providers.calculator { + builtin_dynamic.push(Box::new(calculator::CalculatorProvider::new())); + info!("Registered built-in calculator provider"); + } + + if config.providers.converter { + builtin_dynamic.push(Box::new(converter::ConverterProvider::new())); + info!("Registered built-in converter provider"); + } + + // Built-in static providers + if config.providers.system { + core_providers.push(Box::new(system::SystemProvider::new())); + info!("Registered built-in system provider"); + } +``` + +Then pass `builtin_dynamic` into the `ProviderManager` construction. In `ProviderManager::new()`, accept it as a parameter: + +Change `pub fn new(core_providers: Vec>, native_providers: Vec) -> Self` to: + +```rust +pub fn new( + core_providers: Vec>, + native_providers: Vec, + builtin_dynamic: Vec>, +) -> Self +``` + +And set the field: `builtin_dynamic,` in the struct initialization. + +Update all call sites of `ProviderManager::new()`: +- In `new_with_config()`: pass the `builtin_dynamic` vec +- In `app.rs` (owlry crate, dmenu mode): pass `Vec::new()` as the third argument +- In `app.rs` (local fallback): pass `Vec::new()` as the third argument + +- [ ] **Step 3: Verify it compiles and tests pass** + +Run: `cargo check --workspace && cargo test -p owlry-core --lib` + +Expected: Clean compile, all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/owlry-core/src/config/mod.rs crates/owlry-core/src/providers/mod.rs crates/owlry/src/app.rs +git commit -m "feat(core): register built-in providers in ProviderManager + +Calculator and converter registered as built-in dynamic providers. +System registered as built-in static provider. All gated by config +toggles (calculator, converter, system — default true)." +``` + +--- + +### Task 6: Add conflict detection + +**Files:** +- Modify: `crates/owlry-core/src/providers/mod.rs` + +When users upgrade to the new owlry-core but still have the old `.so` plugins installed, both the built-in and native plugin would produce duplicate results. Skip native plugins whose type_id matches a built-in. + +- [ ] **Step 1: Write test** + +Add to the tests in `crates/owlry-core/src/providers/mod.rs`: + +```rust + #[test] + fn test_builtin_type_ids() { + let pm = ProviderManager::new(vec![], vec![], vec![ + Box::new(calculator::CalculatorProvider::new()), + Box::new(converter::ConverterProvider::new()), + ]); + let ids = pm.builtin_type_ids(); + assert!(ids.contains("calc")); + assert!(ids.contains("conv")); + } +``` + +- [ ] **Step 2: Implement builtin_type_ids and conflict detection** + +Add a helper method to `ProviderManager`: + +```rust + /// Get type IDs of built-in dynamic providers (for conflict detection) + fn builtin_type_ids(&self) -> std::collections::HashSet { + let mut ids: std::collections::HashSet = self + .builtin_dynamic + .iter() + .filter_map(|p| match p.provider_type() { + ProviderType::Plugin(id) => Some(id), + _ => None, + }) + .collect(); + // Also include built-in static providers (system) + for p in &self.providers { + if let ProviderType::Plugin(id) = p.provider_type() { + ids.insert(id); + } + } + ids + } +``` + +In `new_with_config()`, after creating the `ProviderManager` with `Self::new(...)`, add conflict detection before the native plugin classification loop. In the loop that classifies native providers (around the `for provider in native_providers` block), add a skip check: + +```rust + let builtin_ids = manager.builtin_type_ids(); +``` + +Then inside the loop, before the `if provider.is_dynamic()` check: + +```rust + // Skip native plugins that conflict with built-in providers + if builtin_ids.contains(&type_id) { + info!( + "Skipping native plugin '{}' — built-in provider exists", + type_id + ); + continue; + } +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p owlry-core --lib` + +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/owlry-core/src/providers/mod.rs +git commit -m "feat(core): skip native plugins that conflict with built-in providers + +When users upgrade owlry-core but still have old .so plugins installed, +the conflict detection skips the native plugin to prevent duplicate +results." +``` + +--- + +### Task 7: Remove meta package AUR dirs from main repo + +**Files:** +- Remove: `aur/owlry-meta-essentials/` +- Remove: `aur/owlry-meta-full/` +- Remove: `aur/owlry-meta-tools/` +- Remove: `aur/owlry-meta-widgets/` + +- [ ] **Step 1: Remove meta package directories** + +The meta packages have already been deleted from AUR. Remove the local directories: + +```bash +# Hide .git dirs, remove from git tracking, restore .git dirs +for pkg in owlry-meta-essentials owlry-meta-full owlry-meta-tools owlry-meta-widgets; do + dir="aur/$pkg" + if [ -d "$dir/.git" ]; then + mv "$dir/.git" "$dir/.git.bak" + git rm -r "$dir" + # Don't restore .git — the dir is gone from tracking + # But keep the local .git.bak in case we need the AUR repo history + fi +done +``` + +Actually, since the AUR packages are already deleted, just remove the directories entirely: + +```bash +for pkg in owlry-meta-essentials owlry-meta-full owlry-meta-tools owlry-meta-widgets; do + rm -rf "aur/$pkg" +done +git add -A aur/ +``` + +- [ ] **Step 2: Commit** + +```bash +git commit -m "chore: remove retired meta package AUR dirs + +owlry-meta-essentials, owlry-meta-full, owlry-meta-tools, and +owlry-meta-widgets have been deleted from AUR. Remove local dirs." +``` + +--- + +### Task 8: Update main repo README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Update package tables and install instructions** + +In the README: + +1. In the AUR install section, remove the meta package lines and update: +```bash +# Core (includes calculator, converter, system actions) +yay -S owlry + +# Add individual plugins +yay -S owlry-plugin-bookmarks owlry-plugin-weather +``` + +2. In the "Core packages" table, update owlry-core description: +``` +| `owlry-core` | Headless daemon with built-in calculator, converter, and system providers | +``` + +3. Remove the "Meta bundles" table entirely. + +4. Remove calculator, converter, and system from the "Plugin packages" table. + +5. Update the plugin count in the Features section from "14 native plugins" to "11 plugin packages" or similar. + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: update README for built-in providers migration + +Calculator, converter, and system are now built into owlry-core. +Remove meta package references. Update install instructions." +``` + +--- + +### Task 9: Remove retired plugins from plugins repo + +**Files (in owlry-plugins repo):** +- Remove: `crates/owlry-plugin-calculator/` +- Remove: `crates/owlry-plugin-converter/` +- Remove: `crates/owlry-plugin-system/` +- Modify: `Cargo.toml` (workspace members) +- Modify: `Cargo.lock` + +- [ ] **Step 1: Remove crate directories and update workspace** + +```bash +rm -rf crates/owlry-plugin-calculator crates/owlry-plugin-converter crates/owlry-plugin-system +``` + +Edit the workspace `Cargo.toml` to remove the three members from the `[workspace] members` list. + +Run: `cargo check --workspace` to verify remaining plugins still build. + +- [ ] **Step 2: Commit** + +```bash +git add -A +git commit -m "chore: remove calculator, converter, system plugins + +These providers are now built into owlry-core. The plugins are +retired — transitional AUR packages redirect to owlry-core." +``` + +- [ ] **Step 3: Push** + +```bash +git push +``` + +--- + +### Task 10: Update plugins repo README + +**Files (in owlry-plugins repo):** +- Modify: `README.md` + +- [ ] **Step 1: Update plugin listing** + +Remove calculator, converter, and system from the plugin listing. Add a note: + +```markdown +> **Note:** Calculator, converter, and system actions are built into `owlry-core` as of v1.2.0. +> They no longer need to be installed separately. +``` + +Update the plugin count. + +- [ ] **Step 2: Commit and push** + +```bash +git add README.md +git commit -m "docs: update README — calculator, converter, system moved to core" +git push +``` + +--- + +### Task 11: Transitional AUR packages for retired plugins + +**Files (in owlry-plugins repo):** +- Modify: `aur/owlry-plugin-calculator/PKGBUILD` +- Modify: `aur/owlry-plugin-converter/PKGBUILD` +- Modify: `aur/owlry-plugin-system/PKGBUILD` + +- [ ] **Step 1: Create transitional PKGBUILDs** + +For each of the 3 retired plugins, replace the PKGBUILD with a transitional package. Example for calculator (repeat for converter and system): + +```bash +# Maintainer: vikingowl +pkgname=owlry-plugin-calculator +pkgver=1.0.1 +pkgrel=99 +pkgdesc="Transitional package — calculator is now built into owlry-core" +arch=('any') +url="https://somegit.dev/Owlibou/owlry" +license=('GPL-3.0-or-later') +depends=('owlry-core') +replaces=('owlry-plugin-calculator') +``` + +No `source`, `prepare()`, `build()`, `check()`, or `package()` functions. The `pkgrel=99` ensures this version is higher than any previous release. + +Regenerate `.SRCINFO` for each: + +```bash +cd aur/owlry-plugin-calculator && makepkg --printsrcinfo > .SRCINFO +cd ../owlry-plugin-converter && makepkg --printsrcinfo > .SRCINFO +cd ../owlry-plugin-system && makepkg --printsrcinfo > .SRCINFO +``` + +- [ ] **Step 2: Commit transitional PKGBUILDs to plugins repo** + +```bash +# Stage using the .git workaround +for pkg in owlry-plugin-calculator owlry-plugin-converter owlry-plugin-system; do + just aur-stage "$pkg" +done +git commit -m "chore(aur): transitional packages for retired plugins" +git push +``` + +- [ ] **Step 3: Publish to AUR** + +```bash +for pkg in owlry-plugin-calculator owlry-plugin-converter owlry-plugin-system; do + just aur-publish-pkg "$pkg" +done +``` + +--- + +### Task 12: Tag and deploy owlry-core to AUR + +- [ ] **Step 1: Bump, tag, and deploy** + +```bash +just release-crate owlry-core +``` + +This runs the full pipeline: bump → push → tag → AUR update → publish. + +--- + +## Execution Notes + +### Task dependency order + +Tasks 1-6 are sequential (each builds on the previous). +Task 7 is independent (can be done anytime). +Task 8 depends on Tasks 1-6 (README reflects built-in providers). +Tasks 9-11 are in the plugins repo and depend on Task 12 (need the new owlry-core version for transitional package `depends`). +Task 12 depends on Tasks 1-6. + +**Recommended order:** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 12 → 9 → 10 → 11