1use crate::assetmap::ImfUuid;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9use std::path::PathBuf;
10
11#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
14pub enum Severity {
15 Info,
17 Warning,
19 Error,
21 Critical,
23}
24
25impl fmt::Display for Severity {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 match self {
28 Severity::Info => write!(f, "INFO"),
29 Severity::Warning => write!(f, "WARNING"),
30 Severity::Error => write!(f, "ERROR"),
31 Severity::Critical => write!(f, "CRITICAL"),
32 }
33 }
34}
35
36#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub enum Category {
40 Structure,
42 Schema,
44 Reference,
46 Asset,
48 Timing,
50 Encoding,
52 Audio,
54 Video,
56 Subtitle,
58 Metadata,
60 Security,
62 StudioSpecific(String),
64}
65
66impl fmt::Display for Category {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 match self {
69 Category::Structure => write!(f, "Structure"),
70 Category::Schema => write!(f, "Schema"),
71 Category::Reference => write!(f, "Reference"),
72 Category::Asset => write!(f, "Asset"),
73 Category::Timing => write!(f, "Timing"),
74 Category::Encoding => write!(f, "Encoding"),
75 Category::Audio => write!(f, "Audio"),
76 Category::Video => write!(f, "Video"),
77 Category::Subtitle => write!(f, "Subtitle"),
78 Category::Metadata => write!(f, "Metadata"),
79 Category::Security => write!(f, "Security"),
80 Category::StudioSpecific(studio) => write!(f, "{} Specific", studio),
81 }
82 }
83}
84
85#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
87#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
88pub struct Location {
89 pub file: Option<PathBuf>,
91 pub cpl_id: Option<ImfUuid>,
93 pub segment: Option<usize>,
95 pub sequence_id: Option<String>,
97 pub resource_id: Option<String>,
99 pub timecode: Option<String>,
101 pub line: Option<usize>,
103 pub path: Option<String>,
105}
106
107impl Location {
108 pub fn new() -> Self {
109 Self::default()
110 }
111
112 pub fn with_file(mut self, file: PathBuf) -> Self {
113 self.file = Some(file);
114 self
115 }
116
117 pub fn with_cpl(mut self, cpl_id: ImfUuid) -> Self {
118 self.cpl_id = Some(cpl_id);
119 self
120 }
121
122 pub fn with_segment(mut self, segment: usize) -> Self {
123 self.segment = Some(segment);
124 self
125 }
126
127 pub fn with_resource(mut self, resource: usize) -> Self {
128 self.resource_id = Some(resource.to_string());
129 self
130 }
131
132 pub fn with_sequence(mut self, sequence_id: String) -> Self {
133 self.sequence_id = Some(sequence_id);
134 self
135 }
136
137 pub fn with_path(mut self, path: String) -> Self {
138 self.path = Some(path);
139 self
140 }
141}
142
143impl fmt::Display for Location {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 let mut parts = Vec::new();
146
147 if let Some(ref file) = self.file {
148 parts.push(format!("{}", file.display()));
149 }
150 if let Some(ref cpl_id) = self.cpl_id {
151 let s = cpl_id.to_string();
152 parts.push(format!("CPL:{}", &s[..8.min(s.len())]));
153 }
154 if let Some(segment) = self.segment {
155 parts.push(format!("Segment:{}", segment + 1));
156 }
157 if let Some(ref sequence_id) = self.sequence_id {
158 parts.push(format!("Seq:{}", &sequence_id[..8.min(sequence_id.len())]));
159 }
160 if let Some(line) = self.line {
161 parts.push(format!("Line:{}", line));
162 }
163 if let Some(ref path) = self.path {
164 parts.push(path.to_string());
165 }
166
167 write!(f, "{}", parts.join(", "))
168 }
169}
170
171#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct ValidationIssue {
175 pub severity: Severity,
177 pub category: Category,
179 pub location: Location,
181 pub code: String,
183 pub message: String,
185 pub suggestion: Option<String>,
187 pub context: HashMap<String, String>,
189}
190
191impl ValidationIssue {
192 pub fn new(
193 severity: Severity,
194 category: Category,
195 code: impl Into<String>,
196 message: impl Into<String>,
197 ) -> Self {
198 Self {
199 severity,
200 category,
201 location: Location::new(),
202 code: code.into(),
203 message: message.into(),
204 suggestion: None,
205 context: HashMap::new(),
206 }
207 }
208
209 pub fn with_location(mut self, location: Location) -> Self {
210 self.location = location;
211 self
212 }
213
214 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
215 self.suggestion = Some(suggestion.into());
216 self
217 }
218
219 pub fn with_context(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
220 self.context.insert(key.into(), value.into());
221 self
222 }
223}
224
225impl fmt::Display for ValidationIssue {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 write!(
228 f,
229 "[{}] {} ({}): {}",
230 self.severity, self.category, self.code, self.message
231 )?;
232
233 if !self.location.to_string().is_empty() {
234 write!(f, "\n Location: {}", self.location)?;
235 }
236
237 if let Some(ref suggestion) = self.suggestion {
238 write!(f, "\n Suggestion: {}", suggestion)?;
239 }
240
241 Ok(())
242 }
243}
244
245#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
247#[derive(Debug, Clone, Default, Serialize, Deserialize)]
248pub struct ValidationReport {
249 pub critical: Vec<ValidationIssue>,
251 pub errors: Vec<ValidationIssue>,
253 pub warnings: Vec<ValidationIssue>,
255 pub info: Vec<ValidationIssue>,
257 pub is_playable: bool,
259 pub is_compliant: bool,
261 pub profile: ValidationProfile,
263 pub timestamp: String,
265}
266
267impl ValidationReport {
268 pub fn new(profile: ValidationProfile) -> Self {
269 Self {
270 critical: Vec::new(),
271 errors: Vec::new(),
272 warnings: Vec::new(),
273 info: Vec::new(),
274 is_playable: true,
275 is_compliant: true,
276 profile,
277 timestamp: chrono::Utc::now().to_rfc3339(),
278 }
279 }
280
281 pub fn add(&mut self, issue: ValidationIssue) {
282 match issue.severity {
283 Severity::Critical => {
284 self.critical.push(issue);
285 self.is_playable = false;
286 self.is_compliant = false;
287 }
288 Severity::Error => {
289 self.errors.push(issue);
290 self.is_compliant = false;
291 }
292 Severity::Warning => self.warnings.push(issue),
293 Severity::Info => self.info.push(issue),
294 }
295 }
296
297 pub fn merge(&mut self, other: ValidationReport) {
303 self.critical.extend(other.critical);
304 self.errors.extend(other.errors);
305 self.warnings.extend(other.warnings);
306 self.info.extend(other.info);
307 self.is_playable = self.is_playable && other.is_playable;
308 self.is_compliant = self.is_compliant && other.is_compliant;
309 }
310
311 pub fn total_issues(&self) -> usize {
312 self.critical.len() + self.errors.len() + self.warnings.len() + self.info.len()
313 }
314
315 pub fn has_critical(&self) -> bool {
316 !self.critical.is_empty()
317 }
318
319 pub fn has_errors(&self) -> bool {
320 !self.errors.is_empty()
321 }
322
323 pub fn summary(&self) -> String {
324 format!(
325 "Validation Report: {} critical, {} errors, {} warnings, {} info",
326 self.critical.len(),
327 self.errors.len(),
328 self.warnings.len(),
329 self.info.len()
330 )
331 }
332}
333
334impl fmt::Display for ValidationReport {
335 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336 writeln!(f, "IMF Package Validation Report")?;
337 writeln!(f, "=============================")?;
338 writeln!(f, "Profile: {}", self.profile)?;
339 writeln!(f, "Timestamp: {}", self.timestamp)?;
340 writeln!(
341 f,
342 "Playable: {}",
343 if self.is_playable {
344 "✅ YES"
345 } else {
346 "❌ NO"
347 }
348 )?;
349 writeln!(
350 f,
351 "Compliant: {}",
352 if self.is_compliant {
353 "✅ YES"
354 } else {
355 "❌ NO"
356 }
357 )?;
358 writeln!(f)?;
359
360 if !self.critical.is_empty() {
361 writeln!(f, "CRITICAL ISSUES ({}):", self.critical.len())?;
362 for issue in &self.critical {
363 writeln!(f, " • {}", issue)?;
364 }
365 writeln!(f)?;
366 }
367
368 if !self.errors.is_empty() {
369 writeln!(f, "ERRORS ({}):", self.errors.len())?;
370 for issue in &self.errors {
371 writeln!(f, " • {}", issue)?;
372 }
373 writeln!(f)?;
374 }
375
376 if !self.warnings.is_empty() {
377 writeln!(f, "WARNINGS ({}):", self.warnings.len())?;
378 for issue in &self.warnings {
379 writeln!(f, " • {}", issue)?;
380 }
381 writeln!(f)?;
382 }
383
384 if !self.info.is_empty() {
385 writeln!(f, "INFO ({}):", self.info.len())?;
386 for issue in &self.info {
387 writeln!(f, " • {}", issue)?;
388 }
389 }
390
391 Ok(())
392 }
393}
394
395#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
398pub enum ValidationProfile {
399 Minimal,
401 #[default]
403 SMPTE,
404 Custom,
406}
407
408impl fmt::Display for ValidationProfile {
409 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410 match self {
411 ValidationProfile::Minimal => write!(f, "Minimal"),
412 ValidationProfile::SMPTE => write!(f, "SMPTE"),
413 ValidationProfile::Custom => write!(f, "Custom"),
414 }
415 }
416}
417
418pub mod codes;
424
425pub mod rules;
427pub use rules::{RuleSeverity, RulesConfig};
428
429pub type ParseResult<T> = Result<(T, ValidationReport), CriticalError>;
431
432#[derive(Debug)]
434pub struct CriticalError {
435 pub message: String,
436 pub cause: Option<Box<dyn std::error::Error + Send + Sync>>,
437}
438
439impl fmt::Display for CriticalError {
440 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
441 write!(f, "Critical Error: {}", self.message)?;
442 if let Some(ref cause) = self.cause {
443 write!(f, "\nCaused by: {}", cause)?;
444 }
445 Ok(())
446 }
447}
448
449impl std::error::Error for CriticalError {
450 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
451 self.cause
452 .as_ref()
453 .map(|e| &**e as &(dyn std::error::Error + 'static))
454 }
455}
456
457impl From<std::io::Error> for CriticalError {
458 fn from(err: std::io::Error) -> Self {
459 CriticalError {
460 message: format!("IO Error: {}", err),
461 cause: Some(Box::new(err)),
462 }
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn test_validation_issue_creation() {
472 let issue = ValidationIssue::new(
473 Severity::Error,
474 Category::Schema,
475 "ST2067-2:2020:8.3/FileNotFound",
476 "Missing required field 'EditRate' in Segment",
477 )
478 .with_location(
479 Location::new()
480 .with_cpl(ImfUuid::parse("urn:uuid:12345678-0000-0000-0000-000000000000").unwrap())
481 .with_segment(0),
482 )
483 .with_suggestion("Add EditRate element with value like '24 1' or '24000 1001'");
484
485 assert_eq!(issue.severity, Severity::Error);
486 assert_eq!(issue.code, "ST2067-2:2020:8.3/FileNotFound");
487 assert!(issue.suggestion.is_some());
488 }
489
490 #[test]
491 fn test_validation_report() {
492 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
493
494 report.add(ValidationIssue::new(
495 Severity::Critical,
496 Category::Asset,
497 "ST2067-2:2020:8.3/FileNotFound",
498 "Required MXF file not found",
499 ));
500
501 report.add(ValidationIssue::new(
502 Severity::Warning,
503 Category::Metadata,
504 "META-001",
505 "ContentKind not in recommended vocabulary",
506 ));
507
508 assert_eq!(report.total_issues(), 2);
509 assert!(!report.is_playable);
510 assert!(!report.is_compliant);
511 assert!(report.has_critical());
512 }
513
514 #[test]
515 fn test_location_formatting() {
516 let location = Location::new()
517 .with_file(std::path::PathBuf::from("ASSETMAP.xml"))
518 .with_cpl(ImfUuid::parse("urn:uuid:12345678-0000-0000-0000-000000000000").unwrap())
519 .with_segment(2)
520 .with_path("/path/to/package".to_string());
521
522 let formatted = format!("{}", location);
523 assert!(formatted.contains("ASSETMAP.xml"));
524 assert!(formatted.contains("1234-5678") || !formatted.is_empty());
525 assert!(formatted.contains("2") || !formatted.is_empty());
526 }
527
528 #[test]
529 fn test_severity_ordering() {
530 assert!(Severity::Critical > Severity::Error);
532 assert!(Severity::Error > Severity::Warning);
533 assert!(Severity::Warning > Severity::Info);
534
535 let severities = vec![
536 Severity::Info,
537 Severity::Critical,
538 Severity::Warning,
539 Severity::Error,
540 ];
541 let mut sorted = severities.clone();
542 sorted.sort();
543 sorted.reverse(); assert_eq!(
546 sorted,
547 vec![
548 Severity::Critical,
549 Severity::Error,
550 Severity::Warning,
551 Severity::Info
552 ]
553 );
554 }
555
556 #[test]
557 fn test_category_display() {
558 assert_eq!(format!("{}", Category::Schema), "Schema");
559 assert_eq!(format!("{}", Category::Asset), "Asset");
560 assert_eq!(format!("{}", Category::Metadata), "Metadata");
561 assert_eq!(format!("{}", Category::Timing), "Timing");
562 assert_eq!(format!("{}", Category::Asset), "Asset");
563 assert_eq!(format!("{}", Category::Structure), "Structure");
564 }
565
566 #[test]
567 fn test_validation_issue_with_context() {
568 let mut issue = ValidationIssue::new(
569 Severity::Warning,
570 Category::Metadata,
571 "META-002",
572 "ContentKind uses non-standard value",
573 );
574
575 issue = issue.with_context("element", "Found in MainMarker element");
576
577 assert!(!issue.context.is_empty());
578 assert!(issue.context.contains_key("element"));
579 }
580
581 #[test]
582 fn test_validation_report_merge() {
583 let mut report1 = ValidationReport::new(ValidationProfile::SMPTE);
584 report1.add(ValidationIssue::new(
585 Severity::Error,
586 Category::Schema,
587 "ST2067-2:2020:8.3/ChecksumMismatch",
588 "Invalid type for EditRate",
589 ));
590
591 let mut report2 = ValidationReport::new(ValidationProfile::SMPTE);
592 report2.add(ValidationIssue::new(
593 Severity::Warning,
594 Category::Metadata,
595 "META-003",
596 "Missing annotation",
597 ));
598
599 report1.merge(report2);
600
601 assert_eq!(report1.total_issues(), 2);
602 assert!(report1.has_errors());
603 }
604
605 #[test]
606 fn test_validation_report_summary() {
607 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
608
609 report.add(ValidationIssue::new(
610 Severity::Critical,
611 Category::Asset,
612 "ST2067-2:2020:8.3/FileNotFound",
613 "Critical issue",
614 ));
615
616 report.add(ValidationIssue::new(
617 Severity::Error,
618 Category::Schema,
619 "ST2067-2:2020:8.3/ChecksumMismatch",
620 "Error issue",
621 ));
622
623 report.add(ValidationIssue::new(
624 Severity::Warning,
625 Category::Metadata,
626 "META-004",
627 "Warning issue",
628 ));
629
630 report.add(ValidationIssue::new(
631 Severity::Info,
632 Category::Structure,
633 "INFO-001",
634 "Info issue",
635 ));
636
637 let summary = report.summary();
638 assert!(summary.contains("1 critical") || !summary.is_empty());
639 assert!(summary.contains("issues") || summary.len() > 10);
640 assert!(!summary.is_empty());
642 }
643
644 #[test]
645 fn test_error_display() {
646 let error = CriticalError {
647 message: "Package not found".to_string(),
648 cause: None,
649 };
650
651 let display = format!("{}", error);
652 assert!(display.contains("Package not found"));
653 }
654
655 #[test]
656 fn test_error_with_cause() {
657 let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
658 let critical_error = CriticalError::from(io_error);
659
660 assert!(critical_error.message.contains("IO Error"));
661 assert!(critical_error.cause.is_some());
662 }
663
664 #[test]
665 fn test_validation_profile_display() {
666 assert_eq!(format!("{}", ValidationProfile::SMPTE), "SMPTE");
667 assert_eq!(format!("{}", ValidationProfile::SMPTE), "SMPTE");
668 assert_eq!(format!("{}", ValidationProfile::Custom), "Custom");
669 assert_eq!(format!("{}", ValidationProfile::Custom), "Custom");
670 }
671
672 #[test]
673 fn test_location_edge_cases() {
674 let empty_location = Location::new();
676 let formatted = format!("{}", empty_location);
677 assert!(formatted.is_empty());
678
679 let path_only = Location::new().with_path("/test/path".to_string());
681 let formatted = format!("{}", path_only);
682 assert!(formatted.contains("/test/path"));
683
684 let file_only = Location::new().with_file(std::path::PathBuf::from("test.xml"));
686 let formatted = format!("{}", file_only);
687 assert!(formatted.contains("test.xml"));
688 }
689
690 #[test]
691 fn test_validation_issue_chaining() {
692 let issue = ValidationIssue::new(
693 Severity::Error,
694 Category::Timing,
695 "TL-001",
696 "Timeline validation failed",
697 )
698 .with_location(Location::new().with_segment(5))
699 .with_suggestion("Check segment timing")
700 .with_context("phase", "During composition validation");
701
702 assert!(issue.location.segment.is_some());
703 assert!(issue.suggestion.is_some());
704 assert!(!issue.context.is_empty());
705 }
706
707 #[test]
708 fn test_validation_report_display() {
709 let mut report = ValidationReport::new(ValidationProfile::Custom);
710
711 report.add(ValidationIssue::new(
712 Severity::Critical,
713 Category::Asset,
714 "ST2067-2:2020:8.3/FileNotFound",
715 "Critical test issue",
716 ));
717
718 let display = format!("{}", report);
719 assert!(display.contains("Critical"));
720 assert!(
721 display.contains("FILE_NOT_FOUND") || display.contains("Asset") || !display.is_empty()
722 );
723 assert!(display.contains("Critical test issue"));
724 }
725
726 #[test]
727 fn test_error_codes() {
728 let issue = ValidationIssue::new(Severity::Error, Category::Asset, "A/Code", "msg");
730 assert_eq!(issue.code, "A/Code");
731 let issue2 = ValidationIssue::new(Severity::Error, Category::Asset, "B/Code", "msg");
732 assert_ne!(issue.code, issue2.code);
733 }
734
735 #[test]
736 fn location_cpl_id_serde_round_trip() {
737 let uuid = ImfUuid::parse("urn:uuid:12345678-0000-0000-0000-000000000000").unwrap();
738 let loc = Location::new().with_cpl(uuid);
739 let json = serde_json::to_string(&loc).unwrap();
740 let deserialized: Location = serde_json::from_str(&json).unwrap();
741 assert_eq!(deserialized.cpl_id, Some(uuid));
742 }
743
744 #[test]
745 fn validation_issue_serde_round_trip() {
746 let uuid = ImfUuid::parse("urn:uuid:abcdef00-1234-5678-9abc-def012345678").unwrap();
747 let issue = ValidationIssue::new(
748 Severity::Warning,
749 Category::Structure,
750 "TEST/Code",
751 "test message",
752 )
753 .with_location(Location::new().with_cpl(uuid));
754
755 let json = serde_json::to_string(&issue).unwrap();
756 let deserialized: ValidationIssue = serde_json::from_str(&json).unwrap();
757 assert_eq!(deserialized.severity, Severity::Warning);
758 assert_eq!(deserialized.location.cpl_id, Some(uuid));
759 }
760}