Skip to main content

imferno_core/package/
report.rs

1//! Unified IMF report — the single JSON document for UI consumption.
2//!
3//! Combines package metadata, validation, and structural analysis into one structure.
4//! Also provides `format_report()` for pretty-printing to a terminal.
5//!
6//! Note: `ImfReport`, `build_report()`, and `format_report()` are deprecated in
7//! favour of `ValidationResult` / `validate()` / `format_validation_result()`.
8
9#![allow(deprecated)] // This module defines and uses the deprecated types internally.
10
11use crate::assetmap::ImfUuid;
12use crate::cpl::{EssenceDescriptor, SequenceAccess};
13use crate::diagnostics::{Severity, ValidationReport};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16
17use super::ValidationResult;
18use std::fmt::Write;
19#[cfg(feature = "typescript")]
20use ts_rs::TS;
21
22// ── Report structs ───────────────────────────────────────────────────────────
23
24/// Legacy report summary. Deprecated since v2.3.0.
25///
26/// Use [`ValidationResult`] from [`validate()`](crate::package::validate)
27/// or [`Imferno::parse_and_validate()`] instead. `ImfReport` is a lossy
28/// summary that drops essence descriptors, locales, content versions, and
29/// other parsed data from the package.
30///
31/// # Migration
32///
33/// Before:
34/// ```ignore
35/// let report = build_report(&package, &options, None)?;
36/// for cpl in &report.cpls { /* ... */ }
37/// ```
38///
39/// After:
40/// ```ignore
41/// let result = package.validate(&options);
42/// for (id, cpl) in &package.composition_playlists { /* ... */ }
43/// ```
44#[deprecated(
45    since = "2.3.0",
46    note = "Use `ValidationResult` from `validate()` / `Imferno::parse_and_validate()` instead. \
47            ImfReport is a lossy summary that drops essence descriptors, locales, and other parsed data."
48)]
49#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52#[cfg_attr(feature = "typescript", derive(TS))]
53#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
54pub struct ImfReport {
55    pub package: PackageSummary,
56    pub cpls: Vec<CplReport>,
57    pub validation: ValidationReport,
58}
59
60#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63#[cfg_attr(feature = "typescript", derive(TS))]
64#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
65pub struct CplReport {
66    pub id: String,
67    pub title: String,
68    /// Application profile, e.g. "App2E_2021", "App2E_2014", "App5"
69    pub application_profile: Option<String>,
70    /// CPL-level edit rate, e.g. "24000/1001"
71    pub edit_rate: Option<String>,
72    /// Number of segments in this CPL
73    pub segment_count: usize,
74    /// Timecode start address, e.g. "01:00:00:00"
75    pub timecode_start: Option<String>,
76    /// True if this CPL references track files not present in the current package (supplemental IMP)
77    pub is_supplemental: bool,
78    /// Track file UUIDs referenced in this CPL that are not in the current package's AssetMap.
79    /// These must be resolved from an ancestor package.
80    pub unresolved_ancestor_asset_ids: Vec<String>,
81    pub markers: Vec<CplMarker>,
82    /// Virtual tracks (sequences) merged across all segments
83    pub sequences: Vec<CplSequence>,
84}
85
86#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89#[cfg_attr(feature = "typescript", derive(TS))]
90#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
91pub struct CplMarker {
92    pub label: String,
93    pub offset: u64,
94    pub annotation: Option<String>,
95}
96
97#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100#[cfg_attr(feature = "typescript", derive(TS))]
101#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
102pub struct CplSequence {
103    /// Sequence type: "MainImage", "MainAudio", "Subtitles", etc.
104    pub r#type: String,
105    pub id: String,
106    pub track_id: String,
107    /// RFC 5646 language tag extracted from the essence descriptor (e.g. "en", "fr")
108    pub language: Option<String>,
109    /// Audio channel count (e.g. 2, 6, 8) — only for audio sequences
110    pub channel_count: Option<u32>,
111    /// MCA soundfield label (e.g. "5.1", "7.1", "Atmos") — only for audio sequences
112    pub soundfield: Option<String>,
113    pub resources: Vec<CplResource>,
114}
115
116#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119#[cfg_attr(feature = "typescript", derive(TS))]
120#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
121pub struct CplResource {
122    pub id: String,
123    /// Edit rate as "N/D" string, e.g. "24000/1001"
124    pub edit_rate: Option<String>,
125    pub intrinsic_duration: u64,
126    pub source_duration: Option<u64>,
127    pub entry_point: Option<u64>,
128    pub source_encoding: Option<String>,
129    pub track_file_id: Option<String>,
130}
131
132#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135#[cfg_attr(feature = "typescript", derive(TS))]
136#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
137pub struct PackageSummary {
138    pub asset_map_id: String,
139    pub volume_index: u32,
140    pub asset_count: usize,
141    pub cpl_count: usize,
142    pub issue_date: String,
143    pub issuer: Option<String>,
144    pub creator: Option<String>,
145    pub pkl_count: usize,
146    pub scm_count: usize,
147    pub sidecar_count: usize,
148    pub unreferenced_assets: Vec<UnreferencedAsset>,
149}
150
151#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
152#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154#[cfg_attr(feature = "typescript", derive(TS))]
155#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
156pub struct UnreferencedAsset {
157    pub id: String,
158    pub path: String,
159}
160
161// ── build_report ─────────────────────────────────────────────────────────────
162
163/// Map an ApplicationIdentification URL to a friendly profile name
164fn parse_application_profile(url: &str) -> String {
165    // SMPTE ST 2067-21 Application #2 Extended profiles
166    if url.contains("2067-21") {
167        if url.ends_with("2021") || url.contains("/2021") {
168            return "App2E_2021".to_string();
169        }
170        if url.ends_with("2020") || url.contains("/2020") {
171            return "App2E_2020".to_string();
172        }
173        if url.ends_with("2014") || url.contains("/2014") {
174            return "App2E_2014".to_string();
175        }
176        return "App2E".to_string();
177    }
178    // SMPTE ST 2067-20 Application #2
179    if url.contains("2067-20") {
180        return "App2".to_string();
181    }
182    // SMPTE ST 2067-50 Application #5
183    if url.contains("2067-50") || url.contains("2067-5/") {
184        return "App5".to_string();
185    }
186    // Fallback: return the URL tail after the last '/'
187    url.rsplit('/').next().unwrap_or(url).to_string()
188}
189
190/// Map a single resource to a CplResource, falling back to the CPL edit rate
191fn map_resource(r: &crate::cpl::Resource, cpl_er: &Option<String>) -> CplResource {
192    CplResource {
193        id: r.id.to_string(),
194        edit_rate: r
195            .edit_rate
196            .as_ref()
197            .map(|er| format!("{}/{}", er.numerator, er.denominator))
198            .or_else(|| cpl_er.clone()),
199        intrinsic_duration: r.intrinsic_duration,
200        source_duration: r.source_duration,
201        entry_point: r.entry_point,
202        source_encoding: r.source_encoding.as_ref().map(|u| u.to_string()),
203        track_file_id: r.track_file_id.as_ref().map(|u| u.to_string()),
204    }
205}
206
207/// Extract the language from an `EssenceDescriptor` by checking audio, timed-text, and IAB sub-descriptors.
208fn language_from_descriptor(ed: &EssenceDescriptor) -> Option<String> {
209    // Audio: WAVEPCMDescriptor → soundfield_group_label_sub_descriptor → rfc5646_spoken_language
210    if let Some(wave) = &ed.wave_pcm_descriptor {
211        if let Some(subs) = &wave.sub_descriptors {
212            if let Some(sf) = &subs.soundfield_group_label_sub_descriptor {
213                if let Some(lang) = &sf.rfc5646_spoken_language {
214                    let s = lang.as_str();
215                    if !s.is_empty() {
216                        return Some(s.to_string());
217                    }
218                }
219            }
220        }
221    }
222    // IAB: IABEssenceDescriptor → iab_soundfield_label_sub_descriptor → rfc5646_spoken_language
223    if let Some(iab) = &ed.iab_essence_descriptor {
224        if let Some(subs) = &iab.sub_descriptors {
225            if let Some(sf) = &subs.iab_soundfield_label_sub_descriptor {
226                if let Some(lang) = &sf.rfc5646_spoken_language {
227                    let s = lang.as_str();
228                    if !s.is_empty() {
229                        return Some(s.to_string());
230                    }
231                }
232            }
233        }
234    }
235    // Timed text: DCTimedTextDescriptor → rfc5646_language_tag_list
236    if let Some(tt) = &ed.dc_timed_text_descriptor {
237        let langs: Vec<&str> = tt
238            .rfc5646_language_tag_list
239            .iter()
240            .map(|lt| lt.as_str())
241            .filter(|s| !s.is_empty())
242            .collect();
243        if !langs.is_empty() {
244            return Some(langs.join(","));
245        }
246    }
247    None
248}
249
250/// Extract the audio channel count from an essence descriptor.
251fn channel_count_from_descriptor(ed: &EssenceDescriptor) -> Option<u32> {
252    if let Some(wave) = &ed.wave_pcm_descriptor {
253        return wave.channel_count;
254    }
255    if let Some(iab) = &ed.iab_essence_descriptor {
256        return iab.channel_count;
257    }
258    None
259}
260
261/// Extract the soundfield label (e.g. "5.1", "7.1", "Atmos") from an essence descriptor.
262fn soundfield_from_descriptor(ed: &EssenceDescriptor) -> Option<String> {
263    if let Some(wave) = &ed.wave_pcm_descriptor {
264        if let Some(subs) = &wave.sub_descriptors {
265            if let Some(sf) = &subs.soundfield_group_label_sub_descriptor {
266                if let Some(mca) = &sf.mca_tag_symbol {
267                    return Some(mca.to_string());
268                }
269                if let Some(name) = &sf.mca_tag_name {
270                    return Some(name.clone());
271                }
272            }
273        }
274    }
275    if let Some(iab) = &ed.iab_essence_descriptor {
276        if let Some(subs) = &iab.sub_descriptors {
277            if let Some(sf) = &subs.iab_soundfield_label_sub_descriptor {
278                if let Some(mca) = &sf.mca_tag_symbol {
279                    return Some(mca.to_string());
280                }
281            }
282        }
283        // IAB without a label is Dolby Atmos
284        return Some("Atmos".to_string());
285    }
286    None
287}
288
289/// Merge a single trait-object sequence into the track map
290fn merge_sequences_dyn(
291    track_map: &mut HashMap<String, CplSequence>,
292    type_name: &str,
293    seq: &dyn SequenceAccess,
294    cpl_er: &Option<String>,
295    descriptors: &HashMap<ImfUuid, &EssenceDescriptor>,
296) {
297    let tid = seq.track_id().to_string();
298    let resources: Vec<CplResource> = seq
299        .resource_list()
300        .resources
301        .iter()
302        .map(|r| map_resource(r, cpl_er))
303        .collect();
304    if let Some(existing) = track_map.get_mut(&tid) {
305        existing.resources.extend(resources);
306    } else {
307        // Resolve metadata from essence descriptor
308        let ed = seq
309            .resource_list()
310            .resources
311            .first()
312            .and_then(|r| r.source_encoding.as_ref())
313            .and_then(|se| descriptors.get(se).copied());
314        let language = ed.and_then(language_from_descriptor);
315        let channel_count = ed.and_then(channel_count_from_descriptor);
316        let soundfield = ed.and_then(soundfield_from_descriptor);
317        track_map.insert(
318            tid.clone(),
319            CplSequence {
320                r#type: type_name.to_string(),
321                id: seq.id().to_string(),
322                track_id: tid,
323                language,
324                channel_count,
325                soundfield,
326                resources,
327            },
328        );
329    }
330}
331
332/// Extract all virtual tracks from a CPL, merged across segments
333fn extract_sequences(cpl: &crate::cpl::CompositionPlaylist) -> (Option<String>, Vec<CplSequence>) {
334    let edit_rate = cpl
335        .edit_rate
336        .as_ref()
337        .map(|er| format!("{}/{}", er.numerator, er.denominator));
338
339    // Build essence descriptor lookup by ID for language resolution
340    let descriptors: HashMap<ImfUuid, &EssenceDescriptor> =
341        if let Some(edl) = &cpl.essence_descriptor_list {
342            edl.essence_descriptors
343                .iter()
344                .map(|ed| (ed.id, ed))
345                .collect()
346        } else {
347            HashMap::new()
348        };
349
350    let mut track_map: HashMap<String, CplSequence> = HashMap::new();
351
352    for seg in &cpl.segment_list.segments {
353        for (seq, type_name) in seg.sequence_list.all_sequences_typed() {
354            merge_sequences_dyn(&mut track_map, type_name, seq, &edit_rate, &descriptors);
355        }
356    }
357
358    (edit_rate, track_map.into_values().collect())
359}
360
361/// Collect all TrackFileIds referenced in a CPL across all sequence types
362fn collect_track_file_ids(cpl: &crate::cpl::CompositionPlaylist) -> Vec<String> {
363    let mut ids = Vec::new();
364    for seg in &cpl.segment_list.segments {
365        for seq in seg.sequence_list.all_sequences() {
366            for r in &seq.resource_list().resources {
367                if let Some(ref id) = r.track_file_id {
368                    ids.push(id.to_string());
369                }
370            }
371        }
372    }
373    ids
374}
375
376/// Build a full ImfReport from a package, with optional ancestor package for supplemental IMPs.
377#[deprecated(
378    since = "2.3.0",
379    note = "Use `validate()` or `Imferno::parse_and_validate()` instead, which return a \
380            `ValidationResult` with the full parsed package."
381)]
382pub fn build_report(
383    package: &super::Imferno,
384    options: &super::ValidationOptions,
385    ancestor: Option<&super::Imferno>,
386) -> Result<ImfReport, String> {
387    // Unreferenced assets
388    let unreferenced_assets: Vec<UnreferencedAsset> = package
389        .unreferenced_assets()
390        .iter()
391        .map(|a| {
392            let path = a
393                .chunk_list
394                .chunks
395                .first()
396                .map(|c| c.path.as_str())
397                .unwrap_or("")
398                .to_string();
399            UnreferencedAsset {
400                id: a.id.to_string(),
401                path,
402            }
403        })
404        .collect();
405
406    // SCM counts
407    let scm_count = package.sidecar_composition_maps.len();
408    let sidecar_count: usize = package
409        .sidecar_composition_maps
410        .values()
411        .map(|s| s.sidecar_assets.len())
412        .sum();
413
414    let pkg_summary = PackageSummary {
415        asset_map_id: package.asset_map.id.to_string(),
416        volume_index: package.volume_index.index,
417        asset_count: package.asset_map.asset_list.assets.len(),
418        cpl_count: package.composition_playlists.len(),
419        issue_date: package.asset_map.issue_date.clone(),
420        issuer: package.asset_map.issuer.clone(),
421        creator: package.asset_map.creator.clone(),
422        pkl_count: package.packing_lists.len(),
423        scm_count,
424        sidecar_count,
425        unreferenced_assets,
426    };
427
428    // CPL reports
429    let mut cpls = Vec::new();
430    for cpl in package.composition_playlists.values() {
431        let cpl_track_ids = collect_track_file_ids(cpl);
432        let unresolved_ancestor_asset_ids: Vec<String> = cpl_track_ids
433            .iter()
434            .filter(|id| {
435                package.get_asset_path_str(id).is_none()
436                    && ancestor.is_none_or(|a| a.get_asset_path_str(id).is_none())
437            })
438            .cloned()
439            .collect();
440        let is_supplemental = cpl_track_ids
441            .iter()
442            .any(|id| package.get_asset_path_str(id).is_none());
443
444        let markers: Vec<CplMarker> = cpl
445            .segment_list
446            .segments
447            .iter()
448            .flat_map(|seg| seg.sequence_list.marker_sequences.iter())
449            .flat_map(|ms| ms.resource_list.resources.iter())
450            .flat_map(|res| res.markers.iter())
451            .map(|m| CplMarker {
452                label: m.label.to_string(),
453                offset: m.offset,
454                annotation: m.annotation.clone().filter(|a| !a.is_empty()),
455            })
456            .collect();
457
458        let application_profile = cpl
459            .extension_properties
460            .as_ref()
461            .and_then(|ep| ep.application_identification.as_ref())
462            .map(|url| parse_application_profile(url));
463
464        let segment_count = cpl.segment_list.segments.len();
465
466        let timecode_start = cpl
467            .composition_timecode
468            .as_ref()
469            .and_then(|tc| tc.timecode_start_address.clone());
470
471        let (edit_rate, sequences) = extract_sequences(cpl);
472
473        cpls.push(CplReport {
474            id: cpl.id.to_string(),
475            title: cpl.content_title.text.clone(),
476            application_profile,
477            edit_rate,
478            segment_count,
479            timecode_start,
480            is_supplemental,
481            unresolved_ancestor_asset_ids,
482            markers,
483            sequences,
484        });
485    }
486
487    // Validation
488    let validation = package.validate(options);
489
490    Ok(ImfReport {
491        package: pkg_summary,
492        cpls,
493        validation,
494    })
495}
496
497// ── ANSI colour helpers ──────────────────────────────────────────────────────
498
499fn ansi(code: &str, text: &str, enabled: bool) -> String {
500    if enabled {
501        format!("\x1b[{}m{}\x1b[0m", code, text)
502    } else {
503        text.to_string()
504    }
505}
506
507fn c_red(s: &str, on: bool) -> String {
508    ansi("31", s, on)
509}
510fn c_yellow(s: &str, on: bool) -> String {
511    ansi("33", s, on)
512}
513fn c_cyan(s: &str, on: bool) -> String {
514    ansi("36", s, on)
515}
516fn c_green(s: &str, on: bool) -> String {
517    ansi("32", s, on)
518}
519fn c_bold(s: &str, on: bool) -> String {
520    ansi("1", s, on)
521}
522fn c_dim(s: &str, on: bool) -> String {
523    ansi("2", s, on)
524}
525
526// ── format_report ────────────────────────────────────────────────────────────
527
528/// Render an `ImfReport` as a human-readable, optionally ANSI-coloured string.
529#[deprecated(
530    since = "2.3.0",
531    note = "Use `format_validation_result()` instead, which renders the full `ValidationResult`."
532)]
533pub fn format_report(report: &ImfReport, color: bool) -> String {
534    let mut out = String::new();
535    let pkg = &report.package;
536
537    // Package structure
538    let _ = writeln!(out, "  {}  VOLINDEX.xml found", c_green("ok", color));
539    let _ = writeln!(out, "  {}  ASSETMAP.xml found", c_green("ok", color));
540    let _ = writeln!(
541        out,
542        "  {}  {} assets mapped",
543        c_green("ok", color),
544        pkg.asset_count
545    );
546    let _ = writeln!(
547        out,
548        "  {}  {} CPL(s) parsed",
549        c_green("ok", color),
550        pkg.cpl_count
551    );
552
553    // SCMs
554    if pkg.scm_count > 0 {
555        let _ = writeln!(
556            out,
557            "  {}  {} SCM(s) parsed, {} sidecar asset(s) declared",
558            c_green("ok", color),
559            pkg.scm_count,
560            pkg.sidecar_count
561        );
562    }
563
564    // Unreferenced assets
565    if !pkg.unreferenced_assets.is_empty() {
566        let _ = writeln!(
567            out,
568            "  {}  {} unreferenced asset(s)",
569            c_yellow("info", color),
570            pkg.unreferenced_assets.len()
571        );
572        for asset in &pkg.unreferenced_assets {
573            let _ = writeln!(out, "        {}", c_dim(&asset.path, color));
574        }
575    }
576
577    // Timeline / virtual tracks per CPL
578    for cpl in &report.cpls {
579        if !cpl.sequences.is_empty() {
580            let _ = writeln!(
581                out,
582                "{}",
583                c_bold(
584                    &format!("Timeline [CPL:{}]:", &cpl.id[..cpl.id.len().min(8)]),
585                    color,
586                )
587            );
588            for seq in &cpl.sequences {
589                let label = match seq.language.as_deref() {
590                    Some(lang) => format!("{} ({})", seq.r#type, lang),
591                    None => seq.r#type.clone(),
592                };
593                let resource_count = seq.resources.len();
594                let _ = writeln!(
595                    out,
596                    "  {}  {} — {} resource(s)",
597                    c_cyan("track", color),
598                    c_bold(&label, color),
599                    resource_count,
600                );
601            }
602        }
603    }
604
605    // Validation findings
606    let all_issues: Vec<_> = report
607        .validation
608        .critical
609        .iter()
610        .chain(report.validation.errors.iter())
611        .chain(report.validation.warnings.iter())
612        .chain(report.validation.info.iter())
613        .collect();
614
615    if !all_issues.is_empty() {
616        let _ = writeln!(out, "{}", c_bold("Validation findings:", color));
617
618        for issue in &all_issues {
619            let (label, colorize): (&str, fn(&str, bool) -> String) = match issue.severity {
620                Severity::Critical => ("error", c_red),
621                Severity::Error => ("error", c_red),
622                Severity::Warning => ("warning", c_yellow),
623                Severity::Info => ("info", c_cyan),
624            };
625            let location = if let Some(ref c) = issue.location.cpl_id {
626                let s = c.to_string();
627                let short_id = &s[..s.len().min(8)];
628                let detail = match (&issue.location.cpl_filename, &issue.location.cpl_title) {
629                    (Some(fname), Some(title)) => {
630                        format!(" [CPL:{} {} {}]", short_id, fname, title)
631                    }
632                    (Some(fname), None) => format!(" [CPL:{} {}]", short_id, fname),
633                    (None, Some(title)) => format!(" [CPL:{} {}]", short_id, title),
634                    (None, None) => format!(" [CPL:{}]", short_id),
635                };
636                c_dim(&detail, color)
637            } else if let Some(ref f) = issue.location.file {
638                let fname = f.file_name().and_then(|n| n.to_str()).unwrap_or("?");
639                c_dim(&format!(" [{}]", fname), color)
640            } else {
641                String::new()
642            };
643            let _ = writeln!(
644                out,
645                "  {} {}{} {}",
646                colorize(&format!("{:<7}", label), color),
647                c_bold(&issue.code, color),
648                location,
649                issue.message,
650            );
651            if let Some(ref s) = issue.suggestion {
652                let _ = writeln!(out, "          {} {}", c_dim("→", color), c_dim(s, color));
653            }
654        }
655    }
656
657    // Summary line
658    let total_errors = report.validation.critical.len() + report.validation.errors.len();
659    let total_warnings = report.validation.warnings.len();
660    if total_errors > 0 {
661        let mut reasons = Vec::new();
662        reasons.push(format!("{} error(s)", total_errors));
663        if total_warnings > 0 {
664            reasons.push(format!("{} warning(s)", total_warnings));
665        }
666        let _ = writeln!(
667            out,
668            "{} {}",
669            c_red("failed", color),
670            c_bold(&reasons.join(", "), color)
671        );
672    } else if total_warnings > 0 {
673        let _ = writeln!(
674            out,
675            "{}",
676            c_yellow(&format!("valid  {} warning(s)", total_warnings), color)
677        );
678    } else {
679        let _ = writeln!(out, "{}", c_green("valid", color));
680    }
681
682    out
683}
684
685// ── format_validation_result ────────────────────────────────────────────────
686
687/// Output format for `format_validation_result`.
688#[derive(Debug, Clone, Copy, PartialEq, Eq)]
689pub enum ReportFormat {
690    /// Human-readable plain text (with optional ANSI color).
691    Text,
692    /// Markdown — headers, tables, code blocks. Embeddable in PRs, Slack, Notion.
693    Markdown,
694    /// CSV — one row per validation issue. Importable into Excel, dashboards.
695    Csv,
696}
697
698/// Format options for `format_validation_result`.
699#[derive(Debug, Clone)]
700pub struct FormatOptions {
701    pub format: ReportFormat,
702    pub color: bool,
703}
704
705impl Default for FormatOptions {
706    fn default() -> Self {
707        Self {
708            format: ReportFormat::Text,
709            color: false,
710        }
711    }
712}
713
714/// Format a `ValidationResult` (full package + validation) as text, markdown, or CSV.
715///
716/// This is the recommended formatting function — it reads directly from the
717/// `Imferno` struct and `ValidationReport` without the lossy `ImfReport` intermediate.
718pub fn format_validation_result(result: &ValidationResult, opts: &FormatOptions) -> String {
719    match opts.format {
720        ReportFormat::Text => format_text(result, opts.color),
721        ReportFormat::Markdown => format_markdown(result),
722        ReportFormat::Csv => format_csv(result),
723    }
724}
725
726fn format_text(result: &ValidationResult, color: bool) -> String {
727    use std::fmt::Write;
728    let mut out = String::new();
729    let pkg = &result.package;
730    let v = &result.validation;
731
732    // Package structure
733    let _ = writeln!(
734        out,
735        "  {}  ASSETMAP.xml — {} assets",
736        c_green("ok", color),
737        pkg.asset_map.asset_list.assets.len()
738    );
739    let _ = writeln!(
740        out,
741        "  {}  {} CPL(s), {} PKL(s)",
742        c_green("ok", color),
743        pkg.composition_playlists.len(),
744        pkg.packing_lists.len()
745    );
746    if !pkg.sidecar_composition_maps.is_empty() {
747        let _ = writeln!(
748            out,
749            "  {}  {} SCM(s)",
750            c_green("ok", color),
751            pkg.sidecar_composition_maps.len()
752        );
753    }
754
755    // CPL timeline
756    for (uuid, cpl) in &pkg.composition_playlists {
757        let title = &cpl.content_title;
758        let _ = writeln!(
759            out,
760            "\n{}",
761            c_bold(
762                &format!("CPL [{}] {}", &uuid.to_string()[..8], title),
763                color
764            )
765        );
766
767        for seg in &cpl.segment_list.segments {
768            for seq in seg.sequence_list.all_sequences_typed() {
769                let (s, type_name) = seq;
770                let resource_count = s.resource_list().resources.len();
771                let lang = language_from_descriptor_lookup(s, cpl);
772                let label = match lang {
773                    Some(l) => format!("{} ({})", type_name, l),
774                    None => type_name.to_string(),
775                };
776
777                // Per-track media info from essence descriptor
778                let media_detail = descriptor_lookup(s, cpl).and_then(|ed| {
779                    if let Some(vi) = video_info_from_descriptor(ed) {
780                        Some(format!(
781                            "{} {} {} {} {}",
782                            vi.resolution, vi.frame_rate, vi.codec, vi.bit_depth, vi.dynamic_range
783                        ))
784                    } else if ed.iab_essence_descriptor.is_some() {
785                        Some("IAB (Dolby Atmos)".into())
786                    } else if let Some(ai) = audio_info_from_descriptor(ed) {
787                        Some(format!("{} {} {}", ai.format, ai.sample_rate, ai.bit_depth))
788                    } else {
789                        None
790                    }
791                });
792
793                let resources_str = s
794                    .resource_list()
795                    .resources
796                    .iter()
797                    .filter_map(|r| {
798                        r.track_file_id
799                            .as_ref()
800                            .map(|id| id.to_string()[..8].to_string())
801                    })
802                    .collect::<Vec<_>>()
803                    .join(", ");
804                let resources_display = if resources_str.is_empty() {
805                    format!("{} resource(s)", resource_count)
806                } else {
807                    resources_str
808                };
809
810                let detail_str = match media_detail {
811                    Some(d) => format!(
812                        " — {} — {}",
813                        c_dim(&d, color),
814                        c_dim(&resources_display, color)
815                    ),
816                    None => format!(" — {}", c_dim(&resources_display, color)),
817                };
818                let _ = writeln!(
819                    out,
820                    "  {}  {}{}",
821                    c_cyan("track", color),
822                    c_bold(&label, color),
823                    detail_str,
824                );
825            }
826        }
827    }
828
829    // Validation findings
830    format_issues_text(&mut out, v, color);
831    format_summary_text(&mut out, v, color);
832
833    out
834}
835
836fn format_markdown(result: &ValidationResult) -> String {
837    use std::fmt::Write;
838    let mut out = String::new();
839    let pkg = &result.package;
840    let v = &result.validation;
841
842    let _ = writeln!(out, "# IMF Package Validation Report\n");
843    let _ = writeln!(
844        out,
845        "**AssetMap:** {} | **CPLs:** {} | **PKLs:** {}\n",
846        pkg.asset_map.id,
847        pkg.composition_playlists.len(),
848        pkg.packing_lists.len()
849    );
850
851    // CPLs
852    for (uuid, cpl) in &pkg.composition_playlists {
853        let _ = writeln!(
854            out,
855            "## CPL: {} (`{}`)\n",
856            cpl.content_title,
857            &uuid.to_string()[..8]
858        );
859
860        let _ = writeln!(out, "| Track | Language | Resources |");
861        let _ = writeln!(out, "|-------|----------|-----------|");
862        for seg in &cpl.segment_list.segments {
863            for (s, type_name) in seg.sequence_list.all_sequences_typed() {
864                let lang =
865                    language_from_descriptor_lookup(s, cpl).unwrap_or_else(|| "—".to_string());
866                let _ = writeln!(
867                    out,
868                    "| {} | {} | {} |",
869                    type_name,
870                    lang,
871                    s.resource_list().resources.len()
872                );
873            }
874        }
875        let _ = writeln!(out);
876    }
877
878    // Validation issues
879    let all_issues = collect_all_issues(v);
880    if !all_issues.is_empty() {
881        let _ = writeln!(out, "## Validation Findings\n");
882        let _ = writeln!(out, "| Severity | Code | Message |");
883        let _ = writeln!(out, "|----------|------|---------|");
884        for issue in &all_issues {
885            let sev = match issue.severity {
886                Severity::Critical => "🔴 Critical",
887                Severity::Error => "🔴 Error",
888                Severity::Warning => "🟡 Warning",
889                Severity::Info => "🔵 Info",
890            };
891            let msg = issue.message.replace('|', "\\|");
892            let _ = writeln!(out, "| {} | `{}` | {} |", sev, issue.code, msg);
893        }
894        let _ = writeln!(out);
895    }
896
897    // Summary
898    let total_errors = v.critical.len() + v.errors.len();
899    if total_errors > 0 {
900        let _ = writeln!(
901            out,
902            "**Result: FAILED** — {} error(s), {} warning(s)",
903            total_errors,
904            v.warnings.len()
905        );
906    } else if !v.warnings.is_empty() {
907        let _ = writeln!(out, "**Result: VALID** — {} warning(s)", v.warnings.len());
908    } else {
909        let _ = writeln!(out, "**Result: VALID**");
910    }
911
912    out
913}
914
915fn format_csv(result: &ValidationResult) -> String {
916    use std::fmt::Write;
917    let mut out = String::new();
918
919    // Header
920    let _ = writeln!(out, "severity,code,message,cpl_id,suggestion");
921
922    // One row per issue
923    for issue in collect_all_issues(&result.validation) {
924        let cpl_id = issue
925            .location
926            .cpl_id
927            .as_ref()
928            .map(|u| u.to_string())
929            .unwrap_or_default();
930        let suggestion = issue.suggestion.as_deref().unwrap_or("");
931        let _ = writeln!(
932            out,
933            "{},{},\"{}\",{},\"{}\"",
934            match issue.severity {
935                Severity::Critical => "critical",
936                Severity::Error => "error",
937                Severity::Warning => "warning",
938                Severity::Info => "info",
939            },
940            issue.code,
941            issue.message.replace('"', "\"\""),
942            cpl_id,
943            suggestion.replace('"', "\"\""),
944        );
945    }
946
947    out
948}
949
950// ── Shared helpers for new formatter ────────────────────────────────────────
951
952fn collect_all_issues(v: &ValidationReport) -> Vec<&crate::diagnostics::ValidationIssue> {
953    v.critical
954        .iter()
955        .chain(v.errors.iter())
956        .chain(v.warnings.iter())
957        .chain(v.info.iter())
958        .collect()
959}
960
961fn format_issues_text(out: &mut String, v: &ValidationReport, color: bool) {
962    use std::fmt::Write;
963    let all_issues = collect_all_issues(v);
964    if all_issues.is_empty() {
965        return;
966    }
967
968    let _ = writeln!(out, "\n{}", c_bold("Validation findings:", color));
969    for issue in &all_issues {
970        let (label, colorize): (&str, fn(&str, bool) -> String) = match issue.severity {
971            Severity::Critical => ("error", c_red),
972            Severity::Error => ("error", c_red),
973            Severity::Warning => ("warning", c_yellow),
974            Severity::Info => ("info", c_cyan),
975        };
976        let location = if let Some(ref c) = issue.location.cpl_id {
977            let s = c.to_string();
978            let short_id = &s[..s.len().min(8)];
979            let detail = match (&issue.location.cpl_filename, &issue.location.cpl_title) {
980                (Some(fname), Some(title)) => {
981                    format!(" [CPL:{} {} {}]", short_id, fname, title)
982                }
983                (Some(fname), None) => format!(" [CPL:{} {}]", short_id, fname),
984                (None, Some(title)) => format!(" [CPL:{} {}]", short_id, title),
985                (None, None) => format!(" [CPL:{}]", short_id),
986            };
987            c_dim(&detail, color)
988        } else if let Some(ref f) = issue.location.file {
989            let fname = f.file_name().and_then(|n| n.to_str()).unwrap_or("?");
990            c_dim(&format!(" [{}]", fname), color)
991        } else {
992            String::new()
993        };
994        let _ = writeln!(
995            out,
996            "  {} {}{} {}",
997            colorize(&format!("{:<7}", label), color),
998            c_bold(&issue.code, color),
999            location,
1000            issue.message,
1001        );
1002        if let Some(ref s) = issue.suggestion {
1003            let _ = writeln!(out, "          {} {}", c_dim("→", color), c_dim(s, color));
1004        }
1005    }
1006}
1007
1008fn format_summary_text(out: &mut String, v: &ValidationReport, color: bool) {
1009    use std::fmt::Write;
1010    let total_errors = v.critical.len() + v.errors.len();
1011    let total_warnings = v.warnings.len();
1012    if total_errors > 0 {
1013        let mut reasons = Vec::new();
1014        reasons.push(format!("{} error(s)", total_errors));
1015        if total_warnings > 0 {
1016            reasons.push(format!("{} warning(s)", total_warnings));
1017        }
1018        let _ = writeln!(
1019            out,
1020            "\n{} {}",
1021            c_red("failed", color),
1022            c_bold(&reasons.join(", "), color)
1023        );
1024    } else if total_warnings > 0 {
1025        let _ = writeln!(
1026            out,
1027            "\n{}",
1028            c_yellow(&format!("valid  {} warning(s)", total_warnings), color)
1029        );
1030    } else {
1031        let _ = writeln!(out, "\n{}", c_green("valid", color));
1032    }
1033}
1034
1035// ── Media info helpers ──────────────────────────────────────────────────────
1036
1037struct VideoInfo {
1038    resolution: String,
1039    frame_rate: String,
1040    codec: String,
1041    dynamic_range: String,
1042    bit_depth: String,
1043}
1044
1045struct AudioInfo {
1046    format: String,
1047    sample_rate: String,
1048    bit_depth: String,
1049}
1050
1051fn video_info_from_descriptor(ed: &EssenceDescriptor) -> Option<VideoInfo> {
1052    // Try CDCI first (most common for YCbCr), then RGBA
1053    let (width, height, sample_rate, tc, codec, bit_depth, sub_descs) =
1054        if let Some(ref d) = ed.cdci_descriptor {
1055            (
1056                d.stored_width,
1057                d.stored_height,
1058                d.sample_rate.as_ref(),
1059                d.transfer_characteristic.as_ref(),
1060                d.picture_compression.as_ref(),
1061                d.component_depth,
1062                d.sub_descriptors.as_ref(),
1063            )
1064        } else if let Some(ref d) = ed.rgba_descriptor {
1065            (
1066                d.stored_width,
1067                d.stored_height,
1068                d.sample_rate.as_ref(),
1069                d.transfer_characteristic.as_ref(),
1070                d.picture_compression.as_ref(),
1071                None,
1072                d.sub_descriptors.as_ref(),
1073            )
1074        } else {
1075            return None;
1076        };
1077
1078    let resolution = match (width, height) {
1079        (Some(w), Some(h)) => format!("{}x{}", w, h),
1080        _ => "?".into(),
1081    };
1082
1083    let frame_rate = match sample_rate {
1084        Some(r) => {
1085            let fps = r.numerator as f64 / r.denominator as f64;
1086            if r.denominator == 1 {
1087                format!("{}fps", r.numerator)
1088            } else {
1089                format!("{:.2}fps", fps)
1090            }
1091        }
1092        None => "?".into(),
1093    };
1094
1095    use crate::cpl::types::TransferCharacteristic;
1096    let has_dolby_vision = sub_descs
1097        .map(|s| s.phdr_metadata_track_sub_descriptor.is_some())
1098        .unwrap_or(false);
1099    let dynamic_range = if has_dolby_vision {
1100        "Dolby Vision".into()
1101    } else {
1102        match tc {
1103            Some(TransferCharacteristic::PqSt2084) => "HDR10 (PQ)".into(),
1104            Some(TransferCharacteristic::Hlg) => "HLG".into(),
1105            Some(TransferCharacteristic::Bt709) => "SDR".into(),
1106            Some(TransferCharacteristic::Bt2020) => "SDR (BT.2020)".into(),
1107            Some(TransferCharacteristic::Linear) => "Linear".into(),
1108            Some(TransferCharacteristic::Smpte240M) => "SDR (240M)".into(),
1109            Some(TransferCharacteristic::XvYcc709) => "SDR (xvYCC)".into(),
1110            Some(TransferCharacteristic::Unknown(s)) => format!("Unknown ({})", s),
1111            None => "?".into(),
1112        }
1113    };
1114
1115    use crate::cpl::types::VideoCodec;
1116    let codec = match codec {
1117        Some(VideoCodec::Jpeg2000) => "JPEG 2000".into(),
1118        Some(VideoCodec::Jpeg2000Imf2k) => "JPEG 2000 (2K)".into(),
1119        Some(VideoCodec::Jpeg2000Imf4k) => "JPEG 2000 (4K)".into(),
1120        Some(VideoCodec::Jpeg2000Broadcast) => "JPEG 2000 (BCP)".into(),
1121        Some(VideoCodec::Jpeg2000Ht) => "JPEG 2000 HT".into(),
1122        Some(VideoCodec::Vc5) => "VC-5".into(),
1123        Some(VideoCodec::Mpeg2) => "MPEG-2".into(),
1124        Some(VideoCodec::H264) => "H.264".into(),
1125        Some(VideoCodec::H265) => "H.265".into(),
1126        Some(VideoCodec::ProRes) => "ProRes".into(),
1127        Some(VideoCodec::Av1) => "AV1".into(),
1128        Some(VideoCodec::Unknown(s)) => format!("Unknown ({})", s),
1129        None => "?".into(),
1130    };
1131
1132    let bit_depth = match bit_depth {
1133        Some(d) => format!("{}-bit", d),
1134        None => "?".into(),
1135    };
1136
1137    Some(VideoInfo {
1138        resolution,
1139        frame_rate,
1140        codec,
1141        dynamic_range,
1142        bit_depth,
1143    })
1144}
1145
1146fn audio_info_from_descriptor(ed: &EssenceDescriptor) -> Option<AudioInfo> {
1147    if let Some(ref _iab) = ed.iab_essence_descriptor {
1148        return Some(AudioInfo {
1149            format: "IAB (Dolby Atmos)".into(),
1150            sample_rate: "—".into(),
1151            bit_depth: "—".into(),
1152        });
1153    }
1154
1155    let d = ed.wave_pcm_descriptor.as_ref()?;
1156    let channel_count = d.channel_count.unwrap_or(0);
1157
1158    use crate::cpl::types::McaTagSymbol;
1159    let mca = d
1160        .sub_descriptors
1161        .as_ref()
1162        .and_then(|s| s.soundfield_group_label_sub_descriptor.as_ref())
1163        .and_then(|s| s.mca_tag_symbol.as_ref());
1164
1165    let format = match mca {
1166        Some(McaTagSymbol::Sg51) => "5.1 Surround".into(),
1167        Some(McaTagSymbol::Sg71) => "7.1 Surround".into(),
1168        Some(McaTagSymbol::Sg71Ds) => "7.1 Dolby Surround".into(),
1169        Some(McaTagSymbol::SgSt) => "Stereo".into(),
1170        Some(McaTagSymbol::SgMono) => "Mono".into(),
1171        Some(McaTagSymbol::Iab) => "IAB (Dolby Atmos)".into(),
1172        Some(McaTagSymbol::Other(s)) => s.clone(),
1173        _ => match channel_count {
1174            1 => "Mono".into(),
1175            2 => "Stereo".into(),
1176            6 => "5.1".into(),
1177            8 => "7.1".into(),
1178            n => format!("{}ch", n),
1179        },
1180    };
1181
1182    let sample_rate = match d.audio_sample_rate.as_ref().or(d.sample_rate.as_ref()) {
1183        Some(r) => {
1184            let hz = r.numerator as f64 / r.denominator as f64;
1185            if hz >= 1000.0 {
1186                format!("{:.1}kHz", hz / 1000.0)
1187            } else {
1188                format!("{}Hz", hz as u32)
1189            }
1190        }
1191        None => "?".into(),
1192    };
1193
1194    let bit_depth = match d.quantization_bits {
1195        Some(b) => format!("{}-bit", b),
1196        None => "?".into(),
1197    };
1198
1199    Some(AudioInfo {
1200        format,
1201        sample_rate,
1202        bit_depth,
1203    })
1204}
1205
1206/// Look up the essence descriptor for a sequence from the CPL.
1207fn descriptor_lookup<'a>(
1208    seq: &dyn SequenceAccess,
1209    cpl: &'a crate::cpl::CompositionPlaylist,
1210) -> Option<&'a EssenceDescriptor> {
1211    let se = seq
1212        .resource_list()
1213        .resources
1214        .first()?
1215        .source_encoding
1216        .as_ref()?;
1217    let edl = cpl.essence_descriptor_list.as_ref()?;
1218    edl.essence_descriptors.iter().find(|e| &e.id == se)
1219}
1220
1221/// Look up language for a sequence from the CPL's essence descriptor list.
1222fn language_from_descriptor_lookup(
1223    seq: &dyn SequenceAccess,
1224    cpl: &crate::cpl::CompositionPlaylist,
1225) -> Option<String> {
1226    let se = seq
1227        .resource_list()
1228        .resources
1229        .first()?
1230        .source_encoding
1231        .as_ref()?;
1232    let edl = cpl.essence_descriptor_list.as_ref()?;
1233    let ed = edl.essence_descriptors.iter().find(|e| &e.id == se)?;
1234    language_from_descriptor(ed)
1235}