use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::tools::ToolCategory;
fn default_execution() -> String {
"command".to_string()
}
fn default_protocol() -> String {
"jsonrpc".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub name: String,
pub version: String,
pub description: String,
#[serde(default)]
pub author: Option<String>,
pub tools: Vec<PluginToolDef>,
#[serde(default = "default_execution")]
pub execution: String,
#[serde(default)]
pub binary: Option<BinaryPluginConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BinaryPluginConfig {
pub path: String,
#[serde(default = "default_protocol")]
pub protocol: String,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub sha256: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginToolDef {
pub name: String,
pub description: String,
pub parameters: Value,
#[serde(default)]
pub command: String,
#[serde(default)]
pub working_dir: Option<String>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub env: Option<HashMap<String, String>>,
#[serde(default)]
pub category: Option<ToolCategory>,
}
impl PluginManifest {
pub fn is_binary(&self) -> bool {
self.execution == "binary"
}
}
impl PluginToolDef {
pub fn effective_timeout(&self) -> u64 {
self.timeout_secs.unwrap_or(30)
}
pub fn effective_category(&self) -> ToolCategory {
self.category.unwrap_or(ToolCategory::Shell)
}
}
#[derive(Debug, Clone)]
pub struct Plugin {
pub manifest: PluginManifest,
pub path: PathBuf,
pub enabled: bool,
}
impl Plugin {
pub fn new(manifest: PluginManifest, path: PathBuf) -> Self {
Self {
manifest,
path,
enabled: true,
}
}
pub fn name(&self) -> &str {
&self.manifest.name
}
pub fn tool_count(&self) -> usize {
self.manifest.tools.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_plugin_dirs")]
pub plugin_dirs: Vec<String>,
#[serde(default)]
pub allowed_plugins: Vec<String>,
#[serde(default)]
pub blocked_plugins: Vec<String>,
}
impl Default for PluginConfig {
fn default() -> Self {
Self {
enabled: false,
plugin_dirs: default_plugin_dirs(),
allowed_plugins: Vec::new(),
blocked_plugins: Vec::new(),
}
}
}
impl PluginConfig {
pub fn is_plugin_permitted(&self, name: &str) -> bool {
if self.blocked_plugins.contains(&name.to_string()) {
return false;
}
if self.allowed_plugins.is_empty() {
return true;
}
self.allowed_plugins.contains(&name.to_string())
}
}
fn default_plugin_dirs() -> Vec<String> {
vec!["~/.zeptoclaw/plugins".to_string()]
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_plugin_manifest_serialization_roundtrip() {
let manifest = PluginManifest {
name: "test-plugin".to_string(),
version: "1.0.0".to_string(),
description: "A test plugin".to_string(),
author: Some("Tester".to_string()),
tools: vec![PluginToolDef {
name: "test_tool".to_string(),
description: "A test tool".to_string(),
parameters: json!({
"type": "object",
"properties": {
"input": { "type": "string" }
},
"required": ["input"]
}),
command: "echo {{input}}".to_string(),
working_dir: None,
timeout_secs: Some(15),
env: None,
category: None,
}],
execution: "command".to_string(),
binary: None,
};
let json_str = serde_json::to_string(&manifest).unwrap();
let deserialized: PluginManifest = serde_json::from_str(&json_str).unwrap();
assert_eq!(deserialized.name, "test-plugin");
assert_eq!(deserialized.version, "1.0.0");
assert_eq!(deserialized.description, "A test plugin");
assert_eq!(deserialized.author, Some("Tester".to_string()));
assert_eq!(deserialized.tools.len(), 1);
assert_eq!(deserialized.tools[0].name, "test_tool");
assert_eq!(deserialized.tools[0].timeout_secs, Some(15));
}
#[test]
fn test_plugin_manifest_deserialization_from_json() {
let json_str = r#"{
"name": "git-tools",
"version": "1.0.0",
"description": "Git integration tools",
"author": "ZeptoClaw",
"tools": [
{
"name": "git_status",
"description": "Get git status",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"]
},
"command": "git -C {{path}} status --porcelain",
"timeout_secs": 10
}
]
}"#;
let manifest: PluginManifest = serde_json::from_str(json_str).unwrap();
assert_eq!(manifest.name, "git-tools");
assert_eq!(
manifest.tools[0].command,
"git -C {{path}} status --porcelain"
);
}
#[test]
fn test_plugin_tool_def_defaults() {
let json_str = r#"{
"name": "simple_tool",
"description": "A simple tool",
"parameters": { "type": "object", "properties": {} },
"command": "echo hello"
}"#;
let tool_def: PluginToolDef = serde_json::from_str(json_str).unwrap();
assert_eq!(tool_def.name, "simple_tool");
assert!(tool_def.working_dir.is_none());
assert!(tool_def.timeout_secs.is_none());
assert!(tool_def.env.is_none());
assert_eq!(tool_def.effective_timeout(), 30);
}
#[test]
fn test_plugin_tool_def_effective_timeout() {
let tool = PluginToolDef {
name: "t".to_string(),
description: "d".to_string(),
parameters: json!({}),
command: "echo".to_string(),
working_dir: None,
timeout_secs: Some(60),
env: None,
category: None,
};
assert_eq!(tool.effective_timeout(), 60);
let tool_default = PluginToolDef {
name: "t".to_string(),
description: "d".to_string(),
parameters: json!({}),
command: "echo".to_string(),
working_dir: None,
timeout_secs: None,
env: None,
category: None,
};
assert_eq!(tool_default.effective_timeout(), 30);
}
#[test]
fn test_plugin_tool_def_with_env() {
let mut env = HashMap::new();
env.insert("FOO".to_string(), "bar".to_string());
env.insert("BAZ".to_string(), "qux".to_string());
let tool = PluginToolDef {
name: "env_tool".to_string(),
description: "Tool with env".to_string(),
parameters: json!({}),
command: "printenv FOO".to_string(),
working_dir: Some("/tmp".to_string()),
timeout_secs: Some(5),
env: Some(env),
category: None,
};
assert_eq!(tool.env.as_ref().unwrap().get("FOO").unwrap(), "bar");
assert_eq!(tool.working_dir.as_deref(), Some("/tmp"));
}
#[test]
fn test_plugin_config_defaults() {
let config = PluginConfig::default();
assert!(!config.enabled);
assert_eq!(config.plugin_dirs, vec!["~/.zeptoclaw/plugins"]);
assert!(config.allowed_plugins.is_empty());
assert!(config.blocked_plugins.is_empty());
}
#[test]
fn test_plugin_config_deserialization_defaults() {
let json_str = r#"{}"#;
let config: PluginConfig = serde_json::from_str(json_str).unwrap();
assert!(!config.enabled);
assert_eq!(config.plugin_dirs, vec!["~/.zeptoclaw/plugins"]);
assert!(config.allowed_plugins.is_empty());
assert!(config.blocked_plugins.is_empty());
}
#[test]
fn test_plugin_config_is_plugin_permitted_all_allowed() {
let config = PluginConfig::default();
assert!(config.is_plugin_permitted("any-plugin"));
assert!(config.is_plugin_permitted("another-plugin"));
}
#[test]
fn test_plugin_config_is_plugin_permitted_allowlist() {
let config = PluginConfig {
enabled: true,
plugin_dirs: vec![],
allowed_plugins: vec!["good-plugin".to_string()],
blocked_plugins: vec![],
};
assert!(config.is_plugin_permitted("good-plugin"));
assert!(!config.is_plugin_permitted("other-plugin"));
}
#[test]
fn test_plugin_config_is_plugin_permitted_blocklist() {
let config = PluginConfig {
enabled: true,
plugin_dirs: vec![],
allowed_plugins: vec![],
blocked_plugins: vec!["bad-plugin".to_string()],
};
assert!(!config.is_plugin_permitted("bad-plugin"));
assert!(config.is_plugin_permitted("good-plugin"));
}
#[test]
fn test_plugin_config_blocklist_overrides_allowlist() {
let config = PluginConfig {
enabled: true,
plugin_dirs: vec![],
allowed_plugins: vec!["my-plugin".to_string()],
blocked_plugins: vec!["my-plugin".to_string()],
};
assert!(!config.is_plugin_permitted("my-plugin"));
}
#[test]
fn test_plugin_struct_construction() {
let manifest = PluginManifest {
name: "test-plugin".to_string(),
version: "0.1.0".to_string(),
description: "Test".to_string(),
author: None,
tools: vec![PluginToolDef {
name: "t".to_string(),
description: "d".to_string(),
parameters: json!({}),
command: "echo".to_string(),
working_dir: None,
timeout_secs: None,
env: None,
category: None,
}],
execution: "command".to_string(),
binary: None,
};
let plugin = Plugin::new(manifest, PathBuf::from("/tmp/test-plugin"));
assert_eq!(plugin.name(), "test-plugin");
assert!(plugin.enabled);
assert_eq!(plugin.path, PathBuf::from("/tmp/test-plugin"));
assert_eq!(plugin.tool_count(), 1);
}
#[test]
fn test_plugin_manifest_without_author() {
let json_str = r#"{
"name": "minimal",
"version": "1.0.0",
"description": "Minimal plugin",
"tools": [
{
"name": "noop",
"description": "Does nothing",
"parameters": { "type": "object", "properties": {} },
"command": "true"
}
]
}"#;
let manifest: PluginManifest = serde_json::from_str(json_str).unwrap();
assert!(manifest.author.is_none());
assert_eq!(manifest.tools.len(), 1);
}
#[test]
fn test_plugin_tool_parameter_schema() {
let tool = PluginToolDef {
name: "search".to_string(),
description: "Search files".to_string(),
parameters: json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query"
},
"max_results": {
"type": "integer",
"description": "Maximum results",
"default": 10
}
},
"required": ["query"]
}),
command: "grep -r {{query}}".to_string(),
working_dir: None,
timeout_secs: None,
env: None,
category: None,
};
let params = &tool.parameters;
assert_eq!(params["type"], "object");
assert!(params["properties"]["query"].is_object());
assert!(params["properties"]["max_results"].is_object());
assert_eq!(params["required"][0], "query");
}
#[test]
fn test_manifest_default_execution_is_command() {
let json_str = r#"{
"name": "basic-plugin",
"version": "1.0.0",
"description": "Basic",
"tools": [{
"name": "t",
"description": "d",
"parameters": {},
"command": "echo"
}]
}"#;
let manifest: PluginManifest = serde_json::from_str(json_str).unwrap();
assert_eq!(manifest.execution, "command");
assert!(manifest.binary.is_none());
assert!(!manifest.is_binary());
}
#[test]
fn test_manifest_binary_deserialization() {
let json_str = r#"{
"name": "bin-plugin",
"version": "1.0.0",
"description": "Binary plugin",
"execution": "binary",
"binary": {
"path": "bin/my-plugin",
"protocol": "jsonrpc",
"timeout_secs": 60
},
"tools": [{
"name": "my_tool",
"description": "Does stuff",
"parameters": {"type": "object", "properties": {}}
}]
}"#;
let manifest: PluginManifest = serde_json::from_str(json_str).unwrap();
assert_eq!(manifest.execution, "binary");
assert!(manifest.is_binary());
let bin = manifest.binary.unwrap();
assert_eq!(bin.path, "bin/my-plugin");
assert_eq!(bin.protocol, "jsonrpc");
assert_eq!(bin.timeout_secs, Some(60));
assert_eq!(manifest.tools[0].command, "");
}
#[test]
fn test_manifest_binary_config_defaults() {
let json_str = r#"{
"name": "bin-plugin",
"version": "1.0.0",
"description": "Minimal binary",
"execution": "binary",
"binary": { "path": "plugin" },
"tools": [{
"name": "t",
"description": "d",
"parameters": {}
}]
}"#;
let manifest: PluginManifest = serde_json::from_str(json_str).unwrap();
let bin = manifest.binary.unwrap();
assert_eq!(bin.protocol, "jsonrpc");
assert!(bin.timeout_secs.is_none());
}
#[test]
fn test_manifest_is_binary() {
let command_manifest = PluginManifest {
name: "cmd".to_string(),
version: "1.0.0".to_string(),
description: "Command".to_string(),
author: None,
tools: vec![],
execution: "command".to_string(),
binary: None,
};
assert!(!command_manifest.is_binary());
let binary_manifest = PluginManifest {
name: "bin".to_string(),
version: "1.0.0".to_string(),
description: "Binary".to_string(),
author: None,
tools: vec![],
execution: "binary".to_string(),
binary: Some(BinaryPluginConfig {
path: "plugin".to_string(),
protocol: "jsonrpc".to_string(),
timeout_secs: None,
sha256: None,
}),
};
assert!(binary_manifest.is_binary());
}
#[test]
fn test_manifest_backward_compat_no_execution_field() {
let json_str = r#"{
"name": "old-plugin",
"version": "1.0.0",
"description": "Old style",
"tools": [{
"name": "old_tool",
"description": "Old tool",
"parameters": {},
"command": "echo hello"
}]
}"#;
let manifest: PluginManifest = serde_json::from_str(json_str).unwrap();
assert_eq!(manifest.execution, "command");
assert!(manifest.binary.is_none());
assert!(!manifest.is_binary());
assert_eq!(manifest.tools[0].command, "echo hello");
}
#[test]
fn test_plugin_tool_def_with_category() {
let json_str = r#"{
"name": "gcal",
"description": "Google Calendar",
"parameters": {},
"category": "network_write"
}"#;
let def: PluginToolDef = serde_json::from_str(json_str).unwrap();
assert_eq!(def.category, Some(ToolCategory::NetworkWrite));
assert_eq!(def.effective_category(), ToolCategory::NetworkWrite);
}
#[test]
fn test_plugin_tool_def_category_defaults_to_shell() {
let json_str = r#"{
"name": "tool",
"description": "desc",
"parameters": {},
"command": "echo"
}"#;
let def: PluginToolDef = serde_json::from_str(json_str).unwrap();
assert!(def.category.is_none());
assert_eq!(def.effective_category(), ToolCategory::Shell);
}
}