use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PluginAuthor {
#[serde(default)]
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum McpServersField {
Path(String),
Paths(Vec<String>),
Inline(HashMap<String, serde_json::Value>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StringOrArray {
Single(String),
Multiple(Vec<String>),
}
impl StringOrArray {
pub fn to_vec(&self) -> Vec<String> {
match self {
StringOrArray::Single(s) => vec![s.clone()],
StringOrArray::Multiple(v) => v.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManifest {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<PluginAuthor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keywords: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skills: Option<StringOrArray>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commands: Option<StringOrArray>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agents: Option<StringOrArray>,
#[serde(
rename = "mcpServers",
default,
skip_serializing_if = "Option::is_none"
)]
pub mcp_servers: Option<McpServersField>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic_manifest() {
let json = r#"{
"name": "microsoft-docs",
"displayName": "Microsoft Docs",
"version": "0.1.0",
"description": "Search official Microsoft docs.",
"license": "MIT"
}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.name, "microsoft-docs");
assert_eq!(manifest.display_name.as_deref(), Some("Microsoft Docs"));
assert_eq!(manifest.version.as_deref(), Some("0.1.0"));
assert_eq!(manifest.license.as_deref(), Some("MIT"));
assert!(manifest.extra.is_empty());
}
#[test]
fn unrecognized_fields_land_in_extra() {
let json = r#"{
"name": "test-plugin",
"description": "A test plugin.",
"interface": {"displayName": "Test", "category": "Dev"}
}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert!(manifest.extra.contains_key("interface"));
}
#[test]
fn mcp_servers_path_string() {
let json = r#"{"name": "x", "description": "y", "mcpServers": "./.mcp.json"}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert!(matches!(
manifest.mcp_servers,
Some(McpServersField::Path(_))
));
}
#[test]
fn skills_string_or_array() {
let json = r#"{"name": "x", "description": "y", "skills": "./skills/"}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert!(matches!(manifest.skills, Some(StringOrArray::Single(_))));
}
}