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    /// Codec-level encoding issues (J2K profiles, JPEG-XS, AAC params,
51    /// PCM bit depth, etc.). For *container*-level concerns (MXF
52    /// wrapping, partition layout) use `Container`.
53    Encoding,
54    /// MXF / wrapping container constraints — distinct from `Encoding`
55    /// which covers the codec carried inside the container.
56    Container,
57    /// Audio configuration issues
58    Audio,
59    /// Video configuration issues
60    Video,
61    /// Subtitle and caption issues
62    Subtitle,
63    /// Data essence (ISXD, dynamic metadata sidecars, ancillary data
64    /// tracks). NOT for *metadata about* essence — that's `Metadata`.
65    Data,
66    /// Metadata and labeling issues
67    Metadata,
68    /// Security and DRM issues
69    Security,
70    /// Studio-specific requirements
71    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/// Which authority produced a validation finding.
96///
97/// Encodes the "prose vs XSD" provenance distinction that SMPTE ST 2067-3
98/// §5.1 calls out ("the prose document takes precedence on conflict"),
99/// so callers can filter, group, or apply per-source severity overrides
100/// without inspecting the code string.
101///
102/// Currently *inferred* from the code prefix on a `ValidationIssue`
103/// (see `ValidationIssue::source`) — no engine code needs to set it
104/// explicitly. If inference ever becomes ambiguous (e.g. a non-XSD
105/// engine internal rule that wants the `XSD/` prefix for catalogue
106/// reasons), we can add a `with_source()` builder without changing
107/// the public shape.
108#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
110pub enum IssueSource {
111    /// Came from the runtime XSD validator (uppsala). Schema-layer:
112    /// the structural subset of the spec that the XSD DSL can express.
113    XsdLayer,
114    /// Came from a hand-rolled prose-cited rule. Semantic/cross-field/
115    /// value-set checks that XSD can't express; the rule's code
116    /// typically cites a SMPTE prose section (e.g. `ST2067-2:2020:6.4.2`).
117    ProseRule,
118    /// Came from imferno engine internals — parse failures, package
119    /// structure checks, manifest issues — not directly traceable to
120    /// a single SMPTE spec section.
121    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    /// Infer the source authority from a catalogue code string.
136    ///
137    /// Inference rules (checked top-to-bottom):
138    /// - `XSD/...` → XsdLayer
139    /// - `IMFERNO:...` or `IMFERNO/...` → EngineInternal
140    /// - everything else → ProseRule (most catalogue codes cite a spec §)
141    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/// Location where the issue was found
153#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
154#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
155pub struct Location {
156    /// File path if applicable
157    pub file: Option<PathBuf>,
158    /// CPL UUID if applicable
159    pub cpl_id: Option<ImfUuid>,
160    /// CPL filename if applicable
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub cpl_filename: Option<String>,
163    /// CPL content title if applicable
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub cpl_title: Option<String>,
166    /// Segment index (0-based)
167    pub segment: Option<usize>,
168    /// Sequence UUID if applicable
169    pub sequence_id: Option<String>,
170    /// Resource UUID if applicable
171    pub resource_id: Option<String>,
172    /// Timecode if applicable
173    pub timecode: Option<String>,
174    /// Line number in XML file
175    pub line: Option<usize>,
176    /// XPath or field path
177    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/// A single validation issue
255#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ValidationIssue {
258    /// Severity level
259    pub severity: Severity,
260    /// Category of issue
261    pub category: Category,
262    /// Authority that emitted this issue (XSD layer, prose rule, engine
263    /// internal). Inferred from `code` at construction time so the field
264    /// is always consistent with the code prefix — JS consumers can
265    /// filter on `issue.source` directly without re-implementing the
266    /// inference. Deserialised with a default so old reports without the
267    /// field round-trip cleanly.
268    #[serde(default = "default_source_for_deserialize")]
269    pub source: IssueSource,
270    /// Location where issue was found
271    pub location: Location,
272    /// Error code (e.g., "ST2067-2:2020:8.3/FileNotFound")
273    pub code: String,
274    /// Human-readable message
275    pub message: String,
276    /// Suggestion for how to fix
277    pub suggestion: Option<String>,
278    /// Additional context
279    pub context: HashMap<String, String>,
280    /// Other `Location`s when this issue represents an aggregation of
281    /// multiple identical-code occurrences (see
282    /// [`ValidationReport::aggregate`]). Empty for fresh, un-aggregated
283    /// issues. Skipped during serialisation when empty so the on-wire
284    /// shape stays back-compatible for callers that don't aggregate.
285    #[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    /// Build a `ValidationIssue` directly from a typed `ValidationCode`,
312    /// reading severity + category from the catalogue rather than the
313    /// caller. Use this for any rule whose code lives in one of the
314    /// `*_codes` modules — the catalogue is the single source of truth
315    /// for severity and category. Pass the bare enum value:
316    ///
317    /// ```ignore
318    /// use imferno_core::mxf::codes::St2067_2_2016;
319    /// let issue = ValidationIssue::from_code(
320    ///     St2067_2_2016::AudioSampleRateUnsupported,
321    ///     format!("got {hz} Hz"),
322    /// );
323    /// ```
324    pub fn from_code<C: codes::ValidationCode>(code: C, message: impl Into<String>) -> Self {
325        // `code` is consumed only via `&self`-taking methods; we never
326        // need to move it. The string code itself comes from
327        // `code.code()` which returns `&'static str`, so no `Into<String>`
328        // bound on `C` is required.
329        Self::new(
330            code.default_severity(),
331            code.category(),
332            code.code(),
333            message,
334        )
335    }
336
337    /// Number of occurrences this issue represents. `1` for a fresh
338    /// issue, `1 + additional_instances.len()` after aggregation.
339    ///
340    /// JS consumers can compute this themselves as
341    /// `1 + issue.additionalInstances.length` — the field is the source
342    /// of truth, this method is a Rust-side convenience.
343    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
363/// Serde default for `ValidationIssue::source` — used only when
364/// deserialising a payload that pre-dates the field. Returns
365/// `IssueSource::EngineInternal` as a safe fallback; the proper value
366/// will be re-derived if the issue is re-emitted, since `::new` always
367/// computes from the code.
368fn 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/// Comprehensive validation report
398#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
399#[derive(Debug, Clone, Default, Serialize, Deserialize)]
400pub struct ValidationReport {
401    /// Critical issues that prevent usage
402    pub critical: Vec<ValidationIssue>,
403    /// Errors that must be fixed for compliance
404    pub errors: Vec<ValidationIssue>,
405    /// Warnings that should be addressed
406    pub warnings: Vec<ValidationIssue>,
407    /// Informational issues
408    pub info: Vec<ValidationIssue>,
409    /// Issues that were suppressed by a `RuleSeverity::Off` override.
410    /// These are not counted toward `is_playable`/`is_compliant` or
411    /// surfaced by `has_errors`/`summary` — they exist so operators can
412    /// debug their `RulesConfig` (`--show-suppressed`) without re-running
413    /// validation. Each carries a `context["suppressed_by"]` annotation
414    /// naming the rule key that matched.
415    ///
416    /// Skipped during serialisation when empty so reports without
417    /// suppressed rules keep the previous on-wire shape.
418    #[serde(default, skip_serializing_if = "Vec::is_empty")]
419    pub suppressed: Vec<ValidationIssue>,
420    /// Whether the package is playable despite issues
421    pub is_playable: bool,
422    /// Whether the package is compliant with base SMPTE standards
423    pub is_compliant: bool,
424    /// Validation profile used
425    pub profile: ValidationProfile,
426    /// Timestamp of validation
427    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    /// Collapse repeat-offender diagnostics within each severity bucket.
462    ///
463    /// When the same `code` fires N times, the first-encountered issue
464    /// is kept as the canonical instance and the rest contribute only
465    /// their `Location` to its [`ValidationIssue::additional_instances`].
466    /// All other fields (`message`, `suggestion`, `context`, `severity`,
467    /// `category`) are taken from the first occurrence; later instances'
468    /// `additional_instances` (if any — handles repeated aggregation
469    /// idempotently) are merged in.
470    ///
471    /// Aggregation stays within severity buckets, since rules emit at
472    /// a fixed severity per code. Order across the report is preserved
473    /// (first-seen wins) so output remains reproducible.
474    ///
475    /// Idempotent and order-independent with [`Self::apply_rules`] —
476    /// safe to call either before or after.
477    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    /// Merge another report's issues into this one.
508    ///
509    /// The source report's `profile` and `timestamp` are discarded;
510    /// only `self`'s values are retained. The `is_playable` and
511    /// `is_compliant` flags are combined via logical AND.
512    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/// Validation profile determining strictness
607#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
609pub enum ValidationProfile {
610    /// Minimal validation - just check if playable
611    Minimal,
612    /// Standard SMPTE compliance
613    #[default]
614    SMPTE,
615    /// Custom profile with specific rules
616    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
629/// Typed validation-code catalogue with per-spec enums.
630///
631/// Every normative code emitted by the imf-rs validators is defined here,
632/// grouped by the SMPTE specification that defines it.  See [`codes`] for
633/// the full API.
634pub mod codes;
635
636/// ESLint-style per-rule severity overrides for `ValidationReport`.
637pub mod rules;
638pub use rules::{RuleSeverity, RulesConfig};
639
640/// Result type for parsing operations that can accumulate errors
641pub type ParseResult<T> = Result<(T, ValidationReport), CriticalError>;
642
643/// Critical errors that prevent any further processing
644#[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        // SMPTE prose-cited codes — explicit spec section in the prefix.
728        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        // Catch-all: anything we don't recognise falls into ProseRule
737        // (catalogue codes are overwhelmingly spec-cited).
738        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        // Older payload shape without the `source` field — must
788        // round-trip into a sensible default rather than failing.
789        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        // Test severity ordering for proper sorting
843        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(); // Highest first
856
857        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        // Summary is a formatted string, check it contains useful information
953        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        // Test empty location
987        let empty_location = Location::new();
988        let formatted = format!("{}", empty_location);
989        assert!(formatted.is_empty());
990
991        // Test location with only path
992        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        // Test location with only file
997        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        // ValidationIssue stores whatever code string it is given.
1041        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    /// FIX-12 regression: a ValidationReport carrying both a populated
1074    /// `suppressed` bucket and aggregated issues with non-empty
1075    /// `additional_instances` survives a serde JSON round-trip.
1076    #[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        // Aggregated issue: 1 primary + 2 additional instances.
1085        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        // Suppressed issue: demoted by a hypothetical rule key, annotated.
1101        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        // Two distinct codes → two issues in errors bucket.
1151        assert_eq!(out.errors.len(), 2);
1152        // The repeated-code issue carries the extra locations.
1153        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        // First-seen location is retained on the canonical issue.
1161        // Other code stays as a single-instance issue.
1162        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        // Same code in different buckets should NOT merge — different
1191        // buckets indicate the issue was demoted/promoted independently.
1192        // (In practice rules emit at one severity per code, but be
1193        // defensive against post-`apply_rules` weirdness.)
1194        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        // Aggregating already-aggregated report yields same shape.
1211        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        // First-seen order maps to "Z/last" → idx 0, "A/first" → idx 1.
1224        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        // Single issue in a bucket — aggregate is a no-op and should
1231        // leave the issue untouched (no unnecessary clone / re-push).
1232        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}