1use serde::Deserialize;
2use std::collections::HashMap;
3use std::path::Path;
4
5use super::sandbox::TrustSpec;
6
7#[derive(Debug, Clone, Deserialize)]
8pub struct PluginManifest {
9 pub plugin: PluginMeta,
10 #[serde(default)]
11 pub hooks: HashMap<String, HookEntry>,
12 #[serde(default)]
15 pub tools: Vec<ToolEntry>,
16 #[serde(default)]
19 pub trust: TrustSpec,
20}
21
22#[derive(Debug, Clone, Deserialize)]
23pub struct PluginMeta {
24 pub name: String,
25 pub version: String,
26 #[serde(default)]
27 pub description: String,
28 #[serde(default)]
29 pub author: String,
30}
31
32#[derive(Debug, Clone, Deserialize)]
33pub struct HookEntry {
34 pub command: String,
35 #[serde(default = "default_timeout_ms")]
36 pub timeout_ms: u64,
37}
38
39#[derive(Debug, Clone, Deserialize)]
42pub struct ToolEntry {
43 pub name: String,
44 #[serde(default)]
45 pub description: String,
46 pub command: String,
47 #[serde(default = "default_timeout_ms")]
48 pub timeout_ms: u64,
49 #[serde(default)]
51 pub input_schema: serde_json::Value,
52}
53
54fn default_timeout_ms() -> u64 {
55 5000
56}
57
58impl PluginManifest {
59 pub fn from_file(path: &Path) -> Result<Self, ManifestError> {
60 let content = std::fs::read_to_string(path).map_err(|e| ManifestError::Io {
61 path: path.to_path_buf(),
62 source: e,
63 })?;
64 Self::from_str(&content, path)
65 }
66
67 pub fn from_str(content: &str, path: &Path) -> Result<Self, ManifestError> {
68 let manifest: Self = toml::from_str(content).map_err(|e| ManifestError::Parse {
69 path: path.to_path_buf(),
70 source: e,
71 })?;
72 manifest.validate(path)?;
73 Ok(manifest)
74 }
75
76 fn validate(&self, path: &Path) -> Result<(), ManifestError> {
77 if self.plugin.name.is_empty() {
78 return Err(ManifestError::Validation {
79 path: path.to_path_buf(),
80 field: "plugin.name".to_string(),
81 reason: "must not be empty".to_string(),
82 });
83 }
84 if self.plugin.version.is_empty() {
85 return Err(ManifestError::Validation {
86 path: path.to_path_buf(),
87 field: "plugin.version".to_string(),
88 reason: "must not be empty".to_string(),
89 });
90 }
91 for (hook_name, entry) in &self.hooks {
92 if entry.command.is_empty() {
93 return Err(ManifestError::Validation {
94 path: path.to_path_buf(),
95 field: format!("hooks.{hook_name}.command"),
96 reason: "must not be empty".to_string(),
97 });
98 }
99 }
100 for tool in &self.tools {
101 if tool.name.is_empty() {
102 return Err(ManifestError::Validation {
103 path: path.to_path_buf(),
104 field: "tools.name".to_string(),
105 reason: "must not be empty".to_string(),
106 });
107 }
108 if tool.command.is_empty() {
109 return Err(ManifestError::Validation {
110 path: path.to_path_buf(),
111 field: format!("tools.{}.command", tool.name),
112 reason: "must not be empty".to_string(),
113 });
114 }
115 }
116 if let Err(reason) = self.trust.validate() {
117 return Err(ManifestError::Validation {
118 path: path.to_path_buf(),
119 field: "trust.permissions".to_string(),
120 reason,
121 });
122 }
123 Ok(())
124 }
125}
126
127#[derive(Debug, thiserror::Error)]
128pub enum ManifestError {
129 #[error("failed to read plugin manifest at {path}: {source}")]
130 Io {
131 path: std::path::PathBuf,
132 source: std::io::Error,
133 },
134 #[error("failed to parse plugin manifest at {path}: {source}")]
135 Parse {
136 path: std::path::PathBuf,
137 source: toml::de::Error,
138 },
139 #[error("invalid plugin manifest at {path}: {field} {reason}")]
140 Validation {
141 path: std::path::PathBuf,
142 field: String,
143 reason: String,
144 },
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use std::path::PathBuf;
151
152 #[test]
153 fn parse_valid_manifest() {
154 let toml = r#"
155[plugin]
156name = "test-plugin"
157version = "0.1.0"
158description = "A test plugin"
159author = "Test Author"
160
161[hooks.on_session_start]
162command = "test-binary start"
163timeout_ms = 3000
164
165[hooks.pre_read]
166command = "test-binary pre-read"
167"#;
168 let manifest = PluginManifest::from_str(toml, &PathBuf::from("test.toml")).unwrap();
169 assert_eq!(manifest.plugin.name, "test-plugin");
170 assert_eq!(manifest.plugin.version, "0.1.0");
171 assert_eq!(manifest.hooks.len(), 2);
172 assert_eq!(manifest.hooks["on_session_start"].timeout_ms, 3000);
173 assert_eq!(manifest.hooks["pre_read"].timeout_ms, 5000);
174 }
175
176 #[test]
177 fn reject_empty_name() {
178 let toml = r#"
179[plugin]
180name = ""
181version = "0.1.0"
182"#;
183 let err = PluginManifest::from_str(toml, &PathBuf::from("bad.toml")).unwrap_err();
184 assert!(err.to_string().contains("plugin.name"));
185 }
186
187 #[test]
188 fn reject_empty_version() {
189 let toml = r#"
190[plugin]
191name = "test"
192version = ""
193"#;
194 let err = PluginManifest::from_str(toml, &PathBuf::from("bad.toml")).unwrap_err();
195 assert!(err.to_string().contains("plugin.version"));
196 }
197
198 #[test]
199 fn reject_empty_command() {
200 let toml = r#"
201[plugin]
202name = "test"
203version = "0.1.0"
204
205[hooks.pre_read]
206command = ""
207"#;
208 let err = PluginManifest::from_str(toml, &PathBuf::from("bad.toml")).unwrap_err();
209 assert!(err.to_string().contains("hooks.pre_read.command"));
210 }
211
212 #[test]
213 fn minimal_manifest_no_hooks() {
214 let toml = r#"
215[plugin]
216name = "minimal"
217version = "1.0.0"
218"#;
219 let manifest = PluginManifest::from_str(toml, &PathBuf::from("minimal.toml")).unwrap();
220 assert_eq!(manifest.plugin.name, "minimal");
221 assert!(manifest.hooks.is_empty());
222 assert!(manifest.tools.is_empty());
223 }
224
225 #[test]
226 fn parses_tool_entries_with_schema() {
227 let toml = r#"
228[plugin]
229name = "weather"
230version = "1.0.0"
231
232[[tools]]
233name = "weather_lookup"
234description = "Look up the weather for a city"
235command = "weather-bin"
236timeout_ms = 8000
237input_schema = { type = "object", properties = { city = { type = "string" } }, required = ["city"] }
238"#;
239 let manifest = PluginManifest::from_str(toml, &PathBuf::from("weather.toml")).unwrap();
240 assert_eq!(manifest.tools.len(), 1);
241 let t = &manifest.tools[0];
242 assert_eq!(t.name, "weather_lookup");
243 assert_eq!(t.command, "weather-bin");
244 assert_eq!(t.timeout_ms, 8000);
245 assert_eq!(t.input_schema["type"], serde_json::json!("object"));
246 }
247
248 #[test]
249 fn rejects_tool_without_command() {
250 let toml = r#"
251[plugin]
252name = "bad"
253version = "1.0.0"
254
255[[tools]]
256name = "broken"
257command = ""
258"#;
259 let err = PluginManifest::from_str(toml, &PathBuf::from("bad.toml")).unwrap_err();
260 assert!(err.to_string().contains("tools.broken.command"));
261 }
262
263 #[test]
264 fn default_timeout_applied() {
265 let toml = r#"
266[plugin]
267name = "defaults"
268version = "0.1.0"
269
270[hooks.on_session_end]
271command = "plugin-bin stop"
272"#;
273 let manifest = PluginManifest::from_str(toml, &PathBuf::from("test.toml")).unwrap();
274 assert_eq!(manifest.hooks["on_session_end"].timeout_ms, 5000);
275 }
276}