use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::capability::CapabilityDescriptor;
use crate::command_alias::CommandAlias;
use crate::describe::{Platform, Stability};
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
pub description: String,
pub version: semver::Version,
pub stability: Stability,
pub min_dispatcher_version: semver::Version,
pub platforms: Vec<Platform>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub project_requirements: Vec<String>,
pub capabilities: Vec<CapabilityDescriptor>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command_aliases: Vec<CommandAlias>,
}
impl Manifest {
pub fn load(path: &Path) -> Result<Self> {
let raw = std::fs::read_to_string(path)?;
let manifest: Self = toml::from_str(&raw)?;
manifest.validate()?;
Ok(manifest)
}
#[must_use]
pub fn sibling_of(binary: &Path) -> PathBuf {
let mut path = binary.to_path_buf();
path.set_extension("toml");
path
}
fn validate(&self) -> Result<()> {
if self.description.is_empty() {
return Err(Error::contract("manifest description is empty"));
}
if self.description.len() > 80 {
return Err(Error::contract(format!(
"manifest description is {} chars (max 80)",
self.description.len()
)));
}
if self.description.contains('\n') || self.description.contains('\r') {
return Err(Error::contract(
"manifest description must be a single line",
));
}
if self.platforms.is_empty() {
return Err(Error::contract(
"manifest platforms must list at least one OS",
));
}
for requirement in &self.project_requirements {
if requirement.is_empty() {
return Err(Error::contract("manifest project requirement is empty"));
}
if requirement.contains(char::is_whitespace) {
return Err(Error::contract(format!(
"manifest project requirement `{requirement}` contains whitespace"
)));
}
}
for alias in &self.command_aliases {
if alias.name.is_empty() {
return Err(Error::contract("manifest command alias name is empty"));
}
if alias.description.is_empty() {
return Err(Error::contract(format!(
"manifest command alias `{}` description is empty",
alias.name
)));
}
if alias.description.len() > 80 {
return Err(Error::contract(format!(
"manifest command alias `{}` description is {} chars (max 80)",
alias.name,
alias.description.len()
)));
}
if alias.description.contains('\n') || alias.description.contains('\r') {
return Err(Error::contract(format!(
"manifest command alias `{}` description must be a single line",
alias.name
)));
}
if alias.name.contains(char::is_whitespace) {
return Err(Error::contract(format!(
"manifest command alias `{}` contains whitespace",
alias.name
)));
}
if alias.match_first_arg.as_ref().is_some_and(String::is_empty) {
return Err(Error::contract(format!(
"manifest command alias `{}` match_first_arg is empty",
alias.name
)));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::command_alias::CommandAliasTarget;
#[test]
fn sibling_of_strips_extension() {
let unix = Path::new("/usr/local/bin/ready-set-foo");
assert_eq!(
Manifest::sibling_of(unix),
PathBuf::from("/usr/local/bin/ready-set-foo.toml")
);
let win = Path::new("C:/tools/ready-set-foo.exe");
assert_eq!(
Manifest::sibling_of(win),
PathBuf::from("C:/tools/ready-set-foo.toml")
);
}
#[test]
fn parses_a_full_manifest() {
let toml_src = r#"
description = "Reference plugin"
version = "0.1.0"
stability = "stable"
min_dispatcher_version = "0.1.0"
platforms = ["linux", "macos", "windows"]
capabilities = []
"#;
let m: Manifest = toml::from_str(toml_src).unwrap();
m.validate().unwrap();
assert_eq!(m.description, "Reference plugin");
assert_eq!(m.platforms.len(), 3);
assert!(m.capabilities.is_empty());
}
#[test]
fn parses_manifest_with_capability() {
let toml_src = r#"
description = "Reference plugin"
version = "0.1.0"
stability = "stable"
min_dispatcher_version = "0.1.0"
platforms = ["linux", "macos", "windows"]
[[capabilities]]
id = "linting"
title = "Linting"
provider = "rust"
verbs = ["ready", "set", "go"]
default_relevance = "required"
"#;
let m: Manifest = toml::from_str(toml_src).unwrap();
m.validate().unwrap();
assert_eq!(m.capabilities.len(), 1);
assert_eq!(m.capabilities[0].id.as_str(), "linting");
}
#[test]
fn parses_manifest_with_command_aliases() {
let toml_src = r#"
description = "Reference plugin"
version = "0.1.0"
stability = "stable"
min_dispatcher_version = "0.1.0"
platforms = ["linux", "macos", "windows"]
capabilities = []
[[command_aliases]]
name = "rotate"
description = "Rotate due secrets"
target = "go"
capability = "rotation"
[[command_aliases]]
name = "encrypt"
description = "Inspect secret bundles"
match_first_arg = "bundle"
target = "plugin"
"#;
let m: Manifest = toml::from_str(toml_src).unwrap();
m.validate().unwrap();
assert_eq!(m.command_aliases.len(), 2);
assert!(matches!(
m.command_aliases[0].target,
CommandAliasTarget::Go { .. }
));
assert_eq!(
m.command_aliases[1].match_first_arg.as_deref(),
Some("bundle")
);
}
#[test]
fn rejects_manifest_without_capabilities() {
let toml_src = r#"
description = "Reference plugin"
version = "0.1.0"
stability = "stable"
min_dispatcher_version = "0.1.0"
"#;
assert!(toml::from_str::<Manifest>(toml_src).is_err());
}
#[test]
fn rejects_overlong_description() {
let mut m = Manifest {
description: "x".repeat(81),
version: semver::Version::new(0, 1, 0),
stability: Stability::Stable,
min_dispatcher_version: semver::Version::new(0, 1, 0),
platforms: vec![Platform::Linux],
project_requirements: Vec::new(),
capabilities: Vec::new(),
command_aliases: Vec::new(),
};
assert!(m.validate().is_err());
m.description = "ok".into();
m.validate().unwrap();
}
}