Files
owlry/crates/owlry-plugin-websearch/src/lib.rs
vikingowl 384dd016a0 feat: convert to workspace with native plugin architecture
BREAKING: Restructure from monolithic binary to modular plugin ecosystem

Architecture changes:
- Convert to Cargo workspace with crates/ directory
- Create owlry-plugin-api crate with ABI-stable interface (abi_stable)
- Move core binary to crates/owlry/
- Extract providers to native plugin crates (13 plugins)
- Add owlry-lua crate for Lua plugin runtime

Plugin system:
- Plugins loaded from /usr/lib/owlry/plugins/*.so
- Widget providers refresh automatically (universal, not hardcoded)
- Per-plugin config via [plugins.<name>] sections in config.toml
- Backwards compatible with [providers] config format

New features:
- just install-local: build and install core + all plugins
- Plugin config: weather and pomodoro read from [plugins.*]
- HostAPI for plugins: notifications, logging

Documentation:
- Update README with new package structure
- Add docs/PLUGINS.md with all plugin documentation
- Add docs/PLUGIN_DEVELOPMENT.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 03:01:37 +01:00

297 lines
9.4 KiB
Rust

//! Web Search Plugin for Owlry
//!
//! A dynamic provider that opens web searches in the browser.
//! Supports multiple search engines.
//!
//! Examples:
//! - `? rust programming` → Search DuckDuckGo for "rust programming"
//! - `web rust docs` → Search for "rust docs"
//! - `search how to rust` → Search for "how to rust"
use abi_stable::std_types::{ROption, RStr, RString, RVec};
use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
// Plugin metadata
const PLUGIN_ID: &str = "websearch";
const PLUGIN_NAME: &str = "Web Search";
const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
const PLUGIN_DESCRIPTION: &str = "Search the web with configurable search engines";
// Provider metadata
const PROVIDER_ID: &str = "websearch";
const PROVIDER_NAME: &str = "Web Search";
const PROVIDER_PREFIX: &str = "?";
const PROVIDER_ICON: &str = "web-browser";
const PROVIDER_TYPE_ID: &str = "websearch";
/// Common search engine URL templates
/// {query} is replaced with the URL-encoded search term
const SEARCH_ENGINES: &[(&str, &str)] = &[
("google", "https://www.google.com/search?q={query}"),
("duckduckgo", "https://duckduckgo.com/?q={query}"),
("bing", "https://www.bing.com/search?q={query}"),
("startpage", "https://www.startpage.com/search?q={query}"),
("searxng", "https://searx.be/search?q={query}"),
("brave", "https://search.brave.com/search?q={query}"),
("ecosia", "https://www.ecosia.org/search?q={query}"),
];
/// Default search engine if not configured
const DEFAULT_ENGINE: &str = "duckduckgo";
/// Web search provider state
struct WebSearchState {
/// URL template with {query} placeholder
url_template: String,
}
impl WebSearchState {
fn new() -> Self {
Self::with_engine(DEFAULT_ENGINE)
}
fn with_engine(engine_name: &str) -> Self {
let url_template = SEARCH_ENGINES
.iter()
.find(|(name, _)| *name == engine_name.to_lowercase())
.map(|(_, url)| url.to_string())
.unwrap_or_else(|| {
// If not a known engine, treat it as a custom URL template
if engine_name.contains("{query}") {
engine_name.to_string()
} else {
// Fall back to default
SEARCH_ENGINES
.iter()
.find(|(name, _)| *name == DEFAULT_ENGINE)
.map(|(_, url)| url.to_string())
.unwrap()
}
});
Self { url_template }
}
/// Extract the search term from the query
fn extract_search_term(query: &str) -> Option<&str> {
let trimmed = query.trim();
if let Some(rest) = trimmed.strip_prefix("? ") {
Some(rest.trim())
} else if let Some(rest) = trimmed.strip_prefix("?") {
Some(rest.trim())
} else if trimmed.to_lowercase().starts_with("web ") {
Some(trimmed[4..].trim())
} else if trimmed.to_lowercase().starts_with("search ") {
Some(trimmed[7..].trim())
} else {
// In filter mode, accept raw query
Some(trimmed)
}
}
/// URL-encode a search query
fn url_encode(query: &str) -> String {
query
.chars()
.map(|c| match c {
' ' => "+".to_string(),
'&' => "%26".to_string(),
'=' => "%3D".to_string(),
'?' => "%3F".to_string(),
'#' => "%23".to_string(),
'+' => "%2B".to_string(),
'%' => "%25".to_string(),
c if c.is_ascii_alphanumeric() || "-_.~".contains(c) => c.to_string(),
c => format!("%{:02X}", c as u32),
})
.collect()
}
/// Build the search URL from a query
fn build_search_url(&self, search_term: &str) -> String {
let encoded = Self::url_encode(search_term);
self.url_template.replace("{query}", &encoded)
}
/// Evaluate a query and return a PluginItem if valid
fn evaluate(&self, query: &str) -> Option<PluginItem> {
let search_term = Self::extract_search_term(query)?;
if search_term.is_empty() {
return None;
}
let url = self.build_search_url(search_term);
// Use xdg-open to open the browser
let command = format!("xdg-open '{}'", url);
Some(
PluginItem::new(
format!("websearch:{}", search_term),
format!("Search: {}", search_term),
command,
)
.with_description("Open in browser")
.with_icon(PROVIDER_ICON)
.with_keywords(vec!["web".to_string(), "search".to_string()]),
)
}
}
// ============================================================================
// Plugin Interface Implementation
// ============================================================================
extern "C" fn plugin_info() -> PluginInfo {
PluginInfo {
id: RString::from(PLUGIN_ID),
name: RString::from(PLUGIN_NAME),
version: RString::from(PLUGIN_VERSION),
description: RString::from(PLUGIN_DESCRIPTION),
api_version: API_VERSION,
}
}
extern "C" fn plugin_providers() -> RVec<ProviderInfo> {
vec![ProviderInfo {
id: RString::from(PROVIDER_ID),
name: RString::from(PROVIDER_NAME),
prefix: ROption::RSome(RString::from(PROVIDER_PREFIX)),
icon: RString::from(PROVIDER_ICON),
provider_type: ProviderKind::Dynamic,
type_id: RString::from(PROVIDER_TYPE_ID),
}]
.into()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
// TODO: Read search engine from config when plugin config is available
let state = Box::new(WebSearchState::new());
ProviderHandle::from_box(state)
}
extern "C" fn provider_refresh(_handle: ProviderHandle) -> RVec<PluginItem> {
// Dynamic provider - refresh does nothing
RVec::new()
}
extern "C" fn provider_query(handle: ProviderHandle, query: RStr<'_>) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<WebSearchState>
let state = unsafe { &*(handle.ptr as *const WebSearchState) };
let query_str = query.as_str();
match state.evaluate(query_str) {
Some(item) => vec![item].into(),
None => RVec::new(),
}
}
extern "C" fn provider_drop(handle: ProviderHandle) {
if !handle.ptr.is_null() {
// SAFETY: We created this handle from Box<WebSearchState>
unsafe {
handle.drop_as::<WebSearchState>();
}
}
}
// Register the plugin vtable
owlry_plugin! {
info: plugin_info,
providers: plugin_providers,
init: provider_init,
refresh: provider_refresh,
query: provider_query,
drop: provider_drop,
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_search_term() {
assert_eq!(
WebSearchState::extract_search_term("? rust programming"),
Some("rust programming")
);
assert_eq!(
WebSearchState::extract_search_term("?rust"),
Some("rust")
);
assert_eq!(
WebSearchState::extract_search_term("web rust docs"),
Some("rust docs")
);
assert_eq!(
WebSearchState::extract_search_term("search how to rust"),
Some("how to rust")
);
}
#[test]
fn test_url_encode() {
assert_eq!(WebSearchState::url_encode("hello world"), "hello+world");
assert_eq!(WebSearchState::url_encode("foo&bar"), "foo%26bar");
assert_eq!(WebSearchState::url_encode("a=b"), "a%3Db");
assert_eq!(WebSearchState::url_encode("test?query"), "test%3Fquery");
}
#[test]
fn test_build_search_url() {
let state = WebSearchState::with_engine("duckduckgo");
let url = state.build_search_url("rust programming");
assert_eq!(url, "https://duckduckgo.com/?q=rust+programming");
}
#[test]
fn test_build_search_url_google() {
let state = WebSearchState::with_engine("google");
let url = state.build_search_url("rust");
assert_eq!(url, "https://www.google.com/search?q=rust");
}
#[test]
fn test_evaluate() {
let state = WebSearchState::new();
let item = state.evaluate("? rust docs").unwrap();
assert_eq!(item.name.as_str(), "Search: rust docs");
assert!(item.command.as_str().contains("xdg-open"));
assert!(item.command.as_str().contains("duckduckgo"));
}
#[test]
fn test_evaluate_empty() {
let state = WebSearchState::new();
assert!(state.evaluate("?").is_none());
assert!(state.evaluate("? ").is_none());
}
#[test]
fn test_custom_url_template() {
let state = WebSearchState::with_engine("https://custom.search/q={query}");
let url = state.build_search_url("test");
assert_eq!(url, "https://custom.search/q=test");
}
#[test]
fn test_fallback_to_default() {
let state = WebSearchState::with_engine("nonexistent");
let url = state.build_search_url("test");
assert!(url.contains("duckduckgo")); // Falls back to default
}
}