Skip to main content

imferno_core/diagnostics/
mod.rs

1//! IMF validation finding model and report types.
2//!
3//! Cross-cutting types used by all spec modules to return findings.
4
5use crate::assetmap::ImfUuid;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9use std::path::PathBuf;
10
11/// Severity level of validation issues
12#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
14pub enum Severity {
15    /// Informational - Best practice suggestions
16    Info,
17    /// Warning - Should fix but not critical
18    Warning,
19    /// Error - Must fix for compliance
20    Error,
21    /// Critical - Prevents package from being usable
22    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/// Category of validation issue
37#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub enum Category {
40    /// XML structure and syntax issues
41    Structure,
42    /// SMPTE schema compliance
43    Schema,
44    /// UUID and reference resolution
45    Reference,
46    /// Asset file availability and integrity
47    Asset,
48    /// Timing, frame rate, and duration issues
49    Timing,
50    /// Encoding and codec issues
51    Encoding,
52    /// Audio configuration issues
53    Audio,
54    /// Video configuration issues
55    Video,
56    /// Subtitle and caption issues
57    Subtitle,
58    /// Metadata and labeling issues
59    Metadata,
60    /// Security and DRM issues
61    Security,
62    /// Studio-specific requirements
63    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/// Location where the issue was found
86#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
87#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
88pub struct Location {
89    /// File path if applicable
90    pub file: Option<PathBuf>,
91    /// CPL UUID if applicable
92    pub cpl_id: Option<ImfUuid>,
93    /// Segment index (0-based)
94    pub segment: Option<usize>,
95    /// Sequence UUID if applicable
96    pub sequence_id: Option<String>,
97    /// Resource UUID if applicable
98    pub resource_id: Option<String>,
99    /// Timecode if applicable
100    pub timecode: Option<String>,
101    /// Line number in XML file
102    pub line: Option<usize>,
103    /// XPath or field path
104    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/// A single validation issue
172#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct ValidationIssue {
175    /// Severity level
176    pub severity: Severity,
177    /// Category of issue
178    pub category: Category,
179    /// Location where issue was found
180    pub location: Location,
181    /// Error code (e.g., "ST2067-2:2020:8.3/FileNotFound")
182    pub code: String,
183    /// Human-readable message
184    pub message: String,
185    /// Suggestion for how to fix
186    pub suggestion: Option<String>,
187    /// Additional context
188    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/// Comprehensive validation report
246#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
247#[derive(Debug, Clone, Default, Serialize, Deserialize)]
248pub struct ValidationReport {
249    /// Critical issues that prevent usage
250    pub critical: Vec<ValidationIssue>,
251    /// Errors that must be fixed for compliance
252    pub errors: Vec<ValidationIssue>,
253    /// Warnings that should be addressed
254    pub warnings: Vec<ValidationIssue>,
255    /// Informational issues
256    pub info: Vec<ValidationIssue>,
257    /// Whether the package is playable despite issues
258    pub is_playable: bool,
259    /// Whether the package is compliant with base SMPTE standards
260    pub is_compliant: bool,
261    /// Validation profile used
262    pub profile: ValidationProfile,
263    /// Timestamp of validation
264    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    /// Merge another report's issues into this one.
298    ///
299    /// The source report's `profile` and `timestamp` are discarded;
300    /// only `self`'s values are retained. The `is_playable` and
301    /// `is_compliant` flags are combined via logical AND.
302    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/// Validation profile determining strictness
396#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
398pub enum ValidationProfile {
399    /// Minimal validation - just check if playable
400    Minimal,
401    /// Standard SMPTE compliance
402    #[default]
403    SMPTE,
404    /// Custom profile with specific rules
405    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
418/// Typed validation-code catalogue with per-spec enums.
419///
420/// Every normative code emitted by the imf-rs validators is defined here,
421/// grouped by the SMPTE specification that defines it.  See [`codes`] for
422/// the full API.
423pub mod codes;
424
425/// ESLint-style per-rule severity overrides for `ValidationReport`.
426pub mod rules;
427pub use rules::{RuleSeverity, RulesConfig};
428
429/// Result type for parsing operations that can accumulate errors
430pub type ParseResult<T> = Result<(T, ValidationReport), CriticalError>;
431
432/// Critical errors that prevent any further processing
433#[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        // Test severity ordering for proper sorting
531        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(); // Highest first
544
545        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        // Summary is a formatted string, check it contains useful information
641        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        // Test empty location
675        let empty_location = Location::new();
676        let formatted = format!("{}", empty_location);
677        assert!(formatted.is_empty());
678
679        // Test location with only path
680        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        // Test location with only file
685        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        // ValidationIssue stores whatever code string it is given.
729        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}