everruns_core/plugins/
manifest.rs1use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12#[serde(rename_all = "camelCase")]
13pub struct PluginAuthor {
14 #[serde(default)]
16 pub name: String,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub email: Option<String>,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub url: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(untagged)]
31pub enum McpServersField {
32 Path(String),
34 Paths(Vec<String>),
36 Inline(HashMap<String, serde_json::Value>),
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(untagged)]
43pub enum StringOrArray {
44 Single(String),
46 Multiple(Vec<String>),
48}
49
50impl StringOrArray {
51 pub fn to_vec(&self) -> Vec<String> {
53 match self {
54 StringOrArray::Single(s) => vec![s.clone()],
55 StringOrArray::Multiple(v) => v.clone(),
56 }
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct PluginManifest {
68 pub name: String,
70
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub display_name: Option<String>,
74
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub version: Option<String>,
78
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub description: Option<String>,
82
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub author: Option<PluginAuthor>,
86
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub homepage: Option<String>,
90
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub repository: Option<String>,
94
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub license: Option<String>,
98
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub keywords: Vec<String>,
102
103 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub skills: Option<StringOrArray>,
107
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub commands: Option<StringOrArray>,
111
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub agents: Option<StringOrArray>,
115
116 #[serde(
118 rename = "mcpServers",
119 default,
120 skip_serializing_if = "Option::is_none"
121 )]
122 pub mcp_servers: Option<McpServersField>,
123
124 #[serde(flatten)]
127 pub extra: HashMap<String, serde_json::Value>,
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn parse_basic_manifest() {
136 let json = r#"{
137 "name": "microsoft-docs",
138 "displayName": "Microsoft Docs",
139 "version": "0.1.0",
140 "description": "Search official Microsoft docs.",
141 "license": "MIT"
142 }"#;
143 let manifest: PluginManifest = serde_json::from_str(json).unwrap();
144 assert_eq!(manifest.name, "microsoft-docs");
145 assert_eq!(manifest.display_name.as_deref(), Some("Microsoft Docs"));
146 assert_eq!(manifest.version.as_deref(), Some("0.1.0"));
147 assert_eq!(manifest.license.as_deref(), Some("MIT"));
148 assert!(manifest.extra.is_empty());
149 }
150
151 #[test]
152 fn unrecognized_fields_land_in_extra() {
153 let json = r#"{
154 "name": "test-plugin",
155 "description": "A test plugin.",
156 "interface": {"displayName": "Test", "category": "Dev"}
157 }"#;
158 let manifest: PluginManifest = serde_json::from_str(json).unwrap();
159 assert!(manifest.extra.contains_key("interface"));
160 }
161
162 #[test]
163 fn mcp_servers_path_string() {
164 let json = r#"{"name": "x", "description": "y", "mcpServers": "./.mcp.json"}"#;
165 let manifest: PluginManifest = serde_json::from_str(json).unwrap();
166 assert!(matches!(
167 manifest.mcp_servers,
168 Some(McpServersField::Path(_))
169 ));
170 }
171
172 #[test]
173 fn skills_string_or_array() {
174 let json = r#"{"name": "x", "description": "y", "skills": "./skills/"}"#;
175 let manifest: PluginManifest = serde_json::from_str(json).unwrap();
176 assert!(matches!(manifest.skills, Some(StringOrArray::Single(_))));
177 }
178}