Skip to main content

plugin_packager/
validation.rs

1// Copyright 2024 Vincents AI
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4/// Plugin manifest validation framework
5///
6/// This module provides comprehensive validation for plugin manifests including:
7/// - Field presence and format validation
8/// - Version and ABI compatibility checks
9/// - Capability validation
10/// - Dependency resolution validation
11/// - Structured validation reporting
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15/// Validation error severity levels
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum ValidationSeverity {
19    Info,
20    Warning,
21    Error,
22    Critical,
23}
24
25impl ValidationSeverity {
26    pub fn as_str(&self) -> &'static str {
27        match self {
28            ValidationSeverity::Info => "info",
29            ValidationSeverity::Warning => "warning",
30            ValidationSeverity::Error => "error",
31            ValidationSeverity::Critical => "critical",
32        }
33    }
34
35    pub fn try_parse(s: &str) -> Option<Self> {
36        match s.to_lowercase().as_str() {
37            "info" => Some(ValidationSeverity::Info),
38            "warning" => Some(ValidationSeverity::Warning),
39            "error" => Some(ValidationSeverity::Error),
40            "critical" => Some(ValidationSeverity::Critical),
41            _ => None,
42        }
43    }
44}
45
46/// Individual validation issue
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ValidationIssue {
49    pub field: String,
50    pub severity: ValidationSeverity,
51    pub message: String,
52    pub suggestion: Option<String>,
53}
54
55/// Complete validation report
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ValidationReport {
58    pub plugin_id: String,
59    pub plugin_version: String,
60    pub validation_timestamp: String,
61    pub is_valid: bool,
62    pub issues: Vec<ValidationIssue>,
63    pub info_count: usize,
64    pub warning_count: usize,
65    pub error_count: usize,
66    pub critical_count: usize,
67}
68
69impl ValidationReport {
70    /// Check if validation passed (no critical or error issues)
71    pub fn passed(&self) -> bool {
72        self.critical_count == 0 && self.error_count == 0
73    }
74
75    /// Check if validation passed with warnings
76    pub fn passed_with_warnings(&self) -> bool {
77        self.critical_count == 0 && self.error_count == 0 && self.warning_count > 0
78    }
79
80    /// Get summary message
81    pub fn summary(&self) -> String {
82        if self.critical_count > 0 {
83            format!(
84                "Validation failed: {} critical, {} errors",
85                self.critical_count, self.error_count
86            )
87        } else if self.error_count > 0 {
88            format!("Validation failed: {} errors", self.error_count)
89        } else if self.warning_count > 0 {
90            format!(
91                "Validation passed with warnings: {} warnings, {} info",
92                self.warning_count, self.info_count
93            )
94        } else {
95            "Validation passed".to_string()
96        }
97    }
98}
99
100/// Plugin manifest validator
101pub struct ManifestValidator {
102    rules: HashMap<String, ValidationRule>,
103    #[allow(dead_code)] // Reserved for validation rules not yet implemented
104    max_name_length: usize,
105    #[allow(dead_code)] // Reserved for validation rules not yet implemented
106    max_description_length: usize,
107}
108
109/// Validation rule for a specific field
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ValidationRule {
112    pub field_name: String,
113    pub required: bool,
114    pub min_length: Option<usize>,
115    pub max_length: Option<usize>,
116    pub pattern: Option<String>,
117    pub allowed_values: Vec<String>,
118    pub description: String,
119}
120
121impl ManifestValidator {
122    /// Create a new validator with default rules
123    pub fn new() -> Self {
124        let mut validator = Self {
125            rules: HashMap::new(),
126            max_name_length: 100,
127            max_description_length: 500,
128        };
129        validator.add_default_rules();
130        validator
131    }
132
133    /// Add default validation rules
134    fn add_default_rules(&mut self) {
135        self.rules.insert(
136            "name".to_string(),
137            ValidationRule {
138                field_name: "name".to_string(),
139                required: true,
140                min_length: Some(3),
141                max_length: Some(100),
142                pattern: Some("^[a-z0-9_-]+$".to_string()),
143                allowed_values: Vec::new(),
144                description: "Plugin name in lowercase with hyphens/underscores".to_string(),
145            },
146        );
147
148        self.rules.insert(
149            "version".to_string(),
150            ValidationRule {
151                field_name: "version".to_string(),
152                required: true,
153                min_length: Some(5), // x.x.x minimum
154                max_length: Some(20),
155                pattern: Some(r"^\d+\.\d+\.\d+".to_string()),
156                allowed_values: Vec::new(),
157                description: "Semantic version (major.minor.patch)".to_string(),
158            },
159        );
160
161        self.rules.insert(
162            "abi_version".to_string(),
163            ValidationRule {
164                field_name: "abi_version".to_string(),
165                required: true,
166                min_length: Some(1),
167                max_length: Some(10),
168                pattern: Some(r"^\d+(\.\d+)?$".to_string()),
169                allowed_values: vec![
170                    "1".to_string(),
171                    "1.0".to_string(),
172                    "2".to_string(),
173                    "2.0".to_string(),
174                ],
175                description: "ABI version (1, 1.0, 2, or 2.0)".to_string(),
176            },
177        );
178
179        self.rules.insert(
180            "description".to_string(),
181            ValidationRule {
182                field_name: "description".to_string(),
183                required: true,
184                min_length: Some(10),
185                max_length: Some(500),
186                pattern: None,
187                allowed_values: Vec::new(),
188                description: "Plugin description (10-500 chars)".to_string(),
189            },
190        );
191
192        self.rules.insert(
193            "author".to_string(),
194            ValidationRule {
195                field_name: "author".to_string(),
196                required: false,
197                min_length: Some(3),
198                max_length: Some(100),
199                pattern: None,
200                allowed_values: Vec::new(),
201                description: "Plugin author name".to_string(),
202            },
203        );
204
205        self.rules.insert(
206            "license".to_string(),
207            ValidationRule {
208                field_name: "license".to_string(),
209                required: false,
210                min_length: None,
211                max_length: Some(50),
212                pattern: None,
213                allowed_values: vec![
214                    "MIT".to_string(),
215                    "Apache-2.0".to_string(),
216                    "GPL-3.0".to_string(),
217                    "BSD-3-Clause".to_string(),
218                ],
219                description: "SPDX license identifier".to_string(),
220            },
221        );
222    }
223
224    /// Validate plugin name
225    fn validate_name(&self, name: &str) -> Vec<ValidationIssue> {
226        let mut issues = Vec::new();
227        let rule = self.rules.get("name").unwrap();
228
229        if name.is_empty() {
230            issues.push(ValidationIssue {
231                field: "name".to_string(),
232                severity: ValidationSeverity::Critical,
233                message: "Plugin name is required".to_string(),
234                suggestion: Some("Provide a valid plugin name".to_string()),
235            });
236            return issues;
237        }
238
239        if let Some(min_len) = rule.min_length {
240            if name.len() < min_len {
241                issues.push(ValidationIssue {
242                    field: "name".to_string(),
243                    severity: ValidationSeverity::Error,
244                    message: format!("Name too short (minimum {} characters)", min_len),
245                    suggestion: Some("Use a longer, more descriptive name".to_string()),
246                });
247            }
248        }
249
250        if let Some(max_len) = rule.max_length {
251            if name.len() > max_len {
252                issues.push(ValidationIssue {
253                    field: "name".to_string(),
254                    severity: ValidationSeverity::Error,
255                    message: format!("Name too long (maximum {} characters)", max_len),
256                    suggestion: Some("Shorten the plugin name".to_string()),
257                });
258            }
259        }
260
261        // Check pattern
262        if !name
263            .chars()
264            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
265        {
266            issues.push(ValidationIssue {
267                field: "name".to_string(),
268                severity: ValidationSeverity::Error,
269                message:
270                    "Name must contain only lowercase letters, numbers, hyphens, or underscores"
271                        .to_string(),
272                suggestion: Some("Use only: a-z, 0-9, -, _".to_string()),
273            });
274        }
275
276        issues
277    }
278
279    /// Validate version format
280    fn validate_version(&self, version: &str) -> Vec<ValidationIssue> {
281        let mut issues = Vec::new();
282
283        if version.is_empty() {
284            issues.push(ValidationIssue {
285                field: "version".to_string(),
286                severity: ValidationSeverity::Critical,
287                message: "Version is required".to_string(),
288                suggestion: Some("Provide a semantic version (e.g., 1.0.0)".to_string()),
289            });
290            return issues;
291        }
292
293        // Check semantic versioning format
294        let parts: Vec<&str> = version.split('.').collect();
295        if parts.len() < 3 {
296            issues.push(ValidationIssue {
297                field: "version".to_string(),
298                severity: ValidationSeverity::Error,
299                message: "Version must follow semantic versioning (major.minor.patch)".to_string(),
300                suggestion: Some("Use format: 1.0.0 or 1.0.0-rc1".to_string()),
301            });
302        }
303
304        // Validate numeric parts
305        for (i, part) in parts.iter().enumerate() {
306            if i >= 3 {
307                break; // Only check first 3 parts
308            }
309            if part.parse::<u32>().is_err() {
310                issues.push(ValidationIssue {
311                    field: "version".to_string(),
312                    severity: ValidationSeverity::Error,
313                    message: format!("Version part '{}' is not numeric", part),
314                    suggestion: None,
315                });
316            }
317        }
318
319        issues
320    }
321
322    /// Validate ABI version
323    fn validate_abi_version(&self, abi_version: &str) -> Vec<ValidationIssue> {
324        let mut issues = Vec::new();
325
326        if abi_version.is_empty() {
327            issues.push(ValidationIssue {
328                field: "abi_version".to_string(),
329                severity: ValidationSeverity::Critical,
330                message: "ABI version is required".to_string(),
331                suggestion: Some("Specify ABI version: 1, 1.0, 2, or 2.0".to_string()),
332            });
333            return issues;
334        }
335
336        let rule = self.rules.get("abi_version").unwrap();
337        if !rule.allowed_values.contains(&abi_version.to_string()) {
338            issues.push(ValidationIssue {
339                field: "abi_version".to_string(),
340                severity: ValidationSeverity::Error,
341                message: format!("Invalid ABI version: {}", abi_version),
342                suggestion: Some("Use one of: 1, 1.0, 2, 2.0".to_string()),
343            });
344        }
345
346        issues
347    }
348
349    /// Validate description
350    fn validate_description(&self, description: Option<&str>) -> Vec<ValidationIssue> {
351        let mut issues = Vec::new();
352
353        match description {
354            None => {
355                issues.push(ValidationIssue {
356                    field: "description".to_string(),
357                    severity: ValidationSeverity::Warning,
358                    message: "Description is recommended".to_string(),
359                    suggestion: Some("Add a description of what your plugin does".to_string()),
360                });
361            }
362            Some(desc) => {
363                if desc.len() < 10 {
364                    issues.push(ValidationIssue {
365                        field: "description".to_string(),
366                        severity: ValidationSeverity::Warning,
367                        message: "Description should be at least 10 characters".to_string(),
368                        suggestion: Some("Provide a more detailed description".to_string()),
369                    });
370                }
371                if desc.len() > 500 {
372                    issues.push(ValidationIssue {
373                        field: "description".to_string(),
374                        severity: ValidationSeverity::Warning,
375                        message: "Description is very long (>500 chars)".to_string(),
376                        suggestion: Some("Consider shortening the description".to_string()),
377                    });
378                }
379            }
380        }
381
382        issues
383    }
384
385    /// Validate complete manifest
386    pub fn validate_manifest(
387        &self,
388        name: &str,
389        version: &str,
390        abi_version: &str,
391        description: Option<&str>,
392    ) -> ValidationReport {
393        let mut issues = Vec::new();
394
395        // Validate each field
396        issues.extend(self.validate_name(name));
397        issues.extend(self.validate_version(version));
398        issues.extend(self.validate_abi_version(abi_version));
399        issues.extend(self.validate_description(description));
400
401        // Count issues by severity
402        let info_count = issues
403            .iter()
404            .filter(|i| i.severity == ValidationSeverity::Info)
405            .count();
406        let warning_count = issues
407            .iter()
408            .filter(|i| i.severity == ValidationSeverity::Warning)
409            .count();
410        let error_count = issues
411            .iter()
412            .filter(|i| i.severity == ValidationSeverity::Error)
413            .count();
414        let critical_count = issues
415            .iter()
416            .filter(|i| i.severity == ValidationSeverity::Critical)
417            .count();
418
419        let is_valid = critical_count == 0 && error_count == 0;
420
421        ValidationReport {
422            plugin_id: name.to_string(),
423            plugin_version: version.to_string(),
424            validation_timestamp: chrono::Utc::now().to_rfc3339(),
425            is_valid,
426            issues,
427            info_count,
428            warning_count,
429            error_count,
430            critical_count,
431        }
432    }
433}
434
435impl Default for ManifestValidator {
436    fn default() -> Self {
437        Self::new()
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn test_validation_severity_ordering() {
447        assert!(ValidationSeverity::Critical > ValidationSeverity::Error);
448        assert!(ValidationSeverity::Error > ValidationSeverity::Warning);
449        assert!(ValidationSeverity::Warning > ValidationSeverity::Info);
450    }
451
452    #[test]
453    fn test_validation_severity_to_str() {
454        assert_eq!(ValidationSeverity::Critical.as_str(), "critical");
455        assert_eq!(ValidationSeverity::Info.as_str(), "info");
456    }
457
458    #[test]
459    fn test_validation_severity_try_parse() {
460        assert_eq!(
461            ValidationSeverity::try_parse("critical"),
462            Some(ValidationSeverity::Critical)
463        );
464        assert_eq!(ValidationSeverity::try_parse("invalid"), None);
465    }
466
467    #[test]
468    fn test_validator_creation() {
469        let validator = ManifestValidator::new();
470        assert!(validator.rules.contains_key("name"));
471        assert!(validator.rules.contains_key("version"));
472        assert!(validator.rules.contains_key("abi_version"));
473    }
474
475    #[test]
476    fn test_validate_valid_manifest() {
477        let validator = ManifestValidator::new();
478        let report =
479            validator.validate_manifest("my-plugin", "1.0.0", "2.0", Some("A test plugin"));
480
481        assert!(report.is_valid);
482        assert_eq!(report.critical_count, 0);
483        assert_eq!(report.error_count, 0);
484    }
485
486    #[test]
487    fn test_validate_invalid_name() {
488        let validator = ManifestValidator::new();
489        let report = validator.validate_manifest("A", "1.0.0", "2.0", Some("Test"));
490
491        assert!(!report.is_valid);
492        assert!(report.critical_count > 0 || report.error_count > 0);
493    }
494
495    #[test]
496    fn test_validate_invalid_version() {
497        let validator = ManifestValidator::new();
498        let report = validator.validate_manifest("my-plugin", "1.0", "2.0", Some("Test"));
499
500        assert!(!report.is_valid);
501        assert!(report.error_count > 0);
502    }
503
504    #[test]
505    fn test_validate_invalid_abi_version() {
506        let validator = ManifestValidator::new();
507        let report = validator.validate_manifest("my-plugin", "1.0.0", "3.0", Some("Test"));
508
509        assert!(!report.is_valid);
510        assert!(report.error_count > 0);
511    }
512
513    #[test]
514    fn test_validate_short_description() {
515        let validator = ManifestValidator::new();
516        let report = validator.validate_manifest("my-plugin", "1.0.0", "2.0", Some("Test"));
517
518        assert!(report.is_valid); // Still valid but with warning
519        assert_eq!(report.warning_count, 1); // Description too short
520    }
521
522    #[test]
523    fn test_validate_missing_description() {
524        let validator = ManifestValidator::new();
525        let report = validator.validate_manifest("my-plugin", "1.0.0", "2.0", None);
526
527        assert!(report.is_valid);
528        assert_eq!(report.warning_count, 1); // Description recommended
529    }
530
531    #[test]
532    fn test_validation_report_passed() {
533        let report = ValidationReport {
534            plugin_id: "test".to_string(),
535            plugin_version: "1.0.0".to_string(),
536            validation_timestamp: "2024-01-01".to_string(),
537            is_valid: true,
538            issues: Vec::new(),
539            info_count: 0,
540            warning_count: 0,
541            error_count: 0,
542            critical_count: 0,
543        };
544
545        assert!(report.passed());
546        assert!(!report.passed_with_warnings());
547    }
548
549    #[test]
550    fn test_validation_report_passed_with_warnings() {
551        let report = ValidationReport {
552            plugin_id: "test".to_string(),
553            plugin_version: "1.0.0".to_string(),
554            validation_timestamp: "2024-01-01".to_string(),
555            is_valid: true,
556            issues: vec![ValidationIssue {
557                field: "description".to_string(),
558                severity: ValidationSeverity::Warning,
559                message: "Test warning".to_string(),
560                suggestion: None,
561            }],
562            info_count: 0,
563            warning_count: 1,
564            error_count: 0,
565            critical_count: 0,
566        };
567
568        assert!(report.passed());
569        assert!(report.passed_with_warnings());
570    }
571
572    #[test]
573    fn test_validation_report_summary() {
574        let report_passed = ValidationReport {
575            plugin_id: "test".to_string(),
576            plugin_version: "1.0.0".to_string(),
577            validation_timestamp: "2024-01-01".to_string(),
578            is_valid: true,
579            issues: Vec::new(),
580            info_count: 0,
581            warning_count: 0,
582            error_count: 0,
583            critical_count: 0,
584        };
585
586        assert_eq!(report_passed.summary(), "Validation passed");
587    }
588
589    #[test]
590    fn test_validation_issue_creation() {
591        let issue = ValidationIssue {
592            field: "name".to_string(),
593            severity: ValidationSeverity::Error,
594            message: "Name is invalid".to_string(),
595            suggestion: Some("Use lowercase letters only".to_string()),
596        };
597
598        assert_eq!(issue.field, "name");
599        assert_eq!(issue.severity, ValidationSeverity::Error);
600    }
601}