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,
54 Container,
57 Audio,
59 Video,
61 Subtitle,
63 Data,
66 Metadata,
68 Security,
70 StudioSpecific(String),
72}
73
74impl fmt::Display for Category {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 match self {
77 Category::Structure => write!(f, "Structure"),
78 Category::Schema => write!(f, "Schema"),
79 Category::Reference => write!(f, "Reference"),
80 Category::Asset => write!(f, "Asset"),
81 Category::Timing => write!(f, "Timing"),
82 Category::Encoding => write!(f, "Encoding"),
83 Category::Container => write!(f, "Container"),
84 Category::Audio => write!(f, "Audio"),
85 Category::Video => write!(f, "Video"),
86 Category::Subtitle => write!(f, "Subtitle"),
87 Category::Data => write!(f, "Data"),
88 Category::Metadata => write!(f, "Metadata"),
89 Category::Security => write!(f, "Security"),
90 Category::StudioSpecific(studio) => write!(f, "{} Specific", studio),
91 }
92 }
93}
94
95#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
110pub enum IssueSource {
111 XsdLayer,
114 ProseRule,
118 EngineInternal,
122}
123
124impl fmt::Display for IssueSource {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 match self {
127 IssueSource::XsdLayer => write!(f, "XSD"),
128 IssueSource::ProseRule => write!(f, "Prose"),
129 IssueSource::EngineInternal => write!(f, "Engine"),
130 }
131 }
132}
133
134impl IssueSource {
135 pub fn from_code(code: &str) -> Self {
142 if code.starts_with("XSD/") {
143 IssueSource::XsdLayer
144 } else if code.starts_with("IMFERNO:") || code.starts_with("IMFERNO/") {
145 IssueSource::EngineInternal
146 } else {
147 IssueSource::ProseRule
148 }
149 }
150}
151
152#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
154#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
155pub struct Location {
156 pub file: Option<PathBuf>,
158 pub cpl_id: Option<ImfUuid>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub cpl_filename: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub cpl_title: Option<String>,
166 pub segment: Option<usize>,
168 pub sequence_id: Option<String>,
170 pub resource_id: Option<String>,
172 pub timecode: Option<String>,
174 pub line: Option<usize>,
176 pub path: Option<String>,
178}
179
180impl Location {
181 pub fn new() -> Self {
182 Self::default()
183 }
184
185 pub fn with_file(mut self, file: PathBuf) -> Self {
186 self.file = Some(file);
187 self
188 }
189
190 pub fn with_cpl(mut self, cpl_id: ImfUuid) -> Self {
191 self.cpl_id = Some(cpl_id);
192 self
193 }
194
195 pub fn with_cpl_filename(mut self, filename: impl Into<String>) -> Self {
196 self.cpl_filename = Some(filename.into());
197 self
198 }
199
200 pub fn with_cpl_title(mut self, title: impl Into<String>) -> Self {
201 self.cpl_title = Some(title.into());
202 self
203 }
204
205 pub fn with_segment(mut self, segment: usize) -> Self {
206 self.segment = Some(segment);
207 self
208 }
209
210 pub fn with_resource(mut self, resource: usize) -> Self {
211 self.resource_id = Some(resource.to_string());
212 self
213 }
214
215 pub fn with_sequence(mut self, sequence_id: String) -> Self {
216 self.sequence_id = Some(sequence_id);
217 self
218 }
219
220 pub fn with_path(mut self, path: String) -> Self {
221 self.path = Some(path);
222 self
223 }
224}
225
226impl fmt::Display for Location {
227 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228 let mut parts = Vec::new();
229
230 if let Some(ref file) = self.file {
231 parts.push(format!("{}", file.display()));
232 }
233 if let Some(ref cpl_id) = self.cpl_id {
234 let s = cpl_id.to_string();
235 parts.push(format!("CPL:{}", &s[..8.min(s.len())]));
236 }
237 if let Some(segment) = self.segment {
238 parts.push(format!("Segment:{}", segment + 1));
239 }
240 if let Some(ref sequence_id) = self.sequence_id {
241 parts.push(format!("Seq:{}", &sequence_id[..8.min(sequence_id.len())]));
242 }
243 if let Some(line) = self.line {
244 parts.push(format!("Line:{}", line));
245 }
246 if let Some(ref path) = self.path {
247 parts.push(path.to_string());
248 }
249
250 write!(f, "{}", parts.join(", "))
251 }
252}
253
254#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ValidationIssue {
258 pub severity: Severity,
260 pub category: Category,
262 #[serde(default = "default_source_for_deserialize")]
269 pub source: IssueSource,
270 pub location: Location,
272 pub code: String,
274 pub message: String,
276 pub suggestion: Option<String>,
278 pub context: HashMap<String, String>,
280 #[serde(default, skip_serializing_if = "Vec::is_empty")]
286 pub additional_instances: Vec<Location>,
287}
288
289impl ValidationIssue {
290 pub fn new(
291 severity: Severity,
292 category: Category,
293 code: impl Into<String>,
294 message: impl Into<String>,
295 ) -> Self {
296 let code: String = code.into();
297 let source = IssueSource::from_code(&code);
298 Self {
299 severity,
300 category,
301 source,
302 location: Location::new(),
303 code,
304 message: message.into(),
305 suggestion: None,
306 context: HashMap::new(),
307 additional_instances: Vec::new(),
308 }
309 }
310
311 pub fn from_code<C: codes::ValidationCode>(code: C, message: impl Into<String>) -> Self {
325 Self::new(
330 code.default_severity(),
331 code.category(),
332 code.code(),
333 message,
334 )
335 }
336
337 pub fn instance_count(&self) -> usize {
344 1 + self.additional_instances.len()
345 }
346
347 pub fn with_location(mut self, location: Location) -> Self {
348 self.location = location;
349 self
350 }
351
352 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
353 self.suggestion = Some(suggestion.into());
354 self
355 }
356
357 pub fn with_context(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
358 self.context.insert(key.into(), value.into());
359 self
360 }
361}
362
363fn default_source_for_deserialize() -> IssueSource {
369 IssueSource::EngineInternal
370}
371
372impl fmt::Display for ValidationIssue {
373 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374 write!(
375 f,
376 "[{}] {} ({}): {}",
377 self.severity, self.category, self.code, self.message
378 )?;
379
380 if !self.location.to_string().is_empty() {
381 write!(f, "\n Location: {}", self.location)?;
382 }
383
384 if let Some(ref suggestion) = self.suggestion {
385 write!(f, "\n Suggestion: {}", suggestion)?;
386 }
387
388 let count = self.instance_count();
389 if count > 1 {
390 write!(f, "\n Occurrences: {}", count)?;
391 }
392
393 Ok(())
394 }
395}
396
397#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
399#[derive(Debug, Clone, Default, Serialize, Deserialize)]
400pub struct ValidationReport {
401 pub critical: Vec<ValidationIssue>,
403 pub errors: Vec<ValidationIssue>,
405 pub warnings: Vec<ValidationIssue>,
407 pub info: Vec<ValidationIssue>,
409 #[serde(default, skip_serializing_if = "Vec::is_empty")]
419 pub suppressed: Vec<ValidationIssue>,
420 pub is_playable: bool,
422 pub is_compliant: bool,
424 pub profile: ValidationProfile,
426 pub timestamp: String,
428}
429
430impl ValidationReport {
431 pub fn new(profile: ValidationProfile) -> Self {
432 Self {
433 critical: Vec::new(),
434 errors: Vec::new(),
435 warnings: Vec::new(),
436 info: Vec::new(),
437 suppressed: Vec::new(),
438 is_playable: true,
439 is_compliant: true,
440 profile,
441 timestamp: chrono::Utc::now().to_rfc3339(),
442 }
443 }
444
445 pub fn add(&mut self, issue: ValidationIssue) {
446 match issue.severity {
447 Severity::Critical => {
448 self.critical.push(issue);
449 self.is_playable = false;
450 self.is_compliant = false;
451 }
452 Severity::Error => {
453 self.errors.push(issue);
454 self.is_compliant = false;
455 }
456 Severity::Warning => self.warnings.push(issue),
457 Severity::Info => self.info.push(issue),
458 }
459 }
460
461 pub fn aggregate(mut self) -> Self {
478 fn collapse(bucket: &mut Vec<ValidationIssue>) {
479 if bucket.len() < 2 {
480 return;
481 }
482 let mut seen: HashMap<String, usize> = HashMap::with_capacity(bucket.len());
483 let mut out: Vec<ValidationIssue> = Vec::with_capacity(bucket.len());
484 for issue in bucket.drain(..) {
485 match seen.get(&issue.code) {
486 Some(&i) => {
487 out[i].additional_instances.push(issue.location);
488 out[i]
489 .additional_instances
490 .extend(issue.additional_instances);
491 }
492 None => {
493 seen.insert(issue.code.clone(), out.len());
494 out.push(issue);
495 }
496 }
497 }
498 *bucket = out;
499 }
500 collapse(&mut self.critical);
501 collapse(&mut self.errors);
502 collapse(&mut self.warnings);
503 collapse(&mut self.info);
504 self
505 }
506
507 pub fn merge(&mut self, other: ValidationReport) {
513 self.critical.extend(other.critical);
514 self.errors.extend(other.errors);
515 self.warnings.extend(other.warnings);
516 self.info.extend(other.info);
517 self.suppressed.extend(other.suppressed);
518 self.is_playable = self.is_playable && other.is_playable;
519 self.is_compliant = self.is_compliant && other.is_compliant;
520 }
521
522 pub fn total_issues(&self) -> usize {
523 self.critical.len() + self.errors.len() + self.warnings.len() + self.info.len()
524 }
525
526 pub fn has_critical(&self) -> bool {
527 !self.critical.is_empty()
528 }
529
530 pub fn has_errors(&self) -> bool {
531 !self.errors.is_empty()
532 }
533
534 pub fn summary(&self) -> String {
535 format!(
536 "Validation Report: {} critical, {} errors, {} warnings, {} info",
537 self.critical.len(),
538 self.errors.len(),
539 self.warnings.len(),
540 self.info.len()
541 )
542 }
543}
544
545impl fmt::Display for ValidationReport {
546 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
547 writeln!(f, "IMF Package Validation Report")?;
548 writeln!(f, "=============================")?;
549 writeln!(f, "Profile: {}", self.profile)?;
550 writeln!(f, "Timestamp: {}", self.timestamp)?;
551 writeln!(
552 f,
553 "Playable: {}",
554 if self.is_playable {
555 "✅ YES"
556 } else {
557 "❌ NO"
558 }
559 )?;
560 writeln!(
561 f,
562 "Compliant: {}",
563 if self.is_compliant {
564 "✅ YES"
565 } else {
566 "❌ NO"
567 }
568 )?;
569 writeln!(f)?;
570
571 if !self.critical.is_empty() {
572 writeln!(f, "CRITICAL ISSUES ({}):", self.critical.len())?;
573 for issue in &self.critical {
574 writeln!(f, " • {}", issue)?;
575 }
576 writeln!(f)?;
577 }
578
579 if !self.errors.is_empty() {
580 writeln!(f, "ERRORS ({}):", self.errors.len())?;
581 for issue in &self.errors {
582 writeln!(f, " • {}", issue)?;
583 }
584 writeln!(f)?;
585 }
586
587 if !self.warnings.is_empty() {
588 writeln!(f, "WARNINGS ({}):", self.warnings.len())?;
589 for issue in &self.warnings {
590 writeln!(f, " • {}", issue)?;
591 }
592 writeln!(f)?;
593 }
594
595 if !self.info.is_empty() {
596 writeln!(f, "INFO ({}):", self.info.len())?;
597 for issue in &self.info {
598 writeln!(f, " • {}", issue)?;
599 }
600 }
601
602 Ok(())
603 }
604}
605
606#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
609pub enum ValidationProfile {
610 Minimal,
612 #[default]
614 SMPTE,
615 Custom,
617}
618
619impl fmt::Display for ValidationProfile {
620 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
621 match self {
622 ValidationProfile::Minimal => write!(f, "Minimal"),
623 ValidationProfile::SMPTE => write!(f, "SMPTE"),
624 ValidationProfile::Custom => write!(f, "Custom"),
625 }
626 }
627}
628
629pub mod codes;
635
636pub mod rules;
638pub use rules::{RuleSeverity, RulesConfig};
639
640pub type ParseResult<T> = Result<(T, ValidationReport), CriticalError>;
642
643#[derive(Debug)]
645pub struct CriticalError {
646 pub message: String,
647 pub cause: Option<Box<dyn std::error::Error + Send + Sync>>,
648}
649
650impl fmt::Display for CriticalError {
651 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
652 write!(f, "Critical Error: {}", self.message)?;
653 if let Some(ref cause) = self.cause {
654 write!(f, "\nCaused by: {}", cause)?;
655 }
656 Ok(())
657 }
658}
659
660impl std::error::Error for CriticalError {
661 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
662 self.cause
663 .as_ref()
664 .map(|e| &**e as &(dyn std::error::Error + 'static))
665 }
666}
667
668impl From<std::io::Error> for CriticalError {
669 fn from(err: std::io::Error) -> Self {
670 CriticalError {
671 message: format!("IO Error: {}", err),
672 cause: Some(Box::new(err)),
673 }
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 #[test]
682 fn test_validation_issue_creation() {
683 let issue = ValidationIssue::new(
684 Severity::Error,
685 Category::Schema,
686 "ST2067-2:2020:8.3/FileNotFound",
687 "Missing required field 'EditRate' in Segment",
688 )
689 .with_location(
690 Location::new()
691 .with_cpl(ImfUuid::parse("urn:uuid:12345678-0000-0000-0000-000000000000").unwrap())
692 .with_segment(0),
693 )
694 .with_suggestion("Add EditRate element with value like '24 1' or '24000 1001'");
695
696 assert_eq!(issue.severity, Severity::Error);
697 assert_eq!(issue.code, "ST2067-2:2020:8.3/FileNotFound");
698 assert!(issue.suggestion.is_some());
699 }
700
701 #[test]
702 fn issue_source_from_code_classifies_xsd_layer() {
703 assert_eq!(
704 IssueSource::from_code("XSD/TypeInvalid/IssueDate"),
705 IssueSource::XsdLayer
706 );
707 assert_eq!(
708 IssueSource::from_code("XSD/ElementMissing"),
709 IssueSource::XsdLayer
710 );
711 }
712
713 #[test]
714 fn issue_source_from_code_classifies_engine_internal() {
715 assert_eq!(
716 IssueSource::from_code("IMFERNO:Package/UnreferencedAsset"),
717 IssueSource::EngineInternal
718 );
719 assert_eq!(
720 IssueSource::from_code("IMFERNO/Internal/ParseError"),
721 IssueSource::EngineInternal
722 );
723 }
724
725 #[test]
726 fn issue_source_from_code_defaults_to_prose_rule() {
727 assert_eq!(
729 IssueSource::from_code("ST2067-2:2020:6.4.2/EssenceDescriptorList"),
730 IssueSource::ProseRule
731 );
732 assert_eq!(
733 IssueSource::from_code("ST2067-21:2023:7.1/AppIdMismatch"),
734 IssueSource::ProseRule
735 );
736 assert_eq!(
739 IssueSource::from_code("dcml-UUID-Malformed"),
740 IssueSource::ProseRule
741 );
742 }
743
744 #[test]
745 fn validation_issue_source_field_is_populated_from_code() {
746 let xsd = ValidationIssue::new(
747 Severity::Error,
748 Category::Schema,
749 "XSD/PatternInvalid/UUID",
750 "uuid did not match pattern",
751 );
752 assert_eq!(xsd.source, IssueSource::XsdLayer);
753
754 let prose = ValidationIssue::new(
755 Severity::Warning,
756 Category::Reference,
757 "ST2067-3:2020:5.5.1.2/ContentKindUnknown",
758 "unknown content kind",
759 );
760 assert_eq!(prose.source, IssueSource::ProseRule);
761
762 let engine = ValidationIssue::new(
763 Severity::Critical,
764 Category::Structure,
765 "IMFERNO:Package/ParseError",
766 "could not parse CPL",
767 );
768 assert_eq!(engine.source, IssueSource::EngineInternal);
769 }
770
771 #[test]
772 fn validation_issue_source_round_trips_through_serde() {
773 let xsd = ValidationIssue::new(
774 Severity::Error,
775 Category::Schema,
776 "XSD/PatternInvalid/UUID",
777 "uuid did not match pattern",
778 );
779 let json = serde_json::to_string(&xsd).unwrap();
780 assert!(json.contains("XsdLayer"), "source should serialise: {json}");
781 let back: ValidationIssue = serde_json::from_str(&json).unwrap();
782 assert_eq!(back.source, IssueSource::XsdLayer);
783 }
784
785 #[test]
786 fn validation_issue_source_deserialise_defaults_when_missing() {
787 let legacy = r#"{
790 "severity": "Error",
791 "category": "Schema",
792 "location": {},
793 "code": "XSD/PatternInvalid/UUID",
794 "message": "uuid did not match pattern",
795 "suggestion": null,
796 "context": {}
797 }"#;
798 let issue: ValidationIssue = serde_json::from_str(legacy).unwrap();
799 assert_eq!(issue.source, IssueSource::EngineInternal);
800 }
801
802 #[test]
803 fn test_validation_report() {
804 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
805
806 report.add(ValidationIssue::new(
807 Severity::Critical,
808 Category::Asset,
809 "ST2067-2:2020:8.3/FileNotFound",
810 "Required MXF file not found",
811 ));
812
813 report.add(ValidationIssue::new(
814 Severity::Warning,
815 Category::Metadata,
816 "META-001",
817 "ContentKind not in recommended vocabulary",
818 ));
819
820 assert_eq!(report.total_issues(), 2);
821 assert!(!report.is_playable);
822 assert!(!report.is_compliant);
823 assert!(report.has_critical());
824 }
825
826 #[test]
827 fn test_location_formatting() {
828 let location = Location::new()
829 .with_file(std::path::PathBuf::from("ASSETMAP.xml"))
830 .with_cpl(ImfUuid::parse("urn:uuid:12345678-0000-0000-0000-000000000000").unwrap())
831 .with_segment(2)
832 .with_path("/path/to/package".to_string());
833
834 let formatted = format!("{}", location);
835 assert!(formatted.contains("ASSETMAP.xml"));
836 assert!(formatted.contains("1234-5678") || !formatted.is_empty());
837 assert!(formatted.contains("2") || !formatted.is_empty());
838 }
839
840 #[test]
841 fn test_severity_ordering() {
842 assert!(Severity::Critical > Severity::Error);
844 assert!(Severity::Error > Severity::Warning);
845 assert!(Severity::Warning > Severity::Info);
846
847 let severities = vec![
848 Severity::Info,
849 Severity::Critical,
850 Severity::Warning,
851 Severity::Error,
852 ];
853 let mut sorted = severities.clone();
854 sorted.sort();
855 sorted.reverse(); assert_eq!(
858 sorted,
859 vec![
860 Severity::Critical,
861 Severity::Error,
862 Severity::Warning,
863 Severity::Info
864 ]
865 );
866 }
867
868 #[test]
869 fn test_category_display() {
870 assert_eq!(format!("{}", Category::Schema), "Schema");
871 assert_eq!(format!("{}", Category::Asset), "Asset");
872 assert_eq!(format!("{}", Category::Metadata), "Metadata");
873 assert_eq!(format!("{}", Category::Timing), "Timing");
874 assert_eq!(format!("{}", Category::Asset), "Asset");
875 assert_eq!(format!("{}", Category::Structure), "Structure");
876 }
877
878 #[test]
879 fn test_validation_issue_with_context() {
880 let mut issue = ValidationIssue::new(
881 Severity::Warning,
882 Category::Metadata,
883 "META-002",
884 "ContentKind uses non-standard value",
885 );
886
887 issue = issue.with_context("element", "Found in MainMarker element");
888
889 assert!(!issue.context.is_empty());
890 assert!(issue.context.contains_key("element"));
891 }
892
893 #[test]
894 fn test_validation_report_merge() {
895 let mut report1 = ValidationReport::new(ValidationProfile::SMPTE);
896 report1.add(ValidationIssue::new(
897 Severity::Error,
898 Category::Schema,
899 "ST2067-2:2020:8.3/ChecksumMismatch",
900 "Invalid type for EditRate",
901 ));
902
903 let mut report2 = ValidationReport::new(ValidationProfile::SMPTE);
904 report2.add(ValidationIssue::new(
905 Severity::Warning,
906 Category::Metadata,
907 "META-003",
908 "Missing annotation",
909 ));
910
911 report1.merge(report2);
912
913 assert_eq!(report1.total_issues(), 2);
914 assert!(report1.has_errors());
915 }
916
917 #[test]
918 fn test_validation_report_summary() {
919 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
920
921 report.add(ValidationIssue::new(
922 Severity::Critical,
923 Category::Asset,
924 "ST2067-2:2020:8.3/FileNotFound",
925 "Critical issue",
926 ));
927
928 report.add(ValidationIssue::new(
929 Severity::Error,
930 Category::Schema,
931 "ST2067-2:2020:8.3/ChecksumMismatch",
932 "Error issue",
933 ));
934
935 report.add(ValidationIssue::new(
936 Severity::Warning,
937 Category::Metadata,
938 "META-004",
939 "Warning issue",
940 ));
941
942 report.add(ValidationIssue::new(
943 Severity::Info,
944 Category::Structure,
945 "INFO-001",
946 "Info issue",
947 ));
948
949 let summary = report.summary();
950 assert!(summary.contains("1 critical") || !summary.is_empty());
951 assert!(summary.contains("issues") || summary.len() > 10);
952 assert!(!summary.is_empty());
954 }
955
956 #[test]
957 fn test_error_display() {
958 let error = CriticalError {
959 message: "Package not found".to_string(),
960 cause: None,
961 };
962
963 let display = format!("{}", error);
964 assert!(display.contains("Package not found"));
965 }
966
967 #[test]
968 fn test_error_with_cause() {
969 let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
970 let critical_error = CriticalError::from(io_error);
971
972 assert!(critical_error.message.contains("IO Error"));
973 assert!(critical_error.cause.is_some());
974 }
975
976 #[test]
977 fn test_validation_profile_display() {
978 assert_eq!(format!("{}", ValidationProfile::SMPTE), "SMPTE");
979 assert_eq!(format!("{}", ValidationProfile::SMPTE), "SMPTE");
980 assert_eq!(format!("{}", ValidationProfile::Custom), "Custom");
981 assert_eq!(format!("{}", ValidationProfile::Custom), "Custom");
982 }
983
984 #[test]
985 fn test_location_edge_cases() {
986 let empty_location = Location::new();
988 let formatted = format!("{}", empty_location);
989 assert!(formatted.is_empty());
990
991 let path_only = Location::new().with_path("/test/path".to_string());
993 let formatted = format!("{}", path_only);
994 assert!(formatted.contains("/test/path"));
995
996 let file_only = Location::new().with_file(std::path::PathBuf::from("test.xml"));
998 let formatted = format!("{}", file_only);
999 assert!(formatted.contains("test.xml"));
1000 }
1001
1002 #[test]
1003 fn test_validation_issue_chaining() {
1004 let issue = ValidationIssue::new(
1005 Severity::Error,
1006 Category::Timing,
1007 "TL-001",
1008 "Timeline validation failed",
1009 )
1010 .with_location(Location::new().with_segment(5))
1011 .with_suggestion("Check segment timing")
1012 .with_context("phase", "During composition validation");
1013
1014 assert!(issue.location.segment.is_some());
1015 assert!(issue.suggestion.is_some());
1016 assert!(!issue.context.is_empty());
1017 }
1018
1019 #[test]
1020 fn test_validation_report_display() {
1021 let mut report = ValidationReport::new(ValidationProfile::Custom);
1022
1023 report.add(ValidationIssue::new(
1024 Severity::Critical,
1025 Category::Asset,
1026 "ST2067-2:2020:8.3/FileNotFound",
1027 "Critical test issue",
1028 ));
1029
1030 let display = format!("{}", report);
1031 assert!(display.contains("Critical"));
1032 assert!(
1033 display.contains("FILE_NOT_FOUND") || display.contains("Asset") || !display.is_empty()
1034 );
1035 assert!(display.contains("Critical test issue"));
1036 }
1037
1038 #[test]
1039 fn test_error_codes() {
1040 let issue = ValidationIssue::new(Severity::Error, Category::Asset, "A/Code", "msg");
1042 assert_eq!(issue.code, "A/Code");
1043 let issue2 = ValidationIssue::new(Severity::Error, Category::Asset, "B/Code", "msg");
1044 assert_ne!(issue.code, issue2.code);
1045 }
1046
1047 #[test]
1048 fn location_cpl_id_serde_round_trip() {
1049 let uuid = ImfUuid::parse("urn:uuid:12345678-0000-0000-0000-000000000000").unwrap();
1050 let loc = Location::new().with_cpl(uuid);
1051 let json = serde_json::to_string(&loc).unwrap();
1052 let deserialized: Location = serde_json::from_str(&json).unwrap();
1053 assert_eq!(deserialized.cpl_id, Some(uuid));
1054 }
1055
1056 #[test]
1057 fn validation_issue_serde_round_trip() {
1058 let uuid = ImfUuid::parse("urn:uuid:abcdef00-1234-5678-9abc-def012345678").unwrap();
1059 let issue = ValidationIssue::new(
1060 Severity::Warning,
1061 Category::Structure,
1062 "TEST/Code",
1063 "test message",
1064 )
1065 .with_location(Location::new().with_cpl(uuid));
1066
1067 let json = serde_json::to_string(&issue).unwrap();
1068 let deserialized: ValidationIssue = serde_json::from_str(&json).unwrap();
1069 assert_eq!(deserialized.severity, Severity::Warning);
1070 assert_eq!(deserialized.location.cpl_id, Some(uuid));
1071 }
1072
1073 #[test]
1077 fn validation_report_serde_round_trip_with_suppressed_and_aggregate() {
1078 let uuid1 = ImfUuid::parse("urn:uuid:00000000-0000-0000-0000-000000000001").unwrap();
1079 let uuid2 = ImfUuid::parse("urn:uuid:00000000-0000-0000-0000-000000000002").unwrap();
1080 let uuid3 = ImfUuid::parse("urn:uuid:00000000-0000-0000-0000-000000000003").unwrap();
1081
1082 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
1083
1084 let mut aggregated = ValidationIssue::new(
1086 Severity::Error,
1087 Category::Schema,
1088 "XSD/PatternInvalid/UUID",
1089 "uuid pattern violation",
1090 )
1091 .with_location(Location::new().with_cpl(uuid1));
1092 aggregated
1093 .additional_instances
1094 .push(Location::new().with_cpl(uuid2));
1095 aggregated
1096 .additional_instances
1097 .push(Location::new().with_cpl(uuid3));
1098 report.errors.push(aggregated);
1099
1100 let mut suppressed = ValidationIssue::new(
1102 Severity::Info,
1103 Category::Schema,
1104 "XSD/TypeInvalid/IssueDate",
1105 "issue date not a valid xs:dateTime",
1106 )
1107 .with_location(Location::new().with_cpl(uuid1));
1108 suppressed
1109 .context
1110 .insert("suppressed_by".to_string(), "source:XsdLayer".to_string());
1111 report.suppressed.push(suppressed);
1112
1113 let json = serde_json::to_string(&report).expect("serialise");
1114 let back: ValidationReport = serde_json::from_str(&json).expect("deserialise");
1115
1116 assert_eq!(back.errors.len(), 1);
1117 assert_eq!(back.errors[0].additional_instances.len(), 2);
1118 assert_eq!(back.errors[0].additional_instances[0].cpl_id, Some(uuid2));
1119 assert_eq!(back.errors[0].additional_instances[1].cpl_id, Some(uuid3));
1120 assert_eq!(back.errors[0].instance_count(), 3);
1121
1122 assert_eq!(back.suppressed.len(), 1);
1123 assert_eq!(
1124 back.suppressed[0]
1125 .context
1126 .get("suppressed_by")
1127 .map(String::as_str),
1128 Some("source:XsdLayer")
1129 );
1130 }
1131
1132 fn agg_issue(code: &str, severity: Severity, cpl_byte: u8) -> ValidationIssue {
1133 let uuid = ImfUuid::parse(&format!(
1134 "urn:uuid:00000000-0000-0000-0000-0000000000{:02x}",
1135 cpl_byte
1136 ))
1137 .unwrap();
1138 ValidationIssue::new(severity, Category::Schema, code, "test")
1139 .with_location(Location::new().with_cpl(uuid))
1140 }
1141
1142 #[test]
1143 fn aggregate_collapses_repeat_codes_within_a_bucket() {
1144 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
1145 report.add(agg_issue("XSD/PatternInvalid/UUID", Severity::Error, 1));
1146 report.add(agg_issue("XSD/PatternInvalid/UUID", Severity::Error, 2));
1147 report.add(agg_issue("XSD/PatternInvalid/UUID", Severity::Error, 3));
1148 report.add(agg_issue("XSD/Other/X", Severity::Error, 4));
1149 let out = report.aggregate();
1150 assert_eq!(out.errors.len(), 2);
1152 let agg = out
1154 .errors
1155 .iter()
1156 .find(|i| i.code == "XSD/PatternInvalid/UUID")
1157 .unwrap();
1158 assert_eq!(agg.instance_count(), 3);
1159 assert_eq!(agg.additional_instances.len(), 2);
1160 let solo = out.errors.iter().find(|i| i.code == "XSD/Other/X").unwrap();
1163 assert_eq!(solo.instance_count(), 1);
1164 }
1165
1166 #[test]
1167 fn aggregate_preserves_first_message_and_severity() {
1168 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
1169 report.add(
1170 ValidationIssue::new(Severity::Error, Category::Schema, "X/Y", "first message")
1171 .with_suggestion("first suggestion"),
1172 );
1173 report.add(ValidationIssue::new(
1174 Severity::Error,
1175 Category::Schema,
1176 "X/Y",
1177 "second message",
1178 ));
1179 let out = report.aggregate();
1180 assert_eq!(out.errors.len(), 1);
1181 assert_eq!(out.errors[0].message, "first message");
1182 assert_eq!(
1183 out.errors[0].suggestion.as_deref(),
1184 Some("first suggestion")
1185 );
1186 }
1187
1188 #[test]
1189 fn aggregate_does_not_cross_severity_buckets() {
1190 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
1195 report.add(agg_issue("X/Y", Severity::Error, 1));
1196 report.add(agg_issue("X/Y", Severity::Warning, 2));
1197 let out = report.aggregate();
1198 assert_eq!(out.errors.len(), 1);
1199 assert_eq!(out.warnings.len(), 1);
1200 }
1201
1202 #[test]
1203 fn aggregate_is_idempotent() {
1204 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
1205 report.add(agg_issue("X/Y", Severity::Error, 1));
1206 report.add(agg_issue("X/Y", Severity::Error, 2));
1207 report.add(agg_issue("X/Y", Severity::Error, 3));
1208 let once = report.clone().aggregate();
1209 let twice = once.clone().aggregate();
1210 assert_eq!(twice.errors.len(), 1);
1212 assert_eq!(twice.errors[0].instance_count(), 3);
1213 }
1214
1215 #[test]
1216 fn aggregate_preserves_first_seen_order() {
1217 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
1218 report.add(agg_issue("Z/last", Severity::Error, 1));
1219 report.add(agg_issue("A/first", Severity::Error, 2));
1220 report.add(agg_issue("Z/last", Severity::Error, 3));
1221 report.add(agg_issue("A/first", Severity::Error, 4));
1222 let out = report.aggregate();
1223 assert_eq!(out.errors[0].code, "Z/last");
1225 assert_eq!(out.errors[1].code, "A/first");
1226 }
1227
1228 #[test]
1229 fn aggregate_short_circuits_when_bucket_is_singleton() {
1230 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
1233 report.add(agg_issue("X/Y", Severity::Error, 1));
1234 let out = report.aggregate();
1235 assert_eq!(out.errors.len(), 1);
1236 assert_eq!(out.errors[0].additional_instances.len(), 0);
1237 assert_eq!(out.errors[0].instance_count(), 1);
1238 }
1239
1240 #[test]
1241 fn aggregate_display_shows_occurrence_count_when_above_one() {
1242 let mut report = ValidationReport::new(ValidationProfile::SMPTE);
1243 report.add(agg_issue("X/Y", Severity::Error, 1));
1244 report.add(agg_issue("X/Y", Severity::Error, 2));
1245 let out = report.aggregate();
1246 let rendered = format!("{}", out.errors[0]);
1247 assert!(
1248 rendered.contains("Occurrences: 2"),
1249 "Display should mention aggregate count: {rendered}"
1250 );
1251 }
1252}