use crate::http_body::BlockingBodyExt;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use std::time::{Instant, SystemTime};
use crate::value::Value;
fn plugin_coded(code: crate::safety::ErrorCode, message: String, hint: &str) -> anyhow::Error {
anyhow::Error::new(crate::safety::SafetyError {
code,
message,
builtin: "plugin".to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
})
}
#[cfg(feature = "native")]
fn marketplace_fetch(builtin: &str, url: &str) -> Result<reqwest::blocking::Response> {
let mut pin: Option<crate::security::CheckedHttpUrl> = None;
let mut hop = url.to_string();
for _ in 0..=crate::builtins::WEB_MAX_REDIRS {
let client = crate::security::create_hop_client(pin.as_ref())?;
let response = client.get(&hop).send().map_err(|e| {
plugin_coded(
crate::safety::ErrorCode::Network,
format!("could not reach the marketplace at {hop}: {e}"),
"the marketplace is down or the network is unavailable; nothing was changed, so retry later",
)
})?;
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok());
match crate::security::judged_redirect(builtin, &hop, response.status().as_u16(), location)?
{
Some(next) => {
hop = next.url.clone();
pin = Some(next);
}
None => return Ok(response),
}
}
Err(crate::safety::budget_exceeded(
builtin,
format!("more than {} redirects", crate::builtins::WEB_MAX_REDIRS),
"request the final URL directly",
))
}
#[cfg(feature = "native")]
fn is_network_failure(e: &anyhow::Error) -> bool {
e.downcast_ref::<crate::safety::SafetyError>()
.is_some_and(|s| s.code == crate::safety::ErrorCode::Network)
}
const PLUGIN_ABI_HINT: &str =
"the native plugin does not follow the plugin ABI; rebuild it against the current interface or replace it";
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginMetadata {
pub id: String,
pub name: String,
pub version: String,
pub author: String,
pub description: String,
pub categories: Vec<PluginCategory>,
pub min_ironshell_version: String,
pub dependencies: Vec<String>,
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum PluginCategory {
AIBackend,
Builtin,
FileHandler,
Transport,
Syntax,
TUIComponent,
Custom(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum PluginSource {
Builtin,
Manifest(PathBuf),
DynamicLibrary(PathBuf),
Script(PathBuf),
Marketplace { registry: String, package: String },
}
pub trait AIBackendPlugin: Send + Sync {
fn metadata(&self) -> &PluginMetadata;
fn is_available(&self) -> bool;
fn supported_models(&self) -> Vec<String>;
fn chat_completion(&self, model: &str, messages: Vec<crate::ai::ChatMessage>)
-> Result<String>;
fn embeddings(&self, _model: &str, _input: &str) -> Result<Vec<f32>> {
Err(crate::safety::unimplemented(
"plugin",
"embeddings not supported by this backend",
"call chat_completion instead, or use a backend that implements embeddings",
))
}
fn supports_streaming(&self) -> bool {
false
}
}
pub trait BuiltinPlugin: Send + Sync {
fn metadata(&self) -> &PluginMetadata;
fn builtin_names(&self) -> Vec<String>;
fn execute(&self, name: &str, args: Vec<Value>, input: Option<Value>) -> Result<Value>;
fn help(&self, name: &str) -> Option<String>;
fn signature(&self, _name: &str) -> Option<BuiltinSignature> {
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuiltinSignature {
pub name: String,
pub params: Vec<BuiltinParam>,
pub returns: String,
pub accepts_input: bool,
pub examples: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuiltinParam {
pub name: String,
pub type_desc: String,
pub required: bool,
pub default: Option<String>,
}
pub trait FileHandlerPlugin: Send + Sync {
fn metadata(&self) -> &PluginMetadata;
fn supported_extensions(&self) -> Vec<String>;
fn supported_mime_types(&self) -> Vec<String>;
fn read(&self, path: &std::path::Path) -> Result<Value>;
fn write(&self, path: &std::path::Path, value: &Value) -> Result<()>;
}
pub trait TransportPlugin: Send + Sync {
fn metadata(&self) -> &PluginMetadata;
fn scheme(&self) -> &str;
fn connect(&self, uri: &str) -> Result<Box<dyn TransportConnection>>;
}
pub trait TransportConnection: Send + Sync {
fn send(&self, data: &[u8]) -> Result<()>;
fn receive(&self) -> Result<Vec<u8>>;
fn close(&self) -> Result<()>;
fn is_connected(&self) -> bool;
}
pub type PluginInitFn = unsafe extern "C" fn() -> *mut PluginVTable;
pub type PluginDeinitFn = unsafe extern "C" fn(*mut PluginVTable);
#[repr(C)]
pub struct PluginVTable {
pub get_metadata: extern "C" fn() -> *const std::ffi::c_char,
pub get_builtin_names: extern "C" fn() -> *const std::ffi::c_char,
pub execute_builtin: extern "C" fn(
*const std::ffi::c_char,
*const std::ffi::c_char,
*const std::ffi::c_char,
) -> *const std::ffi::c_char,
pub get_help: extern "C" fn(*const std::ffi::c_char) -> *const std::ffi::c_char,
pub free_string: extern "C" fn(*const std::ffi::c_char),
}
#[allow(dead_code)]
pub struct DynamicPlugin {
metadata: PluginMetadata,
library: libloading::Library,
vtable: *mut PluginVTable,
path: PathBuf,
loaded_at: Instant,
last_modified: SystemTime,
}
unsafe impl Send for DynamicPlugin {}
unsafe impl Sync for DynamicPlugin {}
impl DynamicPlugin {
fn is_load_permitted(
canon: &Path,
plugins_off: bool,
allow_roots: &[&str],
agent_mode: bool,
) -> bool {
if plugins_off {
return false;
}
let allowed = allow_roots
.iter()
.map(|r| r.trim())
.filter(|r| !r.is_empty())
.any(|root| {
let r =
std::fs::canonicalize(root).unwrap_or_else(|_| std::path::PathBuf::from(root));
canon.starts_with(&r)
});
allowed || !agent_mode
}
fn authorize_load(path: &Path) -> Result<()> {
let plugins_off = std::env::var("IRONSHELL_PLUGINS")
.map(|v| v.eq_ignore_ascii_case("off") || v == "0")
.unwrap_or(false);
let agent_mode = crate::safety::current_mode() == crate::safety::Mode::Agent;
let allow = std::env::var("IRONSHELL_PLUGIN_ALLOW").unwrap_or_default();
let allow_roots: Vec<&str> = allow.split(';').collect();
let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if Self::is_load_permitted(&canon, plugins_off, &allow_roots, agent_mode) {
Ok(())
} else if plugins_off {
Err(crate::safety::policy_deny(
"plugin",
format!(
"native plugin loading is disabled (IRONSHELL_PLUGINS=off): {}",
path.display()
),
"unset IRONSHELL_PLUGINS=off to allow native plugin loading",
))
} else {
Err(crate::safety::policy_deny(
"plugin",
format!(
"refusing to load native plugin in agent mode (arbitrary code execution): {} — \
allow an audited directory via IRONSHELL_PLUGIN_ALLOW=<dir>",
path.display()
),
"run outside agent mode, or allow the directory with IRONSHELL_PLUGIN_ALLOW",
))
}
}
pub fn load(path: &Path) -> Result<Self> {
Self::authorize_load(path)?;
let last_modified = std::fs::metadata(path)
.context("Failed to get plugin file metadata")?
.modified()
.unwrap_or(SystemTime::UNIX_EPOCH);
let library =
unsafe { libloading::Library::new(path).context("Failed to load dynamic library")? };
let init_fn: libloading::Symbol<PluginInitFn> = unsafe {
library
.get(b"ironshell_plugin_init")
.context("Plugin missing ironshell_plugin_init function")?
};
let vtable = unsafe { init_fn() };
if vtable.is_null() {
return Err(plugin_coded(
crate::safety::ErrorCode::ToolFailed,
"Plugin initialization returned null".to_string(),
PLUGIN_ABI_HINT,
));
}
let metadata_json = unsafe {
let ptr = ((*vtable).get_metadata)();
if ptr.is_null() {
return Err(plugin_coded(
crate::safety::ErrorCode::ToolFailed,
"Plugin returned null metadata".to_string(),
PLUGIN_ABI_HINT,
));
}
let s = std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned();
((*vtable).free_string)(ptr);
s
};
let metadata: PluginMetadata =
serde_json::from_str(&metadata_json).context("Failed to parse plugin metadata")?;
Ok(Self {
metadata,
library,
vtable,
path: path.to_path_buf(),
loaded_at: Instant::now(),
last_modified,
})
}
pub fn needs_reload(&self) -> bool {
if let Ok(meta) = std::fs::metadata(&self.path) {
if let Ok(modified) = meta.modified() {
return modified > self.last_modified;
}
}
false
}
pub fn builtin_names(&self) -> Vec<String> {
unsafe {
let ptr = ((*self.vtable).get_builtin_names)();
if ptr.is_null() {
return vec![];
}
let s = std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned();
((*self.vtable).free_string)(ptr);
serde_json::from_str(&s).unwrap_or_default()
}
}
pub fn execute(&self, name: &str, args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let name_cstr = std::ffi::CString::new(name)?;
let args_json = serde_json::to_string(&args)?;
let args_cstr = std::ffi::CString::new(args_json)?;
let input_json = serde_json::to_string(&input)?;
let input_cstr = std::ffi::CString::new(input_json)?;
unsafe {
let result_ptr = ((*self.vtable).execute_builtin)(
name_cstr.as_ptr(),
args_cstr.as_ptr(),
input_cstr.as_ptr(),
);
if result_ptr.is_null() {
return Err(plugin_coded(
crate::safety::ErrorCode::ToolFailed,
"Plugin returned null result".to_string(),
PLUGIN_ABI_HINT,
));
}
let result_str = std::ffi::CStr::from_ptr(result_ptr)
.to_string_lossy()
.into_owned();
((*self.vtable).free_string)(result_ptr);
if result_str.starts_with("{\"error\":") {
let err: serde_json::Value = serde_json::from_str(&result_str)?;
Err(plugin_coded(
crate::safety::ErrorCode::ToolFailed,
err["error"].as_str().unwrap_or("Unknown error").to_string(),
"the plugin rejected this call; the message says why, so change the arguments",
))
} else {
serde_json::from_str(&result_str).context("Failed to parse plugin result")
}
}
}
pub fn help(&self, name: &str) -> Option<String> {
let name_cstr = std::ffi::CString::new(name).ok()?;
unsafe {
let ptr = ((*self.vtable).get_help)(name_cstr.as_ptr());
if ptr.is_null() {
return None;
}
let s = std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned();
((*self.vtable).free_string)(ptr);
if s.is_empty() {
None
} else {
Some(s)
}
}
}
}
impl Drop for DynamicPlugin {
fn drop(&mut self) {
if let Ok(deinit_fn) = unsafe {
self.library
.get::<PluginDeinitFn>(b"ironshell_plugin_deinit")
} {
unsafe {
deinit_fn(self.vtable);
}
}
}
}
#[derive(Debug, Clone)]
pub struct ScriptPlugin {
metadata: PluginMetadata,
path: PathBuf,
builtins: HashMap<String, String>,
help_text: HashMap<String, String>,
last_modified: SystemTime,
}
impl ScriptPlugin {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path).context("Failed to read script plugin")?;
let mut metadata = PluginMetadata {
id: path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string(),
name: "Script Plugin".to_string(),
version: "1.0.0".to_string(),
author: "Unknown".to_string(),
description: String::new(),
categories: vec![PluginCategory::Builtin],
min_ironshell_version: "0.1.0".to_string(),
dependencies: vec![],
homepage: None,
license: None,
keywords: vec![],
};
let mut builtins = HashMap::new();
let mut help_text = HashMap::new();
for line in content.lines() {
let line = line.trim();
if let Some(directive) = line.strip_prefix("#!") {
let directive = directive.trim();
if let Some((key, value)) = directive.split_once('=') {
let key = key.trim();
let value = value.trim().trim_matches('"');
if let Some(field) = key.strip_prefix("plugin.") {
match field {
"id" => metadata.id = value.to_string(),
"name" => metadata.name = value.to_string(),
"version" => metadata.version = value.to_string(),
"author" => metadata.author = value.to_string(),
"description" => metadata.description = value.to_string(),
"license" => metadata.license = Some(value.to_string()),
"homepage" => metadata.homepage = Some(value.to_string()),
_ => {}
}
} else if let Some(name) = key.strip_prefix("builtin.") {
builtins.insert(name.to_string(), value.to_string());
} else if let Some(name) = key.strip_prefix("help.") {
help_text.insert(name.to_string(), value.to_string());
}
}
}
}
for line in content.lines() {
let line = line.trim();
if line.starts_with("let ") && line.contains("= fn(") {
if let Some((name_part, body)) =
line.strip_prefix("let ").and_then(|s| s.split_once('='))
{
let name = name_part.trim();
let body = body.trim();
if !name.starts_with('_') {
builtins.insert(name.to_string(), body.to_string());
}
}
}
}
let last_modified = std::fs::metadata(path)
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(SystemTime::UNIX_EPOCH);
Ok(Self {
metadata,
path: path.to_path_buf(),
builtins,
help_text,
last_modified,
})
}
pub fn needs_reload(&self) -> bool {
if let Ok(meta) = std::fs::metadata(&self.path) {
if let Ok(modified) = meta.modified() {
return modified > self.last_modified;
}
}
false
}
}
static PLUGIN_REGISTRY: OnceLock<Mutex<PluginRegistry>> = OnceLock::new();
pub fn get_plugin_registry() -> &'static Mutex<PluginRegistry> {
PLUGIN_REGISTRY.get_or_init(|| Mutex::new(PluginRegistry::new()))
}
static HOT_RELOAD_ENABLED: OnceLock<RwLock<bool>> = OnceLock::new();
pub fn set_hot_reload(enabled: bool) {
let lock = HOT_RELOAD_ENABLED.get_or_init(|| RwLock::new(false));
if let Ok(mut guard) = lock.write() {
*guard = enabled;
}
}
pub fn is_hot_reload_enabled() -> bool {
HOT_RELOAD_ENABLED
.get()
.and_then(|lock| lock.read().ok())
.map(|guard| *guard)
.unwrap_or(false)
}
struct ManifestBuiltin {
plugin_id: String,
lambda: Arc<crate::ast::Expr>,
}
static MANIFEST_BUILTIN_COUNT: AtomicUsize = AtomicUsize::new(0);
fn sync_manifest_count(registry: &PluginRegistry) {
MANIFEST_BUILTIN_COUNT.store(registry.manifest_builtins.len(), Ordering::Release);
}
pub struct PluginRegistry {
plugins: HashMap<String, PluginEntry>,
ai_backends: HashMap<String, Arc<dyn AIBackendPlugin>>,
builtins: HashMap<String, (String, Arc<dyn BuiltinPlugin>)>, dynamic_plugins: HashMap<String, Arc<Mutex<DynamicPlugin>>>,
script_plugins: HashMap<String, Arc<RwLock<ScriptPlugin>>>,
file_handlers: HashMap<String, Arc<dyn FileHandlerPlugin>>,
transports: HashMap<String, Arc<dyn TransportPlugin>>,
load_paths: Vec<PathBuf>,
marketplaces: Vec<MarketplaceRegistry>,
dependency_graph: HashMap<String, Vec<String>>,
manifest_builtins: HashMap<String, ManifestBuiltin>,
}
struct PluginEntry {
metadata: PluginMetadata,
source: PluginSource,
enabled: bool,
load_time: std::time::Instant,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceRegistry {
pub name: String,
pub url: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplacePlugin {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub author: String,
pub downloads: u64,
pub categories: Vec<String>,
pub keywords: Vec<String>,
pub homepage: Option<String>,
pub repository: Option<String>,
}
impl PluginRegistry {
pub fn new() -> Self {
let mut registry = Self {
plugins: HashMap::new(),
ai_backends: HashMap::new(),
builtins: HashMap::new(),
dynamic_plugins: HashMap::new(),
script_plugins: HashMap::new(),
file_handlers: HashMap::new(),
transports: HashMap::new(),
load_paths: Vec::new(),
marketplaces: vec![MarketplaceRegistry {
name: "IronShell Official".to_string(),
url: "https://plugins.ironshell.io/api/v1".to_string(),
enabled: true,
}],
dependency_graph: HashMap::new(),
manifest_builtins: HashMap::new(),
};
registry.add_default_load_paths();
registry.register_builtin_plugins();
registry
}
fn add_default_load_paths(&mut self) {
if let Some(home) = dirs::home_dir() {
self.load_paths
.push(home.join(".ironshell").join("plugins"));
}
if let Some(data_dir) = dirs::data_dir() {
self.load_paths
.push(data_dir.join("ironshell").join("plugins"));
}
#[cfg(unix)]
self.load_paths
.push(PathBuf::from("/usr/share/ironshell/plugins"));
#[cfg(windows)]
if let Some(program_data) = std::env::var_os("ProgramData") {
self.load_paths.push(
PathBuf::from(program_data)
.join("IronShell")
.join("plugins"),
);
}
}
fn register_builtin_plugins(&mut self) {
let json_handler = Arc::new(JsonFileHandler);
self.register_file_handler(json_handler).ok();
let csv_handler = Arc::new(CsvFileHandler);
self.register_file_handler(csv_handler).ok();
let toml_handler = Arc::new(TomlFileHandler);
self.register_file_handler(toml_handler).ok();
}
pub fn register_ai_backend(&mut self, plugin: Arc<dyn AIBackendPlugin>) -> Result<()> {
let metadata = plugin.metadata().clone();
let plugin_id = metadata.id.clone();
self.plugins.insert(
plugin_id.clone(),
PluginEntry {
metadata: metadata.clone(),
source: PluginSource::Builtin,
enabled: true,
load_time: std::time::Instant::now(),
},
);
self.ai_backends.insert(plugin_id, plugin);
Ok(())
}
pub fn register_builtin(&mut self, plugin: Arc<dyn BuiltinPlugin>) -> Result<()> {
let metadata = plugin.metadata().clone();
let plugin_id = metadata.id.clone();
self.plugins.insert(
plugin_id.clone(),
PluginEntry {
metadata: metadata.clone(),
source: PluginSource::Builtin,
enabled: true,
load_time: std::time::Instant::now(),
},
);
for name in plugin.builtin_names() {
self.builtins
.insert(name, (plugin_id.clone(), plugin.clone()));
}
Ok(())
}
pub fn register_file_handler(&mut self, plugin: Arc<dyn FileHandlerPlugin>) -> Result<()> {
let metadata = plugin.metadata().clone();
let plugin_id = metadata.id.clone();
self.plugins.insert(
plugin_id,
PluginEntry {
metadata: metadata.clone(),
source: PluginSource::Builtin,
enabled: true,
load_time: std::time::Instant::now(),
},
);
for ext in plugin.supported_extensions() {
self.file_handlers
.insert(ext.to_lowercase(), plugin.clone());
}
Ok(())
}
pub fn register_transport(&mut self, plugin: Arc<dyn TransportPlugin>) -> Result<()> {
let metadata = plugin.metadata().clone();
let plugin_id = metadata.id.clone();
self.plugins.insert(
plugin_id,
PluginEntry {
metadata: metadata.clone(),
source: PluginSource::Builtin,
enabled: true,
load_time: std::time::Instant::now(),
},
);
self.transports.insert(plugin.scheme().to_string(), plugin);
Ok(())
}
pub fn load_dynamic_plugin(&mut self, path: &Path) -> Result<String> {
let plugin = DynamicPlugin::load(path)?;
let plugin_id = plugin.metadata.id.clone();
if self.plugins.contains_key(&plugin_id) {
return Err(crate::safety::bad_state(
"plugin",
&format!("plugin {} is already loaded", plugin_id),
"plugin_unload",
));
}
self.plugins.insert(
plugin_id.clone(),
PluginEntry {
metadata: plugin.metadata.clone(),
source: PluginSource::DynamicLibrary(path.to_path_buf()),
enabled: true,
load_time: Instant::now(),
},
);
let plugin_arc = Arc::new(Mutex::new(plugin));
if let Ok(guard) = plugin_arc.lock() {
for name in guard.builtin_names() {
self.dynamic_plugins
.insert(name.clone(), plugin_arc.clone());
}
}
self.dynamic_plugins.insert(plugin_id.clone(), plugin_arc);
Ok(plugin_id)
}
pub fn load_script_plugin(&mut self, path: &Path) -> Result<String> {
let plugin = ScriptPlugin::load(path)?;
let plugin_id = plugin.metadata.id.clone();
if self.plugins.contains_key(&plugin_id) {
return Err(crate::safety::bad_state(
"plugin",
&format!("plugin {} is already loaded", plugin_id),
"plugin_unload",
));
}
self.plugins.insert(
plugin_id.clone(),
PluginEntry {
metadata: plugin.metadata.clone(),
source: PluginSource::Script(path.to_path_buf()),
enabled: true,
load_time: Instant::now(),
},
);
self.script_plugins
.insert(plugin_id.clone(), Arc::new(RwLock::new(plugin)));
Ok(plugin_id)
}
pub fn load_all_plugins(&mut self) -> Vec<Result<String>> {
let mut results = Vec::new();
let paths = self.load_paths.clone();
for base_path in paths {
if !base_path.exists() {
continue;
}
if let Ok(entries) = std::fs::read_dir(&base_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
match ext {
"dll" | "so" | "dylib" => {
results.push(self.load_dynamic_plugin(&path));
}
"ish" => {
results.push(self.load_script_plugin(&path));
}
"toml"
if path
.file_name()
.map(|n| n.to_str().unwrap_or("").ends_with(".plugin.toml"))
.unwrap_or(false) =>
{
results.push(
load_plugin_from_manifest(path.to_str().unwrap_or("")).map(
|v| {
if let Value::Record(r) = v {
r.get("id")
.and_then(|v| {
if let Value::Str(s) = v {
Some(s.clone())
} else {
None
}
})
.unwrap_or_default()
} else {
String::new()
}
},
),
);
}
_ => {}
}
}
}
}
}
results
}
pub fn reload_plugin(&mut self, plugin_id: &str) -> Result<()> {
let entry = self
.plugins
.get(plugin_id)
.ok_or_else(|| crate::safety::not_found("plugin_reload", "plugin", plugin_id))?;
match &entry.source {
PluginSource::DynamicLibrary(path) => {
let path = path.clone();
self.unload_plugin(plugin_id)?;
self.load_dynamic_plugin(&path)?;
}
PluginSource::Script(path) => {
let path = path.clone();
self.unload_plugin(plugin_id)?;
self.load_script_plugin(&path)?;
}
PluginSource::Manifest(path) => {
let path = path.clone();
self.unload_plugin(plugin_id)?;
load_plugin_from_manifest(path.to_str().unwrap_or(""))?;
}
PluginSource::Builtin => {
return Err(crate::safety::policy_deny(
"plugin",
"Cannot reload built-in plugins",
"built-in plugins are part of the shell and are not reloaded at runtime",
));
}
PluginSource::Marketplace { .. } => {
return Err(crate::safety::policy_deny(
"plugin",
"Use marketplace update to reload",
"a marketplace plugin is reloaded by updating it from its marketplace",
));
}
}
Ok(())
}
pub fn unload_plugin(&mut self, plugin_id: &str) -> Result<()> {
let entry = self
.plugins
.get(plugin_id)
.ok_or_else(|| crate::safety::not_found("plugin_unload", "plugin", plugin_id))?;
if matches!(entry.source, PluginSource::Builtin) {
return Err(crate::safety::policy_deny(
"plugin",
"Cannot unload built-in plugins",
"built-in plugins are part of the shell and are never removed at runtime",
));
}
self.plugins.remove(plugin_id);
self.builtins.retain(|_, (pid, _)| pid != plugin_id);
self.manifest_builtins
.retain(|_, builtin| builtin.plugin_id != plugin_id);
sync_manifest_count(self);
self.dynamic_plugins.remove(plugin_id);
self.script_plugins.remove(plugin_id);
self.ai_backends.remove(plugin_id);
self.file_handlers
.retain(|_, h| h.metadata().id != plugin_id);
self.transports.remove(plugin_id);
self.dependency_graph.remove(plugin_id);
Ok(())
}
pub fn check_hot_reload(&mut self) -> Vec<String> {
let mut reloaded = Vec::new();
if !is_hot_reload_enabled() {
return reloaded;
}
let mut to_reload = Vec::new();
for (plugin_id, plugin) in &self.dynamic_plugins {
if let Ok(guard) = plugin.lock() {
if guard.needs_reload() {
to_reload.push(plugin_id.clone());
}
}
}
for (plugin_id, plugin) in &self.script_plugins {
if let Ok(guard) = plugin.read() {
if guard.needs_reload() {
to_reload.push(plugin_id.clone());
}
}
}
for plugin_id in to_reload {
if self.reload_plugin(&plugin_id).is_ok() {
reloaded.push(plugin_id);
}
}
reloaded
}
pub fn execute_plugin_builtin(
&self,
name: &str,
args: Vec<Value>,
input: Option<Value>,
) -> Option<Result<Value>> {
if let Some(plugin) = self.dynamic_plugins.get(name) {
if let Ok(guard) = plugin.lock() {
return Some(guard.execute(name, args, input));
}
}
for plugin in self.script_plugins.values() {
if let Ok(guard) = plugin.read() {
if guard.builtins.contains_key(name) {
let lambda_src = guard.builtins.get(name).cloned();
if let Some(src) = lambda_src {
return Some(Ok(Value::Str(format!("__plugin_lambda__:{}", src))));
}
}
}
}
if let Some((_, plugin)) = self.builtins.get(name) {
return Some(plugin.execute(name, args, input));
}
None
}
pub fn get_plugin_builtin_help(&self, name: &str) -> Option<String> {
for plugin in self.dynamic_plugins.values() {
if let Ok(guard) = plugin.lock() {
if let Some(help) = guard.help(name) {
return Some(help);
}
}
}
for plugin in self.script_plugins.values() {
if let Ok(guard) = plugin.read() {
if let Some(help) = guard.help_text.get(name) {
return Some(help.clone());
}
}
}
if let Some((_, plugin)) = self.builtins.get(name) {
return plugin.help(name);
}
None
}
pub fn list_plugin_builtins(&self) -> Vec<(String, String)> {
let mut result = Vec::new();
for (id, plugin) in &self.dynamic_plugins {
if let Ok(guard) = plugin.lock() {
for name in guard.builtin_names() {
result.push((name, id.clone()));
}
}
}
for (id, plugin) in &self.script_plugins {
if let Ok(guard) = plugin.read() {
for name in guard.builtins.keys() {
result.push((name.clone(), id.clone()));
}
}
}
for (name, builtin) in &self.manifest_builtins {
result.push((name.clone(), builtin.plugin_id.clone()));
}
for (name, (id, _)) in &self.builtins {
result.push((name.clone(), id.clone()));
}
result.sort();
result
}
#[cfg(feature = "native")]
pub fn search_marketplace(&self, query: &str) -> Result<Vec<MarketplacePlugin>> {
let mut results = Vec::new();
for marketplace in &self.marketplaces {
if !marketplace.enabled {
continue;
}
let url = format!(
"{}/search?q={}",
marketplace.url,
urlencoding::encode(query)
);
let response = marketplace_fetch("marketplace_search", &url)?;
if response.status().is_success() {
let plugins: Vec<MarketplacePlugin> = response
.json_capped()
.context("Failed to parse marketplace response")?;
results.extend(plugins);
}
}
Ok(results)
}
#[cfg(feature = "native")]
pub fn install_from_marketplace(&mut self, plugin_id: &str) -> Result<String> {
let mut down: Vec<String> = Vec::new();
for marketplace in &self.marketplaces {
if !marketplace.enabled {
continue;
}
let url = format!("{}/plugins/{}/download", marketplace.url, plugin_id);
match marketplace_fetch("marketplace_install", &url) {
Ok(resp) if resp.status().is_success() => {
let plugin_dir = self
.load_paths
.first()
.context("No plugin directory configured")?;
std::fs::create_dir_all(plugin_dir)?;
let plugin_path = plugin_dir.join(format!("{}.plugin.toml", plugin_id));
let content = resp.text_capped()?;
std::fs::write(&plugin_path, &content)?;
return load_plugin_from_manifest(plugin_path.to_str().unwrap_or(""))
.map(|_| plugin_id.to_string());
}
Ok(resp) if resp.status() == reqwest::StatusCode::NOT_FOUND => {}
Ok(resp) => down.push(format!(
"{} answered HTTP {}",
marketplace.name,
resp.status().as_u16()
)),
Err(e) if is_network_failure(&e) => {
down.push(format!("{}: {}", marketplace.name, e));
}
Err(e) => return Err(e),
}
}
if !down.is_empty() {
return Err(plugin_coded(
crate::safety::ErrorCode::Network,
format!(
"plugin {plugin_id} could not be checked in every marketplace: {}",
down.join("; ")
),
"a marketplace is down or unreachable, so the plugin may be listed there; nothing was installed; retry later",
));
}
Err(plugin_coded(
crate::safety::ErrorCode::NotFound,
"Plugin not found in any marketplace".to_string(),
"no configured marketplace lists this plugin id; search the marketplaces for what is available",
))
}
#[doc(hidden)]
pub fn __test_set_marketplaces(&mut self, marketplaces: Vec<MarketplaceRegistry>) {
self.marketplaces = marketplaces;
}
pub fn add_marketplace(&mut self, name: String, url: String) {
self.marketplaces.push(MarketplaceRegistry {
name,
url,
enabled: true,
});
}
pub fn list_marketplaces(&self) -> Vec<MarketplaceRegistry> {
self.marketplaces.clone()
}
pub fn get_ai_backend(&self, name: &str) -> Option<Arc<dyn AIBackendPlugin>> {
self.ai_backends.get(name).cloned()
}
pub fn get_builtin(&self, name: &str) -> Option<Arc<dyn BuiltinPlugin>> {
self.builtins.get(name).map(|(_, p)| p.clone())
}
pub fn get_file_handler(&self, extension: &str) -> Option<Arc<dyn FileHandlerPlugin>> {
self.file_handlers.get(&extension.to_lowercase()).cloned()
}
pub fn get_transport(&self, scheme: &str) -> Option<Arc<dyn TransportPlugin>> {
self.transports.get(scheme).cloned()
}
pub fn list_plugins(&self) -> Vec<PluginMetadata> {
let mut v: Vec<PluginMetadata> =
self.plugins.values().map(|e| e.metadata.clone()).collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
pub fn list_by_category(&self, category: &PluginCategory) -> Vec<PluginMetadata> {
let mut v = self
.plugins
.values()
.filter(|e| e.metadata.categories.contains(category))
.map(|e| e.metadata.clone())
.collect::<Vec<_>>();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
pub fn enable_plugin(&mut self, plugin_id: &str) -> Result<()> {
self.plugins
.get_mut(plugin_id)
.ok_or_else(|| crate::safety::not_found("plugin_enable", "plugin", plugin_id))?
.enabled = true;
Ok(())
}
pub fn disable_plugin(&mut self, plugin_id: &str) -> Result<()> {
self.plugins
.get_mut(plugin_id)
.ok_or_else(|| crate::safety::not_found("plugin_disable", "plugin", plugin_id))?
.enabled = false;
Ok(())
}
pub fn is_enabled(&self, plugin_id: &str) -> bool {
self.plugins
.get(plugin_id)
.map(|e| e.enabled)
.unwrap_or(false)
}
pub fn get_metadata(&self, plugin_id: &str) -> Option<PluginMetadata> {
self.plugins.get(plugin_id).map(|e| e.metadata.clone())
}
}
impl Default for PluginRegistry {
fn default() -> Self {
Self::new()
}
}
struct JsonFileHandler;
impl FileHandlerPlugin for JsonFileHandler {
fn metadata(&self) -> &PluginMetadata {
static META: OnceLock<PluginMetadata> = OnceLock::new();
META.get_or_init(|| PluginMetadata {
id: "builtin.json".to_string(),
name: "JSON File Handler".to_string(),
version: "1.0.0".to_string(),
author: "IronShell Team".to_string(),
description: "Native JSON file reading and writing".to_string(),
categories: vec![PluginCategory::FileHandler],
min_ironshell_version: "0.1.0".to_string(),
dependencies: vec![],
homepage: None,
license: Some("Apache-2.0".to_string()),
keywords: vec!["json".to_string(), "file".to_string()],
})
}
fn supported_extensions(&self) -> Vec<String> {
vec!["json".to_string()]
}
fn supported_mime_types(&self) -> Vec<String> {
vec!["application/json".to_string()]
}
fn read(&self, path: &std::path::Path) -> Result<Value> {
let content = std::fs::read_to_string(path)?;
let json: serde_json::Value = serde_json::from_str(&content)?;
Ok(json_to_value(json))
}
fn write(&self, path: &std::path::Path, value: &Value) -> Result<()> {
let json = value_to_json(value);
let content = serde_json::to_string_pretty(&json)?;
std::fs::write(path, content)?;
Ok(())
}
}
struct CsvFileHandler;
impl FileHandlerPlugin for CsvFileHandler {
fn metadata(&self) -> &PluginMetadata {
static META: OnceLock<PluginMetadata> = OnceLock::new();
META.get_or_init(|| PluginMetadata {
id: "builtin.csv".to_string(),
name: "CSV File Handler".to_string(),
version: "1.0.0".to_string(),
author: "IronShell Team".to_string(),
description: "Native CSV file reading and writing".to_string(),
categories: vec![PluginCategory::FileHandler],
min_ironshell_version: "0.1.0".to_string(),
dependencies: vec![],
homepage: None,
license: Some("Apache-2.0".to_string()),
keywords: vec!["csv".to_string(), "file".to_string(), "table".to_string()],
})
}
fn supported_extensions(&self) -> Vec<String> {
vec!["csv".to_string()]
}
fn supported_mime_types(&self) -> Vec<String> {
vec!["text/csv".to_string()]
}
fn read(&self, path: &std::path::Path) -> Result<Value> {
let mut reader = csv::Reader::from_path(path)?;
let headers: Vec<String> = reader.headers()?.iter().map(|s| s.to_string()).collect();
let mut rows = Vec::new();
for result in reader.records() {
let record = result?;
let mut row = std::collections::BTreeMap::new();
for (i, field) in record.iter().enumerate() {
if let Some(header) = headers.get(i) {
row.insert(header.clone(), Value::Str(field.to_string()));
}
}
rows.push(Value::Record(row));
}
Ok(Value::Array(rows))
}
fn write(&self, path: &std::path::Path, value: &Value) -> Result<()> {
let mut writer = csv::Writer::from_path(path)?;
if let Value::Array(rows) = value {
if let Some(Value::Record(first)) = rows.first() {
let headers: Vec<&str> = first.keys().map(|s| s.as_str()).collect();
writer.write_record(&headers)?;
for row in rows {
if let Value::Record(rec) = row {
let values: Vec<String> = headers
.iter()
.map(|h| rec.get(*h).map(|v| v.to_string()).unwrap_or_default())
.collect();
writer.write_record(&values)?;
}
}
}
}
writer.flush()?;
Ok(())
}
}
struct TomlFileHandler;
impl FileHandlerPlugin for TomlFileHandler {
fn metadata(&self) -> &PluginMetadata {
static META: OnceLock<PluginMetadata> = OnceLock::new();
META.get_or_init(|| PluginMetadata {
id: "builtin.toml".to_string(),
name: "TOML File Handler".to_string(),
version: "1.0.0".to_string(),
author: "IronShell Team".to_string(),
description: "Native TOML file reading and writing".to_string(),
categories: vec![PluginCategory::FileHandler],
min_ironshell_version: "0.1.0".to_string(),
dependencies: vec![],
homepage: None,
license: Some("Apache-2.0".to_string()),
keywords: vec!["toml".to_string(), "file".to_string(), "config".to_string()],
})
}
fn supported_extensions(&self) -> Vec<String> {
vec!["toml".to_string()]
}
fn supported_mime_types(&self) -> Vec<String> {
vec!["application/toml".to_string()]
}
fn read(&self, path: &std::path::Path) -> Result<Value> {
let content = std::fs::read_to_string(path)?;
let toml_value: toml::Value = toml::from_str(&content)?;
Ok(toml_to_value(toml_value))
}
fn write(&self, path: &std::path::Path, value: &Value) -> Result<()> {
let toml_value = value_to_toml(value);
let content = toml::to_string_pretty(&toml_value)?;
std::fs::write(path, content)?;
Ok(())
}
}
fn json_to_value(json: serde_json::Value) -> Value {
match json {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::Int(i)
} else if let Some(f) = n.as_f64() {
Value::Float(f)
} else {
Value::Null
}
}
serde_json::Value::String(s) => Value::Str(s),
serde_json::Value::Array(arr) => Value::Array(arr.into_iter().map(json_to_value).collect()),
serde_json::Value::Object(obj) => {
let map = obj
.into_iter()
.map(|(k, v)| (k, json_to_value(v)))
.collect();
Value::Record(map)
}
}
}
fn value_to_json(value: &Value) -> serde_json::Value {
match value {
Value::Null => serde_json::Value::Null,
Value::Bool(b) => serde_json::Value::Bool(*b),
Value::Int(i) => serde_json::Value::Number((*i).into()),
Value::Float(f) => serde_json::Number::from_f64(*f)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
Value::Str(s) => serde_json::Value::String(s.clone()),
Value::Uri(u) => serde_json::Value::String(u.clone()),
Value::Array(arr) => serde_json::Value::Array(arr.iter().map(value_to_json).collect()),
Value::Record(rec) => {
let obj: serde_json::Map<String, serde_json::Value> = rec
.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect();
serde_json::Value::Object(obj)
}
Value::Table(t) => {
let rows: Vec<serde_json::Value> = t
.rows
.iter()
.map(|row| {
let obj: serde_json::Map<String, serde_json::Value> = row
.iter()
.map(|(k, v)| (k.clone(), value_to_json(v)))
.collect();
serde_json::Value::Object(obj)
})
.collect();
serde_json::Value::Array(rows)
}
Value::Lambda(_) => serde_json::Value::String("<lambda>".to_string()),
Value::AsyncLambda(_) => serde_json::Value::String("<async lambda>".to_string()),
Value::Future(_) => serde_json::Value::String("<future>".to_string()),
Value::Builtin(b) => serde_json::Value::String(format!("<builtin:{}>", b.name)),
Value::Error(msg) => serde_json::json!({"error": msg}),
}
}
pub(crate) fn toml_to_value(toml: toml::Value) -> Value {
match toml {
toml::Value::Boolean(b) => Value::Bool(b),
toml::Value::Integer(i) => Value::Int(i),
toml::Value::Float(f) => Value::Float(f),
toml::Value::String(s) => Value::Str(s),
toml::Value::Datetime(dt) => Value::Str(dt.to_string()),
toml::Value::Array(arr) => Value::Array(arr.into_iter().map(toml_to_value).collect()),
toml::Value::Table(table) => {
let map = table
.into_iter()
.map(|(k, v)| (k, toml_to_value(v)))
.collect();
Value::Record(map)
}
}
}
fn value_to_toml(value: &Value) -> toml::Value {
match value {
Value::Null => toml::Value::String("null".to_string()),
Value::Bool(b) => toml::Value::Boolean(*b),
Value::Int(i) => toml::Value::Integer(*i),
Value::Float(f) => toml::Value::Float(*f),
Value::Str(s) => toml::Value::String(s.clone()),
Value::Uri(u) => toml::Value::String(u.clone()),
Value::Array(arr) => toml::Value::Array(arr.iter().map(value_to_toml).collect()),
Value::Record(rec) => {
let table: toml::map::Map<String, toml::Value> = rec
.iter()
.map(|(k, v)| (k.clone(), value_to_toml(v)))
.collect();
toml::Value::Table(table)
}
Value::Table(t) => {
let tables: Vec<toml::Value> = t
.rows
.iter()
.map(|row| {
let table: toml::map::Map<String, toml::Value> = row
.iter()
.map(|(k, v)| (k.clone(), value_to_toml(v)))
.collect();
toml::Value::Table(table)
})
.collect();
toml::Value::Array(tables)
}
Value::Lambda(_) => toml::Value::String("<lambda>".to_string()),
Value::AsyncLambda(_) => toml::Value::String("<async lambda>".to_string()),
Value::Future(_) => toml::Value::String("<future>".to_string()),
Value::Builtin(b) => toml::Value::String(format!("<builtin:{}>", b.name)),
Value::Error(msg) => toml::Value::String(format!("Error: {}", msg)),
}
}
pub fn bi_plugins_list() -> Value {
let registry = get_plugin_registry().lock().unwrap();
let plugins: Vec<Value> = registry
.list_plugins()
.into_iter()
.map(|meta| {
let mut rec = std::collections::BTreeMap::new();
rec.insert("id".to_string(), Value::Str(meta.id));
rec.insert("name".to_string(), Value::Str(meta.name));
rec.insert("version".to_string(), Value::Str(meta.version));
rec.insert("author".to_string(), Value::Str(meta.author));
rec.insert("description".to_string(), Value::Str(meta.description));
rec.insert(
"categories".to_string(),
Value::Array(
meta.categories
.into_iter()
.map(|c| Value::Str(format!("{:?}", c)))
.collect(),
),
);
Value::Record(rec)
})
.collect();
Value::Array(plugins)
}
pub fn bi_plugin_info(plugin_id: &str) -> Value {
let registry = get_plugin_registry().lock().unwrap();
match registry.get_metadata(plugin_id) {
Some(meta) => {
let mut rec = std::collections::BTreeMap::new();
rec.insert("id".to_string(), Value::Str(meta.id));
rec.insert("name".to_string(), Value::Str(meta.name));
rec.insert("version".to_string(), Value::Str(meta.version));
rec.insert("author".to_string(), Value::Str(meta.author));
rec.insert("description".to_string(), Value::Str(meta.description));
rec.insert(
"min_ironshell_version".to_string(),
Value::Str(meta.min_ironshell_version),
);
rec.insert(
"categories".to_string(),
Value::Array(
meta.categories
.into_iter()
.map(|c| Value::Str(format!("{:?}", c)))
.collect(),
),
);
rec.insert(
"dependencies".to_string(),
Value::Array(meta.dependencies.into_iter().map(Value::Str).collect()),
);
rec.insert(
"enabled".to_string(),
Value::Bool(registry.is_enabled(plugin_id)),
);
Value::Record(rec)
}
None => Value::Null,
}
}
pub fn bi_plugin_enable(plugin_id: &str) -> Result<Value> {
let mut registry = get_plugin_registry().lock().unwrap();
registry.enable_plugin(plugin_id)?;
Ok(Value::Bool(true))
}
pub fn bi_plugin_disable(plugin_id: &str) -> Result<Value> {
let mut registry = get_plugin_registry().lock().unwrap();
registry.disable_plugin(plugin_id)?;
Ok(Value::Bool(true))
}
pub fn bi_plugin_categories() -> Value {
let categories = vec![
"AIBackend",
"Builtin",
"FileHandler",
"Transport",
"Syntax",
"TUIComponent",
];
Value::Array(
categories
.into_iter()
.map(|c| Value::Str(c.to_string()))
.collect(),
)
}
pub fn is_manifest_builtin(name: &str) -> bool {
if MANIFEST_BUILTIN_COUNT.load(Ordering::Acquire) == 0 {
return false;
}
get_plugin_registry()
.lock()
.map(|registry| registry.manifest_builtins.contains_key(name))
.unwrap_or(false)
}
pub fn call_manifest_builtin(name: &str, args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let lambda = {
let registry = get_plugin_registry().lock().unwrap();
let Some(entry) = registry.manifest_builtins.get(name) else {
return Err(crate::safety::unknown_builtin(name, Vec::new()));
};
if !registry.is_enabled(&entry.plugin_id) {
return Err(crate::safety::bad_state(
name,
&format!("plugin {} is disabled", entry.plugin_id),
"plugin_enable",
));
}
Arc::clone(&entry.lambda)
};
let mut env = crate::modules::env_with_modules();
let f = crate::eval::eval_expr(&lambda, &mut env)?;
crate::eval::call_value_with_pipe(f, input, args, &mut env)
}
fn authorize_manifest_code(manifest: &Path) -> Result<()> {
let plugins_off = std::env::var("IRONSHELL_PLUGINS")
.map(|v| v.eq_ignore_ascii_case("off") || v == "0")
.unwrap_or(false);
let agent_mode = crate::safety::current_mode() == crate::safety::Mode::Agent;
let allow = std::env::var("IRONSHELL_PLUGIN_ALLOW").unwrap_or_default();
let allow_roots: Vec<&str> = allow.split(';').collect();
let canon = std::fs::canonicalize(manifest).unwrap_or_else(|_| manifest.to_path_buf());
if DynamicPlugin::is_load_permitted(&canon, plugins_off, &allow_roots, agent_mode) {
return Ok(());
}
Err(crate::safety::policy_deny(
"plugin_load",
format!(
"refusing to define [builtins] code from {} (plugins disabled, or agent mode without an allowlist)",
manifest.display()
),
"set IRONSHELL_PLUGIN_ALLOW=<dir> to allow an audited directory, or unset IRONSHELL_PLUGINS=off",
))
}
fn parse_manifest_builtins(table: &toml::Value) -> Result<Vec<(String, crate::ast::Expr)>> {
let Some(map) = table.as_table() else {
return Err(crate::safety::bad_arg(
"plugin_load",
"[builtins] as a table of name = \"fn(...) => ...\" entries",
"not a table",
));
};
let mut parsed = Vec::with_capacity(map.len());
for (name, value) in map {
let identifier = name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !identifier {
return Err(crate::safety::bad_arg(
"plugin_load",
"a [builtins] name: letters, digits and '_', starting with a letter or '_'",
&format!("{name:?}"),
));
}
if crate::builtins::is_dispatched(name) {
return Err(crate::safety::bad_arg(
"plugin_load",
"a [builtins] name that no core builtin serves",
&format!("{name} is a core builtin"),
));
}
let src = value.as_str().ok_or_else(|| {
crate::safety::bad_arg(
"plugin_load",
&format!("[builtins] {name}: a string of IronShell source"),
"not a string",
)
})?;
let lambda = match crate::parser::parse_program(src).ok().as_deref() {
Some([crate::ast::Stmt::Expr(expr @ crate::ast::Expr::Lambda { .. })]) => expr.clone(),
_ => {
return Err(crate::safety::bad_arg(
"plugin_load",
&format!("[builtins] {name}: exactly one lambda literal, fn(...) => ..."),
src,
))
}
};
parsed.push((name.clone(), lambda));
}
Ok(parsed)
}
pub fn load_plugin_from_manifest(path: &str) -> Result<Value> {
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
let manifest_path = std::path::Path::new(path);
let meta = std::fs::metadata(manifest_path)
.map_err(|e| crate::safety::fs_error("plugin_load", manifest_path, &e))?;
if !meta.is_file() {
return Err(crate::safety::bad_arg(
"plugin_load",
"a path to a manifest file",
&format!("{path:?}"),
));
}
if meta.len() > MAX_MANIFEST_BYTES {
return Err(crate::safety::bad_arg(
"plugin_load",
"a manifest of at most 1 MiB",
&format!("{} bytes", meta.len()),
));
}
let content = std::fs::read_to_string(manifest_path)
.map_err(|e| crate::safety::fs_error("plugin_load", manifest_path, &e))?;
let manifest: toml::Value = toml::from_str(&content).map_err(|e| {
crate::safety::bad_arg(
"plugin_load",
"a TOML plugin manifest",
&format!("unparseable: {e}"),
)
})?;
let plugin_section = manifest.get("plugin").ok_or_else(|| {
crate::safety::bad_arg(
"plugin_load",
"a manifest with a [plugin] table",
"no [plugin] table",
)
})?;
let id = plugin_section
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
crate::safety::bad_arg("plugin_load", "plugin.id: a string", "no plugin id")
})?;
let id_ok = !id.is_empty()
&& id.len() <= 128
&& !id.starts_with(['-', '.'])
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
if !id_ok {
return Err(crate::safety::bad_arg(
"plugin_load",
"plugin.id: 1 to 128 letters, digits, '.', '_' or '-', not starting with '-' or '.'",
&format!("{id:?}"),
));
}
let builtins = match manifest.get("builtins") {
Some(table) => parse_manifest_builtins(table)?,
None => Vec::new(),
};
if !builtins.is_empty() {
authorize_manifest_code(manifest_path)?;
}
let name = plugin_section
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(id);
let version = plugin_section
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("1.0.0");
let author = plugin_section
.get("author")
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
let description = plugin_section
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
let min_version = plugin_section
.get("min_ironshell_version")
.and_then(|v| v.as_str())
.unwrap_or("0.1.0");
let categories_arr = plugin_section
.get("categories")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.filter_map(|s| match s.to_lowercase().as_str() {
"aibackend" | "ai_backend" => Some(PluginCategory::AIBackend),
"builtin" | "builtins" => Some(PluginCategory::Builtin),
"filehandler" | "file_handler" => Some(PluginCategory::FileHandler),
"transport" => Some(PluginCategory::Transport),
"syntax" => Some(PluginCategory::Syntax),
"tuicomponent" | "tui_component" => Some(PluginCategory::TUIComponent),
other => Some(PluginCategory::Custom(other.to_string())),
})
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec![PluginCategory::Custom("unknown".to_string())]);
let dependencies = plugin_section
.get("dependencies")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let homepage = plugin_section
.get("homepage")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let license = plugin_section
.get("license")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let keywords = plugin_section
.get("keywords")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let metadata = PluginMetadata {
id: id.to_string(),
name: name.to_string(),
version: version.to_string(),
author: author.to_string(),
description: description.to_string(),
categories: categories_arr,
min_ironshell_version: min_version.to_string(),
dependencies,
homepage,
license,
keywords,
};
let mut registry = get_plugin_registry().lock().unwrap();
if registry.plugins.contains_key(&metadata.id) {
return Err(crate::safety::bad_state(
"plugin_load",
&format!("plugin {} is already loaded", metadata.id),
"plugin_unload",
));
}
for (name, _) in &builtins {
if let Some(owner) = registry.manifest_builtins.get(name) {
return Err(crate::safety::bad_state(
"plugin_load",
&format!(
"builtin {name} is already provided by plugin {}",
owner.plugin_id
),
"plugin_unload",
));
}
}
for (name, lambda) in builtins {
registry.manifest_builtins.insert(
name,
ManifestBuiltin {
plugin_id: metadata.id.clone(),
lambda: Arc::new(lambda),
},
);
}
sync_manifest_count(®istry);
registry.plugins.insert(
metadata.id.clone(),
PluginEntry {
metadata: metadata.clone(),
source: PluginSource::Manifest(manifest_path.to_path_buf()),
enabled: true,
load_time: std::time::Instant::now(),
},
);
let mut result = std::collections::BTreeMap::new();
result.insert("id".to_string(), Value::Str(metadata.id));
result.insert("name".to_string(), Value::Str(metadata.name));
result.insert("version".to_string(), Value::Str(metadata.version));
result.insert("status".to_string(), Value::Str("registered".to_string()));
Ok(Value::Record(result))
}
pub fn unload_plugin(plugin_id: &str) -> Result<Value> {
let mut registry = get_plugin_registry().lock().unwrap();
if plugin_id.starts_with("builtin.") {
return Err(crate::safety::policy_deny(
"plugin_unload",
format!("plugin_unload: Cannot unload built-in plugin: {plugin_id}"),
"built-in plugins are part of the shell and are never removed at runtime",
));
}
if registry.plugins.remove(plugin_id).is_some() {
registry.builtins.retain(|_key, (pid, _)| pid != plugin_id);
registry
.manifest_builtins
.retain(|_, builtin| builtin.plugin_id != plugin_id);
sync_manifest_count(®istry);
let mut result = std::collections::BTreeMap::new();
result.insert("id".to_string(), Value::Str(plugin_id.to_string()));
result.insert("status".to_string(), Value::Str("unloaded".to_string()));
Ok(Value::Record(result))
} else {
Err(crate::safety::not_found(
"plugin_unload",
"plugin",
plugin_id,
))
}
}
pub fn bi_plugin_load_dynamic(path: &str) -> Result<Value> {
let mut registry = get_plugin_registry().lock().unwrap();
let plugin_id = registry.load_dynamic_plugin(Path::new(path))?;
let mut result = std::collections::BTreeMap::new();
result.insert("id".to_string(), Value::Str(plugin_id));
result.insert("status".to_string(), Value::Str("loaded".to_string()));
result.insert("type".to_string(), Value::Str("dynamic".to_string()));
Ok(Value::Record(result))
}
pub fn bi_plugin_load_script(path: &str) -> Result<Value> {
let mut registry = get_plugin_registry().lock().unwrap();
let plugin_id = registry.load_script_plugin(Path::new(path))?;
let mut result = std::collections::BTreeMap::new();
result.insert("id".to_string(), Value::Str(plugin_id));
result.insert("status".to_string(), Value::Str("loaded".to_string()));
result.insert("type".to_string(), Value::Str("script".to_string()));
Ok(Value::Record(result))
}
pub fn bi_plugin_load_all() -> Value {
let mut registry = get_plugin_registry().lock().unwrap();
let results = registry.load_all_plugins();
let loaded: Vec<Value> = results
.into_iter()
.filter_map(|r| r.ok())
.map(Value::Str)
.collect();
Value::Array(loaded)
}
pub fn bi_plugin_reload(plugin_id: &str) -> Result<Value> {
let mut registry = get_plugin_registry().lock().unwrap();
registry.reload_plugin(plugin_id)?;
let mut result = std::collections::BTreeMap::new();
result.insert("id".to_string(), Value::Str(plugin_id.to_string()));
result.insert("status".to_string(), Value::Str("reloaded".to_string()));
Ok(Value::Record(result))
}
pub fn bi_plugin_unload(plugin_id: &str) -> Result<Value> {
unload_plugin(plugin_id)
}
pub fn bi_plugin_hot_reload(enable: bool) -> Value {
set_hot_reload(enable);
Value::Bool(enable)
}
pub fn bi_plugin_check_updates() -> Value {
let mut registry = get_plugin_registry().lock().unwrap();
let reloaded = registry.check_hot_reload();
Value::Array(reloaded.into_iter().map(Value::Str).collect())
}
pub fn bi_plugin_builtins() -> Value {
let registry = get_plugin_registry().lock().unwrap();
let builtins = registry.list_plugin_builtins();
let list: Vec<Value> = builtins
.into_iter()
.map(|(name, plugin_id)| {
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(name));
rec.insert("plugin".to_string(), Value::Str(plugin_id));
Value::Record(rec)
})
.collect();
Value::Array(list)
}
pub fn bi_plugin_builtin_help(name: &str) -> Value {
let registry = get_plugin_registry().lock().unwrap();
match registry.get_plugin_builtin_help(name) {
Some(help) => Value::Str(help),
None => Value::Null,
}
}
#[cfg(feature = "native")]
pub fn bi_marketplace_search(query: &str) -> Result<Value> {
let registry = get_plugin_registry().lock().unwrap();
let results = registry.search_marketplace(query)?;
let list: Vec<Value> = results
.into_iter()
.map(|p| {
let mut rec = std::collections::BTreeMap::new();
rec.insert("id".to_string(), Value::Str(p.id));
rec.insert("name".to_string(), Value::Str(p.name));
rec.insert("version".to_string(), Value::Str(p.version));
rec.insert("description".to_string(), Value::Str(p.description));
rec.insert("author".to_string(), Value::Str(p.author));
rec.insert("downloads".to_string(), Value::Int(p.downloads as i64));
rec.insert(
"categories".to_string(),
Value::Array(p.categories.into_iter().map(Value::Str).collect()),
);
Value::Record(rec)
})
.collect();
Ok(Value::Array(list))
}
#[cfg(feature = "native")]
pub fn bi_marketplace_install(plugin_id: &str) -> Result<Value> {
let mut registry = get_plugin_registry().lock().unwrap();
let id = registry.install_from_marketplace(plugin_id)?;
let mut result = std::collections::BTreeMap::new();
result.insert("id".to_string(), Value::Str(id));
result.insert("status".to_string(), Value::Str("installed".to_string()));
Ok(Value::Record(result))
}
pub fn bi_marketplace_list() -> Value {
let registry = get_plugin_registry().lock().unwrap();
let marketplaces = registry.list_marketplaces();
let list: Vec<Value> = marketplaces
.into_iter()
.map(|m| {
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(m.name));
rec.insert("url".to_string(), Value::Str(m.url));
rec.insert("enabled".to_string(), Value::Bool(m.enabled));
Value::Record(rec)
})
.collect();
Value::Array(list)
}
pub fn bi_marketplace_add(name: &str, url: &str) -> Value {
let mut registry = get_plugin_registry().lock().unwrap();
registry.add_marketplace(name.to_string(), url.to_string());
Value::Bool(true)
}
pub fn bi_plugin_source(plugin_id: &str) -> Value {
let registry = get_plugin_registry().lock().unwrap();
if let Some(entry) = registry.plugins.get(plugin_id) {
let source_str = match &entry.source {
PluginSource::Builtin => "builtin".to_string(),
PluginSource::Manifest(p) => format!("manifest:{}", p.display()),
PluginSource::DynamicLibrary(p) => format!("dynamic:{}", p.display()),
PluginSource::Script(p) => format!("script:{}", p.display()),
PluginSource::Marketplace { registry, package } => {
format!("marketplace:{}:{}", registry, package)
}
};
let mut rec = std::collections::BTreeMap::new();
rec.insert("id".to_string(), Value::Str(plugin_id.to_string()));
rec.insert("source".to_string(), Value::Str(source_str));
rec.insert("enabled".to_string(), Value::Bool(entry.enabled));
rec.insert(
"load_time".to_string(),
Value::Int(entry.load_time.elapsed().as_secs() as i64),
);
Value::Record(rec)
} else {
Value::Null
}
}
pub fn bi_plugins_by_source(source_type: &str) -> Value {
let registry = get_plugin_registry().lock().unwrap();
let filtered: Vec<Value> = registry
.plugins
.iter()
.filter(|(_, entry)| {
let matches = match source_type.to_lowercase().as_str() {
"builtin" => matches!(entry.source, PluginSource::Builtin),
"dynamic" | "library" => matches!(entry.source, PluginSource::DynamicLibrary(_)),
"script" => matches!(entry.source, PluginSource::Script(_)),
"manifest" => matches!(entry.source, PluginSource::Manifest(_)),
"marketplace" => matches!(entry.source, PluginSource::Marketplace { .. }),
_ => true,
};
matches
})
.map(|(id, entry)| {
let mut rec = std::collections::BTreeMap::new();
rec.insert("id".to_string(), Value::Str(id.clone()));
rec.insert("name".to_string(), Value::Str(entry.metadata.name.clone()));
rec.insert(
"version".to_string(),
Value::Str(entry.metadata.version.clone()),
);
rec.insert("enabled".to_string(), Value::Bool(entry.enabled));
Value::Record(rec)
})
.collect();
let mut filtered = filtered;
filtered.sort_by_key(|v| match v {
Value::Record(r) => r
.get("id")
.map(|i| i.to_display_string())
.unwrap_or_default(),
_ => String::new(),
});
Value::Array(filtered)
}
#[cfg(test)]
mod tests {
use super::*;
static REGISTRY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn registry_guard() -> (
std::sync::MutexGuard<'static, ()>,
std::sync::MutexGuard<'static, ()>,
) {
let env = crate::safety::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
(env, REGISTRY_LOCK.lock().unwrap_or_else(|e| e.into_inner()))
}
#[test]
fn plugin_load_policy_is_default_deny_in_agent_mode() {
use std::path::Path;
let p = Path::new("/opt/plugins/libfoo.so");
assert!(!DynamicPlugin::is_load_permitted(p, true, &[], false));
assert!(!DynamicPlugin::is_load_permitted(p, true, &[], true));
assert!(!DynamicPlugin::is_load_permitted(p, false, &[], true));
assert!(!DynamicPlugin::is_load_permitted(p, false, &[""], true));
assert!(DynamicPlugin::is_load_permitted(p, false, &[], false));
}
fn manifest_fixture(tag: &str, body: &str) -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir =
std::env::temp_dir().join(format!("ae-plugin-{tag}-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir).expect("fixture directory");
let path = dir.join("plugin.toml");
std::fs::write(&path, body).expect("fixture manifest");
path.to_string_lossy().into_owned()
}
fn call_manifest(name: &str, args: Vec<Value>) -> Result<Value> {
let mut env = crate::env::Env::new();
crate::builtins::call_with_input(name, args, None, &mut env)
}
fn load_error(path: &str) -> String {
load_plugin_from_manifest(path)
.expect_err("the manifest should be refused")
.to_string()
}
fn is_registered(plugin_id: &str) -> bool {
get_plugin_registry()
.lock()
.unwrap()
.get_metadata(plugin_id)
.is_some()
}
#[test]
fn manifest_builtins_register_call_disable_and_unload() {
let _guard = registry_guard();
let path = manifest_fixture(
"reg",
"[plugin]\nid = \"zz-fixture-math\"\n\n[builtins]\nzz_double = \"fn(x) => x * 2\"\n",
);
load_plugin_from_manifest(&path).expect("a lambda builtin loads");
assert!(is_manifest_builtin("zz_double"));
assert!(get_plugin_registry()
.lock()
.unwrap()
.list_plugin_builtins()
.contains(&("zz_double".to_string(), "zz-fixture-math".to_string())));
assert!(matches!(
call_manifest("zz_double", vec![Value::Int(4)]),
Ok(Value::Int(8))
));
assert_eq!(
crate::safety::effect_of("zz_double"),
crate::safety::Effect::Exec
);
get_plugin_registry()
.lock()
.unwrap()
.disable_plugin("zz-fixture-math")
.expect("disable");
assert!(call_manifest("zz_double", vec![Value::Int(4)]).is_err());
get_plugin_registry()
.lock()
.unwrap()
.enable_plugin("zz-fixture-math")
.expect("enable");
assert!(matches!(
call_manifest("zz_double", vec![Value::Int(5)]),
Ok(Value::Int(10))
));
assert!(unload_plugin("zz-fixture-math").is_ok());
assert!(!is_manifest_builtin("zz_double"));
assert!(call_manifest("zz_double", vec![Value::Int(4)]).is_err());
assert_eq!(
crate::safety::effect_of("zz_double"),
crate::safety::Effect::Pure
);
}
#[test]
fn explain_effects_resolves_a_manifest_builtin_as_exec() {
let _guard = registry_guard();
let path = manifest_fixture(
"explain",
"[plugin]\nid = \"zz-fixture-explain\"\n\n[builtins]\nzz_triple = \"fn(x) => x * 3\"\n",
);
load_plugin_from_manifest(&path).expect("a lambda builtin loads");
let report = crate::explain::explain("zz_triple(1)");
assert!(unload_plugin("zz-fixture-explain").is_ok());
let Ok(Value::Record(r)) = report else {
panic!("explain returns a record")
};
let Some(Value::Array(unresolved)) = r.get("unresolved") else {
panic!("explain reports unresolved calls")
};
assert!(
!unresolved.contains(&Value::Str("zz_triple".into())),
"{unresolved:?}"
);
let Some(Value::Array(calls)) = r.get("calls") else {
panic!("explain reports calls")
};
let Some(Value::Record(call)) = calls.first() else {
panic!("one call site")
};
assert_eq!(call.get("builtin"), Some(&Value::Str("zz_triple".into())));
assert_eq!(call.get("effect"), Some(&Value::Str("exec".into())));
assert_ne!(call.get("decision"), Some(&Value::Str("unknown".into())));
}
#[test]
fn manifest_builtin_names_refuse_core_dotted_and_non_lambda_entries() {
let _guard = registry_guard();
let cases = [
("zz-fixture-core", "len = \"fn(x) => x\"\n"),
("zz-fixture-dot", "\"zz.dot\" = \"fn(x) => x\"\n"),
("zz-fixture-body", "zz_not_lambda = \"1 + 1\"\n"),
];
for (id, entry) in cases {
let path = manifest_fixture(
id,
&format!("[plugin]\nid = \"{id}\"\n\n[builtins]\n{entry}"),
);
let err = load_error(&path);
assert!(err.contains("E_BAD_ARG"), "{id}: {err}");
assert!(!is_registered(id), "{id} should not be registered");
}
assert!(!is_manifest_builtin("len"));
}
#[test]
fn manifest_builtin_names_are_exclusive_across_plugins() {
let _guard = registry_guard();
let first = manifest_fixture(
"first",
"[plugin]\nid = \"zz-fixture-first\"\n\n[builtins]\nzz_shared = \"fn(x) => x\"\n",
);
load_plugin_from_manifest(&first).expect("the first provider loads");
let second = manifest_fixture(
"second",
"[plugin]\nid = \"zz-fixture-second\"\n\n[builtins]\nzz_shared = \"fn(x) => x + 1\"\n",
);
let err = load_error(&second);
assert!(err.contains("E_BAD_STATE"), "{err}");
assert!(!is_registered("zz-fixture-second"));
assert!(matches!(
call_manifest("zz_shared", vec![Value::Int(3)]),
Ok(Value::Int(3))
));
assert!(unload_plugin("zz-fixture-first").is_ok());
assert!(!is_manifest_builtin("zz_shared"));
}
#[test]
fn test_plugin_registry_creation() {
let registry = PluginRegistry::new();
assert!(!registry.plugins.is_empty());
assert!(registry.file_handlers.contains_key("json"));
assert!(registry.file_handlers.contains_key("csv"));
assert!(registry.file_handlers.contains_key("toml"));
}
#[test]
fn test_json_conversion() {
let json = serde_json::json!({
"name": "test",
"count": 42,
"active": true,
"items": [1, 2, 3]
});
let value = json_to_value(json.clone());
let back = value_to_json(&value);
assert_eq!(json, back);
}
#[test]
fn test_plugin_list() {
let list = bi_plugins_list();
if let Value::Array(plugins) = list {
assert!(!plugins.is_empty());
} else {
panic!("Expected array");
}
}
#[test]
fn test_plugin_info() {
let info = bi_plugin_info("builtin.json");
if let Value::Record(rec) = info {
assert_eq!(rec.get("id"), Some(&Value::Str("builtin.json".to_string())));
} else {
panic!("Expected record");
}
}
#[test]
fn test_plugin_enable_disable() {
let _g = registry_guard();
let result = bi_plugin_enable("builtin.json");
assert!(result.is_ok());
let result = bi_plugin_disable("builtin.json");
assert!(result.is_ok());
bi_plugin_enable("builtin.json").unwrap();
}
#[test]
fn test_toml_conversion() {
let toml_str = r#"
name = "test"
count = 42
active = true
items = [1, 2, 3]
"#;
let toml_value: toml::Value = toml::from_str(toml_str).unwrap();
let value = toml_to_value(toml_value);
if let Value::Record(rec) = value {
assert_eq!(rec.get("name"), Some(&Value::Str("test".to_string())));
assert_eq!(rec.get("count"), Some(&Value::Int(42)));
assert_eq!(rec.get("active"), Some(&Value::Bool(true)));
} else {
panic!("Expected record");
}
}
#[test]
fn test_plugin_categories() {
let cats = bi_plugin_categories();
if let Value::Array(arr) = cats {
assert!(arr.len() >= 5);
assert!(arr.contains(&Value::Str("Builtin".to_string())));
assert!(arr.contains(&Value::Str("FileHandler".to_string())));
} else {
panic!("Expected array");
}
}
#[test]
fn test_hot_reload_toggle() {
assert!(!is_hot_reload_enabled());
set_hot_reload(true);
assert!(is_hot_reload_enabled());
set_hot_reload(false);
assert!(!is_hot_reload_enabled());
}
#[test]
fn test_plugin_source() {
let _g = registry_guard();
let source = bi_plugin_source("builtin.json");
if let Value::Record(rec) = source {
assert_eq!(rec.get("id"), Some(&Value::Str("builtin.json".to_string())));
assert_eq!(rec.get("source"), Some(&Value::Str("builtin".to_string())));
assert_eq!(rec.get("enabled"), Some(&Value::Bool(true)));
} else {
panic!("Expected record");
}
}
#[test]
fn test_plugins_by_source() {
let builtin = bi_plugins_by_source("builtin");
if let Value::Array(arr) = builtin {
assert!(!arr.is_empty());
} else {
panic!("Expected array");
}
}
#[test]
fn test_marketplace_list() {
let list = bi_marketplace_list();
if let Value::Array(arr) = list {
assert!(!arr.is_empty());
} else {
panic!("Expected array");
}
}
#[test]
fn test_plugin_builtins() {
let builtins = bi_plugin_builtins();
assert!(matches!(builtins, Value::Array(_)));
}
#[test]
fn test_script_plugin_parsing() {
let content = r#"
#! plugin.id = "test-plugin"
#! plugin.name = "Test Plugin"
#! plugin.version = "1.0.0"
#! plugin.author = "Test Author"
#! builtin.double = "fn(x) => x * 2"
#! help.double = "Doubles the input value"
let triple = fn(x) => x * 3
"#;
let temp_dir = std::env::temp_dir();
let temp_file = temp_dir.join("test_plugin.ish");
std::fs::write(&temp_file, content).unwrap();
let plugin = ScriptPlugin::load(&temp_file).unwrap();
assert_eq!(plugin.metadata.id, "test-plugin");
assert_eq!(plugin.metadata.name, "Test Plugin");
assert_eq!(plugin.metadata.author, "Test Author");
assert!(plugin.builtins.contains_key("double"));
assert!(plugin.builtins.contains_key("triple"));
assert!(plugin.help_text.contains_key("double"));
std::fs::remove_file(temp_file).ok();
}
#[test]
fn test_plugin_metadata_serialization() {
let metadata = PluginMetadata {
id: "test".to_string(),
name: "Test".to_string(),
version: "1.0.0".to_string(),
author: "Author".to_string(),
description: "Desc".to_string(),
categories: vec![PluginCategory::Builtin],
min_ironshell_version: "0.1.0".to_string(),
dependencies: vec![],
homepage: Some("https://example.com".to_string()),
license: Some("MIT".to_string()),
keywords: vec!["test".to_string()],
};
let json = serde_json::to_string(&metadata).unwrap();
let parsed: PluginMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(metadata.id, parsed.id);
assert_eq!(metadata.homepage, parsed.homepage);
assert_eq!(metadata.license, parsed.license);
}
}