1use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15#[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#[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#[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 pub fn passed(&self) -> bool {
72 self.critical_count == 0 && self.error_count == 0
73 }
74
75 pub fn passed_with_warnings(&self) -> bool {
77 self.critical_count == 0 && self.error_count == 0 && self.warning_count > 0
78 }
79
80 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
100pub struct ManifestValidator {
102 rules: HashMap<String, ValidationRule>,
103 #[allow(dead_code)] max_name_length: usize,
105 #[allow(dead_code)] max_description_length: usize,
107}
108
109#[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 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 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), 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 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 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 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 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 for (i, part) in parts.iter().enumerate() {
306 if i >= 3 {
307 break; }
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 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 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 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 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 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); assert_eq!(report.warning_count, 1); }
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); }
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}