1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5pub const MANIFEST_FILENAMES: &[&str] = &[
7 "plugin.manifest.toml",
8 "plugin.manifest.yaml",
9 "plugin.manifest.yml",
10 "plugin.manifest.json",
11];
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct PluginManifest {
16 pub plugin: PluginInfo,
18
19 #[serde(default)]
21 pub commands: Vec<ManifestCommand>,
22
23 #[serde(default)]
25 pub config: Option<PluginConfig>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PluginInfo {
30 pub name: String,
31 pub version: String,
32 pub description: String,
33 #[serde(default)]
34 pub author: String,
35 #[serde(default)]
36 pub license: String,
37 #[serde(default)]
38 pub homepage: String,
39 #[serde(default)]
40 pub repository: String,
41 #[serde(default)]
42 pub experimental: bool,
43 #[serde(default)]
44 pub min_meta_version: Option<String>,
45 #[serde(
47 default,
48 alias = "helpDescription",
49 skip_serializing_if = "Option::is_none"
50 )]
51 pub help_description: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ManifestCommand {
56 pub name: String,
57 pub description: String,
58 #[serde(default)]
59 pub long_description: Option<String>,
60 #[serde(
63 default,
64 alias = "helpDescription",
65 skip_serializing_if = "Option::is_none"
66 )]
67 pub help_description: Option<String>,
68 #[serde(default)]
69 pub aliases: Vec<String>,
70 #[serde(default)]
71 pub args: Vec<ManifestArg>,
72 #[serde(default)]
73 pub subcommands: Vec<ManifestCommand>,
74 #[serde(default)]
75 pub examples: Vec<Example>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ManifestArg {
80 pub name: String,
81 #[serde(default)]
82 pub short: Option<char>,
83 #[serde(default)]
84 pub long: Option<String>,
85 pub help: String,
86 #[serde(default)]
87 pub required: bool,
88 #[serde(default)]
89 pub takes_value: bool,
90 #[serde(default)]
91 pub default_value: Option<String>,
92 #[serde(default)]
93 pub possible_values: Vec<String>,
94 #[serde(default)]
95 pub value_type: ArgValueType,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, Default)]
99#[serde(rename_all = "lowercase")]
100pub enum ArgValueType {
101 #[default]
102 String,
103 Number,
104 Bool,
105 Path,
106 Url,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct Example {
111 pub command: String,
112 pub description: String,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct PluginConfig {
117 #[serde(default)]
119 pub execution: ExecutionConfig,
120
121 #[serde(default)]
123 pub capabilities: Vec<String>,
124
125 #[serde(default)]
127 pub required_env: Vec<String>,
128
129 #[serde(default)]
131 pub dependencies: Vec<Dependency>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct ExecutionConfig {
136 #[serde(default = "default_exec_mode")]
138 pub mode: String,
139
140 pub binary: Option<String>,
142
143 pub docker_image: Option<String>,
145
146 pub wasm_module: Option<String>,
148
149 #[serde(default = "default_protocol")]
151 pub protocol: String,
152}
153
154fn default_exec_mode() -> String {
155 "process".to_string()
156}
157
158fn default_protocol() -> String {
159 "cli".to_string()
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct Dependency {
164 pub name: String,
165 pub version: String,
166 #[serde(default)]
167 pub optional: bool,
168}
169
170impl PluginManifest {
171 pub fn from_file(path: &Path) -> Result<Self> {
173 let content = std::fs::read_to_string(path)?;
174 Self::from_toml_str(&content)
175 }
176
177 pub fn from_file_auto(path: &Path) -> Result<Self> {
181 let content = std::fs::read_to_string(path)
182 .with_context(|| format!("Failed to read manifest {}", path.display()))?;
183 let ext = path
184 .extension()
185 .and_then(|e| e.to_str())
186 .unwrap_or("")
187 .to_ascii_lowercase();
188 let manifest: PluginManifest = match ext.as_str() {
189 "json" => serde_json::from_str(&content)
190 .with_context(|| format!("Invalid JSON manifest {}", path.display()))?,
191 "yaml" | "yml" => serde_yaml::from_str(&content)
192 .with_context(|| format!("Invalid YAML manifest {}", path.display()))?,
193 _ => toml::from_str(&content)
194 .with_context(|| format!("Invalid TOML manifest {}", path.display()))?,
195 };
196 manifest.validate()?;
197 Ok(manifest)
198 }
199
200 pub fn find_in_dir(dir: &Path) -> Option<PathBuf> {
202 MANIFEST_FILENAMES
203 .iter()
204 .map(|name| dir.join(name))
205 .find(|p| p.is_file())
206 }
207
208 pub fn is_manifest_path(path: &Path) -> bool {
210 path.file_name()
211 .and_then(|n| n.to_str())
212 .map(|n| MANIFEST_FILENAMES.contains(&n))
213 .unwrap_or(false)
214 }
215
216 pub fn from_toml_str(content: &str) -> Result<Self> {
218 let manifest: PluginManifest = toml::from_str(content)?;
219 manifest.validate()?;
220 Ok(manifest)
221 }
222
223 pub fn resolve_binary(&self, manifest_path: &Path) -> Result<PathBuf> {
227 let dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
228 let rel = self
229 .config
230 .as_ref()
231 .and_then(|c| c.execution.binary.as_deref())
232 .unwrap_or(self.plugin.name.as_str());
233 Ok(dir.join(rel))
234 }
235
236 pub fn validate(&self) -> Result<()> {
238 if self.plugin.name.is_empty() {
240 return Err(anyhow::anyhow!("Plugin name cannot be empty"));
241 }
242
243 if self.plugin.version.is_empty() {
244 return Err(anyhow::anyhow!("Plugin version cannot be empty"));
245 }
246
247 for cmd in &self.commands {
249 Self::validate_command(cmd)?;
250 }
251
252 if let Some(ref config) = self.config {
254 let exec = &config.execution;
255 match exec.mode.as_str() {
256 "process" => {
257 if exec.binary.is_none() {
258 return Err(anyhow::anyhow!("Binary path required for process mode"));
259 }
260 }
261 "docker" => {
262 if exec.docker_image.is_none() {
263 return Err(anyhow::anyhow!("Docker image required for docker mode"));
264 }
265 }
266 "wasm" => {
267 if exec.wasm_module.is_none() {
268 return Err(anyhow::anyhow!("WASM module required for wasm mode"));
269 }
270 }
271 mode => {
272 return Err(anyhow::anyhow!("Unknown execution mode: {}", mode));
273 }
274 }
275 }
276
277 Ok(())
278 }
279
280 fn validate_command(cmd: &ManifestCommand) -> Result<()> {
281 if cmd.name.is_empty() {
282 return Err(anyhow::anyhow!("Command name cannot be empty"));
283 }
284
285 for arg in &cmd.args {
287 if arg.name.is_empty() {
288 return Err(anyhow::anyhow!("Argument name cannot be empty"));
289 }
290
291 if !arg.required && arg.short.is_none() && arg.long.is_none() {
293 return Err(anyhow::anyhow!(
294 "Argument '{}' must have either short or long flag",
295 arg.name
296 ));
297 }
298 }
299
300 for subcmd in &cmd.subcommands {
302 Self::validate_command(subcmd)?;
303 }
304
305 Ok(())
306 }
307
308 pub fn example() -> Self {
310 PluginManifest {
311 plugin: PluginInfo {
312 name: "example-plugin".to_string(),
313 version: "0.1.0".to_string(),
314 description: "An example metarepo plugin".to_string(),
315 author: "Your Name".to_string(),
316 license: "MIT".to_string(),
317 homepage: "https://github.com/yourusername/example-plugin".to_string(),
318 repository: "https://github.com/yourusername/example-plugin".to_string(),
319 experimental: false,
320 min_meta_version: Some("0.4.0".to_string()),
321 help_description: Some(
322 "The example plugin demonstrates the manifest format.\n\n\
323 This text renders as a man-page-style Description section on \
324 `meta example --help`."
325 .to_string(),
326 ),
327 },
328 commands: vec![ManifestCommand {
329 name: "example".to_string(),
330 description: "Example command".to_string(),
331 long_description: Some(
332 "This is a longer description of the example command.".to_string(),
333 ),
334 help_description: None,
335 aliases: vec!["ex".to_string()],
336 args: vec![
337 ManifestArg {
338 name: "verbose".to_string(),
339 short: Some('v'),
340 long: Some("verbose".to_string()),
341 help: "Enable verbose output".to_string(),
342 required: false,
343 takes_value: false,
344 default_value: None,
345 possible_values: vec![],
346 value_type: ArgValueType::Bool,
347 },
348 ManifestArg {
349 name: "input".to_string(),
350 short: Some('i'),
351 long: Some("input".to_string()),
352 help: "Input file path".to_string(),
353 required: true,
354 takes_value: true,
355 default_value: None,
356 possible_values: vec![],
357 value_type: ArgValueType::Path,
358 },
359 ],
360 subcommands: vec![ManifestCommand {
361 name: "run".to_string(),
362 description: "Run the example".to_string(),
363 long_description: None,
364 help_description: None,
365 aliases: vec![],
366 args: vec![],
367 subcommands: vec![],
368 examples: vec![],
369 }],
370 examples: vec![Example {
371 command: "meta example -v --input file.txt run".to_string(),
372 description: "Run the example with verbose output".to_string(),
373 }],
374 }],
375 config: Some(PluginConfig {
376 execution: ExecutionConfig {
377 mode: "process".to_string(),
378 binary: Some("./bin/example-plugin".to_string()),
379 docker_image: None,
380 wasm_module: None,
381 protocol: "cli".to_string(),
382 },
383 capabilities: vec!["filesystem".to_string(), "network".to_string()],
384 required_env: vec![],
385 dependencies: vec![],
386 }),
387 }
388 }
389
390 pub fn write_example(path: &Path) -> Result<()> {
392 let manifest = Self::example();
393 let content = toml::to_string_pretty(&manifest)?;
394 std::fs::write(path, content)?;
395 Ok(())
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use tempfile::tempdir;
403
404 const TOML_SRC: &str = r#"
405[plugin]
406name = "foo"
407version = "0.1.0"
408description = "A foo plugin"
409
410[[commands]]
411name = "greet"
412description = "Greet someone"
413
414[[commands.args]]
415name = "name"
416help = "Who to greet"
417required = true
418takes_value = true
419
420[config.execution]
421binary = "./foo.sh"
422"#;
423
424 const YAML_SRC: &str = r#"
425plugin:
426 name: foo
427 version: 0.1.0
428 description: A foo plugin
429commands:
430 - name: greet
431 description: Greet someone
432 args:
433 - name: name
434 help: Who to greet
435 required: true
436 takes_value: true
437config:
438 execution:
439 binary: ./foo.sh
440"#;
441
442 const JSON_SRC: &str = r#"
443{
444 "plugin": { "name": "foo", "version": "0.1.0", "description": "A foo plugin" },
445 "commands": [
446 { "name": "greet", "description": "Greet someone",
447 "args": [ { "name": "name", "help": "Who to greet", "required": true, "takes_value": true } ] }
448 ],
449 "config": { "execution": { "binary": "./foo.sh" } }
450}
451"#;
452
453 fn write(dir: &Path, name: &str, content: &str) -> PathBuf {
454 let p = dir.join(name);
455 std::fs::write(&p, content).unwrap();
456 p
457 }
458
459 #[test]
460 fn loads_all_three_formats_equivalently() {
461 let dir = tempdir().unwrap();
462 for (file, src) in [
463 ("plugin.manifest.toml", TOML_SRC),
464 ("plugin.manifest.yaml", YAML_SRC),
465 ("plugin.manifest.json", JSON_SRC),
466 ] {
467 let path = write(dir.path(), file, src);
468 let m = PluginManifest::from_file_auto(&path).unwrap();
469 assert_eq!(m.plugin.name, "foo");
470 assert_eq!(m.commands.len(), 1);
471 assert_eq!(m.commands[0].name, "greet");
472 assert_eq!(m.commands[0].args[0].name, "name");
473 }
474 }
475
476 #[test]
477 fn find_in_dir_prefers_toml_then_yaml_then_json() {
478 let dir = tempdir().unwrap();
479 write(dir.path(), "plugin.manifest.json", JSON_SRC);
480 assert!(PluginManifest::find_in_dir(dir.path())
481 .unwrap()
482 .ends_with("plugin.manifest.json"));
483 write(dir.path(), "plugin.manifest.toml", TOML_SRC);
484 assert!(PluginManifest::find_in_dir(dir.path())
485 .unwrap()
486 .ends_with("plugin.manifest.toml"));
487 }
488
489 #[test]
490 fn resolve_binary_is_relative_to_manifest() {
491 let dir = tempdir().unwrap();
492 let path = write(dir.path(), "plugin.manifest.toml", TOML_SRC);
493 let m = PluginManifest::from_file_auto(&path).unwrap();
494 let bin = m.resolve_binary(&path).unwrap();
495 assert_eq!(bin, dir.path().join("foo.sh"));
496 }
497
498 #[test]
499 fn is_manifest_path_matches_known_names() {
500 assert!(PluginManifest::is_manifest_path(Path::new(
501 "/x/plugin.manifest.yaml"
502 )));
503 assert!(!PluginManifest::is_manifest_path(Path::new("/x/foo.sh")));
504 }
505}