Skip to main content

metarepo_core/
plugin_manifest.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5/// Manifest filenames the loader recognizes, in priority order.
6pub const MANIFEST_FILENAMES: &[&str] = &[
7    "plugin.manifest.toml",
8    "plugin.manifest.yaml",
9    "plugin.manifest.yml",
10    "plugin.manifest.json",
11];
12
13/// Plugin manifest structure (plugin.toml)
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct PluginManifest {
16    /// Plugin metadata
17    pub plugin: PluginInfo,
18
19    /// Commands provided by the plugin
20    #[serde(default)]
21    pub commands: Vec<ManifestCommand>,
22
23    /// Plugin configuration options
24    #[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    /// Optional long, man-page-style help body for the plugin's top-level command.
46    #[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    /// Optional long, man-page-style help body rendered as a `Description:`
61    /// section on `--help`.
62    #[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    /// How the plugin should be executed
118    #[serde(default)]
119    pub execution: ExecutionConfig,
120
121    /// Plugin capabilities
122    #[serde(default)]
123    pub capabilities: Vec<String>,
124
125    /// Required environment variables
126    #[serde(default)]
127    pub required_env: Vec<String>,
128
129    /// Plugin dependencies
130    #[serde(default)]
131    pub dependencies: Vec<Dependency>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct ExecutionConfig {
136    /// Execution mode: "process", "wasm", "docker"
137    #[serde(default = "default_exec_mode")]
138    pub mode: String,
139
140    /// Path to the executable (relative to manifest)
141    pub binary: Option<String>,
142
143    /// Docker image for docker mode
144    pub docker_image: Option<String>,
145
146    /// WASM module for wasm mode
147    pub wasm_module: Option<String>,
148
149    /// Communication protocol: "json-rpc", "cli", "grpc"
150    #[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    /// Load manifest from a TOML file
172    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    /// Load a manifest, choosing the parser by file extension
178    /// (`.toml`, `.yaml`/`.yml`, `.json`). Defaults to TOML for unknown
179    /// extensions.
180    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    /// Find a `plugin.manifest.*` file directly inside `dir`, if one exists.
201    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    /// Whether a path is a recognized manifest filename.
209    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    /// Parse manifest from TOML string
217    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    /// Resolve the plugin's executable path relative to the manifest's location.
224    /// Uses `config.execution.binary` when set, falling back to a sibling file
225    /// named after the plugin.
226    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    /// Validate the manifest
237    pub fn validate(&self) -> Result<()> {
238        // Validate plugin info
239        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        // Validate commands
248        for cmd in &self.commands {
249            Self::validate_command(cmd)?;
250        }
251
252        // Validate execution config if present
253        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        // Validate arguments
286        for arg in &cmd.args {
287            if arg.name.is_empty() {
288                return Err(anyhow::anyhow!("Argument name cannot be empty"));
289            }
290
291            // Ensure either short or long flag is provided for non-positional args
292            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        // Recursively validate subcommands
301        for subcmd in &cmd.subcommands {
302            Self::validate_command(subcmd)?;
303        }
304
305        Ok(())
306    }
307
308    /// Generate a sample manifest
309    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    /// Write example manifest to file
391    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}