236 lines
7.6 KiB
Rust
236 lines
7.6 KiB
Rust
//! Plugin discovery — scans directories for plugin manifests.
|
|
//!
|
|
//! Mirrors OpenClaw's `discovery.ts`: scans bundled, global, and workspace
|
|
//! extension directories for subdirectories containing `zeroclaw.plugin.toml`.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use super::manifest::{
|
|
load_manifest, ManifestLoadResult, PluginManifest, PLUGIN_MANIFEST_FILENAME,
|
|
};
|
|
use super::registry::{DiagnosticLevel, PluginDiagnostic, PluginOrigin};
|
|
|
|
/// A discovered plugin before loading.
|
|
#[derive(Debug)]
|
|
pub struct DiscoveredPlugin {
|
|
pub manifest: PluginManifest,
|
|
pub dir: PathBuf,
|
|
pub origin: PluginOrigin,
|
|
}
|
|
|
|
/// Result of a discovery scan.
|
|
pub struct DiscoveryResult {
|
|
pub plugins: Vec<DiscoveredPlugin>,
|
|
pub diagnostics: Vec<PluginDiagnostic>,
|
|
}
|
|
|
|
/// Scan a single extensions directory for plugin subdirectories.
|
|
fn scan_dir(dir: &Path, origin: PluginOrigin) -> (Vec<DiscoveredPlugin>, Vec<PluginDiagnostic>) {
|
|
let mut plugins = Vec::new();
|
|
let mut diagnostics = Vec::new();
|
|
|
|
let entries = match std::fs::read_dir(dir) {
|
|
Ok(e) => e,
|
|
Err(_) => return (plugins, diagnostics),
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if !path.is_dir() {
|
|
continue;
|
|
}
|
|
// Skip hidden directories
|
|
if entry
|
|
.file_name()
|
|
.to_str()
|
|
.map_or(false, |n| n.starts_with('.'))
|
|
{
|
|
continue;
|
|
}
|
|
// Must contain a manifest
|
|
if !path.join(PLUGIN_MANIFEST_FILENAME).exists() {
|
|
continue;
|
|
}
|
|
|
|
match load_manifest(&path) {
|
|
ManifestLoadResult::Ok { manifest, .. } => {
|
|
plugins.push(DiscoveredPlugin {
|
|
manifest,
|
|
dir: path,
|
|
origin: origin.clone(),
|
|
});
|
|
}
|
|
ManifestLoadResult::Err { error, path: mp } => {
|
|
diagnostics.push(PluginDiagnostic {
|
|
level: DiagnosticLevel::Warn,
|
|
plugin_id: None,
|
|
source: Some(mp.display().to_string()),
|
|
message: error,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
(plugins, diagnostics)
|
|
}
|
|
|
|
/// Discover plugins from all standard locations.
|
|
///
|
|
/// Search order (later wins on ID conflict, matching OpenClaw's precedence):
|
|
/// 1. Bundled: `<binary_dir>/extensions/`
|
|
/// 2. Global: `~/.zeroclaw/extensions/`
|
|
/// 3. Workspace: `<workspace>/.zeroclaw/extensions/`
|
|
/// 4. Extra paths from config `[plugins] load_paths`
|
|
pub fn discover_plugins(workspace_dir: Option<&Path>, extra_paths: &[PathBuf]) -> DiscoveryResult {
|
|
let mut all_plugins = Vec::new();
|
|
let mut all_diagnostics = Vec::new();
|
|
|
|
// 1. Bundled — next to the binary
|
|
if let Ok(exe) = std::env::current_exe() {
|
|
if let Some(exe_dir) = exe.parent() {
|
|
let bundled = exe_dir.join("extensions");
|
|
let (p, d) = scan_dir(&bundled, PluginOrigin::Bundled);
|
|
all_plugins.extend(p);
|
|
all_diagnostics.extend(d);
|
|
}
|
|
}
|
|
|
|
// 2. Global — ~/.zeroclaw/extensions/
|
|
if let Some(home) = dirs_home() {
|
|
let global = home.join(".zeroclaw").join("extensions");
|
|
let (p, d) = scan_dir(&global, PluginOrigin::Global);
|
|
all_plugins.extend(p);
|
|
all_diagnostics.extend(d);
|
|
}
|
|
|
|
// 3. Workspace — <workspace>/.zeroclaw/extensions/
|
|
if let Some(ws) = workspace_dir {
|
|
let ws_ext = ws.join(".zeroclaw").join("extensions");
|
|
let (p, d) = scan_dir(&ws_ext, PluginOrigin::Workspace);
|
|
all_plugins.extend(p);
|
|
all_diagnostics.extend(d);
|
|
}
|
|
|
|
// 4. Extra paths from config
|
|
for extra in extra_paths {
|
|
let (p, d) = scan_dir(extra, PluginOrigin::Global);
|
|
all_plugins.extend(p);
|
|
all_diagnostics.extend(d);
|
|
}
|
|
|
|
// Deduplicate by ID — last wins (workspace overrides global overrides bundled)
|
|
let mut seen = std::collections::HashMap::new();
|
|
for (i, plugin) in all_plugins.iter().enumerate() {
|
|
seen.insert(plugin.manifest.id.clone(), i);
|
|
}
|
|
let mut deduped: Vec<DiscoveredPlugin> = Vec::with_capacity(seen.len());
|
|
// Collect in insertion order of the winning index.
|
|
// Sort descending for safe `swap_remove` on a shrinking vec, then restore
|
|
// ascending order to preserve deterministic winner ordering.
|
|
let mut indices: Vec<usize> = seen.values().copied().collect();
|
|
indices.sort_unstable_by(|a, b| b.cmp(a));
|
|
for i in indices {
|
|
deduped.push(all_plugins.swap_remove(i));
|
|
}
|
|
deduped.reverse();
|
|
|
|
DiscoveryResult {
|
|
plugins: deduped,
|
|
diagnostics: all_diagnostics,
|
|
}
|
|
}
|
|
|
|
fn dirs_home() -> Option<PathBuf> {
|
|
directories::BaseDirs::new().map(|d| d.home_dir().to_path_buf())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
fn make_plugin_dir(parent: &Path, id: &str) {
|
|
let dir = parent.join(id);
|
|
fs::create_dir_all(&dir).unwrap();
|
|
fs::write(
|
|
dir.join(PLUGIN_MANIFEST_FILENAME),
|
|
format!(
|
|
r#"
|
|
id = "{id}"
|
|
name = "Test {id}"
|
|
version = "0.1.0"
|
|
"#
|
|
),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn discover_from_workspace() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ws = tmp.path().join("project");
|
|
let ext_dir = ws.join(".zeroclaw").join("extensions");
|
|
fs::create_dir_all(&ext_dir).unwrap();
|
|
make_plugin_dir(&ext_dir, "my-plugin");
|
|
|
|
let result = discover_plugins(Some(&ws), &[]);
|
|
assert!(result.plugins.iter().any(|p| p.manifest.id == "my-plugin"));
|
|
}
|
|
|
|
#[test]
|
|
fn discover_from_extra_paths() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ext_dir = tmp.path().join("custom-plugins");
|
|
fs::create_dir_all(&ext_dir).unwrap();
|
|
make_plugin_dir(&ext_dir, "custom-one");
|
|
|
|
let result = discover_plugins(None, &[ext_dir]);
|
|
assert!(result.plugins.iter().any(|p| p.manifest.id == "custom-one"));
|
|
}
|
|
|
|
#[test]
|
|
fn discover_handles_multiple_plugins_without_panicking() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ext_dir = tmp.path().join("custom-plugins");
|
|
fs::create_dir_all(&ext_dir).unwrap();
|
|
make_plugin_dir(&ext_dir, "custom-one");
|
|
make_plugin_dir(&ext_dir, "custom-two");
|
|
|
|
let result = discover_plugins(None, &[ext_dir]);
|
|
let ids: std::collections::HashSet<String> = result
|
|
.plugins
|
|
.iter()
|
|
.map(|p| p.manifest.id.clone())
|
|
.collect();
|
|
assert!(ids.contains("custom-one"));
|
|
assert!(ids.contains("custom-two"));
|
|
}
|
|
|
|
#[test]
|
|
fn discover_skips_hidden_dirs() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ext_dir = tmp.path().join("ext");
|
|
fs::create_dir_all(&ext_dir).unwrap();
|
|
make_plugin_dir(&ext_dir, ".hidden-plugin");
|
|
make_plugin_dir(&ext_dir, "visible-plugin");
|
|
|
|
let (plugins, _) = super::scan_dir(&ext_dir, PluginOrigin::Workspace);
|
|
assert_eq!(plugins.len(), 1);
|
|
assert_eq!(plugins[0].manifest.id, "visible-plugin");
|
|
}
|
|
|
|
#[test]
|
|
fn discover_records_bad_manifest() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let ext_dir = tmp.path().join("ext");
|
|
let bad = ext_dir.join("bad-plugin");
|
|
fs::create_dir_all(&bad).unwrap();
|
|
fs::write(bad.join(PLUGIN_MANIFEST_FILENAME), "not valid toml {{{{").unwrap();
|
|
|
|
let (plugins, diagnostics) = super::scan_dir(&ext_dir, PluginOrigin::Workspace);
|
|
assert!(plugins.is_empty());
|
|
assert_eq!(diagnostics.len(), 1);
|
|
assert_eq!(diagnostics[0].level, DiagnosticLevel::Warn);
|
|
}
|
|
}
|