use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SdkPluginConfig {
#[serde(rename = "type")]
pub plugin_type: String,
pub path: String,
}
impl SdkPluginConfig {
pub fn local(path: impl Into<String>) -> Self {
Self {
plugin_type: "local".to_string(),
path: path.into(),
}
}
pub fn validate(&self) -> Result<(), String> {
if self.plugin_type != "local" {
return Err(format!(
"Unsupported plugin type: {}. Only 'local' is supported.",
self.plugin_type
));
}
let path = Path::new(&self.path);
if !path.exists() {
return Err(format!("Plugin path does not exist: {}", self.path));
}
if !path.is_dir() {
return Err(format!("Plugin path is not a directory: {}", self.path));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginMetadata {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Plugin {
pub metadata: PluginMetadata,
pub path: PathBuf,
pub commands: Vec<String>,
}
impl Plugin {
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, String> {
let path = path.as_ref();
if !path.is_dir() {
return Err(format!("Plugin path is not a directory: {:?}", path));
}
let metadata_path = path.join(".claude-plugin").join("plugin.json");
let metadata_content = fs::read_to_string(&metadata_path)
.map_err(|e| format!("Failed to read plugin.json: {}", e))?;
let metadata: PluginMetadata = serde_json::from_str(&metadata_content)
.map_err(|e| format!("Invalid plugin.json: {}", e))?;
let mut commands = Vec::new();
let commands_dir = path.join("commands");
if commands_dir.exists()
&& commands_dir.is_dir()
&& let Ok(entries) = fs::read_dir(&commands_dir)
{
for entry in entries.flatten() {
if let Ok(file_type) = entry.file_type()
&& file_type.is_file()
&& let Some(file_name) = entry.file_name().to_str()
&& file_name.ends_with(".md")
{
let command_name = file_name.trim_end_matches(".md").to_string();
commands.push(command_name);
}
}
}
commands.sort();
Ok(Plugin {
metadata,
path: path.to_path_buf(),
commands,
})
}
}
pub struct PluginLoader {
config: SdkPluginConfig,
}
impl PluginLoader {
pub fn new(config: SdkPluginConfig) -> Self {
Self { config }
}
pub fn config(&self) -> &SdkPluginConfig {
&self.config
}
pub fn load(&self) -> Result<Plugin, String> {
self.config.validate()?;
Plugin::from_path(&self.config.path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sdk_plugin_config_local() {
let config = SdkPluginConfig::local("./plugins/test");
assert_eq!(config.plugin_type, "local");
assert_eq!(config.path, "./plugins/test");
}
#[test]
fn test_sdk_plugin_config_serialization() {
let config = SdkPluginConfig::local("./my-plugin");
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("\"type\":\"local\""));
assert!(json.contains("\"path\":\"./my-plugin\""));
}
#[test]
fn test_sdk_plugin_config_deserialization() {
let json = r#"{"type":"local","path":"/path/to/plugin"}"#;
let config: SdkPluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.plugin_type, "local");
assert_eq!(config.path, "/path/to/plugin");
}
#[test]
fn test_sdk_plugin_config_equality() {
let config1 = SdkPluginConfig::local("./plugin");
let config2 = SdkPluginConfig::local("./plugin");
assert_eq!(config1, config2);
}
#[test]
fn test_sdk_plugin_config_validate_invalid_type() {
let config = SdkPluginConfig {
plugin_type: "remote".to_string(),
path: "./plugin".to_string(),
};
assert!(config.validate().is_err());
}
#[test]
fn test_sdk_plugin_config_validate_nonexistent_path() {
let config = SdkPluginConfig::local("/nonexistent/path/12345");
assert!(config.validate().is_err());
}
#[test]
fn test_plugin_metadata_creation() {
let metadata = PluginMetadata {
name: "test-plugin".to_string(),
description: Some("Test description".to_string()),
version: Some("1.0.0".to_string()),
author: Some("Test Author".to_string()),
};
assert_eq!(metadata.name, "test-plugin");
assert_eq!(metadata.description, Some("Test description".to_string()));
}
#[test]
fn test_plugin_metadata_serialization() {
let metadata = PluginMetadata {
name: "my-plugin".to_string(),
description: Some("A test plugin".to_string()),
version: Some("1.0.0".to_string()),
author: None,
};
let json = serde_json::to_string(&metadata).unwrap();
assert!(json.contains("\"name\":\"my-plugin\""));
assert!(json.contains("\"description\":\"A test plugin\""));
assert!(json.contains("\"version\":\"1.0.0\""));
assert!(!json.contains("\"author\""));
}
#[test]
fn test_plugin_metadata_deserialization() {
let json =
r#"{"name":"test","description":"Test plugin","version":"1.0","author":"Author"}"#;
let metadata: PluginMetadata = serde_json::from_str(json).unwrap();
assert_eq!(metadata.name, "test");
assert_eq!(metadata.description, Some("Test plugin".to_string()));
assert_eq!(metadata.version, Some("1.0".to_string()));
assert_eq!(metadata.author, Some("Author".to_string()));
}
#[test]
fn test_plugin_metadata_minimal() {
let json = r#"{"name":"minimal"}"#;
let metadata: PluginMetadata = serde_json::from_str(json).unwrap();
assert_eq!(metadata.name, "minimal");
assert!(metadata.description.is_none());
assert!(metadata.version.is_none());
assert!(metadata.author.is_none());
}
#[test]
fn test_plugin_creation() {
let metadata = PluginMetadata {
name: "test-plugin".to_string(),
description: None,
version: None,
author: None,
};
let plugin = Plugin {
metadata,
path: PathBuf::from("./plugin"),
commands: vec!["cmd1".to_string(), "cmd2".to_string()],
};
assert_eq!(plugin.metadata.name, "test-plugin");
assert_eq!(plugin.commands.len(), 2);
}
#[test]
fn test_plugin_commands_sorted() {
let metadata = PluginMetadata {
name: "test".to_string(),
description: None,
version: None,
author: None,
};
let mut plugin = Plugin {
metadata,
path: PathBuf::from("./plugin"),
commands: vec![
"zebra".to_string(),
"apple".to_string(),
"monkey".to_string(),
],
};
plugin.commands.sort();
assert_eq!(plugin.commands[0], "apple");
assert_eq!(plugin.commands[2], "zebra");
}
#[test]
fn test_plugin_loader_creation() {
let config = SdkPluginConfig::local("./plugin");
let loader = PluginLoader::new(config);
assert_eq!(loader.config.plugin_type, "local");
}
#[test]
fn test_plugin_loader_load_nonexistent() {
let config = SdkPluginConfig::local("/nonexistent/plugin");
let loader = PluginLoader::new(config);
assert!(loader.load().is_err());
}
#[test]
fn test_plugin_config_and_loader_workflow() {
let config = SdkPluginConfig::local("./my-plugin");
match config.validate() {
Ok(_) => {
let loader = PluginLoader::new(config);
let _ = loader.load();
}
Err(_) => {
assert_eq!(config.plugin_type, "local");
}
}
}
#[test]
fn test_multiple_plugins_configs() {
let config1 = SdkPluginConfig::local("./plugin1");
let config2 = SdkPluginConfig::local("./plugin2");
assert_ne!(config1.path, config2.path);
assert_eq!(config1.plugin_type, config2.plugin_type);
}
#[test]
fn test_plugin_metadata_round_trip() {
let original = PluginMetadata {
name: "round-trip".to_string(),
description: Some("Test".to_string()),
version: Some("2.0.0".to_string()),
author: Some("Test Author".to_string()),
};
let json = serde_json::to_string(&original).unwrap();
let deserialized: PluginMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(original.name, deserialized.name);
assert_eq!(original.description, deserialized.description);
assert_eq!(original.version, deserialized.version);
assert_eq!(original.author, deserialized.author);
}
}