Skip to main content

morph_cli/core/plugins/
manifest.rs

1use serde::{Deserialize, Serialize};
2use std::path::Path;
3
4/// Local representation of a Morph CLI Plugin Manifest (`morph-cli-plugin.toml`).
5/// Enforces metadata validation, local SemVer compatibility parsing (supporting carat `^`,
6/// tilde `~`, bounds `>=`, and exact operators), duplicate recipe checking, and 
7/// missing field diagnostic warnings.
8#[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    // Split by spaces or commas to support multiple constraints: e.g. ">=0.1.0 <2.0.0"
50    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 it's a wildcard exact match (e.g. "1.x", "1.*"), check element equality up to the wildcard
148    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(&current_parts, &range_parts) != std::cmp::Ordering::Less
175    } else if clean_range.starts_with("<=") {
176        version_cmp(&current_parts, &range_parts) != std::cmp::Ordering::Greater
177    } else if clean_range.starts_with('>') {
178        version_cmp(&current_parts, &range_parts) == std::cmp::Ordering::Greater
179    } else if clean_range.starts_with('<') {
180        version_cmp(&current_parts, &range_parts) == std::cmp::Ordering::Less
181    } else if clean_range.starts_with('^') {
182        let is_greater_or_equal = version_cmp(&current_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(&current_parts, &range_parts) != std::cmp::Ordering::Less
198    } else {
199        version_cmp(&current_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            // Validate unsupported morph-cli version
256            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}