use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
#[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>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginToolDef {
pub name: String,
pub description: String,
pub parameters: Value,
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>>,
}
impl PluginToolDef {
pub fn effective_timeout(&self) -> u64 {
self.timeout_secs.unwrap_or(30)
}
}
#[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,
}],
};
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,
};
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,
};
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),
};
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,
}],
};
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,
};
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");
}
}