use crate::plugin::auth::OAuthRequirements;
use crate::plugin::network::NetworkPolicy;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginManifest {
#[serde(flatten)]
pub identity: PluginIdentity,
pub publisher: PluginPublisher,
pub presentation: PresentationMetadata,
pub network_policy: NetworkPolicy,
#[serde(default)]
pub auth: Option<OAuthRequirements>,
pub tools: Vec<ExportedTool>,
#[serde(default)]
pub max_memory_bytes: Option<u64>,
#[serde(rename = "api_version")]
pub api_version: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginIdentity {
pub id: String,
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginPublisher {
pub name: String,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub contact: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PresentationMetadata {
pub description: String,
#[serde(default)]
pub long_description: Option<String>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExportedTool {
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
#[serde(default)]
pub requires_approval: bool,
#[serde(default)]
pub auth_requirements: Option<ToolAuthRequirements>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolAuthRequirements {
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default)]
pub available_unauthenticated: bool,
}
impl PluginManifest {
pub fn validate(&self) -> Result<(), ManifestValidationError> {
if self.identity.id.is_empty() {
return Err(ManifestValidationError::MissingField("identity.id".into()));
}
if self.identity.name.is_empty() {
return Err(ManifestValidationError::MissingField(
"identity.name".into(),
));
}
if self.identity.version.is_empty() {
return Err(ManifestValidationError::MissingField(
"identity.version".into(),
));
}
if self.publisher.name.is_empty() {
return Err(ManifestValidationError::MissingField(
"publisher.name".into(),
));
}
if self.presentation.description.is_empty() {
return Err(ManifestValidationError::MissingField(
"presentation.description".into(),
));
}
if self.api_version != "1.0" {
return Err(ManifestValidationError::UnsupportedApiVersion(
self.api_version.clone(),
));
}
let mut names = std::collections::HashSet::new();
for tool in &self.tools {
if !names.insert(&tool.name) {
return Err(ManifestValidationError::DuplicateToolName(
tool.name.clone(),
));
}
}
Ok(())
}
pub fn get_tool(&self, name: &str) -> Option<&ExportedTool> {
self.tools.iter().find(|t| t.name == name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestValidationError {
MissingField(String),
UnsupportedApiVersion(String),
DuplicateToolName(String),
}
impl std::fmt::Display for ManifestValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingField(field) => write!(f, "Missing required field: {}", field),
Self::UnsupportedApiVersion(version) => {
write!(f, "Unsupported API version: {}", version)
}
Self::DuplicateToolName(name) => write!(f, "Duplicate tool name: {}", name),
}
}
}
impl std::error::Error for ManifestValidationError {}
#[cfg(test)]
mod tests {
use super::*;
fn create_valid_manifest() -> PluginManifest {
PluginManifest {
identity: PluginIdentity {
id: "com.example.my-plugin".to_string(),
name: "My Plugin".to_string(),
version: "1.0.0".to_string(),
},
publisher: PluginPublisher {
name: "Example Corp".to_string(),
url: Some("https://example.com".to_string()),
contact: None,
},
presentation: PresentationMetadata {
description: "A test plugin".to_string(),
long_description: None,
icon: None,
category: Some("integrations".to_string()),
keywords: vec!["test".to_string()],
},
network_policy: NetworkPolicy::Allowlist(vec!["api.example.com".to_string()]),
auth: None,
tools: vec![ExportedTool {
name: "fetch_data".to_string(),
description: "Fetch data from the API".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
requires_approval: false,
auth_requirements: None,
}],
max_memory_bytes: None,
api_version: "1.0".to_string(),
}
}
#[test]
fn test_valid_manifest() {
let manifest = create_valid_manifest();
assert!(manifest.validate().is_ok());
}
#[test]
fn test_missing_identity_id() {
let mut manifest = create_valid_manifest();
manifest.identity.id = "".to_string();
assert!(matches!(
manifest.validate().unwrap_err(),
ManifestValidationError::MissingField(field) if field == "identity.id"
));
}
#[test]
fn test_duplicate_tool_names() {
let mut manifest = create_valid_manifest();
manifest.tools.push(manifest.tools[0].clone());
assert!(matches!(
manifest.validate().unwrap_err(),
ManifestValidationError::DuplicateToolName(name) if name == "fetch_data"
));
}
#[test]
fn test_unsupported_api_version() {
let mut manifest = create_valid_manifest();
manifest.api_version = "2.0".to_string();
assert!(matches!(
manifest.validate().unwrap_err(),
ManifestValidationError::UnsupportedApiVersion(version) if version == "2.0"
));
}
}