use crate::spec_ai_plugin::abi::{PluginModuleRef, PluginToolRef, PLUGIN_API_VERSION};
use crate::spec_ai_plugin::error::PluginError;
use abi_stable::library::RootModule;
use anyhow::Result;
use std::path::{Path, PathBuf};
use tracing::{debug, error, info, warn};
#[derive(Debug, Default, Clone)]
pub struct LoadStats {
pub total: usize,
pub loaded: usize,
pub failed: usize,
pub tools_loaded: usize,
}
pub struct LoadedPlugin {
pub path: PathBuf,
pub name: String,
pub tools: Vec<PluginToolRef>,
}
impl std::fmt::Debug for LoadedPlugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoadedPlugin")
.field("path", &self.path)
.field("name", &self.name)
.field("tools_count", &self.tools.len())
.finish()
}
}
pub struct PluginLoader {
plugins: Vec<LoadedPlugin>,
}
impl PluginLoader {
pub fn new() -> Self {
Self {
plugins: Vec::new(),
}
}
pub fn load_directory(&mut self, dir: &Path) -> Result<LoadStats> {
let mut stats = LoadStats::default();
if !dir.exists() {
info!("Plugin directory does not exist: {}", dir.display());
return Ok(stats);
}
if !dir.is_dir() {
return Err(PluginError::NotADirectory(dir.to_path_buf()).into());
}
info!("Scanning plugin directory: {}", dir.display());
for entry in walkdir::WalkDir::new(dir)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !Self::is_plugin_library(path) {
continue;
}
stats.total += 1;
match self.load_plugin(path) {
Ok(tool_count) => {
stats.loaded += 1;
stats.tools_loaded += tool_count;
info!("Loaded plugin: {} ({} tools)", path.display(), tool_count);
}
Err(e) => {
stats.failed += 1;
error!("Failed to load plugin {}: {}", path.display(), e);
}
}
}
Ok(stats)
}
fn load_plugin(&mut self, path: &Path) -> Result<usize> {
debug!("Loading plugin from: {}", path.display());
let module =
PluginModuleRef::load_from_file(path).map_err(|e| PluginError::LoadFailed {
path: path.to_path_buf(),
message: e.to_string(),
})?;
let plugin_version = (module.api_version())();
if plugin_version != PLUGIN_API_VERSION {
return Err(PluginError::VersionMismatch {
expected: PLUGIN_API_VERSION,
found: plugin_version,
path: path.to_path_buf(),
}
.into());
}
let plugin_name = (module.plugin_name())().to_string();
debug!("Plugin '{}' passed version check", plugin_name);
if self.plugins.iter().any(|p| p.name == plugin_name) {
return Err(PluginError::DuplicatePlugin(plugin_name).into());
}
let tool_refs = (module.get_tools())();
let tool_count = tool_refs.len();
let tools: Vec<PluginToolRef> = tool_refs.into_iter().collect();
for tool in &tools {
if let Some(init) = tool.initialize {
let context = "{}"; if !init(context.into()) {
warn!(
"Tool '{}' initialization failed",
(tool.info)().name.as_str()
);
}
}
}
self.plugins.push(LoadedPlugin {
path: path.to_path_buf(),
name: plugin_name,
tools,
});
Ok(tool_count)
}
fn is_plugin_library(path: &Path) -> bool {
if !path.is_file() {
return false;
}
let Some(ext) = path.extension() else {
return false;
};
#[cfg(target_os = "macos")]
let expected = "dylib";
#[cfg(target_os = "linux")]
let expected = "so";
#[cfg(target_os = "windows")]
let expected = "dll";
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let expected = "so";
ext == expected
}
pub fn plugins(&self) -> &[LoadedPlugin] {
&self.plugins
}
pub fn all_tools(&self) -> impl Iterator<Item = (PluginToolRef, &str)> {
self.plugins
.iter()
.flat_map(|p| p.tools.iter().map(move |t| (*t, p.name.as_str())))
}
pub fn plugin_count(&self) -> usize {
self.plugins.len()
}
pub fn tool_count(&self) -> usize {
self.plugins.iter().map(|p| p.tools.len()).sum()
}
}
impl Default for PluginLoader {
fn default() -> Self {
Self::new()
}
}
pub fn expand_tilde(path: &Path) -> PathBuf {
if let Ok(path_str) = path.to_str().ok_or(()) {
if let Some(stripped) = path_str.strip_prefix("~/") {
if let Some(home) = dirs_home() {
return home.join(stripped);
}
}
}
path.to_path_buf()
}
fn dirs_home() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var("USERPROFILE").ok().map(PathBuf::from)
}
#[cfg(not(target_os = "windows"))]
{
std::env::var("HOME").ok().map(PathBuf::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_is_plugin_library() {
assert!(!PluginLoader::is_plugin_library(Path::new(
"/tmp/nonexistent/libplugin.dylib"
)));
assert!(!PluginLoader::is_plugin_library(Path::new(
"/tmp/test/plugin.txt"
)));
assert!(!PluginLoader::is_plugin_library(Path::new(
"/tmp/test/plugin"
)));
}
#[test]
fn test_expand_tilde() {
let home = dirs_home().unwrap_or_else(|| PathBuf::from("/home/user"));
let expanded = expand_tilde(Path::new("~/test"));
assert!(expanded.starts_with(&home) || expanded == Path::new("~/test"));
let absolute = expand_tilde(Path::new("/absolute/path"));
assert_eq!(absolute, Path::new("/absolute/path"));
}
#[test]
fn test_load_stats_default() {
let stats = LoadStats::default();
assert_eq!(stats.total, 0);
assert_eq!(stats.loaded, 0);
assert_eq!(stats.failed, 0);
assert_eq!(stats.tools_loaded, 0);
}
}