1use serde::{Deserialize, Serialize};
2use std::path::Path;
3
4#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9pub struct PluginManifest {
10 pub name: String,
11 pub version: String,
12 pub description: Option<String>,
13 pub author: Option<Author>,
14 #[serde(default)]
15 pub recipes: Vec<RecipeEntry>,
16 #[serde(default)]
17 pub compatibility: Compatibility,
18 #[serde(default)]
19 pub metadata: serde_json::Value,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, Default)]
23pub struct Author {
24 pub name: String,
25 pub email: Option<String>,
26 pub url: Option<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30pub struct RecipeEntry {
31 pub name: String,
32 pub description: Option<String>,
33 pub entry_point: Option<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37pub struct Compatibility {
38 pub morph_cli_version: String,
39 pub language: Option<Vec<String>>,
40 pub features: Option<Vec<String>>,
41}
42
43pub fn is_valid_version_range(range: &str) -> bool {
44 let range = range.trim();
45 if range.is_empty() {
46 return false;
47 }
48
49 let parts: Vec<&str> = range.split(|c| c == ' ' || c == ',').filter(|s| !s.is_empty()).collect();
51 if parts.is_empty() {
52 return false;
53 }
54 for part in parts {
55 let clean = part.trim()
56 .trim_start_matches(">=")
57 .trim_start_matches("<=")
58 .trim_start_matches('>')
59 .trim_start_matches('<')
60 .trim_start_matches('^')
61 .trim_start_matches('~')
62 .trim();
63
64 if clean == "*" || clean == "x" || clean == "X" {
65 continue;
66 }
67
68 let subparts: Vec<&str> = clean.split('.').collect();
69 if subparts.is_empty() || subparts.len() > 3 {
70 return false;
71 }
72 for subpart in subparts {
73 if subpart.chars().any(|c| !c.is_ascii_digit() && c != 'x' && c != 'X' && c != '*') {
74 return false;
75 }
76 }
77 }
78 true
79}
80
81fn parse_version(v: &str) -> Vec<u32> {
82 v.trim()
83 .trim_start_matches(">=")
84 .trim_start_matches("<=")
85 .trim_start_matches('>')
86 .trim_start_matches('<')
87 .trim_start_matches('^')
88 .trim_start_matches('~')
89 .split('.')
90 .map(|s| {
91 if s == "*" || s == "x" || s == "X" {
92 0
93 } else {
94 s.parse::<u32>().unwrap_or(0)
95 }
96 })
97 .collect()
98}
99
100fn version_cmp(v1: &[u32], v2: &[u32]) -> std::cmp::Ordering {
101 for i in 0..v1.len().max(v2.len()) {
102 let n1 = v1.get(i).copied().unwrap_or(0);
103 let n2 = v2.get(i).copied().unwrap_or(0);
104 if n1 != n2 {
105 return n1.cmp(&n2);
106 }
107 }
108 std::cmp::Ordering::Equal
109}
110
111pub fn satisfies_version(range: &str, current: &str) -> bool {
112 let range = range.trim();
113 if range.is_empty() {
114 return false;
115 }
116 let parts: Vec<&str> = range.split(|c| c == ' ' || c == ',').filter(|s| !s.is_empty()).collect();
117 if parts.is_empty() {
118 return false;
119 }
120 for part in parts {
121 if !satisfies_single_constraint(part, current) {
122 return false;
123 }
124 }
125 true
126}
127
128fn satisfies_single_constraint(range: &str, current: &str) -> bool {
129 let clean_range = range.trim();
130 let current_parts = parse_version(current);
131 let clean_ver = clean_range
132 .trim_start_matches(">=")
133 .trim_start_matches("<=")
134 .trim_start_matches('>')
135 .trim_start_matches('<')
136 .trim_start_matches('^')
137 .trim_start_matches('~')
138 .trim();
139
140 if clean_ver == "*" || clean_ver == "x" || clean_ver == "X" {
141 return true;
142 }
143
144 let range_elems: Vec<&str> = clean_ver.split('.').collect();
145 let current_elems: Vec<&str> = current.trim().split('.').collect();
146
147 if !clean_range.starts_with(">=")
149 && !clean_range.starts_with("<=")
150 && !clean_range.starts_with('>')
151 && !clean_range.starts_with('<')
152 && !clean_range.starts_with('^')
153 && !clean_range.starts_with('~')
154 && range_elems.iter().any(|&s| s == "x" || s == "X" || s == "*")
155 {
156 for (i, &elem) in range_elems.iter().enumerate() {
157 if elem == "x" || elem == "X" || elem == "*" {
158 continue;
159 }
160 if let Some(&curr) = current_elems.get(i) {
161 if elem != curr {
162 return false;
163 }
164 } else {
165 return false;
166 }
167 }
168 return true;
169 }
170
171 let range_parts = parse_version(clean_ver);
172
173 if clean_range.starts_with(">=") {
174 version_cmp(¤t_parts, &range_parts) != std::cmp::Ordering::Less
175 } else if clean_range.starts_with("<=") {
176 version_cmp(¤t_parts, &range_parts) != std::cmp::Ordering::Greater
177 } else if clean_range.starts_with('>') {
178 version_cmp(¤t_parts, &range_parts) == std::cmp::Ordering::Greater
179 } else if clean_range.starts_with('<') {
180 version_cmp(¤t_parts, &range_parts) == std::cmp::Ordering::Less
181 } else if clean_range.starts_with('^') {
182 let is_greater_or_equal = version_cmp(¤t_parts, &range_parts) != std::cmp::Ordering::Less;
183 if !is_greater_or_equal {
184 return false;
185 }
186 let major = range_parts.first().copied().unwrap_or(0);
187 let minor = range_parts.get(1).copied().unwrap_or(0);
188 if major > 0 {
189 current_parts.first() == range_parts.first()
190 } else if minor > 0 {
191 current_parts.get(0..2) == range_parts.get(0..2)
192 } else {
193 current_parts.get(0..3) == range_parts.get(0..3)
194 }
195 } else if clean_range.starts_with('~') {
196 current_parts.get(0..2) == range_parts.get(0..2)
197 && version_cmp(¤t_parts, &range_parts) != std::cmp::Ordering::Less
198 } else {
199 version_cmp(¤t_parts, &range_parts) == std::cmp::Ordering::Equal
200 }
201}
202
203impl PluginManifest {
204 pub fn from_path(path: &Path) -> Result<Self, ManifestError> {
205 let content = std::fs::read_to_string(path)?;
206 Self::from_toml(&content)
207 }
208
209 pub fn from_toml(content: &str) -> Result<Self, ManifestError> {
210 toml::from_str(content).map_err(ManifestError::ParseError)
211 }
212
213 pub fn validate(&self) -> Vec<ValidationError> {
214 let mut errors = Vec::new();
215
216 if self.name.is_empty() {
217 errors.push(ValidationError {
218 field: "name".to_string(),
219 message: "Plugin name cannot be empty".to_string(),
220 });
221 }
222
223 if self.version.is_empty() {
224 errors.push(ValidationError {
225 field: "version".to_string(),
226 message: "Version cannot be empty".to_string(),
227 });
228 } else if !is_valid_version_range(&self.version) {
229 errors.push(ValidationError {
230 field: "version".to_string(),
231 message: format!("Invalid SemVer version string: `{}`", self.version),
232 });
233 }
234
235 if let Some(author) = &self.author {
236 if author.name.is_empty() {
237 errors.push(ValidationError {
238 field: "author.name".to_string(),
239 message: "Author name cannot be empty if author field is defined".to_string(),
240 });
241 }
242 }
243
244 if self.compatibility.morph_cli_version.is_empty() {
245 errors.push(ValidationError {
246 field: "compatibility.morph_cli_version".to_string(),
247 message: "morph-cli version required".to_string(),
248 });
249 } else if !is_valid_version_range(&self.compatibility.morph_cli_version) {
250 errors.push(ValidationError {
251 field: "compatibility.morph_cli_version".to_string(),
252 message: format!("Invalid SemVer compatibility range: `{}`", self.compatibility.morph_cli_version),
253 });
254 } else {
255 let current_morph_version = env!("CARGO_PKG_VERSION");
257 if !satisfies_version(&self.compatibility.morph_cli_version, current_morph_version) {
258 errors.push(ValidationError {
259 field: "compatibility.morph_cli_version".to_string(),
260 message: format!(
261 "Unsupported morph-cli version: current version is `{}` but the plugin requires `{}`",
262 current_morph_version,
263 self.compatibility.morph_cli_version
264 ),
265 });
266 }
267 }
268
269 if self.recipes.is_empty() {
270 errors.push(ValidationError {
271 field: "recipes".to_string(),
272 message: "At least one recipe must be defined".to_string(),
273 });
274 }
275
276 let mut recipe_names = std::collections::HashSet::new();
277 for recipe in &self.recipes {
278 if recipe.name.is_empty() {
279 errors.push(ValidationError {
280 field: "recipes[].name".to_string(),
281 message: "Recipe name cannot be empty".to_string(),
282 });
283 } else if !recipe_names.insert(recipe.name.clone()) {
284 errors.push(ValidationError {
285 field: "recipes".to_string(),
286 message: format!("Duplicate recipe name `{}` found in manifest", recipe.name),
287 });
288 }
289 }
290
291 errors
292 }
293
294 pub fn is_valid(&self) -> bool {
295 self.validate().is_empty()
296 }
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ValidationError {
301 pub field: String,
302 pub message: String,
303}
304
305impl std::fmt::Display for ValidationError {
306 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307 write!(f, "[field: `{}`] error: {}", self.field, self.message)
308 }
309}
310
311#[derive(Debug, thiserror::Error)]
312pub enum ManifestError {
313 #[error("Failed to read manifest: {0}")]
314 IoError(#[from] std::io::Error),
315 #[error("Failed to parse manifest: {0}")]
316 ParseError(toml::de::Error),
317 #[error("Invalid manifest: {0}")]
318 Invalid(String),
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 const VALID_MANIFEST: &str = r#"
326name = "my-plugin"
327version = "1.0.0"
328description = "A test plugin"
329
330[author]
331name = "Test Author"
332email = "test@example.com"
333
334[[recipes]]
335name = "test-recipe"
336description = "A test recipe"
337
338[compatibility]
339morph_cli_version = ">=0.1.0"
340language = ["javascript", "typescript"]
341"#;
342
343 const INVALID_MANIFEST: &str = "this is not valid toml at all";
344
345 #[test]
346 fn test_parse_valid_manifest() {
347 let manifest = PluginManifest::from_toml(VALID_MANIFEST).unwrap();
348 assert_eq!(manifest.name, "my-plugin");
349 assert_eq!(manifest.version, "1.0.0");
350 assert_eq!(manifest.recipes.len(), 1);
351 }
352
353 #[test]
354 fn test_invalid_manifest() {
355 let result = PluginManifest::from_toml(INVALID_MANIFEST);
356 assert!(result.is_err());
357 }
358
359 #[test]
360 fn test_validation_empty_name() {
361 let content = r#"
362name = ""
363version = "1.0.0"
364
365[[recipes]]
366name = "test"
367
368[compatibility]
369morph_cli_version = "1.0.0"
370"#;
371 let manifest = PluginManifest::from_toml(content).unwrap();
372 let errors = manifest.validate();
373 assert!(errors.iter().any(|e| e.field == "name"));
374 }
375
376 #[test]
377 fn test_validation_empty_recipes() {
378 let content = r#"
379name = "test"
380version = "1.0.0"
381
382[compatibility]
383morph_cli_version = "1.0.0"
384"#;
385 let manifest = PluginManifest::from_toml(content).unwrap();
386 let errors = manifest.validate();
387 assert!(errors.iter().any(|e| e.field == "recipes"));
388 }
389
390 #[test]
391 fn test_is_valid() {
392 let manifest = PluginManifest::from_toml(VALID_MANIFEST).unwrap();
393 assert!(manifest.is_valid());
394 }
395
396 #[test]
397 fn test_manifest_debug() {
398 let manifest = PluginManifest::from_toml(VALID_MANIFEST).unwrap();
399 let debug = format!("{:?}", manifest);
400 assert!(debug.contains("my-plugin"));
401 }
402
403 #[test]
404 fn test_semver_range_checks() {
405 assert!(is_valid_version_range("1.0.0"));
406 assert!(is_valid_version_range(">=0.1.0"));
407 assert!(is_valid_version_range("^0.2.1"));
408 assert!(is_valid_version_range("~1.0"));
409 assert!(!is_valid_version_range("invalid-semver"));
410 assert!(!is_valid_version_range("1.2.3.4"));
411 }
412
413 #[test]
414 fn test_satisfies_version_checks() {
415 assert!(satisfies_version(">=0.1.0", "0.1.0"));
416 assert!(satisfies_version(">=0.1.0", "1.2.3"));
417 assert!(satisfies_version("^0.1.0", "0.1.5"));
418 assert!(satisfies_version("~1.2.0", "1.2.4"));
419 assert!(!satisfies_version("^0.1.0", "0.2.0"));
420 }
421
422 #[test]
423 fn test_duplicate_recipe_names() {
424 let content = r#"
425name = "dup-plugin"
426version = "1.0.0"
427
428[[recipes]]
429name = "recipe-a"
430
431[[recipes]]
432name = "recipe-a"
433
434[compatibility]
435morph_cli_version = ">=0.1.0"
436"#;
437 let manifest = PluginManifest::from_toml(content).unwrap();
438 let errors = manifest.validate();
439 assert!(errors.iter().any(|e| e.field == "recipes" && e.message.contains("Duplicate recipe name")));
440 }
441
442 #[test]
443 fn test_compound_semver_range_checks() {
444 assert!(is_valid_version_range(">=0.1.0 <2.0.0"));
445 assert!(is_valid_version_range(">=0.1.0, <2.0.0"));
446 assert!(satisfies_version(">=0.1.0 <2.0.0", "0.5.0"));
447 assert!(satisfies_version(">=0.1.0 <2.0.0", "1.9.9"));
448 assert!(!satisfies_version(">=0.1.0 <2.0.0", "2.0.0"));
449 assert!(!satisfies_version(">=0.1.0 <2.0.0", "0.0.9"));
450 }
451
452 #[test]
453 fn test_wildcard_semver_range_checks() {
454 assert!(is_valid_version_range("1.x"));
455 assert!(is_valid_version_range("1.x.x"));
456 assert!(is_valid_version_range("1.*"));
457 assert!(satisfies_version("1.x", "1.2.3"));
458 assert!(satisfies_version("1.*", "1.5.0"));
459 }
460
461 #[test]
462 fn test_empty_author_name() {
463 let content = r#"
464name = "author-plugin"
465version = "1.0.0"
466
467[author]
468name = ""
469
470[[recipes]]
471name = "test-recipe"
472
473[compatibility]
474morph_cli_version = ">=0.1.0"
475"#;
476 let manifest = PluginManifest::from_toml(content).unwrap();
477 let errors = manifest.validate();
478 assert!(errors.iter().any(|e| e.field == "author.name"));
479 }
480}