use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use thiserror::Error;
use tokio::sync::RwLock;
#[allow(dead_code)]
#[derive(Error, Debug)]
pub enum ManifestError {
#[error("Failed to read manifest file: {0}")]
ReadError(String),
#[error("Failed to parse manifest: {0}")]
ParseError(String),
#[error("Invalid manifest: {0}")]
ValidationError(String),
#[error("Plugin not found: {0}")]
NotFound(String),
#[error("Version mismatch: required {required}, found {found}")]
VersionMismatch { required: String, found: String },
#[error("Dependency error: {0}")]
DependencyError(String),
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginInfo,
#[serde(default)]
pub capabilities: PluginCapabilities,
#[serde(default)]
pub config: PluginConfig,
#[serde(default)]
pub dependencies: HashMap<String, String>,
#[serde(default)]
pub hooks: PluginHooks,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo {
pub name: String,
pub version: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub category: Option<PluginCategory>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginCategory {
Transform,
Integration,
Ai,
Utility,
ControlFlow,
Custom,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginCapabilities {
#[serde(default)]
pub node_types: Vec<String>,
#[serde(default)]
pub supports_streaming: bool,
#[serde(default)]
pub supports_batching: bool,
#[serde(default)]
pub supports_parallel: bool,
#[serde(default)]
pub sandboxed: bool,
#[serde(default)]
pub resource_requirements: ResourceRequirements,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ResourceRequirements {
#[serde(default)]
pub min_memory_mb: Option<u64>,
#[serde(default)]
pub max_memory_mb: Option<u64>,
#[serde(default)]
pub cpu_cores: Option<u32>,
#[serde(default)]
pub requires_network: bool,
#[serde(default)]
pub requires_filesystem: bool,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginConfig {
#[serde(default)]
pub schema: Option<String>,
#[serde(default)]
pub defaults: HashMap<String, serde_json::Value>,
#[serde(default)]
pub env_vars: Vec<String>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginHooks {
#[serde(default)]
pub on_load: Option<String>,
#[serde(default)]
pub on_unload: Option<String>,
#[serde(default)]
pub before_execute: Option<String>,
#[serde(default)]
pub after_execute: Option<String>,
}
#[allow(dead_code)]
impl PluginManifest {
pub fn from_toml(toml_str: &str) -> Result<Self, ManifestError> {
toml::from_str(toml_str).map_err(|e| ManifestError::ParseError(e.to_string()))
}
pub fn from_file(path: &Path) -> Result<Self, ManifestError> {
let contents =
std::fs::read_to_string(path).map_err(|e| ManifestError::ReadError(e.to_string()))?;
Self::from_toml(&contents)
}
pub fn to_toml(&self) -> Result<String, ManifestError> {
toml::to_string_pretty(self).map_err(|e| ManifestError::ParseError(e.to_string()))
}
pub fn validate(&self) -> Result<(), ManifestError> {
if self.plugin.name.is_empty() {
return Err(ManifestError::ValidationError(
"Plugin name is required".into(),
));
}
if self.plugin.version.is_empty() {
return Err(ManifestError::ValidationError(
"Plugin version is required".into(),
));
}
if !is_valid_semver(&self.plugin.version) {
return Err(ManifestError::ValidationError(format!(
"Invalid version format: {}",
self.plugin.version
)));
}
for node_type in &self.capabilities.node_types {
if node_type.is_empty() {
return Err(ManifestError::ValidationError(
"Empty node type is not allowed".into(),
));
}
}
Ok(())
}
pub fn satisfies_version(&self, requirement: &str) -> bool {
check_version_requirement(&self.plugin.version, requirement)
}
}
#[allow(dead_code)]
fn is_valid_semver(version: &str) -> bool {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() < 2 || parts.len() > 3 {
return false;
}
parts.iter().all(|p| p.parse::<u32>().is_ok())
}
#[allow(dead_code)]
fn check_version_requirement(version: &str, requirement: &str) -> bool {
let requirement = requirement.trim();
if requirement.starts_with(">=") {
let req_ver = requirement.trim_start_matches(">=").trim();
compare_versions(version, req_ver) >= 0
} else if requirement.starts_with('>') {
let req_ver = requirement.trim_start_matches('>').trim();
compare_versions(version, req_ver) > 0
} else if requirement.starts_with("<=") {
let req_ver = requirement.trim_start_matches("<=").trim();
compare_versions(version, req_ver) <= 0
} else if requirement.starts_with('<') {
let req_ver = requirement.trim_start_matches('<').trim();
compare_versions(version, req_ver) < 0
} else if requirement.starts_with('=') {
let req_ver = requirement.trim_start_matches('=').trim();
compare_versions(version, req_ver) == 0
} else {
compare_versions(version, requirement) == 0
}
}
#[allow(dead_code)]
fn compare_versions(v1: &str, v2: &str) -> i32 {
let parse_version =
|v: &str| -> Vec<u32> { v.split('.').filter_map(|p| p.parse().ok()).collect() };
let parts1 = parse_version(v1);
let parts2 = parse_version(v2);
for i in 0..3 {
let p1 = parts1.get(i).copied().unwrap_or(0);
let p2 = parts2.get(i).copied().unwrap_or(0);
match p1.cmp(&p2) {
std::cmp::Ordering::Greater => return 1,
std::cmp::Ordering::Less => return -1,
std::cmp::Ordering::Equal => continue,
}
}
0
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct LoadedPlugin {
pub manifest: PluginManifest,
pub path: PathBuf,
pub loaded_at: SystemTime,
pub manifest_modified: SystemTime,
pub state: PluginState,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginState {
Ready,
Loading,
Failed,
Disabled,
NeedsReload,
}
#[allow(dead_code)]
pub struct PluginManager {
plugins: Arc<RwLock<HashMap<String, LoadedPlugin>>>,
search_paths: Vec<PathBuf>,
hot_reload_enabled: bool,
reload_interval: Duration,
reload_task_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
}
#[allow(dead_code)]
impl PluginManager {
pub fn new() -> Self {
Self {
plugins: Arc::new(RwLock::new(HashMap::new())),
search_paths: vec![],
hot_reload_enabled: false,
reload_interval: Duration::from_secs(5),
reload_task_handle: Arc::new(RwLock::new(None)),
}
}
pub fn add_search_path(&mut self, path: impl Into<PathBuf>) {
self.search_paths.push(path.into());
}
pub fn enable_hot_reload(&mut self, interval: Duration) {
self.hot_reload_enabled = true;
self.reload_interval = interval;
}
pub async fn start_hot_reload(&self) -> Result<(), ManifestError> {
if !self.hot_reload_enabled {
return Err(ManifestError::ValidationError(
"Hot-reload is not enabled. Call enable_hot_reload() first.".to_string(),
));
}
if self.reload_task_handle.read().await.is_some() {
return Ok(()); }
let plugins = Arc::clone(&self.plugins);
let interval = self.reload_interval;
let handle = tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(interval);
loop {
interval_timer.tick().await;
let needs_reload = {
let plugins_lock = plugins.read().await;
let mut to_reload = Vec::new();
for (name, loaded) in plugins_lock.iter() {
let manifest_path = loaded.path.join("plugin.toml");
if let Ok(metadata) = std::fs::metadata(&manifest_path) {
if let Ok(modified) = metadata.modified() {
if modified > loaded.manifest_modified {
to_reload.push((name.clone(), loaded.path.clone()));
tracing::info!("Detected changes in plugin: {}", name);
}
}
}
}
to_reload
};
for (name, path) in needs_reload {
let manifest_path = path.join("plugin.toml");
match PluginManifest::from_file(&manifest_path) {
Ok(manifest) => {
if manifest.validate().is_ok() {
let manifest_modified = std::fs::metadata(&manifest_path)
.and_then(|m| m.modified())
.unwrap_or_else(|_| SystemTime::now());
let loaded = LoadedPlugin {
manifest: manifest.clone(),
path: path.clone(),
loaded_at: SystemTime::now(),
manifest_modified,
state: PluginState::Ready,
};
let mut plugins_write = plugins.write().await;
plugins_write.insert(name.clone(), loaded);
tracing::info!(
"Hot-reloaded plugin: {} v{}",
manifest.plugin.name,
manifest.plugin.version
);
}
}
Err(e) => {
tracing::error!("Failed to reload plugin {}: {}", name, e);
}
}
}
}
});
*self.reload_task_handle.write().await = Some(handle);
tracing::info!("Started hot-reload task with interval: {:?}", interval);
Ok(())
}
pub async fn stop_hot_reload(&self) {
if let Some(handle) = self.reload_task_handle.write().await.take() {
handle.abort();
tracing::info!("Stopped hot-reload task");
}
}
pub async fn discover(&self) -> Result<Vec<PluginManifest>, ManifestError> {
let mut manifests = Vec::new();
for path in &self.search_paths {
if !path.exists() {
continue;
}
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let entry_path = entry.path();
if entry_path.is_dir() {
let manifest_path = entry_path.join("plugin.toml");
if manifest_path.exists() {
match PluginManifest::from_file(&manifest_path) {
Ok(manifest) => {
if manifest.validate().is_ok() {
manifests.push(manifest);
}
}
Err(e) => {
tracing::warn!(
"Failed to load manifest from {:?}: {}",
manifest_path,
e
);
}
}
}
}
}
}
}
Ok(manifests)
}
pub async fn load(&self, manifest: PluginManifest, path: PathBuf) -> Result<(), ManifestError> {
manifest.validate()?;
for (dep_name, dep_version) in &manifest.dependencies {
let plugins = self.plugins.read().await;
if let Some(loaded) = plugins.get(dep_name) {
if !loaded.manifest.satisfies_version(dep_version) {
return Err(ManifestError::DependencyError(format!(
"Plugin {} requires {} {}, but found {}",
manifest.plugin.name, dep_name, dep_version, loaded.manifest.plugin.version
)));
}
} else if dep_name != "oxify-engine" {
return Err(ManifestError::DependencyError(format!(
"Plugin {} requires {} which is not loaded",
manifest.plugin.name, dep_name
)));
}
}
let manifest_modified = std::fs::metadata(&path)
.and_then(|m| m.modified())
.unwrap_or_else(|_| SystemTime::now());
let loaded = LoadedPlugin {
manifest: manifest.clone(),
path,
loaded_at: SystemTime::now(),
manifest_modified,
state: PluginState::Ready,
};
let mut plugins = self.plugins.write().await;
plugins.insert(manifest.plugin.name.clone(), loaded);
tracing::info!(
"Loaded plugin: {} v{}",
manifest.plugin.name,
manifest.plugin.version
);
Ok(())
}
pub async fn unload(&self, name: &str) -> bool {
let mut plugins = self.plugins.write().await;
if let Some(loaded) = plugins.remove(name) {
tracing::info!("Unloaded plugin: {}", loaded.manifest.plugin.name);
true
} else {
false
}
}
pub async fn get(&self, name: &str) -> Option<PluginManifest> {
let plugins = self.plugins.read().await;
plugins.get(name).map(|p| p.manifest.clone())
}
pub async fn list(&self) -> Vec<PluginManifest> {
let plugins = self.plugins.read().await;
plugins.values().map(|p| p.manifest.clone()).collect()
}
pub async fn check_for_reloads(&self) -> Vec<String> {
if !self.hot_reload_enabled {
return vec![];
}
let mut needs_reload = Vec::new();
let plugins = self.plugins.read().await;
for (name, loaded) in plugins.iter() {
let manifest_path = loaded.path.join("plugin.toml");
if let Ok(metadata) = std::fs::metadata(&manifest_path) {
if let Ok(modified) = metadata.modified() {
if modified > loaded.manifest_modified {
needs_reload.push(name.clone());
}
}
}
}
needs_reload
}
pub async fn reload(&self, name: &str) -> Result<(), ManifestError> {
let path = {
let plugins = self.plugins.read().await;
plugins
.get(name)
.map(|p| p.path.clone())
.ok_or_else(|| ManifestError::NotFound(name.to_string()))?
};
let manifest_path = path.join("plugin.toml");
let manifest = PluginManifest::from_file(&manifest_path)?;
self.unload(name).await;
self.load(manifest, path).await?;
tracing::info!("Reloaded plugin: {}", name);
Ok(())
}
pub async fn find_by_node_type(&self, node_type: &str) -> Vec<PluginManifest> {
let plugins = self.plugins.read().await;
plugins
.values()
.filter(|p| {
p.manifest
.capabilities
.node_types
.contains(&node_type.to_string())
})
.map(|p| p.manifest.clone())
.collect()
}
pub async fn find_by_category(&self, category: PluginCategory) -> Vec<PluginManifest> {
let plugins = self.plugins.read().await;
plugins
.values()
.filter(|p| p.manifest.plugin.category == Some(category))
.map(|p| p.manifest.clone())
.collect()
}
pub async fn stats(&self) -> PluginStats {
let plugins = self.plugins.read().await;
let mut stats = PluginStats {
total_plugins: plugins.len(),
..Default::default()
};
for loaded in plugins.values() {
match loaded.state {
PluginState::Ready => stats.ready_plugins += 1,
PluginState::Loading => stats.loading_plugins += 1,
PluginState::Failed => stats.failed_plugins += 1,
PluginState::Disabled => stats.disabled_plugins += 1,
PluginState::NeedsReload => stats.needs_reload_plugins += 1,
}
stats.total_node_types += loaded.manifest.capabilities.node_types.len();
}
stats
}
}
impl Default for PluginManager {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Default, Clone)]
#[allow(dead_code)]
pub struct PluginStats {
pub total_plugins: usize,
pub ready_plugins: usize,
pub loading_plugins: usize,
pub failed_plugins: usize,
pub disabled_plugins: usize,
pub needs_reload_plugins: usize,
pub total_node_types: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manifest_parsing() {
let toml = r#"
[plugin]
name = "test-plugin"
version = "1.0.0"
description = "A test plugin"
author = "Test Author"
[capabilities]
node_types = ["custom_node"]
supports_streaming = true
"#;
let manifest = PluginManifest::from_toml(toml).unwrap();
assert_eq!(manifest.plugin.name, "test-plugin");
assert_eq!(manifest.plugin.version, "1.0.0");
assert!(manifest.capabilities.supports_streaming);
assert_eq!(manifest.capabilities.node_types, vec!["custom_node"]);
}
#[test]
fn test_manifest_validation() {
let mut manifest = PluginManifest {
plugin: PluginInfo {
name: "test".to_string(),
version: "1.0.0".to_string(),
description: None,
author: None,
license: None,
homepage: None,
repository: None,
keywords: vec![],
category: None,
},
capabilities: Default::default(),
config: Default::default(),
dependencies: Default::default(),
hooks: Default::default(),
};
assert!(manifest.validate().is_ok());
manifest.plugin.name = "".to_string();
assert!(manifest.validate().is_err());
manifest.plugin.name = "test".to_string();
manifest.plugin.version = "invalid".to_string();
assert!(manifest.validate().is_err());
}
#[test]
fn test_version_comparison() {
assert_eq!(compare_versions("1.0.0", "1.0.0"), 0);
assert_eq!(compare_versions("1.0.1", "1.0.0"), 1);
assert_eq!(compare_versions("1.0.0", "1.0.1"), -1);
assert_eq!(compare_versions("2.0.0", "1.9.9"), 1);
assert_eq!(compare_versions("1.0", "1.0.0"), 0);
}
#[test]
fn test_version_requirements() {
assert!(check_version_requirement("1.0.0", ">=1.0.0"));
assert!(check_version_requirement("1.0.1", ">=1.0.0"));
assert!(!check_version_requirement("0.9.0", ">=1.0.0"));
assert!(check_version_requirement("0.9.0", "<1.0.0"));
assert!(!check_version_requirement("1.0.0", "<1.0.0"));
assert!(check_version_requirement("1.0.0", "=1.0.0"));
assert!(!check_version_requirement("1.0.1", "=1.0.0"));
}
#[test]
fn test_semver_validation() {
assert!(is_valid_semver("1.0.0"));
assert!(is_valid_semver("1.0"));
assert!(is_valid_semver("0.1.0"));
assert!(!is_valid_semver("invalid"));
assert!(!is_valid_semver("1"));
assert!(!is_valid_semver("1.0.0.0"));
}
#[tokio::test]
async fn test_plugin_manager() {
let manager = PluginManager::new();
let manifest = PluginManifest {
plugin: PluginInfo {
name: "test-plugin".to_string(),
version: "1.0.0".to_string(),
description: Some("Test".to_string()),
author: None,
license: None,
homepage: None,
repository: None,
keywords: vec![],
category: Some(PluginCategory::Utility),
},
capabilities: PluginCapabilities {
node_types: vec!["custom_test".to_string()],
..Default::default()
},
config: Default::default(),
dependencies: Default::default(),
hooks: Default::default(),
};
manager.load(manifest, PathBuf::from("/tmp")).await.unwrap();
let plugins = manager.list().await;
assert_eq!(plugins.len(), 1);
let found = manager.find_by_node_type("custom_test").await;
assert_eq!(found.len(), 1);
let by_category = manager.find_by_category(PluginCategory::Utility).await;
assert_eq!(by_category.len(), 1);
let unloaded = manager.unload("test-plugin").await;
assert!(unloaded);
let plugins = manager.list().await;
assert_eq!(plugins.len(), 0);
}
#[tokio::test]
async fn test_plugin_stats() {
let manager = PluginManager::new();
let manifest = PluginManifest {
plugin: PluginInfo {
name: "stats-test".to_string(),
version: "1.0.0".to_string(),
description: None,
author: None,
license: None,
homepage: None,
repository: None,
keywords: vec![],
category: None,
},
capabilities: PluginCapabilities {
node_types: vec!["node1".to_string(), "node2".to_string()],
..Default::default()
},
config: Default::default(),
dependencies: Default::default(),
hooks: Default::default(),
};
manager.load(manifest, PathBuf::from("/tmp")).await.unwrap();
let stats = manager.stats().await;
assert_eq!(stats.total_plugins, 1);
assert_eq!(stats.ready_plugins, 1);
assert_eq!(stats.total_node_types, 2);
}
#[tokio::test]
async fn test_hot_reload_start_stop() {
let mut manager = PluginManager::new();
manager.enable_hot_reload(Duration::from_millis(100));
let result = manager.start_hot_reload().await;
assert!(result.is_ok());
tokio::time::sleep(Duration::from_millis(50)).await;
manager.stop_hot_reload().await;
}
#[tokio::test]
async fn test_hot_reload_not_enabled() {
let manager = PluginManager::new();
let result = manager.start_hot_reload().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_hot_reload_idempotent() {
let mut manager = PluginManager::new();
manager.enable_hot_reload(Duration::from_millis(100));
manager.start_hot_reload().await.unwrap();
let result = manager.start_hot_reload().await;
assert!(result.is_ok());
manager.stop_hot_reload().await;
}
#[test]
fn test_manifest_to_toml() {
let manifest = PluginManifest {
plugin: PluginInfo {
name: "serialization-test".to_string(),
version: "2.0.0".to_string(),
description: Some("Test serialization".to_string()),
author: Some("Test Author".to_string()),
license: Some("MIT".to_string()),
homepage: None,
repository: None,
keywords: vec!["test".to_string()],
category: Some(PluginCategory::Transform),
},
capabilities: PluginCapabilities {
node_types: vec!["transform".to_string()],
supports_streaming: true,
..Default::default()
},
config: Default::default(),
dependencies: Default::default(),
hooks: Default::default(),
};
let toml_str = manifest.to_toml().unwrap();
assert!(toml_str.contains("serialization-test"));
assert!(toml_str.contains("2.0.0"));
let parsed = PluginManifest::from_toml(&toml_str).unwrap();
assert_eq!(parsed.plugin.name, manifest.plugin.name);
assert_eq!(parsed.plugin.version, manifest.plugin.version);
}
}