use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginManifest {
pub name: String,
pub version: String,
pub description: Option<String>,
pub author: Option<Author>,
#[serde(default)]
pub recipes: Vec<RecipeEntry>,
#[serde(default)]
pub compatibility: Compatibility,
#[serde(default)]
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Author {
pub name: String,
pub email: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RecipeEntry {
pub name: String,
pub description: Option<String>,
pub entry_point: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Compatibility {
pub morph_cli_version: String,
pub language: Option<Vec<String>>,
pub features: Option<Vec<String>>,
}
pub fn is_valid_version_range(range: &str) -> bool {
let range = range.trim();
if range.is_empty() {
return false;
}
let parts: Vec<&str> = range.split(|c| c == ' ' || c == ',').filter(|s| !s.is_empty()).collect();
if parts.is_empty() {
return false;
}
for part in parts {
let clean = part.trim()
.trim_start_matches(">=")
.trim_start_matches("<=")
.trim_start_matches('>')
.trim_start_matches('<')
.trim_start_matches('^')
.trim_start_matches('~')
.trim();
if clean == "*" || clean == "x" || clean == "X" {
continue;
}
let subparts: Vec<&str> = clean.split('.').collect();
if subparts.is_empty() || subparts.len() > 3 {
return false;
}
for subpart in subparts {
if subpart.chars().any(|c| !c.is_ascii_digit() && c != 'x' && c != 'X' && c != '*') {
return false;
}
}
}
true
}
fn parse_version(v: &str) -> Vec<u32> {
v.trim()
.trim_start_matches(">=")
.trim_start_matches("<=")
.trim_start_matches('>')
.trim_start_matches('<')
.trim_start_matches('^')
.trim_start_matches('~')
.split('.')
.map(|s| {
if s == "*" || s == "x" || s == "X" {
0
} else {
s.parse::<u32>().unwrap_or(0)
}
})
.collect()
}
fn version_cmp(v1: &[u32], v2: &[u32]) -> std::cmp::Ordering {
for i in 0..v1.len().max(v2.len()) {
let n1 = v1.get(i).copied().unwrap_or(0);
let n2 = v2.get(i).copied().unwrap_or(0);
if n1 != n2 {
return n1.cmp(&n2);
}
}
std::cmp::Ordering::Equal
}
pub fn satisfies_version(range: &str, current: &str) -> bool {
let range = range.trim();
if range.is_empty() {
return false;
}
let parts: Vec<&str> = range.split(|c| c == ' ' || c == ',').filter(|s| !s.is_empty()).collect();
if parts.is_empty() {
return false;
}
for part in parts {
if !satisfies_single_constraint(part, current) {
return false;
}
}
true
}
fn satisfies_single_constraint(range: &str, current: &str) -> bool {
let clean_range = range.trim();
let current_parts = parse_version(current);
let clean_ver = clean_range
.trim_start_matches(">=")
.trim_start_matches("<=")
.trim_start_matches('>')
.trim_start_matches('<')
.trim_start_matches('^')
.trim_start_matches('~')
.trim();
if clean_ver == "*" || clean_ver == "x" || clean_ver == "X" {
return true;
}
let range_elems: Vec<&str> = clean_ver.split('.').collect();
let current_elems: Vec<&str> = current.trim().split('.').collect();
if !clean_range.starts_with(">=")
&& !clean_range.starts_with("<=")
&& !clean_range.starts_with('>')
&& !clean_range.starts_with('<')
&& !clean_range.starts_with('^')
&& !clean_range.starts_with('~')
&& range_elems.iter().any(|&s| s == "x" || s == "X" || s == "*")
{
for (i, &elem) in range_elems.iter().enumerate() {
if elem == "x" || elem == "X" || elem == "*" {
continue;
}
if let Some(&curr) = current_elems.get(i) {
if elem != curr {
return false;
}
} else {
return false;
}
}
return true;
}
let range_parts = parse_version(clean_ver);
if clean_range.starts_with(">=") {
version_cmp(¤t_parts, &range_parts) != std::cmp::Ordering::Less
} else if clean_range.starts_with("<=") {
version_cmp(¤t_parts, &range_parts) != std::cmp::Ordering::Greater
} else if clean_range.starts_with('>') {
version_cmp(¤t_parts, &range_parts) == std::cmp::Ordering::Greater
} else if clean_range.starts_with('<') {
version_cmp(¤t_parts, &range_parts) == std::cmp::Ordering::Less
} else if clean_range.starts_with('^') {
let is_greater_or_equal = version_cmp(¤t_parts, &range_parts) != std::cmp::Ordering::Less;
if !is_greater_or_equal {
return false;
}
let major = range_parts.first().copied().unwrap_or(0);
let minor = range_parts.get(1).copied().unwrap_or(0);
if major > 0 {
current_parts.first() == range_parts.first()
} else if minor > 0 {
current_parts.get(0..2) == range_parts.get(0..2)
} else {
current_parts.get(0..3) == range_parts.get(0..3)
}
} else if clean_range.starts_with('~') {
current_parts.get(0..2) == range_parts.get(0..2)
&& version_cmp(¤t_parts, &range_parts) != std::cmp::Ordering::Less
} else {
version_cmp(¤t_parts, &range_parts) == std::cmp::Ordering::Equal
}
}
impl PluginManifest {
pub fn from_path(path: &Path) -> Result<Self, ManifestError> {
let content = std::fs::read_to_string(path)?;
Self::from_toml(&content)
}
pub fn from_toml(content: &str) -> Result<Self, ManifestError> {
toml::from_str(content).map_err(ManifestError::ParseError)
}
pub fn validate(&self) -> Vec<ValidationError> {
let mut errors = Vec::new();
if self.name.is_empty() {
errors.push(ValidationError {
field: "name".to_string(),
message: "Plugin name cannot be empty".to_string(),
});
}
if self.version.is_empty() {
errors.push(ValidationError {
field: "version".to_string(),
message: "Version cannot be empty".to_string(),
});
} else if !is_valid_version_range(&self.version) {
errors.push(ValidationError {
field: "version".to_string(),
message: format!("Invalid SemVer version string: `{}`", self.version),
});
}
if let Some(author) = &self.author {
if author.name.is_empty() {
errors.push(ValidationError {
field: "author.name".to_string(),
message: "Author name cannot be empty if author field is defined".to_string(),
});
}
}
if self.compatibility.morph_cli_version.is_empty() {
errors.push(ValidationError {
field: "compatibility.morph_cli_version".to_string(),
message: "morph-cli version required".to_string(),
});
} else if !is_valid_version_range(&self.compatibility.morph_cli_version) {
errors.push(ValidationError {
field: "compatibility.morph_cli_version".to_string(),
message: format!("Invalid SemVer compatibility range: `{}`", self.compatibility.morph_cli_version),
});
} else {
let current_morph_version = env!("CARGO_PKG_VERSION");
if !satisfies_version(&self.compatibility.morph_cli_version, current_morph_version) {
errors.push(ValidationError {
field: "compatibility.morph_cli_version".to_string(),
message: format!(
"Unsupported morph-cli version: current version is `{}` but the plugin requires `{}`",
current_morph_version,
self.compatibility.morph_cli_version
),
});
}
}
if self.recipes.is_empty() {
errors.push(ValidationError {
field: "recipes".to_string(),
message: "At least one recipe must be defined".to_string(),
});
}
let mut recipe_names = std::collections::HashSet::new();
for recipe in &self.recipes {
if recipe.name.is_empty() {
errors.push(ValidationError {
field: "recipes[].name".to_string(),
message: "Recipe name cannot be empty".to_string(),
});
} else if !recipe_names.insert(recipe.name.clone()) {
errors.push(ValidationError {
field: "recipes".to_string(),
message: format!("Duplicate recipe name `{}` found in manifest", recipe.name),
});
}
}
errors
}
pub fn is_valid(&self) -> bool {
self.validate().is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationError {
pub field: String,
pub message: String,
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[field: `{}`] error: {}", self.field, self.message)
}
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
#[error("Failed to read manifest: {0}")]
IoError(#[from] std::io::Error),
#[error("Failed to parse manifest: {0}")]
ParseError(toml::de::Error),
#[error("Invalid manifest: {0}")]
Invalid(String),
}
#[cfg(test)]
mod tests {
use super::*;
const VALID_MANIFEST: &str = r#"
name = "my-plugin"
version = "1.0.0"
description = "A test plugin"
[author]
name = "Test Author"
email = "test@example.com"
[[recipes]]
name = "test-recipe"
description = "A test recipe"
[compatibility]
morph_cli_version = ">=0.1.0"
language = ["javascript", "typescript"]
"#;
const INVALID_MANIFEST: &str = "this is not valid toml at all";
#[test]
fn test_parse_valid_manifest() {
let manifest = PluginManifest::from_toml(VALID_MANIFEST).unwrap();
assert_eq!(manifest.name, "my-plugin");
assert_eq!(manifest.version, "1.0.0");
assert_eq!(manifest.recipes.len(), 1);
}
#[test]
fn test_invalid_manifest() {
let result = PluginManifest::from_toml(INVALID_MANIFEST);
assert!(result.is_err());
}
#[test]
fn test_validation_empty_name() {
let content = r#"
name = ""
version = "1.0.0"
[[recipes]]
name = "test"
[compatibility]
morph_cli_version = "1.0.0"
"#;
let manifest = PluginManifest::from_toml(content).unwrap();
let errors = manifest.validate();
assert!(errors.iter().any(|e| e.field == "name"));
}
#[test]
fn test_validation_empty_recipes() {
let content = r#"
name = "test"
version = "1.0.0"
[compatibility]
morph_cli_version = "1.0.0"
"#;
let manifest = PluginManifest::from_toml(content).unwrap();
let errors = manifest.validate();
assert!(errors.iter().any(|e| e.field == "recipes"));
}
#[test]
fn test_is_valid() {
let manifest = PluginManifest::from_toml(VALID_MANIFEST).unwrap();
assert!(manifest.is_valid());
}
#[test]
fn test_manifest_debug() {
let manifest = PluginManifest::from_toml(VALID_MANIFEST).unwrap();
let debug = format!("{:?}", manifest);
assert!(debug.contains("my-plugin"));
}
#[test]
fn test_semver_range_checks() {
assert!(is_valid_version_range("1.0.0"));
assert!(is_valid_version_range(">=0.1.0"));
assert!(is_valid_version_range("^0.2.1"));
assert!(is_valid_version_range("~1.0"));
assert!(!is_valid_version_range("invalid-semver"));
assert!(!is_valid_version_range("1.2.3.4"));
}
#[test]
fn test_satisfies_version_checks() {
assert!(satisfies_version(">=0.1.0", "0.1.0"));
assert!(satisfies_version(">=0.1.0", "1.2.3"));
assert!(satisfies_version("^0.1.0", "0.1.5"));
assert!(satisfies_version("~1.2.0", "1.2.4"));
assert!(!satisfies_version("^0.1.0", "0.2.0"));
}
#[test]
fn test_duplicate_recipe_names() {
let content = r#"
name = "dup-plugin"
version = "1.0.0"
[[recipes]]
name = "recipe-a"
[[recipes]]
name = "recipe-a"
[compatibility]
morph_cli_version = ">=0.1.0"
"#;
let manifest = PluginManifest::from_toml(content).unwrap();
let errors = manifest.validate();
assert!(errors.iter().any(|e| e.field == "recipes" && e.message.contains("Duplicate recipe name")));
}
#[test]
fn test_compound_semver_range_checks() {
assert!(is_valid_version_range(">=0.1.0 <2.0.0"));
assert!(is_valid_version_range(">=0.1.0, <2.0.0"));
assert!(satisfies_version(">=0.1.0 <2.0.0", "0.5.0"));
assert!(satisfies_version(">=0.1.0 <2.0.0", "1.9.9"));
assert!(!satisfies_version(">=0.1.0 <2.0.0", "2.0.0"));
assert!(!satisfies_version(">=0.1.0 <2.0.0", "0.0.9"));
}
#[test]
fn test_wildcard_semver_range_checks() {
assert!(is_valid_version_range("1.x"));
assert!(is_valid_version_range("1.x.x"));
assert!(is_valid_version_range("1.*"));
assert!(satisfies_version("1.x", "1.2.3"));
assert!(satisfies_version("1.*", "1.5.0"));
}
#[test]
fn test_empty_author_name() {
let content = r#"
name = "author-plugin"
version = "1.0.0"
[author]
name = ""
[[recipes]]
name = "test-recipe"
[compatibility]
morph_cli_version = ">=0.1.0"
"#;
let manifest = PluginManifest::from_toml(content).unwrap();
let errors = manifest.validate();
assert!(errors.iter().any(|e| e.field == "author.name"));
}
}