use std::collections::HashMap;
use std::path::{Path, PathBuf};
use extism::{Manifest, Plugin, Wasm};
use crate::manifest::{PluginConfig, PluginManifest, PluginPermissions};
use crate::{Hook, HookContext, HookResult, PluginError, PluginResult};
pub struct PluginHost {
plugins: Vec<LoadedPlugin>,
plugin_dir: PathBuf,
default_permissions: PluginPermissions,
}
pub struct LoadedPlugin {
pub name: String,
pub manifest: PluginManifest,
pub config: Option<PluginConfig>,
instance: Plugin,
pub enabled: bool,
}
impl LoadedPlugin {
pub fn has_hook(&self, hook: Hook) -> bool {
self.manifest.has_hook(hook)
}
pub fn call_hook(&mut self, hook: Hook, context: &HookContext) -> PluginResult<HookResult> {
let func_name = hook.function_name();
if !self.has_hook(hook) {
return Ok(HookResult::ok());
}
let input = context.to_bytes();
let output = self
.instance
.call::<&[u8], Vec<u8>>(func_name, &input)
.map_err(|e| PluginError::ExecutionError(e.to_string()))?;
let result = HookResult::from_bytes(&output)?;
Ok(result)
}
}
impl PluginHost {
pub fn new(plugin_dir: impl Into<PathBuf>) -> Self {
Self {
plugins: vec![],
plugin_dir: plugin_dir.into(),
default_permissions: PluginPermissions::read_only(),
}
}
pub fn with_default_dir() -> Self {
let plugin_dir = dirs::home_dir()
.map(|h| h.join(".rx").join("plugins"))
.unwrap_or_else(|| PathBuf::from(".rx/plugins"));
Self::new(plugin_dir)
}
pub fn set_default_permissions(&mut self, permissions: PluginPermissions) {
self.default_permissions = permissions;
}
pub fn plugin_dir(&self) -> &Path {
&self.plugin_dir
}
pub fn ensure_plugin_dir(&self) -> PluginResult<()> {
std::fs::create_dir_all(&self.plugin_dir).map_err(|e| {
PluginError::LoadError(format!("Failed to create plugin directory: {}", e))
})
}
pub fn load(&mut self, name: &str, wasm_path: &Path) -> PluginResult<()> {
self.load_with_config(name, wasm_path, None)
}
pub fn load_with_config(
&mut self,
name: &str,
wasm_path: &Path,
config: Option<PluginConfig>,
) -> PluginResult<()> {
if !wasm_path.exists() {
return Err(PluginError::NotFound {
path: wasm_path.display().to_string(),
});
}
tracing::info!("Loading plugin '{}' from {:?}", name, wasm_path);
let wasm_bytes = std::fs::read(wasm_path)
.map_err(|e| PluginError::LoadError(format!("Failed to read Wasm file: {}", e)))?;
let manifest = self.extract_or_create_manifest(name, wasm_path, &wasm_bytes)?;
let permissions = config
.as_ref()
.and_then(|c| c.permissions.clone())
.unwrap_or_else(|| manifest.permissions.clone());
let extism_manifest = self.create_extism_manifest(&wasm_bytes, &permissions)?;
let instance = Plugin::new(&extism_manifest, [], true)
.map_err(|e| PluginError::LoadError(format!("Failed to create plugin: {}", e)))?;
let enabled = config.as_ref().map(|c| c.enabled).unwrap_or(true);
self.plugins.push(LoadedPlugin {
name: name.to_string(),
manifest,
config,
instance,
enabled,
});
tracing::info!("Successfully loaded plugin '{}'", name);
Ok(())
}
fn extract_or_create_manifest(
&self,
name: &str,
wasm_path: &Path,
_wasm_bytes: &[u8],
) -> PluginResult<PluginManifest> {
let manifest_path = wasm_path.with_extension("toml");
if manifest_path.exists() {
let content = std::fs::read_to_string(&manifest_path).map_err(|e| {
PluginError::InvalidManifest(format!("Failed to read manifest: {}", e))
})?;
return PluginManifest::from_toml(&content).map_err(|e| {
PluginError::InvalidManifest(format!("Invalid manifest TOML: {}", e))
});
}
Ok(PluginManifest {
name: name.to_string(),
version: "0.0.0".to_string(),
description: String::new(),
author: None,
license: None,
homepage: None,
min_rx_version: None,
hooks: vec![
"pre_resolve".to_string(),
"post_resolve".to_string(),
"pre_build".to_string(),
"post_build".to_string(),
"pre_publish".to_string(),
],
permissions: self.default_permissions.clone(),
})
}
fn create_extism_manifest(
&self,
wasm_bytes: &[u8],
permissions: &PluginPermissions,
) -> PluginResult<Manifest> {
let wasm = Wasm::data(wasm_bytes.to_vec());
let mut manifest = Manifest::new([wasm]);
if permissions.network && !permissions.allowed_hosts.is_empty() {
manifest = manifest.with_allowed_hosts(permissions.allowed_hosts.iter().cloned());
}
Ok(manifest)
}
pub fn load_from_dir(&mut self, dir: &Path) -> PluginResult<usize> {
if !dir.exists() {
return Ok(0);
}
let mut count = 0;
for entry in std::fs::read_dir(dir).map_err(|e| {
PluginError::LoadError(format!("Failed to read plugin directory: {}", e))
})? {
let entry = entry
.map_err(|e| PluginError::LoadError(format!("Failed to read entry: {}", e)))?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "wasm") {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
match self.load(name, &path) {
Ok(_) => count += 1,
Err(e) => {
tracing::warn!("Failed to load plugin {:?}: {}", path, e);
}
}
}
}
Ok(count)
}
pub fn load_from_config(
&mut self,
configs: &HashMap<String, PluginConfig>,
) -> PluginResult<usize> {
let mut count = 0;
for (name, config) in configs {
if !config.enabled {
tracing::debug!("Skipping disabled plugin '{}'", name);
continue;
}
let path =
if config.source.starts_with("http://") || config.source.starts_with("https://") {
match self.download_plugin(name, &config.source) {
Ok(p) => p,
Err(e) => {
tracing::warn!("Failed to download plugin '{}': {}", name, e);
continue;
}
}
} else {
PathBuf::from(&config.source)
};
match self.load_with_config(name, &path, Some(config.clone())) {
Ok(_) => count += 1,
Err(e) => {
tracing::warn!("Failed to load plugin '{}': {}", name, e);
}
}
}
Ok(count)
}
fn download_plugin(&self, name: &str, url: &str) -> PluginResult<PathBuf> {
self.ensure_plugin_dir()?;
let dest_path = self.plugin_dir.join(format!("{}.wasm", name));
let response = reqwest::blocking::get(url)
.map_err(|e| PluginError::LoadError(format!("Failed to download plugin: {}", e)))?;
if !response.status().is_success() {
return Err(PluginError::LoadError(format!(
"Failed to download plugin: HTTP {}",
response.status()
)));
}
let bytes = response
.bytes()
.map_err(|e| PluginError::LoadError(format!("Failed to read response: {}", e)))?;
std::fs::write(&dest_path, &bytes)
.map_err(|e| PluginError::LoadError(format!("Failed to save plugin: {}", e)))?;
Ok(dest_path)
}
pub fn execute_hook(&mut self, hook: Hook, context: &HookContext) -> PluginResult<HookResult> {
tracing::debug!("Executing hook {:?}", hook);
let mut combined_result = HookResult::ok();
for plugin in &mut self.plugins {
if !plugin.enabled {
continue;
}
if !plugin.has_hook(hook) {
continue;
}
tracing::trace!("Running hook {:?} on plugin '{}'", hook, plugin.name);
match plugin.call_hook(hook, context) {
Ok(result) => {
for msg in &result.messages {
println!("[{}] {}", plugin.name, msg);
}
combined_result.merge(result);
if !combined_result.continue_operation {
tracing::info!("Plugin '{}' stopped operation at {:?}", plugin.name, hook);
break;
}
}
Err(e) => {
tracing::warn!("Plugin '{}' hook {:?} failed: {}", plugin.name, hook, e);
}
}
}
Ok(combined_result)
}
pub fn plugin_count(&self) -> usize {
self.plugins.len()
}
pub fn enabled_count(&self) -> usize {
self.plugins.iter().filter(|p| p.enabled).count()
}
pub fn list_plugins(&self) -> Vec<&LoadedPlugin> {
self.plugins.iter().collect()
}
pub fn get_plugin(&self, name: &str) -> Option<&LoadedPlugin> {
self.plugins.iter().find(|p| p.name == name)
}
pub fn remove_plugin(&mut self, name: &str) -> bool {
let len_before = self.plugins.len();
self.plugins.retain(|p| p.name != name);
self.plugins.len() < len_before
}
pub fn enable_plugin(&mut self, name: &str) -> bool {
if let Some(plugin) = self.plugins.iter_mut().find(|p| p.name == name) {
plugin.enabled = true;
true
} else {
false
}
}
pub fn disable_plugin(&mut self, name: &str) -> bool {
if let Some(plugin) = self.plugins.iter_mut().find(|p| p.name == name) {
plugin.enabled = false;
true
} else {
false
}
}
}
impl Default for PluginHost {
fn default() -> Self {
Self::with_default_dir()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plugin_host_creation() {
let host = PluginHost::with_default_dir();
assert_eq!(host.plugin_count(), 0);
}
#[test]
fn test_load_nonexistent_plugin() {
let mut host = PluginHost::with_default_dir();
let result = host.load("test", Path::new("/nonexistent/plugin.wasm"));
assert!(result.is_err());
}
}