Skip to main content

imferno_core/assetmap/
mod.rs

1//! SMPTE ST 2067-2 Core Constraints — AssetMap, PKL, and foundational IMF types.
2//!
3//! This module covers:
4//! - Foundational primitives: [`ImfUuid`], [`SmpteUl`], [`ImfTypeError`]
5//! - PKL types: [`AssetHash`], [`HashAlgorithm`], [`MimeType`]
6//! - Namespace detection: [`AssetMapNamespace`], [`PklNamespace`], [`CoreConstraintsNamespace`]
7//! - Document parsers: [`parse_assetmap`], [`parse_pkl`], [`parse_opl`]
8//! - Re-exports from [`volindex`]: [`VolumeIndex`], [`parse_volindex`]
9
10pub mod codes;
11pub mod volindex;
12pub mod volindex_codes;
13
14// Re-export VOLINDEX types
15pub use volindex::{parse_volindex, VolindexError, VolumeIndex};
16
17use base64::Engine;
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20use uuid::Uuid;
21
22// ─── Error ────────────────────────────────────────────────────────────────────
23
24#[derive(Debug, Error, PartialEq)]
25pub enum ImfTypeError {
26    #[error("Invalid UUID '{0}': expected urn:uuid:<uuid> or bare UUID")]
27    InvalidUuid(String),
28    #[error("Invalid edit rate '{0}': expected 'numerator denominator'")]
29    InvalidEditRate(String),
30    #[error("Invalid hash: {0}")]
31    InvalidHash(String),
32    #[error("Invalid language tag '{0}': must be non-empty")]
33    InvalidLanguageTag(String),
34    #[error("Invalid SMPTE UL '{0}': expected 16 hex bytes in dotted groups")]
35    InvalidUl(String),
36}
37
38// ─── SmpteUl ─────────────────────────────────────────────────────────────────
39
40/// A SMPTE Universal Label — 16-byte identifier per ST 336M.
41///
42/// Byte layout:
43/// ```text
44/// Bytes 1-4:  Object Identifier (always 06.0E.2B.34)
45/// Byte  5:    Category designator
46/// Byte  6:    Registry designator
47/// Byte  7:    Structure designator
48/// Byte  8:    Version number  ← MUST BE IGNORED for comparison (ST 298M)
49/// Bytes 9-16: Item-specific identification
50/// ```
51///
52/// Per ST 298M, byte 8 (the registry version number) is masked when comparing
53/// ULs for semantic identity. Two ULs that differ only in byte 8 are the same item.
54#[derive(Debug, Clone, Copy)]
55pub struct SmpteUl(pub [u8; 16]);
56
57impl SmpteUl {
58    /// Parse a UL from string form.
59    ///
60    /// Accepted formats:
61    /// - `urn:smpte:ul:060e2b34.04010106.04010101.03030000` (4 groups of 4 bytes)
62    /// - `060e2b34.04010106.04010101.03030000` (bare 4-group form)
63    /// - `060e2b34.0401.0106.04010101.03030000` (5-group variant from some test data)
64    pub fn parse(s: &str) -> Result<Self, ImfTypeError> {
65        let hex_part = s.strip_prefix("urn:smpte:ul:").unwrap_or(s).trim();
66
67        // Remove dots to get a contiguous hex string
68        let hex_str: String = hex_part.chars().filter(|c| *c != '.').collect();
69
70        if hex_str.len() != 32 {
71            return Err(ImfTypeError::InvalidUl(s.to_string()));
72        }
73
74        let mut bytes = [0u8; 16];
75        for i in 0..16 {
76            bytes[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16)
77                .map_err(|_| ImfTypeError::InvalidUl(s.to_string()))?;
78        }
79
80        Ok(SmpteUl(bytes))
81    }
82
83    /// Compare two ULs ignoring byte 8 (index 7) — the registry version number.
84    ///
85    /// Per ST 298M, the version byte must be masked for semantic comparison.
86    pub fn matches_ignoring_version(&self, other: &SmpteUl) -> bool {
87        for i in 0..16 {
88            if i == 7 {
89                continue; // skip version byte
90            }
91            if self.0[i] != other.0[i] {
92                return false;
93            }
94        }
95        true
96    }
97
98    /// Return the UL with byte 8 zeroed for use as a canonical match key.
99    pub fn normalized(&self) -> Self {
100        let mut bytes = self.0;
101        bytes[7] = 0;
102        SmpteUl(bytes)
103    }
104
105    /// The discriminating bytes (bytes 9-16) that identify the specific item.
106    pub fn item_bytes(&self) -> &[u8] {
107        &self.0[8..]
108    }
109}
110
111impl PartialEq for SmpteUl {
112    /// Equality comparison ignores byte 8 (version), per ST 298M.
113    fn eq(&self, other: &Self) -> bool {
114        self.matches_ignoring_version(other)
115    }
116}
117
118impl Eq for SmpteUl {}
119
120impl std::hash::Hash for SmpteUl {
121    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
122        // Hash with byte 8 zeroed so equal items hash identically
123        let norm = self.normalized();
124        norm.0.hash(state);
125    }
126}
127
128impl std::fmt::Display for SmpteUl {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        write!(
131            f,
132            "urn:smpte:ul:{:02x}{:02x}{:02x}{:02x}.{:02x}{:02x}{:02x}{:02x}.{:02x}{:02x}{:02x}{:02x}.{:02x}{:02x}{:02x}{:02x}",
133            self.0[0], self.0[1], self.0[2], self.0[3],
134            self.0[4], self.0[5], self.0[6], self.0[7],
135            self.0[8], self.0[9], self.0[10], self.0[11],
136            self.0[12], self.0[13], self.0[14], self.0[15],
137        )
138    }
139}
140
141// ─── ImfUuid ──────────────────────────────────────────────────────────────────
142
143/// A SMPTE IMF UUID.
144///
145/// In XML documents UUIDs appear as `urn:uuid:<uuid>`. In JSON/WASM output
146/// they serialise as bare UUID strings (`"0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85"`).
147/// Deserialization accepts both forms.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
149#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
150#[cfg_attr(feature = "typescript", ts(type = "string"))]
151pub struct ImfUuid(pub Uuid);
152
153impl ImfUuid {
154    /// Parse from `urn:uuid:...` or a bare UUID string.
155    ///
156    /// Lenient: accepts both forms. Useful for callers that get UUIDs
157    /// from non-XML sources (JSON APIs, manual construction, test
158    /// fixtures) where the URN prefix may legitimately be absent.
159    ///
160    /// XML deserialization should prefer [`parse_urn`](Self::parse_urn)
161    /// since the SMPTE dcml:UUIDType XSD pattern explicitly requires
162    /// the `urn:uuid:` prefix.
163    pub fn parse(s: &str) -> Result<Self, ImfTypeError> {
164        let bare = s.strip_prefix("urn:uuid:").unwrap_or(s);
165        Uuid::parse_str(bare)
166            .map(ImfUuid)
167            .map_err(|_| ImfTypeError::InvalidUuid(s.to_string()))
168    }
169
170    /// Strict parse: requires the `urn:uuid:` prefix per
171    /// SMPTE ST 433 dcml:UUIDType (xs:anyURI restricted to
172    /// `urn:uuid:[hex]{8}-[hex]{4}-[hex]{4}-[hex]{4}-[hex]{12}`).
173    ///
174    /// Used by the XML deserializer so CPL/PKL/SCM instances with bare
175    /// UUIDs (lacking the URN prefix) fail at parse time — matching
176    /// what an XSD-strict validator would catch.
177    pub fn parse_urn(s: &str) -> Result<Self, ImfTypeError> {
178        let bare = s
179            .strip_prefix("urn:uuid:")
180            .ok_or_else(|| ImfTypeError::InvalidUuid(s.to_string()))?;
181        Uuid::parse_str(bare)
182            .map(ImfUuid)
183            .map_err(|_| ImfTypeError::InvalidUuid(s.to_string()))
184    }
185
186    /// Return the URN form used in XML: `urn:uuid:<uuid>`.
187    pub fn to_urn(&self) -> String {
188        format!("urn:uuid:{}", self.0)
189    }
190}
191
192impl std::fmt::Display for ImfUuid {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        self.0.fmt(f)
195    }
196}
197
198impl Serialize for ImfUuid {
199    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
200        s.serialize_str(&self.0.to_string())
201    }
202}
203
204impl<'de> Deserialize<'de> for ImfUuid {
205    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
206        let s = String::deserialize(d)?;
207        // Lenient: accepts both `urn:uuid:...` and bare UUIDs.
208        //
209        // XSD-strict UUID validation (rejecting bare UUIDs in CPL/PKL/SCM
210        // XML per SMPTE dcml:UUIDType) cannot happen here without also
211        // rejecting bare UUIDs in JSON wire format — same `Deserialize`
212        // impl handles both formats, and JSON intentionally uses bare
213        // UUIDs (see `uuid_roundtrip_serde` test). Making this strict
214        // would break the JSON API contract.
215        //
216        // The runtime XSD validator (`crate::xsd`) is the right layer
217        // for XSD-strict UUID checking. See the documented uppsala
218        // v0.4.0 limitation for the current gap.
219        ImfUuid::parse(&s).map_err(serde::de::Error::custom)
220    }
221}
222
223#[cfg(feature = "jsonschema")]
224impl schemars::JsonSchema for ImfUuid {
225    fn schema_name() -> String {
226        "ImfUuid".to_owned()
227    }
228
229    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
230        let mut schema = gen.subschema_for::<String>().into_object();
231        schema.metadata().description = Some(
232            "A SMPTE IMF UUID, serialised as a bare UUID string (e.g. \"0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85\")".to_owned()
233        );
234        schema.format = Some("uuid".to_owned());
235        schema.into()
236    }
237}
238
239// ─── AssetHash ────────────────────────────────────────────────────────────────
240
241/// A decoded asset hash from a Packing List, per SMPTE ST 2067-2 §9.
242///
243/// PKL files carry base64-encoded SHA-1 or SHA-256 digests for each tracked asset.
244#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct AssetHash {
247    algorithm: HashAlgorithm,
248    bytes: Vec<u8>,
249}
250
251/// The hash algorithm used for a PKL asset digest.
252///
253/// Per SMPTE ST 2067-2:2020 §9, SHA-1 is the default algorithm.
254/// SHA-256 is supported via the `<HashAlgorithm>` element using
255/// XML Digital Signature algorithm URIs.
256#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
258pub enum HashAlgorithm {
259    /// SHA-1 (default per ST 2067-2 §9).
260    /// URI: `http://www.w3.org/2000/09/xmldsig#sha1`
261    Sha1,
262    /// SHA-256.
263    /// URI: `http://www.w3.org/2001/04/xmlenc#sha256`
264    Sha256,
265}
266
267impl HashAlgorithm {
268    /// Parse a hash algorithm from an XML Digital Signature algorithm URI.
269    ///
270    /// Per ST 2067-2:2020 §9, the `<HashAlgorithm>` element uses
271    /// `ds:DigestMethodType` which carries an `Algorithm` attribute URI.
272    pub fn from_uri(uri: &str) -> Option<Self> {
273        match uri.trim() {
274            "http://www.w3.org/2000/09/xmldsig#sha1" => Some(Self::Sha1),
275            "http://www.w3.org/2001/04/xmlenc#sha256" => Some(Self::Sha256),
276            _ => None,
277        }
278    }
279
280    /// Expected digest length in bytes for this algorithm.
281    pub fn digest_len(&self) -> usize {
282        match self {
283            Self::Sha1 => 20,
284            Self::Sha256 => 32,
285        }
286    }
287}
288
289impl std::fmt::Display for HashAlgorithm {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        match self {
292            Self::Sha1 => write!(f, "SHA-1"),
293            Self::Sha256 => write!(f, "SHA-256"),
294        }
295    }
296}
297
298impl AssetHash {
299    /// Return the hash algorithm (SHA-1 or SHA-256).
300    pub fn algorithm(&self) -> HashAlgorithm {
301        self.algorithm
302    }
303
304    /// Return the raw digest bytes.
305    pub fn bytes(&self) -> &[u8] {
306        &self.bytes
307    }
308
309    /// Decode a base64-encoded SHA-1 digest as found in PKL `<Hash>` elements.
310    ///
311    /// Per SMPTE ST 2067-2 §9, SHA-1 produces a 20-byte digest.
312    pub fn from_base64_sha1(b64: &str) -> Result<Self, ImfTypeError> {
313        let bytes = base64::engine::general_purpose::STANDARD
314            .decode(b64)
315            .map_err(|e| ImfTypeError::InvalidHash(e.to_string()))?;
316        if bytes.len() != 20 {
317            return Err(ImfTypeError::InvalidHash(format!(
318                "SHA-1 digest must be 20 bytes, got {}",
319                bytes.len()
320            )));
321        }
322        Ok(Self {
323            algorithm: HashAlgorithm::Sha1,
324            bytes,
325        })
326    }
327
328    /// Decode a base64-encoded SHA-256 digest.
329    ///
330    /// SHA-256 produces a 32-byte digest.
331    pub fn from_base64_sha256(b64: &str) -> Result<Self, ImfTypeError> {
332        let bytes = base64::engine::general_purpose::STANDARD
333            .decode(b64)
334            .map_err(|e| ImfTypeError::InvalidHash(e.to_string()))?;
335        if bytes.len() != 32 {
336            return Err(ImfTypeError::InvalidHash(format!(
337                "SHA-256 digest must be 32 bytes, got {}",
338                bytes.len()
339            )));
340        }
341        Ok(Self {
342            algorithm: HashAlgorithm::Sha256,
343            bytes,
344        })
345    }
346
347    /// Decode a base64-encoded digest for the given algorithm.
348    pub fn from_base64(b64: &str, algorithm: HashAlgorithm) -> Result<Self, ImfTypeError> {
349        match algorithm {
350            HashAlgorithm::Sha1 => Self::from_base64_sha1(b64),
351            HashAlgorithm::Sha256 => Self::from_base64_sha256(b64),
352        }
353    }
354
355    /// Encode the hash bytes as base64, as used in PKL XML.
356    pub fn to_base64(&self) -> String {
357        base64::engine::general_purpose::STANDARD.encode(&self.bytes)
358    }
359}
360
361// ─── MimeType ─────────────────────────────────────────────────────────────────
362
363/// MIME type as used in `<Type>` elements in PKL assets (SMPTE ST 2067-2 §9).
364#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366pub enum MimeType {
367    /// `text/xml` — CPL and other XML documents
368    TextXml,
369    /// `application/xml` — alternative XML MIME type
370    ApplicationXml,
371    /// `application/mxf` — MXF essence files
372    ApplicationMxf,
373    /// Unrecognised; the original string is preserved.
374    Other(String),
375}
376
377impl MimeType {
378    pub fn parse(s: &str) -> Self {
379        match s.trim() {
380            "text/xml" => Self::TextXml,
381            "application/xml" => Self::ApplicationXml,
382            "application/mxf" => Self::ApplicationMxf,
383            other => Self::Other(other.to_string()),
384        }
385    }
386
387    pub fn is_xml(&self) -> bool {
388        matches!(self, Self::TextXml | Self::ApplicationXml)
389    }
390
391    pub fn is_mxf(&self) -> bool {
392        matches!(self, Self::ApplicationMxf)
393    }
394}
395
396impl std::fmt::Display for MimeType {
397    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398        match self {
399            Self::TextXml => write!(f, "text/xml"),
400            Self::ApplicationXml => write!(f, "application/xml"),
401            Self::ApplicationMxf => write!(f, "application/mxf"),
402            Self::Other(s) => write!(f, "{}", s),
403        }
404    }
405}
406
407// ─── AssetMapNamespace ────────────────────────────────────────────────────────
408
409/// The detected SMPTE spec version of an AssetMap document, derived from its root xmlns.
410#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
411#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
412pub enum AssetMapNamespace {
413    /// DCI era — `http://www.smpte-ra.org/schemas/429-9/2007/AM`
414    #[default]
415    Dci429_9,
416    /// SMPTE ST 2067-9:2016 — `http://www.smpte-ra.org/schemas/2067-9/2016`
417    ///
418    /// ST 2067-9 has only the 2016 edition published; there is no
419    /// 2020 successor. The SCM extension (ST 2067-9:2018) is a
420    /// separate document tracked by `scm::ScmNamespace`.
421    Smpte2067_9_2016,
422    /// Unrecognised namespace; the original URI is preserved.
423    Unknown(String),
424}
425
426impl AssetMapNamespace {
427    /// Detect AssetMap spec version from a namespace URI.
428    pub fn from_uri(uri: &str) -> Self {
429        match uri.trim() {
430            "http://www.smpte-ra.org/schemas/429-9/2007/AM" => Self::Dci429_9,
431            "http://www.smpte-ra.org/schemas/2067-9/2016" => Self::Smpte2067_9_2016,
432            other => Self::Unknown(other.to_string()),
433        }
434    }
435
436    /// Returns the normative spec document identifier.
437    pub fn spec_id(&self) -> &str {
438        match self {
439            Self::Dci429_9 => "ST 429-9:2007",
440            Self::Smpte2067_9_2016 => "ST 2067-9:2016",
441            Self::Unknown(_) => "Unknown",
442        }
443    }
444}
445
446impl std::fmt::Display for AssetMapNamespace {
447    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448        match self {
449            Self::Dci429_9 => write!(f, "http://www.smpte-ra.org/schemas/429-9/2007/AM"),
450            Self::Smpte2067_9_2016 => write!(f, "http://www.smpte-ra.org/schemas/2067-9/2016"),
451            Self::Unknown(s) => write!(f, "{}", s),
452        }
453    }
454}
455
456// ─── PklNamespace ─────────────────────────────────────────────────────────────
457
458/// The detected SMPTE spec version of a PKL document, derived from its root xmlns.
459///
460/// PKL schema evolved across three eras: DCI 429-8, IMF 2067-2 (2013-2016), and 2020.
461#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
462#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
463pub enum PklNamespace {
464    /// DCI era — `http://www.smpte-ra.org/schemas/429-8/2007/PKL`
465    #[default]
466    Dci429_8,
467    /// SMPTE ST 2067-2:2013 — `http://www.smpte-ra.org/schemas/2067-2/2013`
468    Smpte2067_2_2013,
469    /// SMPTE ST 2067-2:2016 — `http://www.smpte-ra.org/schemas/2067-2/2016`
470    Smpte2067_2_2016,
471    /// SMPTE ST 2067-2:2016 (PKL variant) — `http://www.smpte-ra.org/schemas/2067-2/2016/PKL`
472    Smpte2067_2_2016Pkl,
473    /// SMPTE ST 2067-2:2020 — `http://www.smpte-ra.org/ns/2067-2/2020`
474    Smpte2067_2_2020,
475    /// Unrecognised namespace; the original URI is preserved.
476    Unknown(String),
477}
478
479impl PklNamespace {
480    /// Detect PKL spec version from a namespace URI.
481    pub fn from_uri(uri: &str) -> Self {
482        match uri.trim() {
483            "http://www.smpte-ra.org/schemas/429-8/2007/PKL" => Self::Dci429_8,
484            "http://www.smpte-ra.org/schemas/2067-2/2013" => Self::Smpte2067_2_2013,
485            "http://www.smpte-ra.org/schemas/2067-2/2016" => Self::Smpte2067_2_2016,
486            "http://www.smpte-ra.org/schemas/2067-2/2016/PKL" => Self::Smpte2067_2_2016Pkl,
487            "http://www.smpte-ra.org/ns/2067-2/2020" => Self::Smpte2067_2_2020,
488            other => Self::Unknown(other.to_string()),
489        }
490    }
491
492    /// Returns the normative spec document identifier.
493    pub fn spec_id(&self) -> &str {
494        match self {
495            Self::Dci429_8 => "ST 429-8:2007",
496            Self::Smpte2067_2_2013 => "ST 2067-2:2013",
497            Self::Smpte2067_2_2016 | Self::Smpte2067_2_2016Pkl => "ST 2067-2:2016",
498            Self::Smpte2067_2_2020 => "ST 2067-2:2020",
499            Self::Unknown(_) => "Unknown",
500        }
501    }
502}
503
504impl std::fmt::Display for PklNamespace {
505    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
506        match self {
507            Self::Dci429_8 => write!(f, "http://www.smpte-ra.org/schemas/429-8/2007/PKL"),
508            Self::Smpte2067_2_2013 => write!(f, "http://www.smpte-ra.org/schemas/2067-2/2013"),
509            Self::Smpte2067_2_2016 => write!(f, "http://www.smpte-ra.org/schemas/2067-2/2016"),
510            Self::Smpte2067_2_2016Pkl => {
511                write!(f, "http://www.smpte-ra.org/schemas/2067-2/2016/PKL")
512            }
513            Self::Smpte2067_2_2020 => write!(f, "http://www.smpte-ra.org/ns/2067-2/2020"),
514            Self::Unknown(s) => write!(f, "{}", s),
515        }
516    }
517}
518
519// ─── CoreConstraintsNamespace ─────────────────────────────────────────────────
520
521/// The detected SMPTE core constraints spec version, from inner xmlns declarations in CPLs.
522///
523/// CPL documents reference core constraints namespaces for elements defined in ST 2067-2.
524/// This is distinct from the CPL namespace (ST 2067-3) and determines which core constraint
525/// rules apply.
526#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
527#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
528pub enum CoreConstraintsNamespace {
529    /// SMPTE ST 2067-2:2013 — `http://www.smpte-ra.org/schemas/2067-2/2013`
530    Smpte2067_2_2013,
531    /// SMPTE ST 2067-2:2016 — `http://www.smpte-ra.org/schemas/2067-2/2016`
532    #[default]
533    Smpte2067_2_2016,
534    /// SMPTE ST 2067-2:2020 — `http://www.smpte-ra.org/ns/2067-2/2020`
535    Smpte2067_2_2020,
536    /// Unrecognised namespace; the original URI is preserved.
537    Unknown(String),
538}
539
540impl CoreConstraintsNamespace {
541    /// Detect core constraints spec version from a namespace URI.
542    pub fn from_uri(uri: &str) -> Self {
543        match uri.trim() {
544            "http://www.smpte-ra.org/schemas/2067-2/2013" => Self::Smpte2067_2_2013,
545            "http://www.smpte-ra.org/schemas/2067-2/2016" => Self::Smpte2067_2_2016,
546            "http://www.smpte-ra.org/ns/2067-2/2020" => Self::Smpte2067_2_2020,
547            other => Self::Unknown(other.to_string()),
548        }
549    }
550
551    /// Returns the normative spec document identifier.
552    pub fn spec_id(&self) -> &str {
553        match self {
554            Self::Smpte2067_2_2013 => "ST 2067-2:2013",
555            Self::Smpte2067_2_2016 => "ST 2067-2:2016",
556            Self::Smpte2067_2_2020 => "ST 2067-2:2020",
557            Self::Unknown(_) => "Unknown",
558        }
559    }
560}
561
562impl std::fmt::Display for CoreConstraintsNamespace {
563    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
564        match self {
565            Self::Smpte2067_2_2013 => write!(f, "http://www.smpte-ra.org/schemas/2067-2/2013"),
566            Self::Smpte2067_2_2016 => write!(f, "http://www.smpte-ra.org/schemas/2067-2/2016"),
567            Self::Smpte2067_2_2020 => write!(f, "http://www.smpte-ra.org/ns/2067-2/2020"),
568            Self::Unknown(s) => write!(f, "{}", s),
569        }
570    }
571}
572
573// ─── detect_root_namespace ────────────────────────────────────────────────────
574
575/// Extract the default namespace URI from an XML document's root element.
576///
577/// Searches for the first `xmlns="..."` (non-prefixed) declaration. This is used
578/// by parsers to detect which spec version a document conforms to.
579pub fn detect_root_namespace(xml: &str) -> Option<String> {
580    use std::sync::LazyLock;
581    // Match xmlns="..." but NOT xmlns:prefix="..."
582    // We look for xmlns= preceded by whitespace (not by a colon)
583    static RE_XMLNS: LazyLock<regex::Regex> =
584        LazyLock::new(|| regex::Regex::new(r#"(?:^|[\s<])xmlns="([^"]*)""#).unwrap());
585    RE_XMLNS.captures(xml).map(|cap| cap[1].to_string())
586}
587
588// ─── Parse error ──────────────────────────────────────────────────────────────
589
590/// Errors that can occur when parsing an AssetMap, PKL, OPL, or VOLINDEX.
591#[derive(Debug, Error)]
592pub enum AssetMapParseError {
593    /// The XML is structurally invalid or missing required elements.
594    #[error("XML parse error: {0}")]
595    Xml(#[from] quick_xml::DeError),
596    /// A required field contains an invalid value (bad UUID, bad hash, etc.).
597    #[error("Invalid field '{field}': {source}")]
598    Field {
599        field: &'static str,
600        #[source]
601        source: ImfTypeError,
602    },
603}
604
605// ─── Private raw deserialization layer ────────────────────────────────────────
606
607mod raw {
608    use serde::Deserialize;
609
610    fn default_volume_index() -> u32 {
611        1
612    }
613
614    #[derive(Deserialize)]
615    pub struct AssetMap {
616        #[serde(rename = "Id")]
617        pub id: String,
618        #[serde(rename = "AnnotationText", default)]
619        pub annotation_text: Option<String>,
620        #[serde(rename = "Creator", default)]
621        pub creator: Option<String>,
622        #[serde(rename = "VolumeCount")]
623        pub volume_count: u32,
624        #[serde(rename = "IssueDate")]
625        pub issue_date: String,
626        #[serde(rename = "Issuer", default)]
627        pub issuer: Option<String>,
628        #[serde(rename = "AssetList")]
629        pub asset_list: AssetList,
630    }
631
632    #[derive(Deserialize)]
633    pub struct AssetList {
634        #[serde(rename = "Asset")]
635        pub assets: Vec<Asset>,
636    }
637
638    #[derive(Deserialize)]
639    pub struct Asset {
640        #[serde(rename = "Id")]
641        pub id: String,
642        #[serde(rename = "PackingList", default)]
643        pub packing_list: Option<bool>,
644        #[serde(rename = "ChunkList")]
645        pub chunk_list: ChunkList,
646    }
647
648    #[derive(Deserialize)]
649    pub struct ChunkList {
650        #[serde(rename = "Chunk")]
651        pub chunks: Vec<Chunk>,
652    }
653
654    #[derive(Deserialize)]
655    pub struct Chunk {
656        #[serde(rename = "Path")]
657        pub path: String,
658        #[serde(rename = "VolumeIndex", default = "default_volume_index")]
659        pub volume_index: u32,
660    }
661
662    // ── OPL (ST 2067-100) ──────────────────────────────────────────────────
663
664    #[derive(Deserialize)]
665    pub struct OutputProfileList {
666        #[serde(rename = "Id")]
667        pub id: String,
668        #[serde(rename = "Annotation", default)]
669        pub annotation: Option<String>,
670        #[serde(rename = "IssueDate")]
671        pub issue_date: String,
672        #[serde(rename = "Issuer", default)]
673        pub issuer: Option<String>,
674        #[serde(rename = "Creator", default)]
675        pub creator: Option<String>,
676        #[serde(rename = "CompositionPlaylistId")]
677        pub composition_playlist_id: String,
678    }
679
680    // ── PKL ─────────────────────────────────────────────────────────────────
681
682    #[derive(Deserialize)]
683    pub struct PackingList {
684        #[serde(rename = "Id")]
685        pub id: String,
686        #[serde(rename = "AnnotationText", default)]
687        pub annotation_text: Option<String>,
688        #[serde(rename = "IssueDate")]
689        pub issue_date: String,
690        #[serde(rename = "Issuer", default)]
691        pub issuer: Option<String>,
692        #[serde(rename = "Creator", default)]
693        pub creator: Option<String>,
694        /// SMPTE ST 2067-2 §9: Optional group identifier for partial deliveries.
695        #[serde(rename = "GroupId", default)]
696        pub group_id: Option<String>,
697        #[serde(rename = "AssetList")]
698        pub asset_list: PklAssetList,
699    }
700
701    #[derive(Deserialize)]
702    pub struct PklAssetList {
703        #[serde(rename = "Asset")]
704        pub assets: Vec<PklAsset>,
705    }
706
707    /// `ds:DigestMethodType` — carries an `Algorithm` attribute URI.
708    /// Used in `<HashAlgorithm Algorithm="..."/>` per SMPTE ST 2067-2 §9.
709    #[derive(Deserialize)]
710    pub struct DigestMethod {
711        #[serde(rename = "@Algorithm")]
712        pub algorithm: String,
713    }
714
715    #[derive(Deserialize)]
716    pub struct PklAsset {
717        #[serde(rename = "Id")]
718        pub id: String,
719        #[serde(rename = "AnnotationText", default)]
720        pub annotation_text: Option<String>,
721        #[serde(rename = "Hash")]
722        pub hash: String,
723        #[serde(rename = "Size")]
724        pub size: u64,
725        #[serde(rename = "Type")]
726        pub mime_type: String,
727        #[serde(rename = "OriginalFileName", default)]
728        pub original_file_name: Option<String>,
729        /// SMPTE ST 2067-2 §9: Optional hash algorithm override.
730        /// When absent, SHA-1 is assumed (default per spec).
731        #[serde(rename = "HashAlgorithm", default)]
732        pub hash_algorithm: Option<DigestMethod>,
733    }
734}
735
736// ─── Public domain types ───────────────────────────────────────────────────────
737
738// VolumeIndex lives in the volindex submodule and is re-exported at the top of this file.
739
740/// ASSETMAP.xml — maps UUIDs to physical file paths (ST 429-9 §6).
741#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
742#[derive(Debug, Serialize, Deserialize, PartialEq)]
743#[serde(rename_all = "camelCase")]
744#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
745#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
746#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
747#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
748pub struct AssetMap {
749    /// The SMPTE spec version detected from the root xmlns.
750    #[serde(skip)]
751    pub namespace: AssetMapNamespace,
752    /// Unique identifier for this AssetMap (ST 429-9 §6.2).
753    pub id: ImfUuid,
754    pub annotation_text: Option<String>,
755    pub creator: Option<String>,
756    pub volume_count: u32,
757    /// ISO 8601 issue date (e.g. `"2016-10-06T08:35:02-00:00"`).
758    pub issue_date: String,
759    pub issuer: Option<String>,
760    pub asset_list: AssetList,
761}
762
763impl AssetMap {
764    fn from_raw(
765        raw: raw::AssetMap,
766        namespace: AssetMapNamespace,
767    ) -> Result<Self, AssetMapParseError> {
768        Ok(Self {
769            namespace,
770            id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
771                field: "Id",
772                source,
773            })?,
774            annotation_text: raw.annotation_text,
775            creator: raw.creator,
776            volume_count: raw.volume_count,
777            issue_date: raw.issue_date,
778            issuer: raw.issuer,
779            asset_list: AssetList::from_raw(raw.asset_list)?,
780        })
781    }
782}
783
784/// The `<AssetList>` element in ASSETMAP.xml.
785#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
786#[derive(Debug, Serialize, Deserialize, PartialEq)]
787#[serde(rename_all = "camelCase")]
788#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
789#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
790#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
791#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
792pub struct AssetList {
793    pub assets: Vec<Asset>,
794}
795
796impl AssetList {
797    fn from_raw(raw: raw::AssetList) -> Result<Self, AssetMapParseError> {
798        let assets = raw
799            .assets
800            .into_iter()
801            .map(Asset::from_raw)
802            .collect::<Result<Vec<_>, _>>()?;
803        Ok(Self { assets })
804    }
805}
806
807/// A single asset entry in ASSETMAP.xml.
808#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
809#[derive(Debug, Serialize, Deserialize, PartialEq)]
810#[serde(rename_all = "camelCase")]
811#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
812#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
813#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
814#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
815pub struct Asset {
816    /// UUID identifying this asset.
817    pub id: ImfUuid,
818    /// Present and `true` when this entry refers to the Packing List file.
819    pub packing_list: Option<bool>,
820    pub chunk_list: ChunkList,
821}
822
823impl Asset {
824    fn from_raw(raw: raw::Asset) -> Result<Self, AssetMapParseError> {
825        Ok(Self {
826            id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
827                field: "Id",
828                source,
829            })?,
830            packing_list: raw.packing_list,
831            chunk_list: ChunkList::from_raw(raw.chunk_list),
832        })
833    }
834}
835
836/// A list of file chunks for a single asset.
837#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
838#[derive(Debug, Serialize, Deserialize, PartialEq)]
839#[serde(rename_all = "camelCase")]
840#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
841#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
842#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
843#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
844pub struct ChunkList {
845    pub chunks: Vec<Chunk>,
846}
847
848impl ChunkList {
849    fn from_raw(raw: raw::ChunkList) -> Self {
850        Self {
851            chunks: raw
852                .chunks
853                .into_iter()
854                .map(|c| Chunk {
855                    path: c.path,
856                    volume_index: c.volume_index,
857                })
858                .collect(),
859        }
860    }
861}
862
863/// A single file path entry in a ChunkList.
864#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
865#[derive(Debug, Serialize, Deserialize, PartialEq)]
866#[serde(rename_all = "camelCase")]
867#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
868#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
869#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
870#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
871pub struct Chunk {
872    /// File path relative to the IMP root directory.
873    pub path: String,
874    pub volume_index: u32,
875}
876
877/// OPL XML — Output Profile List (SMPTE ST 2067-100).
878///
879/// Defines output processing instructions for a composition: image scaling,
880/// cropping, pixel encoding, and audio routing/mixing macros.
881///
882/// `macros` carries every `<Macro xsi:type="...">` entry from the
883/// `<MacroList>`. The list is structurally extracted via a small
884/// `quick_xml` walker rather than serde because the XSD uses
885/// `xsi:type` polymorphism with vendor-specific extension namespaces
886/// (`opl:PresetMacroType`, `arm:AudioRoutingMixingMacroType`, etc.).
887/// Type-specific payload fields land in [`OplMacro::extra_fields`]
888/// as `(local_name, text)` pairs so the parser doesn't have to know
889/// every macro subtype to round-trip the list.
890#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
891#[derive(Debug, Serialize, Deserialize, PartialEq)]
892#[serde(rename_all = "camelCase")]
893pub struct OutputProfileList {
894    pub id: ImfUuid,
895    pub annotation: Option<String>,
896    /// ISO 8601 issue date.
897    pub issue_date: String,
898    pub issuer: Option<String>,
899    pub creator: Option<String>,
900    /// The CPL that this OPL targets.
901    pub composition_playlist_id: ImfUuid,
902    /// Each `<Macro xsi:type="...">` entry from the `<MacroList>`.
903    /// Empty when the OPL has no macros (or has `<MacroList/>`).
904    #[serde(default)]
905    pub macros: Vec<OplMacro>,
906}
907
908/// A single macro entry from an OPL `<MacroList>`.
909///
910/// Captures the polymorphic `xsi:type` attribute plus the common
911/// `Name` / `Annotation` fields from the abstract `MacroType`. All
912/// other children of the macro are stored as `(local_name, text)`
913/// pairs in [`Self::extra_fields`] so that vendor-specific extensions
914/// remain accessible without the parser knowing each subtype.
915#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
916#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
917#[serde(rename_all = "camelCase")]
918pub struct OplMacro {
919    /// Value of the `xsi:type` attribute, e.g. `"opl:PresetMacroType"`.
920    /// `None` when the writer omitted the attribute (non-conformant,
921    /// but we don't reject — the validator can flag it).
922    pub xsi_type: Option<String>,
923    /// `MacroType.Name` per ST 2067-100 §6.5.1; required by the XSD.
924    pub name: String,
925    /// `MacroType.Annotation` (optional, §6.5.2).
926    pub annotation: Option<String>,
927    /// Every other direct-child element of the `<Macro>` element,
928    /// as `(local_name, text_body)`. Element nesting deeper than one
929    /// level is flattened into the outermost name with the joined
930    /// text body — callers that need structured access to nested
931    /// macro extensions should re-parse the OPL with a richer model.
932    pub extra_fields: Vec<(String, String)>,
933}
934
935impl OutputProfileList {
936    fn from_raw(
937        raw: raw::OutputProfileList,
938        macros: Vec<OplMacro>,
939    ) -> Result<Self, AssetMapParseError> {
940        Ok(Self {
941            id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
942                field: "Id",
943                source,
944            })?,
945            annotation: raw.annotation,
946            issue_date: raw.issue_date,
947            issuer: raw.issuer,
948            creator: raw.creator,
949            composition_playlist_id: ImfUuid::parse(&raw.composition_playlist_id).map_err(
950                |source| AssetMapParseError::Field {
951                    field: "CompositionPlaylistId",
952                    source,
953                },
954            )?,
955            macros,
956        })
957    }
958}
959
960/// PKL XML — Packing List (SMPTE ST 2067-2 §9).
961///
962/// Assets carry SHA-1 (default) or SHA-256 checksums. The algorithm is
963/// determined by the optional `<HashAlgorithm>` element on each asset.
964#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
965#[derive(Debug, Serialize, Deserialize, PartialEq)]
966#[serde(rename_all = "camelCase")]
967pub struct PackingList {
968    /// The SMPTE spec version detected from the root xmlns.
969    #[serde(skip)]
970    pub namespace: PklNamespace,
971    pub id: ImfUuid,
972    pub annotation_text: Option<String>,
973    /// ISO 8601 issue date.
974    pub issue_date: String,
975    pub issuer: Option<String>,
976    pub creator: Option<String>,
977    /// Optional group identifier for partial deliveries (SMPTE ST 2067-2 §9).
978    pub group_id: Option<ImfUuid>,
979    pub asset_list: PklAssetList,
980}
981
982impl PackingList {
983    fn from_raw(
984        raw: raw::PackingList,
985        namespace: PklNamespace,
986    ) -> Result<Self, AssetMapParseError> {
987        let group_id = raw
988            .group_id
989            .map(|s| ImfUuid::parse(&s))
990            .transpose()
991            .map_err(|source| AssetMapParseError::Field {
992                field: "GroupId",
993                source,
994            })?;
995
996        Ok(Self {
997            namespace,
998            id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
999                field: "Id",
1000                source,
1001            })?,
1002            annotation_text: raw.annotation_text,
1003            issue_date: raw.issue_date,
1004            issuer: raw.issuer,
1005            creator: raw.creator,
1006            group_id,
1007            asset_list: PklAssetList::from_raw(raw.asset_list)?,
1008        })
1009    }
1010}
1011
1012/// The `<AssetList>` element in a PKL.
1013#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1014#[derive(Debug, Serialize, Deserialize, PartialEq)]
1015#[serde(rename_all = "camelCase")]
1016pub struct PklAssetList {
1017    pub assets: Vec<PklAsset>,
1018}
1019
1020impl PklAssetList {
1021    fn from_raw(raw: raw::PklAssetList) -> Result<Self, AssetMapParseError> {
1022        let assets = raw
1023            .assets
1024            .into_iter()
1025            .map(PklAsset::from_raw)
1026            .collect::<Result<Vec<_>, _>>()?;
1027        Ok(Self { assets })
1028    }
1029}
1030
1031/// A single asset entry in a PKL.
1032#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
1033#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1034#[serde(rename_all = "camelCase")]
1035pub struct PklAsset {
1036    pub id: ImfUuid,
1037    pub annotation_text: Option<String>,
1038    /// SHA-1 digest decoded from the base64 `<Hash>` element (SMPTE ST 2067-2 §9.3).
1039    pub hash: AssetHash,
1040    /// File size in bytes.
1041    pub size: u64,
1042    /// MIME type of the asset file (SMPTE ST 2067-2 §9.4).
1043    pub mime_type: MimeType,
1044    pub original_file_name: Option<String>,
1045}
1046
1047impl PklAsset {
1048    fn from_raw(raw: raw::PklAsset) -> Result<Self, AssetMapParseError> {
1049        // Determine hash algorithm from <HashAlgorithm Algorithm="..."/> element.
1050        // Per ST 2067-2 §9, SHA-1 is the default when the element is absent.
1051        let algorithm = match &raw.hash_algorithm {
1052            Some(dm) => {
1053                HashAlgorithm::from_uri(&dm.algorithm).ok_or_else(|| AssetMapParseError::Field {
1054                    field: "HashAlgorithm",
1055                    source: ImfTypeError::InvalidHash(format!(
1056                        "unsupported hash algorithm URI: {}",
1057                        dm.algorithm
1058                    )),
1059                })?
1060            }
1061            None => HashAlgorithm::Sha1,
1062        };
1063
1064        Ok(Self {
1065            id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
1066                field: "Id",
1067                source,
1068            })?,
1069            annotation_text: raw.annotation_text,
1070            hash: AssetHash::from_base64(&raw.hash, algorithm).map_err(|source| {
1071                AssetMapParseError::Field {
1072                    field: "Hash",
1073                    source,
1074                }
1075            })?,
1076            size: raw.size,
1077            mime_type: MimeType::parse(&raw.mime_type),
1078            original_file_name: raw.original_file_name,
1079        })
1080    }
1081}
1082
1083// ─── Parse functions ──────────────────────────────────────────────────────────
1084
1085// parse_volindex is re-exported from the volindex module at the top of this file.
1086
1087/// Parse ASSETMAP.xml (ST 429-9 §6).
1088///
1089/// Detects the SMPTE spec version from the root `xmlns` attribute and stores it
1090/// on the returned `AssetMap.namespace` field.
1091pub fn parse_assetmap(xml_content: &str) -> Result<AssetMap, AssetMapParseError> {
1092    // Missing root xmlns lands in Unknown rather than silently defaulting to
1093    // DCI 429-9 (the first enum variant) — see FIX-3 in
1094    // docs/parser-audit-2026-06.md.
1095    let namespace = detect_root_namespace(xml_content)
1096        .map(|uri| AssetMapNamespace::from_uri(&uri))
1097        .unwrap_or_else(|| AssetMapNamespace::Unknown(String::new()));
1098    let raw: raw::AssetMap = quick_xml::de::from_str(xml_content)?;
1099    AssetMap::from_raw(raw, namespace)
1100}
1101
1102/// Parse PKL XML (SMPTE ST 2067-2 §9).
1103///
1104/// Detects the SMPTE spec version from the root `xmlns` attribute and stores it
1105/// on the returned `PackingList.namespace` field.
1106pub fn parse_pkl(xml_content: &str) -> Result<PackingList, AssetMapParseError> {
1107    // Missing root xmlns lands in Unknown rather than silently defaulting to
1108    // DCI 429-8 (the first enum variant) — see FIX-3 in
1109    // docs/parser-audit-2026-06.md.
1110    let namespace = detect_root_namespace(xml_content)
1111        .map(|uri| PklNamespace::from_uri(&uri))
1112        .unwrap_or_else(|| PklNamespace::Unknown(String::new()));
1113    let raw: raw::PackingList = quick_xml::de::from_str(xml_content)?;
1114    PackingList::from_raw(raw, namespace)
1115}
1116
1117/// Parse OPL XML (SMPTE ST 2067-100).
1118///
1119/// Extracts the core metadata (Id, Annotation, IssueDate, Issuer, Creator,
1120/// CompositionPlaylistId) via serde, and then walks the `<MacroList>` with
1121/// an event-driven `quick_xml` reader to capture each
1122/// `<Macro xsi:type="...">` entry. The XSD's `xsi:type` polymorphism (with
1123/// vendor-specific extension namespaces) is too dynamic for serde derives,
1124/// so the macros are captured as flexible `OplMacro` records — the abstract
1125/// `MacroType` fields (`Name`, `Annotation`) plus an `extra_fields` bag of
1126/// `(local_name, text)` pairs for everything else. Malformed
1127/// `<MacroList>` content (mid-walk XML errors) is treated as "no macros"
1128/// rather than a parse failure, because serde already validated the
1129/// outer document structure and we don't want a vendor-quirk macro to
1130/// take down the whole parse.
1131pub fn parse_opl(xml_content: &str) -> Result<OutputProfileList, AssetMapParseError> {
1132    let raw: raw::OutputProfileList = quick_xml::de::from_str(xml_content)?;
1133    let macros = parse_opl_macros(xml_content).unwrap_or_default();
1134    OutputProfileList::from_raw(raw, macros)
1135}
1136
1137/// Walk an OPL XML document and return every `<Macro xsi:type="...">`
1138/// entry found inside `<MacroList>`.
1139///
1140/// Returns `Ok(empty)` when the OPL has no `<MacroList>` or an
1141/// empty/self-closing one. Returns `Err` only on genuinely
1142/// unrecoverable XML errors during the walk — callers (see
1143/// `parse_opl`) treat that as "no macros" rather than propagating.
1144fn parse_opl_macros(xml_content: &str) -> Result<Vec<OplMacro>, quick_xml::Error> {
1145    use quick_xml::events::Event;
1146    use quick_xml::reader::Reader;
1147
1148    let mut reader = Reader::from_str(xml_content);
1149    reader.trim_text(true);
1150
1151    let mut macros: Vec<OplMacro> = Vec::new();
1152    let mut buf = Vec::new();
1153    let mut in_macro_list = 0u32;
1154    let mut current: Option<OplMacroBuilder> = None;
1155    // Tracks the local name of the element currently accumulating text;
1156    // `None` means we're not inside a leaf element with text content.
1157    let mut text_target: Option<String> = None;
1158
1159    loop {
1160        match reader.read_event_into(&mut buf) {
1161            Ok(Event::Start(e)) => {
1162                let local = local_name(e.name().as_ref());
1163                if local == "MacroList" {
1164                    in_macro_list += 1;
1165                    continue;
1166                }
1167                if in_macro_list == 0 {
1168                    continue;
1169                }
1170                if local == "Macro" {
1171                    // Read the xsi:type attribute (any prefix).
1172                    let xsi_type = e
1173                        .attributes()
1174                        .with_checks(false)
1175                        .filter_map(|a| a.ok())
1176                        .find_map(|a| {
1177                            if local_name(a.key.as_ref()) == "type" {
1178                                std::str::from_utf8(&a.value).ok().map(str::to_string)
1179                            } else {
1180                                None
1181                            }
1182                        });
1183                    current = Some(OplMacroBuilder {
1184                        xsi_type,
1185                        name: String::new(),
1186                        annotation: None,
1187                        extra_fields: Vec::new(),
1188                    });
1189                    text_target = None;
1190                } else if current.is_some() {
1191                    // Direct child of a Macro — start accumulating text
1192                    // for it. Nested children get folded into the
1193                    // outermost name's text via the `text_target`.
1194                    if text_target.is_none() {
1195                        text_target = Some(local);
1196                    }
1197                }
1198            }
1199            Ok(Event::End(e)) => {
1200                let local = local_name(e.name().as_ref());
1201                if local == "MacroList" {
1202                    in_macro_list = in_macro_list.saturating_sub(1);
1203                    continue;
1204                }
1205                if local == "Macro" {
1206                    if let Some(builder) = current.take() {
1207                        macros.push(OplMacro {
1208                            xsi_type: builder.xsi_type,
1209                            name: builder.name,
1210                            annotation: builder.annotation,
1211                            extra_fields: builder.extra_fields,
1212                        });
1213                    }
1214                    text_target = None;
1215                } else if let Some(target) = &text_target {
1216                    if target == &local {
1217                        text_target = None;
1218                    }
1219                }
1220            }
1221            Ok(Event::Empty(e)) => {
1222                // Self-closing element: still record its presence with empty text.
1223                if let (true, Some(builder)) = (in_macro_list > 0, current.as_mut()) {
1224                    let local = local_name(e.name().as_ref());
1225                    match local.as_str() {
1226                        "Name" => {} // empty Name is structurally invalid; skip
1227                        "Annotation" => builder.annotation = Some(String::new()),
1228                        _ => builder.extra_fields.push((local, String::new())),
1229                    }
1230                }
1231            }
1232            Ok(Event::Text(t)) => {
1233                if let (Some(builder), Some(target)) = (current.as_mut(), text_target.as_ref()) {
1234                    let text = t.unescape().unwrap_or_default().into_owned();
1235                    match target.as_str() {
1236                        "Name" => builder.name.push_str(&text),
1237                        "Annotation" => {
1238                            builder.annotation =
1239                                Some(builder.annotation.take().map(|a| a + &text).unwrap_or(text))
1240                        }
1241                        other => {
1242                            // Append to an existing entry for the same
1243                            // element if it already exists; otherwise create.
1244                            if let Some(entry) =
1245                                builder.extra_fields.iter_mut().rfind(|(n, _)| n == other)
1246                            {
1247                                entry.1.push_str(&text);
1248                            } else {
1249                                builder.extra_fields.push((other.to_string(), text));
1250                            }
1251                        }
1252                    }
1253                }
1254            }
1255            Ok(Event::Eof) => break,
1256            Err(e) => return Err(e),
1257            _ => {}
1258        }
1259        buf.clear();
1260    }
1261
1262    Ok(macros)
1263}
1264
1265/// Strip an XML-namespaced tag down to its local-name component.
1266fn local_name(tag: &[u8]) -> String {
1267    let s = std::str::from_utf8(tag).unwrap_or("");
1268    match s.rsplit_once(':') {
1269        Some((_, local)) => local.to_string(),
1270        None => s.to_string(),
1271    }
1272}
1273
1274/// Mutable builder for one macro entry; flushed into [`OplMacro`] on `</Macro>`.
1275struct OplMacroBuilder {
1276    xsi_type: Option<String>,
1277    name: String,
1278    annotation: Option<String>,
1279    extra_fields: Vec<(String, String)>,
1280}
1281
1282// ─── Tests ────────────────────────────────────────────────────────────────────
1283
1284#[cfg(test)]
1285mod tests {
1286    use super::*;
1287    use pretty_assertions::assert_eq;
1288    use std::path::PathBuf;
1289
1290    fn test_data(name: &str) -> PathBuf {
1291        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1292            .join("../../test-data")
1293            .join(name)
1294    }
1295
1296    // ── ImfUuid ──────────────────────────────────────────────────────────────
1297
1298    /// SMPTE ST 2067-2 §7: UUIDs are serialized as urn:uuid: URNs in XML.
1299    #[test]
1300    fn uuid_parse_urn_form() {
1301        let id = ImfUuid::parse("urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85").unwrap();
1302        assert_eq!(id.to_string(), "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
1303        assert_eq!(id.to_urn(), "urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
1304    }
1305
1306    #[test]
1307    fn uuid_parse_bare_form() {
1308        let id = ImfUuid::parse("0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85").unwrap();
1309        assert_eq!(id.to_string(), "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
1310    }
1311
1312    #[test]
1313    fn uuid_parse_invalid() {
1314        assert!(ImfUuid::parse("not-a-uuid").is_err());
1315        assert!(ImfUuid::parse("").is_err());
1316        assert!(ImfUuid::parse("urn:uuid:not-valid").is_err());
1317    }
1318
1319    #[test]
1320    fn uuid_roundtrip_serde() {
1321        let id = ImfUuid::parse("urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85").unwrap();
1322        let json = serde_json::to_string(&id).unwrap();
1323        // Serializes as bare UUID (no urn: prefix) for JSON
1324        assert_eq!(json, r#""0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85""#);
1325        let back: ImfUuid = serde_json::from_str(&json).unwrap();
1326        assert_eq!(id, back);
1327    }
1328
1329    #[test]
1330    fn uuid_deserialize_urn_from_json() {
1331        // Deserializer must accept urn: form too
1332        let back: ImfUuid =
1333            serde_json::from_str(r#""urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85""#).unwrap();
1334        assert_eq!(back.to_string(), "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
1335    }
1336
1337    // ── SmpteUl ──────────────────────────────────────────────────────────────
1338
1339    /// ST 298M: Byte 8 (registry version) must be ignored for semantic identity.
1340    #[test]
1341    fn smpte_ul_parse_4group() {
1342        let ul = SmpteUl::parse("060e2b34.04010106.04010101.03030000").unwrap();
1343        assert_eq!(ul.0[0], 0x06);
1344        assert_eq!(ul.0[7], 0x06); // byte 8 = version
1345        assert_eq!(ul.0[12], 0x03);
1346    }
1347
1348    #[test]
1349    fn smpte_ul_parse_urn_form() {
1350        let ul = SmpteUl::parse("urn:smpte:ul:060e2b34.04010106.04010101.03030000").unwrap();
1351        assert_eq!(ul.0[0], 0x06);
1352    }
1353
1354    #[test]
1355    fn smpte_ul_parse_5group_variant() {
1356        // Variant format from some test data
1357        let ul = SmpteUl::parse("urn:smpte:ul:060e2b34.0401.0101.04010101.01020000").unwrap();
1358        assert_eq!(ul.0[4], 0x04);
1359        assert_eq!(ul.0[5], 0x01);
1360    }
1361
1362    #[test]
1363    fn smpte_ul_version_agnostic_equality() {
1364        // Same UL at different registry versions
1365        let v1 = SmpteUl::parse("060e2b34.04010101.04010101.03030000").unwrap();
1366        let v6 = SmpteUl::parse("060e2b34.04010106.04010101.03030000").unwrap();
1367        let vd = SmpteUl::parse("060e2b34.0401010d.04010101.03030000").unwrap();
1368        assert_eq!(v1, v6, "version 01 == version 06");
1369        assert_eq!(v6, vd, "version 06 == version 0d");
1370    }
1371
1372    #[test]
1373    fn smpte_ul_different_items_not_equal() {
1374        let a = SmpteUl::parse("060e2b34.04010106.04010101.03030000").unwrap();
1375        let b = SmpteUl::parse("060e2b34.04010106.04010101.03040000").unwrap();
1376        assert_ne!(a, b);
1377    }
1378
1379    #[test]
1380    fn smpte_ul_display_roundtrip() {
1381        let ul = SmpteUl::parse("urn:smpte:ul:060e2b34.04010106.04010101.03030000").unwrap();
1382        let s = ul.to_string();
1383        assert!(s.starts_with("urn:smpte:ul:"));
1384        let ul2 = SmpteUl::parse(&s).unwrap();
1385        assert_eq!(ul, ul2);
1386    }
1387
1388    #[test]
1389    fn smpte_ul_parse_invalid() {
1390        assert!(SmpteUl::parse("not-a-ul").is_err());
1391        assert!(SmpteUl::parse("060e2b34.04010106").is_err()); // too short
1392    }
1393
1394    // ── AssetHash ────────────────────────────────────────────────────────────
1395
1396    /// SMPTE ST 2067-2 §9: PKL assets carry base64-encoded SHA-1 digests.
1397    #[test]
1398    fn asset_hash_sha1_roundtrip() {
1399        // SHA-1 of empty bytes
1400        let b64 = "2jmj7l5rSw0yVb/vlWAYkK/YBwk=";
1401        let h = AssetHash::from_base64_sha1(b64).unwrap();
1402        assert_eq!(h.algorithm, HashAlgorithm::Sha1);
1403        assert_eq!(h.bytes.len(), 20);
1404        assert_eq!(h.to_base64(), b64);
1405    }
1406
1407    /// SMPTE ST 2067-2 §9: SHA-1 digest must be exactly 20 bytes.
1408    #[test]
1409    fn asset_hash_sha1_wrong_length_rejected() {
1410        let err = AssetHash::from_base64_sha1("AAAA").unwrap_err();
1411        assert!(err.to_string().contains("20 bytes"));
1412    }
1413
1414    #[test]
1415    fn asset_hash_sha256_roundtrip() {
1416        let b64 = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=";
1417        let h = AssetHash::from_base64_sha256(b64).unwrap();
1418        assert_eq!(h.algorithm, HashAlgorithm::Sha256);
1419        assert_eq!(h.bytes.len(), 32);
1420        assert_eq!(h.to_base64(), b64);
1421    }
1422
1423    #[test]
1424    fn asset_hash_sha256_wrong_length_rejected() {
1425        let err = AssetHash::from_base64_sha256("2jmj7l5rSw0yVb/vlWAYkK/YBwk=").unwrap_err();
1426        assert!(err.to_string().contains("32 bytes"));
1427    }
1428
1429    #[test]
1430    fn asset_hash_from_base64_routes_correctly() {
1431        let sha1_b64 = "2jmj7l5rSw0yVb/vlWAYkK/YBwk=";
1432        let sha256_b64 = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=";
1433
1434        let h1 = AssetHash::from_base64(sha1_b64, HashAlgorithm::Sha1).unwrap();
1435        assert_eq!(h1.algorithm, HashAlgorithm::Sha1);
1436
1437        let h2 = AssetHash::from_base64(sha256_b64, HashAlgorithm::Sha256).unwrap();
1438        assert_eq!(h2.algorithm, HashAlgorithm::Sha256);
1439    }
1440
1441    #[test]
1442    fn asset_hash_invalid_base64() {
1443        assert!(AssetHash::from_base64_sha1("not-valid-base64!!!").is_err());
1444    }
1445
1446    // ── HashAlgorithm ─────────────────────────────────────────────────────────
1447
1448    /// SMPTE ST 2067-2 §9: HashAlgorithm URI parsing.
1449    #[test]
1450    fn hash_algorithm_from_uri() {
1451        assert_eq!(
1452            HashAlgorithm::from_uri("http://www.w3.org/2000/09/xmldsig#sha1"),
1453            Some(HashAlgorithm::Sha1)
1454        );
1455        assert_eq!(
1456            HashAlgorithm::from_uri("http://www.w3.org/2001/04/xmlenc#sha256"),
1457            Some(HashAlgorithm::Sha256)
1458        );
1459        assert_eq!(HashAlgorithm::from_uri("http://example.com/unknown"), None);
1460    }
1461
1462    #[test]
1463    fn hash_algorithm_digest_len() {
1464        assert_eq!(HashAlgorithm::Sha1.digest_len(), 20);
1465        assert_eq!(HashAlgorithm::Sha256.digest_len(), 32);
1466    }
1467
1468    #[test]
1469    fn hash_algorithm_display() {
1470        assert_eq!(HashAlgorithm::Sha1.to_string(), "SHA-1");
1471        assert_eq!(HashAlgorithm::Sha256.to_string(), "SHA-256");
1472    }
1473
1474    // ── VOLINDEX ──────────────────────────────────────────────────────────────
1475
1476    /// ST 429-9 §5: VOLINDEX.xml contains a single <Index> element.
1477    #[test]
1478    fn volindex_parses_index_element() {
1479        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
1480<VolumeIndex xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
1481    <Index>1</Index>
1482</VolumeIndex>"#;
1483        let result = parse_volindex(xml).unwrap();
1484        assert_eq!(result.index, 1);
1485    }
1486
1487    // ── ASSETMAP ──────────────────────────────────────────────────────────────
1488
1489    /// ST 429-9 §6.2: AssetMap Id must be a valid UUID URN.
1490    #[test]
1491    fn assetmap_id_is_imf_uuid() {
1492        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
1493<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
1494    <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1495    <AnnotationText>MERIDIAN</AnnotationText>
1496    <Creator>Clipster 6.1.0.0 Beta (build 111500)</Creator>
1497    <VolumeCount>1</VolumeCount>
1498    <IssueDate>2016-10-06T08:35:02-00:00</IssueDate>
1499    <Issuer>R&amp;S</Issuer>
1500    <AssetList>
1501        <Asset>
1502            <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1503            <ChunkList>
1504                <Chunk>
1505                    <Path>CPL_0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85.xml</Path>
1506                    <VolumeIndex>1</VolumeIndex>
1507                </Chunk>
1508            </ChunkList>
1509        </Asset>
1510        <Asset>
1511            <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1512            <PackingList>true</PackingList>
1513            <ChunkList>
1514                <Chunk>
1515                    <Path>PKL_f5e93462-aed2-44ad-a4ba-2adb65823e7c.xml</Path>
1516                    <VolumeIndex>1</VolumeIndex>
1517                </Chunk>
1518            </ChunkList>
1519        </Asset>
1520    </AssetList>
1521</AssetMap>"#;
1522
1523        let result = parse_assetmap(xml).unwrap();
1524        assert_eq!(
1525            result.id,
1526            ImfUuid::parse("urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7").unwrap()
1527        );
1528        assert_eq!(result.annotation_text, Some("MERIDIAN".to_string()));
1529        assert_eq!(result.volume_count, 1);
1530        assert_eq!(result.asset_list.assets.len(), 2);
1531
1532        // ST 429-9 §6.3: Asset entries carry UUID references to package files.
1533        let cpl_asset = &result.asset_list.assets[0];
1534        assert_eq!(
1535            cpl_asset.id,
1536            ImfUuid::parse("urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85").unwrap()
1537        );
1538        assert_eq!(cpl_asset.packing_list, None);
1539        assert_eq!(
1540            cpl_asset.chunk_list.chunks[0].path,
1541            "CPL_0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85.xml"
1542        );
1543
1544        // ST 429-9 §6.3: PackingList flag marks the PKL asset.
1545        let pkl_asset = &result.asset_list.assets[1];
1546        assert_eq!(pkl_asset.packing_list, Some(true));
1547        assert_eq!(
1548            pkl_asset.chunk_list.chunks[0].path,
1549            "PKL_f5e93462-aed2-44ad-a4ba-2adb65823e7c.xml"
1550        );
1551    }
1552
1553    /// ST 429-9 §6.2: Invalid UUID in AssetMap <Id> yields a typed error.
1554    #[test]
1555    fn assetmap_invalid_uuid_returns_field_error() {
1556        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1557<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
1558    <Id>not-a-valid-uuid</Id>
1559    <VolumeCount>1</VolumeCount>
1560    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1561    <AssetList><Asset>
1562        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1563        <ChunkList><Chunk><Path>foo.xml</Path></Chunk></ChunkList>
1564    </Asset></AssetList>
1565</AssetMap>"#;
1566        let err = parse_assetmap(xml).unwrap_err();
1567        assert!(
1568            matches!(err, AssetMapParseError::Field { field: "Id", .. }),
1569            "expected Field error for Id, got: {err}"
1570        );
1571    }
1572
1573    // ── PKL ───────────────────────────────────────────────────────────────────
1574
1575    /// SMPTE ST 2067-2 §9: PKL carries SHA-1 hashes, sizes, and MIME types.
1576    #[test]
1577    fn pkl_parses_assets_with_strong_types() {
1578        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
1579<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1580    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1581    <AnnotationText>MERIDIAN</AnnotationText>
1582    <IssueDate>2016-10-06T08:35:02-00:00</IssueDate>
1583    <Issuer>R&amp;S</Issuer>
1584    <Creator>Clipster 6.1.0.0 Beta (build 111500)</Creator>
1585    <AssetList>
1586        <Asset>
1587            <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1588            <AnnotationText>Meridian UHD 5994P</AnnotationText>
1589            <Hash>IW0J5IZBsAxLMCCmWtHvfHhjVUw=</Hash>
1590            <Size>15214</Size>
1591            <Type>text/xml</Type>
1592            <OriginalFileName>CPL_0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85.xml</OriginalFileName>
1593        </Asset>
1594        <Asset>
1595            <Id>urn:uuid:61d91654-2650-4abf-abbc-ad2c7f640bf8</Id>
1596            <Hash>fL7SnTeNskm71I4otXqr/T0D5LQ=</Hash>
1597            <Size>79486353</Size>
1598            <Type>application/mxf</Type>
1599            <OriginalFileName>MERIDIAN_Netflix_Photon_161006_00.mxf</OriginalFileName>
1600        </Asset>
1601    </AssetList>
1602</PackingList>"#;
1603
1604        let result = parse_pkl(xml).unwrap();
1605        assert_eq!(
1606            result.id,
1607            ImfUuid::parse("urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c").unwrap()
1608        );
1609        assert_eq!(result.annotation_text, Some("MERIDIAN".to_string()));
1610        assert_eq!(result.issuer, Some("R&S".to_string()));
1611        assert_eq!(result.asset_list.assets.len(), 2);
1612
1613        let cpl_asset = &result.asset_list.assets[0];
1614        assert_eq!(cpl_asset.hash.algorithm, HashAlgorithm::Sha1);
1615        assert_eq!(cpl_asset.hash.bytes.len(), 20);
1616        assert_eq!(cpl_asset.hash.to_base64(), "IW0J5IZBsAxLMCCmWtHvfHhjVUw=");
1617        assert_eq!(cpl_asset.size, 15214);
1618        assert_eq!(cpl_asset.mime_type, MimeType::TextXml);
1619        assert!(cpl_asset.mime_type.is_xml());
1620
1621        let mxf_asset = &result.asset_list.assets[1];
1622        assert_eq!(mxf_asset.mime_type, MimeType::ApplicationMxf);
1623        assert!(mxf_asset.mime_type.is_mxf());
1624    }
1625
1626    /// SMPTE ST 2067-2 §9: PKL with explicit SHA-1 <HashAlgorithm> element.
1627    #[test]
1628    fn pkl_explicit_sha1_hash_algorithm() {
1629        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1630<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1631    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1632    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1633    <AssetList><Asset>
1634        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1635        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1636        <Size>1024</Size>
1637        <Type>application/mxf</Type>
1638        <HashAlgorithm Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
1639    </Asset></AssetList>
1640</PackingList>"#;
1641        let result = parse_pkl(xml).unwrap();
1642        assert_eq!(
1643            result.asset_list.assets[0].hash.algorithm,
1644            HashAlgorithm::Sha1
1645        );
1646    }
1647
1648    /// SMPTE ST 2067-2 §9: PKL with SHA-256 <HashAlgorithm>.
1649    #[test]
1650    fn pkl_sha256_hash_algorithm() {
1651        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1652<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1653    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1654    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1655    <AssetList><Asset>
1656        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1657        <Hash>47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=</Hash>
1658        <Size>1024</Size>
1659        <Type>application/mxf</Type>
1660        <HashAlgorithm Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
1661    </Asset></AssetList>
1662</PackingList>"#;
1663        let result = parse_pkl(xml).unwrap();
1664        assert_eq!(
1665            result.asset_list.assets[0].hash.algorithm,
1666            HashAlgorithm::Sha256
1667        );
1668        assert_eq!(result.asset_list.assets[0].hash.bytes.len(), 32);
1669    }
1670
1671    /// SMPTE ST 2067-2 §9: PKL without <HashAlgorithm> defaults to SHA-1.
1672    #[test]
1673    fn pkl_missing_hash_algorithm_defaults_to_sha1() {
1674        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1675<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1676    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1677    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1678    <AssetList><Asset>
1679        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1680        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1681        <Size>1024</Size>
1682        <Type>application/mxf</Type>
1683    </Asset></AssetList>
1684</PackingList>"#;
1685        let result = parse_pkl(xml).unwrap();
1686        assert_eq!(
1687            result.asset_list.assets[0].hash.algorithm,
1688            HashAlgorithm::Sha1
1689        );
1690    }
1691
1692    /// SMPTE ST 2067-2 §9: PKL with <GroupId> for partial deliveries.
1693    #[test]
1694    fn pkl_with_group_id() {
1695        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1696<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1697    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1698    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1699    <GroupId>urn:uuid:aabbccdd-1122-3344-5566-778899aabbcc</GroupId>
1700    <AssetList><Asset>
1701        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1702        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1703        <Size>1024</Size>
1704        <Type>application/mxf</Type>
1705    </Asset></AssetList>
1706</PackingList>"#;
1707        let result = parse_pkl(xml).unwrap();
1708        assert_eq!(
1709            result.group_id,
1710            Some(ImfUuid::parse("urn:uuid:aabbccdd-1122-3344-5566-778899aabbcc").unwrap())
1711        );
1712    }
1713
1714    /// SMPTE ST 2067-2 §9: Unrecognised MIME type is preserved as MimeType::Other.
1715    #[test]
1716    fn pkl_unknown_mime_type_preserved() {
1717        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1718<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1719    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1720    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1721    <AssetList><Asset>
1722        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1723        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1724        <Size>512</Size>
1725        <Type>application/octet-stream</Type>
1726    </Asset></AssetList>
1727</PackingList>"#;
1728        let result = parse_pkl(xml).unwrap();
1729        assert_eq!(
1730            result.asset_list.assets[0].mime_type,
1731            MimeType::Other("application/octet-stream".to_string())
1732        );
1733    }
1734
1735    // ── Namespace compatibility ──────────────────────────────────────────────
1736
1737    /// ST 2067-2: PKL namespace versions must all parse identically.
1738    #[test]
1739    fn pkl_parses_with_2067_2_2016_namespace() {
1740        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1741<PackingList xmlns="http://www.smpte-ra.org/schemas/2067-2/2016">
1742    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1743    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1744    <AssetList><Asset>
1745        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1746        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1747        <Size>1024</Size>
1748        <Type>application/mxf</Type>
1749    </Asset></AssetList>
1750</PackingList>"#;
1751        let result = parse_pkl(xml).unwrap();
1752        assert_eq!(result.asset_list.assets.len(), 1);
1753        assert_eq!(result.namespace, PklNamespace::Smpte2067_2_2016);
1754        assert_eq!(result.namespace.spec_id(), "ST 2067-2:2016");
1755    }
1756
1757    #[test]
1758    fn pkl_parses_with_2067_2_2020_namespace() {
1759        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1760<PackingList xmlns="http://www.smpte-ra.org/ns/2067-2/2020">
1761    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1762    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1763    <AssetList><Asset>
1764        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1765        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1766        <Size>1024</Size>
1767        <Type>application/mxf</Type>
1768    </Asset></AssetList>
1769</PackingList>"#;
1770        let result = parse_pkl(xml).unwrap();
1771        assert_eq!(result.namespace, PklNamespace::Smpte2067_2_2020);
1772    }
1773
1774    #[test]
1775    fn pkl_detects_dci_429_8_namespace() {
1776        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1777<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1778    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1779    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1780    <AssetList><Asset>
1781        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1782        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1783        <Size>1024</Size>
1784        <Type>application/mxf</Type>
1785    </Asset></AssetList>
1786</PackingList>"#;
1787        let result = parse_pkl(xml).unwrap();
1788        assert_eq!(result.namespace, PklNamespace::Dci429_8);
1789    }
1790
1791    #[test]
1792    fn assetmap_parses_with_2067_9_namespace() {
1793        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1794<AssetMap xmlns="http://www.smpte-ra.org/schemas/2067-9/2016">
1795    <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1796    <VolumeCount>1</VolumeCount>
1797    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1798    <AssetList>
1799        <Asset>
1800            <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1801            <ChunkList><Chunk><Path>test.xml</Path></Chunk></ChunkList>
1802        </Asset>
1803    </AssetList>
1804</AssetMap>"#;
1805        let result = parse_assetmap(xml).unwrap();
1806        assert_eq!(result.asset_list.assets.len(), 1);
1807        assert_eq!(result.namespace, AssetMapNamespace::Smpte2067_9_2016);
1808    }
1809
1810    /// `http://www.smpte-ra.org/ns/2067-9/2020` is not a registered namespace —
1811    /// ST 2067-9 has only the 2016 edition. A document declaring this URI
1812    /// still parses but lands in `Unknown`.
1813    #[test]
1814    fn assetmap_with_fake_2020_namespace_lands_in_unknown() {
1815        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1816<AssetMap xmlns="http://www.smpte-ra.org/ns/2067-9/2020">
1817    <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1818    <VolumeCount>1</VolumeCount>
1819    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1820    <AssetList>
1821        <Asset>
1822            <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1823            <ChunkList><Chunk><Path>test.xml</Path></Chunk></ChunkList>
1824        </Asset>
1825    </AssetList>
1826</AssetMap>"#;
1827        let result = parse_assetmap(xml).unwrap();
1828        assert!(matches!(result.namespace, AssetMapNamespace::Unknown(_)));
1829    }
1830
1831    #[test]
1832    fn assetmap_detects_dci_429_9_namespace() {
1833        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1834<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
1835    <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1836    <VolumeCount>1</VolumeCount>
1837    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1838    <AssetList>
1839        <Asset>
1840            <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1841            <ChunkList><Chunk><Path>test.xml</Path></Chunk></ChunkList>
1842        </Asset>
1843    </AssetList>
1844</AssetMap>"#;
1845        let result = parse_assetmap(xml).unwrap();
1846        assert_eq!(result.namespace, AssetMapNamespace::Dci429_9);
1847    }
1848
1849    /// FIX-3 regression: AssetMap without root xmlns lands in `Unknown("")`,
1850    /// not the first variant (`Dci429_9`).
1851    #[test]
1852    fn assetmap_without_root_xmlns_lands_in_unknown_not_dci() {
1853        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1854<AssetMap>
1855    <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1856    <VolumeCount>1</VolumeCount>
1857    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1858    <AssetList>
1859        <Asset>
1860            <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1861            <ChunkList><Chunk><Path>test.xml</Path></Chunk></ChunkList>
1862        </Asset>
1863    </AssetList>
1864</AssetMap>"#;
1865        let result = parse_assetmap(xml).expect("AssetMap should parse without xmlns");
1866        assert!(
1867            matches!(result.namespace, AssetMapNamespace::Unknown(ref s) if s.is_empty()),
1868            "expected Unknown(\"\") for missing xmlns, got {:?}",
1869            result.namespace
1870        );
1871    }
1872
1873    /// FIX-3 regression: PKL without root xmlns lands in `Unknown("")`,
1874    /// not the first variant (`Dci429_8`).
1875    #[test]
1876    fn pkl_without_root_xmlns_lands_in_unknown_not_dci() {
1877        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1878<PackingList>
1879    <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1880    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1881    <AssetList><Asset>
1882        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1883        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1884        <Size>1024</Size>
1885        <Type>application/xml</Type>
1886    </Asset></AssetList>
1887</PackingList>"#;
1888        let result = parse_pkl(xml).expect("PKL should parse without xmlns");
1889        assert!(
1890            matches!(result.namespace, PklNamespace::Unknown(ref s) if s.is_empty()),
1891            "expected Unknown(\"\") for missing xmlns, got {:?}",
1892            result.namespace
1893        );
1894    }
1895
1896    // ── OPL ──────────────────────────────────────────────────────────────────
1897
1898    /// SMPTE ST 2067-100: OPL metadata fields are parsed correctly.
1899    #[test]
1900    fn opl_parses_core_metadata() {
1901        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1902<OutputProfileList xmlns="http://www.smpte-ra.org/schemas/2067-100/2014">
1903    <Id>urn:uuid:8cf83c32-4949-4f00-b081-01e12b18932f</Id>
1904    <Annotation>OPL Example</Annotation>
1905    <IssueDate>2016-06-14T19:22:37-00:00</IssueDate>
1906    <Issuer>Clipster</Issuer>
1907    <Creator>Clipster 5.9.3.7</Creator>
1908    <CompositionPlaylistId>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</CompositionPlaylistId>
1909    <AliasList/>
1910    <MacroList/>
1911</OutputProfileList>"#;
1912        let result = parse_opl(xml).unwrap();
1913        assert_eq!(
1914            result.id.to_string(),
1915            "8cf83c32-4949-4f00-b081-01e12b18932f"
1916        );
1917        assert_eq!(result.annotation.as_deref(), Some("OPL Example"));
1918        assert_eq!(result.issuer.as_deref(), Some("Clipster"));
1919        assert_eq!(result.creator.as_deref(), Some("Clipster 5.9.3.7"));
1920        assert_eq!(
1921            result.composition_playlist_id.to_string(),
1922            "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85"
1923        );
1924    }
1925
1926    /// SMPTE ST 2067-100: OPL with complex macros parses without error.
1927    #[test]
1928    fn opl_parses_real_test_file() {
1929        let xml = std::fs::read_to_string(test_data(
1930            "OPL/OPL_8cf83c32-4949-4f00-b081-01e12b18932f.xml",
1931        ))
1932        .unwrap();
1933        let result = parse_opl(&xml).unwrap();
1934        assert_eq!(
1935            result.id.to_string(),
1936            "8cf83c32-4949-4f00-b081-01e12b18932f"
1937        );
1938        assert_eq!(
1939            result.composition_playlist_id.to_string(),
1940            "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85"
1941        );
1942    }
1943
1944    // ── FIX-8: OPL MacroList walker ──────────────────────────────────────────
1945
1946    /// `<MacroList/>` produces an empty `macros` Vec, not an error.
1947    #[test]
1948    fn opl_with_empty_macro_list_yields_no_macros() {
1949        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1950<OutputProfileList xmlns="http://www.smpte-ra.org/schemas/2067-100/2014">
1951    <Id>urn:uuid:8cf83c32-4949-4f00-b081-01e12b18932f</Id>
1952    <IssueDate>2016-06-14T19:22:37Z</IssueDate>
1953    <Issuer>x</Issuer>
1954    <Creator>x</Creator>
1955    <CompositionPlaylistId>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</CompositionPlaylistId>
1956    <MacroList/>
1957</OutputProfileList>"#;
1958        let result = parse_opl(xml).unwrap();
1959        assert!(result.macros.is_empty());
1960    }
1961
1962    /// Single preset macro: xsi:type, Name, Annotation, and the
1963    /// type-specific Preset field land in `extra_fields`.
1964    #[test]
1965    fn opl_with_preset_macro_captures_all_fields() {
1966        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1967<OutputProfileList xmlns="http://www.smpte-ra.org/schemas/2067-100/2014"
1968    xmlns:opl="http://www.smpte-ra.org/schemas/2067-100/2014"
1969    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
1970    <Id>urn:uuid:8cf83c32-4949-4f00-b081-01e12b18932f</Id>
1971    <IssueDate>2016-06-14T19:22:37Z</IssueDate>
1972    <Issuer>x</Issuer>
1973    <Creator>x</Creator>
1974    <CompositionPlaylistId>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</CompositionPlaylistId>
1975    <MacroList>
1976        <Macro xsi:type="opl:PresetMacroType">
1977            <Name>HD1080p</Name>
1978            <Annotation>Preset for 1080p HD</Annotation>
1979            <Preset>urn:smpte:opl:preset:hd1080p</Preset>
1980        </Macro>
1981    </MacroList>
1982</OutputProfileList>"#;
1983        let result = parse_opl(xml).unwrap();
1984        assert_eq!(result.macros.len(), 1);
1985        let m = &result.macros[0];
1986        assert_eq!(m.xsi_type.as_deref(), Some("opl:PresetMacroType"));
1987        assert_eq!(m.name, "HD1080p");
1988        assert_eq!(m.annotation.as_deref(), Some("Preset for 1080p HD"));
1989        assert!(
1990            m.extra_fields
1991                .iter()
1992                .any(|(k, v)| k == "Preset" && v == "urn:smpte:opl:preset:hd1080p"),
1993            "expected Preset URI in extra_fields, got {:?}",
1994            m.extra_fields
1995        );
1996    }
1997
1998    /// Multiple macros, mixed xsi:type prefixes, are all captured in order.
1999    #[test]
2000    fn opl_with_multiple_macros_captures_all_in_order() {
2001        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
2002<OutputProfileList xmlns="http://www.smpte-ra.org/schemas/2067-100/2014"
2003    xmlns:opl="http://www.smpte-ra.org/schemas/2067-100/2014"
2004    xmlns:arm="http://www.smpte-ra.org/schemas/2067-103/2014"
2005    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
2006    <Id>urn:uuid:8cf83c32-4949-4f00-b081-01e12b18932f</Id>
2007    <IssueDate>2016-06-14T19:22:37Z</IssueDate>
2008    <Issuer>x</Issuer>
2009    <Creator>x</Creator>
2010    <CompositionPlaylistId>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</CompositionPlaylistId>
2011    <MacroList>
2012        <Macro xsi:type="opl:PresetMacroType">
2013            <Name>P1</Name>
2014            <Preset>urn:p1</Preset>
2015        </Macro>
2016        <Macro xsi:type="arm:AudioRoutingMixingMacroType">
2017            <Name>AudioMix</Name>
2018            <Annotation>5.1 downmix</Annotation>
2019        </Macro>
2020    </MacroList>
2021</OutputProfileList>"#;
2022        let result = parse_opl(xml).unwrap();
2023        assert_eq!(result.macros.len(), 2);
2024        assert_eq!(result.macros[0].name, "P1");
2025        assert_eq!(
2026            result.macros[0].xsi_type.as_deref(),
2027            Some("opl:PresetMacroType")
2028        );
2029        assert_eq!(result.macros[1].name, "AudioMix");
2030        assert_eq!(
2031            result.macros[1].xsi_type.as_deref(),
2032            Some("arm:AudioRoutingMixingMacroType")
2033        );
2034        assert_eq!(result.macros[1].annotation.as_deref(), Some("5.1 downmix"));
2035    }
2036
2037    /// SMPTE ST 2067-100: OPL with simple preset macro from ISXD test data.
2038    #[test]
2039    fn opl_parses_isxd_test_file() {
2040        let xml = std::fs::read_to_string(test_data(
2041            "ISXD/CompleteIMP/OPL_af6b288d-27e8-441f-9a36-2c4ab9025d19.xml",
2042        ))
2043        .unwrap();
2044        let result = parse_opl(&xml).unwrap();
2045        assert_eq!(
2046            result.id.to_string(),
2047            "af6b288d-27e8-441f-9a36-2c4ab9025d19"
2048        );
2049        assert_eq!(
2050            result.composition_playlist_id.to_string(),
2051            "b2d74f92-1990-41e0-869f-2179a50f7090"
2052        );
2053    }
2054}