use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const MANIFEST_FILENAMES: &[&str] = &[
"plugin.manifest.toml",
"plugin.manifest.yaml",
"plugin.manifest.yml",
"plugin.manifest.json",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginInfo,
#[serde(default)]
pub commands: Vec<ManifestCommand>,
#[serde(default)]
pub config: Option<PluginConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo {
pub name: String,
pub version: String,
pub description: String,
#[serde(default)]
pub author: String,
#[serde(default)]
pub license: String,
#[serde(default)]
pub homepage: String,
#[serde(default)]
pub repository: String,
#[serde(default)]
pub experimental: bool,
#[serde(default)]
pub min_meta_version: Option<String>,
#[serde(
default,
alias = "helpDescription",
skip_serializing_if = "Option::is_none"
)]
pub help_description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestCommand {
pub name: String,
pub description: String,
#[serde(default)]
pub long_description: Option<String>,
#[serde(
default,
alias = "helpDescription",
skip_serializing_if = "Option::is_none"
)]
pub help_description: Option<String>,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default)]
pub args: Vec<ManifestArg>,
#[serde(default)]
pub subcommands: Vec<ManifestCommand>,
#[serde(default)]
pub examples: Vec<Example>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestArg {
pub name: String,
#[serde(default)]
pub short: Option<char>,
#[serde(default)]
pub long: Option<String>,
pub help: String,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub takes_value: bool,
#[serde(default)]
pub default_value: Option<String>,
#[serde(default)]
pub possible_values: Vec<String>,
#[serde(default)]
pub value_type: ArgValueType,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ArgValueType {
#[default]
String,
Number,
Bool,
Path,
Url,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Example {
pub command: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginConfig {
#[serde(default)]
pub execution: ExecutionConfig,
#[serde(default)]
pub capabilities: Vec<String>,
#[serde(default)]
pub required_env: Vec<String>,
#[serde(default)]
pub dependencies: Vec<Dependency>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExecutionConfig {
#[serde(default = "default_exec_mode")]
pub mode: String,
pub binary: Option<String>,
pub docker_image: Option<String>,
pub wasm_module: Option<String>,
#[serde(default = "default_protocol")]
pub protocol: String,
}
fn default_exec_mode() -> String {
"process".to_string()
}
fn default_protocol() -> String {
"cli".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Dependency {
pub name: String,
pub version: String,
#[serde(default)]
pub optional: bool,
}
impl PluginManifest {
pub fn from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
Self::from_toml_str(&content)
}
pub fn from_file_auto(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read manifest {}", path.display()))?;
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let manifest: PluginManifest = match ext.as_str() {
"json" => serde_json::from_str(&content)
.with_context(|| format!("Invalid JSON manifest {}", path.display()))?,
"yaml" | "yml" => serde_yaml::from_str(&content)
.with_context(|| format!("Invalid YAML manifest {}", path.display()))?,
_ => toml::from_str(&content)
.with_context(|| format!("Invalid TOML manifest {}", path.display()))?,
};
manifest.validate()?;
Ok(manifest)
}
pub fn find_in_dir(dir: &Path) -> Option<PathBuf> {
MANIFEST_FILENAMES
.iter()
.map(|name| dir.join(name))
.find(|p| p.is_file())
}
pub fn is_manifest_path(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| MANIFEST_FILENAMES.contains(&n))
.unwrap_or(false)
}
pub fn from_toml_str(content: &str) -> Result<Self> {
let manifest: PluginManifest = toml::from_str(content)?;
manifest.validate()?;
Ok(manifest)
}
pub fn resolve_binary(&self, manifest_path: &Path) -> Result<PathBuf> {
let dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
let rel = self
.config
.as_ref()
.and_then(|c| c.execution.binary.as_deref())
.unwrap_or(self.plugin.name.as_str());
Ok(dir.join(rel))
}
pub fn validate(&self) -> Result<()> {
if self.plugin.name.is_empty() {
return Err(anyhow::anyhow!("Plugin name cannot be empty"));
}
if self.plugin.version.is_empty() {
return Err(anyhow::anyhow!("Plugin version cannot be empty"));
}
for cmd in &self.commands {
Self::validate_command(cmd)?;
}
if let Some(ref config) = self.config {
let exec = &config.execution;
match exec.mode.as_str() {
"process" => {
if exec.binary.is_none() {
return Err(anyhow::anyhow!("Binary path required for process mode"));
}
}
"docker" => {
if exec.docker_image.is_none() {
return Err(anyhow::anyhow!("Docker image required for docker mode"));
}
}
"wasm" => {
if exec.wasm_module.is_none() {
return Err(anyhow::anyhow!("WASM module required for wasm mode"));
}
}
mode => {
return Err(anyhow::anyhow!("Unknown execution mode: {}", mode));
}
}
}
Ok(())
}
fn validate_command(cmd: &ManifestCommand) -> Result<()> {
if cmd.name.is_empty() {
return Err(anyhow::anyhow!("Command name cannot be empty"));
}
for arg in &cmd.args {
if arg.name.is_empty() {
return Err(anyhow::anyhow!("Argument name cannot be empty"));
}
if !arg.required && arg.short.is_none() && arg.long.is_none() {
return Err(anyhow::anyhow!(
"Argument '{}' must have either short or long flag",
arg.name
));
}
}
for subcmd in &cmd.subcommands {
Self::validate_command(subcmd)?;
}
Ok(())
}
pub fn example() -> Self {
PluginManifest {
plugin: PluginInfo {
name: "example-plugin".to_string(),
version: "0.1.0".to_string(),
description: "An example metarepo plugin".to_string(),
author: "Your Name".to_string(),
license: "MIT".to_string(),
homepage: "https://github.com/yourusername/example-plugin".to_string(),
repository: "https://github.com/yourusername/example-plugin".to_string(),
experimental: false,
min_meta_version: Some("0.4.0".to_string()),
help_description: Some(
"The example plugin demonstrates the manifest format.\n\n\
This text renders as a man-page-style Description section on \
`meta example --help`."
.to_string(),
),
},
commands: vec![ManifestCommand {
name: "example".to_string(),
description: "Example command".to_string(),
long_description: Some(
"This is a longer description of the example command.".to_string(),
),
help_description: None,
aliases: vec!["ex".to_string()],
args: vec![
ManifestArg {
name: "verbose".to_string(),
short: Some('v'),
long: Some("verbose".to_string()),
help: "Enable verbose output".to_string(),
required: false,
takes_value: false,
default_value: None,
possible_values: vec![],
value_type: ArgValueType::Bool,
},
ManifestArg {
name: "input".to_string(),
short: Some('i'),
long: Some("input".to_string()),
help: "Input file path".to_string(),
required: true,
takes_value: true,
default_value: None,
possible_values: vec![],
value_type: ArgValueType::Path,
},
],
subcommands: vec![ManifestCommand {
name: "run".to_string(),
description: "Run the example".to_string(),
long_description: None,
help_description: None,
aliases: vec![],
args: vec![],
subcommands: vec![],
examples: vec![],
}],
examples: vec![Example {
command: "meta example -v --input file.txt run".to_string(),
description: "Run the example with verbose output".to_string(),
}],
}],
config: Some(PluginConfig {
execution: ExecutionConfig {
mode: "process".to_string(),
binary: Some("./bin/example-plugin".to_string()),
docker_image: None,
wasm_module: None,
protocol: "cli".to_string(),
},
capabilities: vec!["filesystem".to_string(), "network".to_string()],
required_env: vec![],
dependencies: vec![],
}),
}
}
pub fn write_example(path: &Path) -> Result<()> {
let manifest = Self::example();
let content = toml::to_string_pretty(&manifest)?;
std::fs::write(path, content)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
const TOML_SRC: &str = r#"
[plugin]
name = "foo"
version = "0.1.0"
description = "A foo plugin"
[[commands]]
name = "greet"
description = "Greet someone"
[[commands.args]]
name = "name"
help = "Who to greet"
required = true
takes_value = true
[config.execution]
binary = "./foo.sh"
"#;
const YAML_SRC: &str = r#"
plugin:
name: foo
version: 0.1.0
description: A foo plugin
commands:
- name: greet
description: Greet someone
args:
- name: name
help: Who to greet
required: true
takes_value: true
config:
execution:
binary: ./foo.sh
"#;
const JSON_SRC: &str = r#"
{
"plugin": { "name": "foo", "version": "0.1.0", "description": "A foo plugin" },
"commands": [
{ "name": "greet", "description": "Greet someone",
"args": [ { "name": "name", "help": "Who to greet", "required": true, "takes_value": true } ] }
],
"config": { "execution": { "binary": "./foo.sh" } }
}
"#;
fn write(dir: &Path, name: &str, content: &str) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, content).unwrap();
p
}
#[test]
fn loads_all_three_formats_equivalently() {
let dir = tempdir().unwrap();
for (file, src) in [
("plugin.manifest.toml", TOML_SRC),
("plugin.manifest.yaml", YAML_SRC),
("plugin.manifest.json", JSON_SRC),
] {
let path = write(dir.path(), file, src);
let m = PluginManifest::from_file_auto(&path).unwrap();
assert_eq!(m.plugin.name, "foo");
assert_eq!(m.commands.len(), 1);
assert_eq!(m.commands[0].name, "greet");
assert_eq!(m.commands[0].args[0].name, "name");
}
}
#[test]
fn find_in_dir_prefers_toml_then_yaml_then_json() {
let dir = tempdir().unwrap();
write(dir.path(), "plugin.manifest.json", JSON_SRC);
assert!(PluginManifest::find_in_dir(dir.path())
.unwrap()
.ends_with("plugin.manifest.json"));
write(dir.path(), "plugin.manifest.toml", TOML_SRC);
assert!(PluginManifest::find_in_dir(dir.path())
.unwrap()
.ends_with("plugin.manifest.toml"));
}
#[test]
fn resolve_binary_is_relative_to_manifest() {
let dir = tempdir().unwrap();
let path = write(dir.path(), "plugin.manifest.toml", TOML_SRC);
let m = PluginManifest::from_file_auto(&path).unwrap();
let bin = m.resolve_binary(&path).unwrap();
assert_eq!(bin, dir.path().join("foo.sh"));
}
#[test]
fn is_manifest_path_matches_known_names() {
assert!(PluginManifest::is_manifest_path(Path::new(
"/x/plugin.manifest.yaml"
)));
assert!(!PluginManifest::is_manifest_path(Path::new("/x/foo.sh")));
}
}