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    pub fn parse(s: &str) -> Result<Self, ImfTypeError> {
156        let bare = s.strip_prefix("urn:uuid:").unwrap_or(s);
157        Uuid::parse_str(bare)
158            .map(ImfUuid)
159            .map_err(|_| ImfTypeError::InvalidUuid(s.to_string()))
160    }
161
162    /// Return the URN form used in XML: `urn:uuid:<uuid>`.
163    pub fn to_urn(&self) -> String {
164        format!("urn:uuid:{}", self.0)
165    }
166}
167
168impl std::fmt::Display for ImfUuid {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        self.0.fmt(f)
171    }
172}
173
174impl Serialize for ImfUuid {
175    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
176        s.serialize_str(&self.0.to_string())
177    }
178}
179
180impl<'de> Deserialize<'de> for ImfUuid {
181    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
182        let s = String::deserialize(d)?;
183        ImfUuid::parse(&s).map_err(serde::de::Error::custom)
184    }
185}
186
187#[cfg(feature = "jsonschema")]
188impl schemars::JsonSchema for ImfUuid {
189    fn schema_name() -> String {
190        "ImfUuid".to_owned()
191    }
192
193    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
194        let mut schema = gen.subschema_for::<String>().into_object();
195        schema.metadata().description = Some(
196            "A SMPTE IMF UUID, serialised as a bare UUID string (e.g. \"0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85\")".to_owned()
197        );
198        schema.format = Some("uuid".to_owned());
199        schema.into()
200    }
201}
202
203// ─── AssetHash ────────────────────────────────────────────────────────────────
204
205/// A decoded asset hash from a Packing List, per SMPTE ST 2067-2 §9.
206///
207/// PKL files carry base64-encoded SHA-1 or SHA-256 digests for each tracked asset.
208#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct AssetHash {
211    algorithm: HashAlgorithm,
212    bytes: Vec<u8>,
213}
214
215/// The hash algorithm used for a PKL asset digest.
216///
217/// Per SMPTE ST 2067-2:2020 §9, SHA-1 is the default algorithm.
218/// SHA-256 is supported via the `<HashAlgorithm>` element using
219/// XML Digital Signature algorithm URIs.
220#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222pub enum HashAlgorithm {
223    /// SHA-1 (default per ST 2067-2 §9).
224    /// URI: `http://www.w3.org/2000/09/xmldsig#sha1`
225    Sha1,
226    /// SHA-256.
227    /// URI: `http://www.w3.org/2001/04/xmlenc#sha256`
228    Sha256,
229}
230
231impl HashAlgorithm {
232    /// Parse a hash algorithm from an XML Digital Signature algorithm URI.
233    ///
234    /// Per ST 2067-2:2020 §9, the `<HashAlgorithm>` element uses
235    /// `ds:DigestMethodType` which carries an `Algorithm` attribute URI.
236    pub fn from_uri(uri: &str) -> Option<Self> {
237        match uri.trim() {
238            "http://www.w3.org/2000/09/xmldsig#sha1" => Some(Self::Sha1),
239            "http://www.w3.org/2001/04/xmlenc#sha256" => Some(Self::Sha256),
240            _ => None,
241        }
242    }
243
244    /// Expected digest length in bytes for this algorithm.
245    pub fn digest_len(&self) -> usize {
246        match self {
247            Self::Sha1 => 20,
248            Self::Sha256 => 32,
249        }
250    }
251}
252
253impl std::fmt::Display for HashAlgorithm {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        match self {
256            Self::Sha1 => write!(f, "SHA-1"),
257            Self::Sha256 => write!(f, "SHA-256"),
258        }
259    }
260}
261
262impl AssetHash {
263    /// Return the hash algorithm (SHA-1 or SHA-256).
264    pub fn algorithm(&self) -> HashAlgorithm {
265        self.algorithm
266    }
267
268    /// Return the raw digest bytes.
269    pub fn bytes(&self) -> &[u8] {
270        &self.bytes
271    }
272
273    /// Decode a base64-encoded SHA-1 digest as found in PKL `<Hash>` elements.
274    ///
275    /// Per SMPTE ST 2067-2 §9, SHA-1 produces a 20-byte digest.
276    pub fn from_base64_sha1(b64: &str) -> Result<Self, ImfTypeError> {
277        let bytes = base64::engine::general_purpose::STANDARD
278            .decode(b64)
279            .map_err(|e| ImfTypeError::InvalidHash(e.to_string()))?;
280        if bytes.len() != 20 {
281            return Err(ImfTypeError::InvalidHash(format!(
282                "SHA-1 digest must be 20 bytes, got {}",
283                bytes.len()
284            )));
285        }
286        Ok(Self {
287            algorithm: HashAlgorithm::Sha1,
288            bytes,
289        })
290    }
291
292    /// Decode a base64-encoded SHA-256 digest.
293    ///
294    /// SHA-256 produces a 32-byte digest.
295    pub fn from_base64_sha256(b64: &str) -> Result<Self, ImfTypeError> {
296        let bytes = base64::engine::general_purpose::STANDARD
297            .decode(b64)
298            .map_err(|e| ImfTypeError::InvalidHash(e.to_string()))?;
299        if bytes.len() != 32 {
300            return Err(ImfTypeError::InvalidHash(format!(
301                "SHA-256 digest must be 32 bytes, got {}",
302                bytes.len()
303            )));
304        }
305        Ok(Self {
306            algorithm: HashAlgorithm::Sha256,
307            bytes,
308        })
309    }
310
311    /// Decode a base64-encoded digest for the given algorithm.
312    pub fn from_base64(b64: &str, algorithm: HashAlgorithm) -> Result<Self, ImfTypeError> {
313        match algorithm {
314            HashAlgorithm::Sha1 => Self::from_base64_sha1(b64),
315            HashAlgorithm::Sha256 => Self::from_base64_sha256(b64),
316        }
317    }
318
319    /// Encode the hash bytes as base64, as used in PKL XML.
320    pub fn to_base64(&self) -> String {
321        base64::engine::general_purpose::STANDARD.encode(&self.bytes)
322    }
323}
324
325// ─── MimeType ─────────────────────────────────────────────────────────────────
326
327/// MIME type as used in `<Type>` elements in PKL assets (SMPTE ST 2067-2 §9).
328#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330pub enum MimeType {
331    /// `text/xml` — CPL and other XML documents
332    TextXml,
333    /// `application/xml` — alternative XML MIME type
334    ApplicationXml,
335    /// `application/mxf` — MXF essence files
336    ApplicationMxf,
337    /// Unrecognised; the original string is preserved.
338    Other(String),
339}
340
341impl MimeType {
342    pub fn parse(s: &str) -> Self {
343        match s.trim() {
344            "text/xml" => Self::TextXml,
345            "application/xml" => Self::ApplicationXml,
346            "application/mxf" => Self::ApplicationMxf,
347            other => Self::Other(other.to_string()),
348        }
349    }
350
351    pub fn is_xml(&self) -> bool {
352        matches!(self, Self::TextXml | Self::ApplicationXml)
353    }
354
355    pub fn is_mxf(&self) -> bool {
356        matches!(self, Self::ApplicationMxf)
357    }
358}
359
360impl std::fmt::Display for MimeType {
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        match self {
363            Self::TextXml => write!(f, "text/xml"),
364            Self::ApplicationXml => write!(f, "application/xml"),
365            Self::ApplicationMxf => write!(f, "application/mxf"),
366            Self::Other(s) => write!(f, "{}", s),
367        }
368    }
369}
370
371// ─── AssetMapNamespace ────────────────────────────────────────────────────────
372
373/// The detected SMPTE spec version of an AssetMap document, derived from its root xmlns.
374#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
375#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
376pub enum AssetMapNamespace {
377    /// DCI era — `http://www.smpte-ra.org/schemas/429-9/2007/AM`
378    #[default]
379    Dci429_9,
380    /// SMPTE ST 2067-9:2016 — `http://www.smpte-ra.org/schemas/2067-9/2016`
381    Smpte2067_9_2016,
382    /// SMPTE ST 2067-9:2020 — `http://www.smpte-ra.org/ns/2067-9/2020`
383    Smpte2067_9_2020,
384    /// Unrecognised namespace; the original URI is preserved.
385    Unknown(String),
386}
387
388impl AssetMapNamespace {
389    /// Detect AssetMap spec version from a namespace URI.
390    pub fn from_uri(uri: &str) -> Self {
391        match uri.trim() {
392            "http://www.smpte-ra.org/schemas/429-9/2007/AM" => Self::Dci429_9,
393            "http://www.smpte-ra.org/schemas/2067-9/2016" => Self::Smpte2067_9_2016,
394            "http://www.smpte-ra.org/ns/2067-9/2020" => Self::Smpte2067_9_2020,
395            other => Self::Unknown(other.to_string()),
396        }
397    }
398
399    /// Returns the normative spec document identifier.
400    pub fn spec_id(&self) -> &str {
401        match self {
402            Self::Dci429_9 => "ST 429-9:2007",
403            Self::Smpte2067_9_2016 => "ST 2067-9:2016",
404            Self::Smpte2067_9_2020 => "ST 2067-9:2020",
405            Self::Unknown(_) => "Unknown",
406        }
407    }
408}
409
410impl std::fmt::Display for AssetMapNamespace {
411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        match self {
413            Self::Dci429_9 => write!(f, "http://www.smpte-ra.org/schemas/429-9/2007/AM"),
414            Self::Smpte2067_9_2016 => write!(f, "http://www.smpte-ra.org/schemas/2067-9/2016"),
415            Self::Smpte2067_9_2020 => write!(f, "http://www.smpte-ra.org/ns/2067-9/2020"),
416            Self::Unknown(s) => write!(f, "{}", s),
417        }
418    }
419}
420
421// ─── PklNamespace ─────────────────────────────────────────────────────────────
422
423/// The detected SMPTE spec version of a PKL document, derived from its root xmlns.
424///
425/// PKL schema evolved across three eras: DCI 429-8, IMF 2067-2 (2013-2016), and 2020.
426#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
427#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
428pub enum PklNamespace {
429    /// DCI era — `http://www.smpte-ra.org/schemas/429-8/2007/PKL`
430    #[default]
431    Dci429_8,
432    /// SMPTE ST 2067-2:2013 — `http://www.smpte-ra.org/schemas/2067-2/2013`
433    Smpte2067_2_2013,
434    /// SMPTE ST 2067-2:2016 — `http://www.smpte-ra.org/schemas/2067-2/2016`
435    Smpte2067_2_2016,
436    /// SMPTE ST 2067-2:2016 (PKL variant) — `http://www.smpte-ra.org/schemas/2067-2/2016/PKL`
437    Smpte2067_2_2016Pkl,
438    /// SMPTE ST 2067-2:2020 — `http://www.smpte-ra.org/ns/2067-2/2020`
439    Smpte2067_2_2020,
440    /// Unrecognised namespace; the original URI is preserved.
441    Unknown(String),
442}
443
444impl PklNamespace {
445    /// Detect PKL spec version from a namespace URI.
446    pub fn from_uri(uri: &str) -> Self {
447        match uri.trim() {
448            "http://www.smpte-ra.org/schemas/429-8/2007/PKL" => Self::Dci429_8,
449            "http://www.smpte-ra.org/schemas/2067-2/2013" => Self::Smpte2067_2_2013,
450            "http://www.smpte-ra.org/schemas/2067-2/2016" => Self::Smpte2067_2_2016,
451            "http://www.smpte-ra.org/schemas/2067-2/2016/PKL" => Self::Smpte2067_2_2016Pkl,
452            "http://www.smpte-ra.org/ns/2067-2/2020" => Self::Smpte2067_2_2020,
453            other => Self::Unknown(other.to_string()),
454        }
455    }
456
457    /// Returns the normative spec document identifier.
458    pub fn spec_id(&self) -> &str {
459        match self {
460            Self::Dci429_8 => "ST 429-8:2007",
461            Self::Smpte2067_2_2013 => "ST 2067-2:2013",
462            Self::Smpte2067_2_2016 | Self::Smpte2067_2_2016Pkl => "ST 2067-2:2016",
463            Self::Smpte2067_2_2020 => "ST 2067-2:2020",
464            Self::Unknown(_) => "Unknown",
465        }
466    }
467}
468
469impl std::fmt::Display for PklNamespace {
470    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471        match self {
472            Self::Dci429_8 => write!(f, "http://www.smpte-ra.org/schemas/429-8/2007/PKL"),
473            Self::Smpte2067_2_2013 => write!(f, "http://www.smpte-ra.org/schemas/2067-2/2013"),
474            Self::Smpte2067_2_2016 => write!(f, "http://www.smpte-ra.org/schemas/2067-2/2016"),
475            Self::Smpte2067_2_2016Pkl => {
476                write!(f, "http://www.smpte-ra.org/schemas/2067-2/2016/PKL")
477            }
478            Self::Smpte2067_2_2020 => write!(f, "http://www.smpte-ra.org/ns/2067-2/2020"),
479            Self::Unknown(s) => write!(f, "{}", s),
480        }
481    }
482}
483
484// ─── CoreConstraintsNamespace ─────────────────────────────────────────────────
485
486/// The detected SMPTE core constraints spec version, from inner xmlns declarations in CPLs.
487///
488/// CPL documents reference core constraints namespaces for elements defined in ST 2067-2.
489/// This is distinct from the CPL namespace (ST 2067-3) and determines which core constraint
490/// rules apply.
491#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
492#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
493pub enum CoreConstraintsNamespace {
494    /// SMPTE ST 2067-2:2013 — `http://www.smpte-ra.org/schemas/2067-2/2013`
495    Smpte2067_2_2013,
496    /// SMPTE ST 2067-2:2016 — `http://www.smpte-ra.org/schemas/2067-2/2016`
497    #[default]
498    Smpte2067_2_2016,
499    /// SMPTE ST 2067-2:2020 — `http://www.smpte-ra.org/ns/2067-2/2020`
500    Smpte2067_2_2020,
501    /// Unrecognised namespace; the original URI is preserved.
502    Unknown(String),
503}
504
505impl CoreConstraintsNamespace {
506    /// Detect core constraints spec version from a namespace URI.
507    pub fn from_uri(uri: &str) -> Self {
508        match uri.trim() {
509            "http://www.smpte-ra.org/schemas/2067-2/2013" => Self::Smpte2067_2_2013,
510            "http://www.smpte-ra.org/schemas/2067-2/2016" => Self::Smpte2067_2_2016,
511            "http://www.smpte-ra.org/ns/2067-2/2020" => Self::Smpte2067_2_2020,
512            other => Self::Unknown(other.to_string()),
513        }
514    }
515
516    /// Returns the normative spec document identifier.
517    pub fn spec_id(&self) -> &str {
518        match self {
519            Self::Smpte2067_2_2013 => "ST 2067-2:2013",
520            Self::Smpte2067_2_2016 => "ST 2067-2:2016",
521            Self::Smpte2067_2_2020 => "ST 2067-2:2020",
522            Self::Unknown(_) => "Unknown",
523        }
524    }
525}
526
527impl std::fmt::Display for CoreConstraintsNamespace {
528    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
529        match self {
530            Self::Smpte2067_2_2013 => write!(f, "http://www.smpte-ra.org/schemas/2067-2/2013"),
531            Self::Smpte2067_2_2016 => write!(f, "http://www.smpte-ra.org/schemas/2067-2/2016"),
532            Self::Smpte2067_2_2020 => write!(f, "http://www.smpte-ra.org/ns/2067-2/2020"),
533            Self::Unknown(s) => write!(f, "{}", s),
534        }
535    }
536}
537
538// ─── detect_root_namespace ────────────────────────────────────────────────────
539
540/// Extract the default namespace URI from an XML document's root element.
541///
542/// Searches for the first `xmlns="..."` (non-prefixed) declaration. This is used
543/// by parsers to detect which spec version a document conforms to.
544pub fn detect_root_namespace(xml: &str) -> Option<String> {
545    use std::sync::LazyLock;
546    // Match xmlns="..." but NOT xmlns:prefix="..."
547    // We look for xmlns= preceded by whitespace (not by a colon)
548    static RE_XMLNS: LazyLock<regex::Regex> =
549        LazyLock::new(|| regex::Regex::new(r#"(?:^|[\s<])xmlns="([^"]*)""#).unwrap());
550    RE_XMLNS.captures(xml).map(|cap| cap[1].to_string())
551}
552
553// ─── Parse error ──────────────────────────────────────────────────────────────
554
555/// Errors that can occur when parsing an AssetMap, PKL, OPL, or VOLINDEX.
556#[derive(Debug, Error)]
557pub enum AssetMapParseError {
558    /// The XML is structurally invalid or missing required elements.
559    #[error("XML parse error: {0}")]
560    Xml(#[from] quick_xml::DeError),
561    /// A required field contains an invalid value (bad UUID, bad hash, etc.).
562    #[error("Invalid field '{field}': {source}")]
563    Field {
564        field: &'static str,
565        #[source]
566        source: ImfTypeError,
567    },
568}
569
570// ─── Private raw deserialization layer ────────────────────────────────────────
571
572mod raw {
573    use serde::Deserialize;
574
575    fn default_volume_index() -> u32 {
576        1
577    }
578
579    #[derive(Deserialize)]
580    pub struct AssetMap {
581        #[serde(rename = "Id")]
582        pub id: String,
583        #[serde(rename = "AnnotationText", default)]
584        pub annotation_text: Option<String>,
585        #[serde(rename = "Creator", default)]
586        pub creator: Option<String>,
587        #[serde(rename = "VolumeCount")]
588        pub volume_count: u32,
589        #[serde(rename = "IssueDate")]
590        pub issue_date: String,
591        #[serde(rename = "Issuer", default)]
592        pub issuer: Option<String>,
593        #[serde(rename = "AssetList")]
594        pub asset_list: AssetList,
595    }
596
597    #[derive(Deserialize)]
598    pub struct AssetList {
599        #[serde(rename = "Asset")]
600        pub assets: Vec<Asset>,
601    }
602
603    #[derive(Deserialize)]
604    pub struct Asset {
605        #[serde(rename = "Id")]
606        pub id: String,
607        #[serde(rename = "PackingList", default)]
608        pub packing_list: Option<bool>,
609        #[serde(rename = "ChunkList")]
610        pub chunk_list: ChunkList,
611    }
612
613    #[derive(Deserialize)]
614    pub struct ChunkList {
615        #[serde(rename = "Chunk")]
616        pub chunks: Vec<Chunk>,
617    }
618
619    #[derive(Deserialize)]
620    pub struct Chunk {
621        #[serde(rename = "Path")]
622        pub path: String,
623        #[serde(rename = "VolumeIndex", default = "default_volume_index")]
624        pub volume_index: u32,
625    }
626
627    // ── OPL (ST 2067-100) ──────────────────────────────────────────────────
628
629    #[derive(Deserialize)]
630    pub struct OutputProfileList {
631        #[serde(rename = "Id")]
632        pub id: String,
633        #[serde(rename = "Annotation", default)]
634        pub annotation: Option<String>,
635        #[serde(rename = "IssueDate")]
636        pub issue_date: String,
637        #[serde(rename = "Issuer", default)]
638        pub issuer: Option<String>,
639        #[serde(rename = "Creator", default)]
640        pub creator: Option<String>,
641        #[serde(rename = "CompositionPlaylistId")]
642        pub composition_playlist_id: String,
643    }
644
645    // ── PKL ─────────────────────────────────────────────────────────────────
646
647    #[derive(Deserialize)]
648    pub struct PackingList {
649        #[serde(rename = "Id")]
650        pub id: String,
651        #[serde(rename = "AnnotationText", default)]
652        pub annotation_text: Option<String>,
653        #[serde(rename = "IssueDate")]
654        pub issue_date: String,
655        #[serde(rename = "Issuer", default)]
656        pub issuer: Option<String>,
657        #[serde(rename = "Creator", default)]
658        pub creator: Option<String>,
659        /// SMPTE ST 2067-2 §9: Optional group identifier for partial deliveries.
660        #[serde(rename = "GroupId", default)]
661        pub group_id: Option<String>,
662        #[serde(rename = "AssetList")]
663        pub asset_list: PklAssetList,
664    }
665
666    #[derive(Deserialize)]
667    pub struct PklAssetList {
668        #[serde(rename = "Asset")]
669        pub assets: Vec<PklAsset>,
670    }
671
672    /// `ds:DigestMethodType` — carries an `Algorithm` attribute URI.
673    /// Used in `<HashAlgorithm Algorithm="..."/>` per SMPTE ST 2067-2 §9.
674    #[derive(Deserialize)]
675    pub struct DigestMethod {
676        #[serde(rename = "@Algorithm")]
677        pub algorithm: String,
678    }
679
680    #[derive(Deserialize)]
681    pub struct PklAsset {
682        #[serde(rename = "Id")]
683        pub id: String,
684        #[serde(rename = "AnnotationText", default)]
685        pub annotation_text: Option<String>,
686        #[serde(rename = "Hash")]
687        pub hash: String,
688        #[serde(rename = "Size")]
689        pub size: u64,
690        #[serde(rename = "Type")]
691        pub mime_type: String,
692        #[serde(rename = "OriginalFileName", default)]
693        pub original_file_name: Option<String>,
694        /// SMPTE ST 2067-2 §9: Optional hash algorithm override.
695        /// When absent, SHA-1 is assumed (default per spec).
696        #[serde(rename = "HashAlgorithm", default)]
697        pub hash_algorithm: Option<DigestMethod>,
698    }
699}
700
701// ─── Public domain types ───────────────────────────────────────────────────────
702
703// VolumeIndex lives in the volindex submodule and is re-exported at the top of this file.
704
705/// ASSETMAP.xml — maps UUIDs to physical file paths (ST 429-9 §6).
706#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
707#[derive(Debug, Serialize, Deserialize, PartialEq)]
708#[serde(rename_all = "camelCase")]
709#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
710#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
711#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
712#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
713pub struct AssetMap {
714    /// The SMPTE spec version detected from the root xmlns.
715    #[serde(skip)]
716    pub namespace: AssetMapNamespace,
717    /// Unique identifier for this AssetMap (ST 429-9 §6.2).
718    pub id: ImfUuid,
719    pub annotation_text: Option<String>,
720    pub creator: Option<String>,
721    pub volume_count: u32,
722    /// ISO 8601 issue date (e.g. `"2016-10-06T08:35:02-00:00"`).
723    pub issue_date: String,
724    pub issuer: Option<String>,
725    pub asset_list: AssetList,
726}
727
728impl AssetMap {
729    fn from_raw(
730        raw: raw::AssetMap,
731        namespace: AssetMapNamespace,
732    ) -> Result<Self, AssetMapParseError> {
733        Ok(Self {
734            namespace,
735            id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
736                field: "Id",
737                source,
738            })?,
739            annotation_text: raw.annotation_text,
740            creator: raw.creator,
741            volume_count: raw.volume_count,
742            issue_date: raw.issue_date,
743            issuer: raw.issuer,
744            asset_list: AssetList::from_raw(raw.asset_list)?,
745        })
746    }
747}
748
749/// The `<AssetList>` element in ASSETMAP.xml.
750#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
751#[derive(Debug, Serialize, Deserialize, PartialEq)]
752#[serde(rename_all = "camelCase")]
753#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
754#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
755#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
756#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
757pub struct AssetList {
758    pub assets: Vec<Asset>,
759}
760
761impl AssetList {
762    fn from_raw(raw: raw::AssetList) -> Result<Self, AssetMapParseError> {
763        let assets = raw
764            .assets
765            .into_iter()
766            .map(Asset::from_raw)
767            .collect::<Result<Vec<_>, _>>()?;
768        Ok(Self { assets })
769    }
770}
771
772/// A single asset entry in ASSETMAP.xml.
773#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
774#[derive(Debug, Serialize, Deserialize, PartialEq)]
775#[serde(rename_all = "camelCase")]
776#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
777#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
778#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
779#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
780pub struct Asset {
781    /// UUID identifying this asset.
782    pub id: ImfUuid,
783    /// Present and `true` when this entry refers to the Packing List file.
784    pub packing_list: Option<bool>,
785    pub chunk_list: ChunkList,
786}
787
788impl Asset {
789    fn from_raw(raw: raw::Asset) -> Result<Self, AssetMapParseError> {
790        Ok(Self {
791            id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
792                field: "Id",
793                source,
794            })?,
795            packing_list: raw.packing_list,
796            chunk_list: ChunkList::from_raw(raw.chunk_list),
797        })
798    }
799}
800
801/// A list of file chunks for a single asset.
802#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
803#[derive(Debug, Serialize, Deserialize, PartialEq)]
804#[serde(rename_all = "camelCase")]
805#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
806#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
807#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
808#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
809pub struct ChunkList {
810    pub chunks: Vec<Chunk>,
811}
812
813impl ChunkList {
814    fn from_raw(raw: raw::ChunkList) -> Self {
815        Self {
816            chunks: raw
817                .chunks
818                .into_iter()
819                .map(|c| Chunk {
820                    path: c.path,
821                    volume_index: c.volume_index,
822                })
823                .collect(),
824        }
825    }
826}
827
828/// A single file path entry in a ChunkList.
829#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
830#[derive(Debug, Serialize, Deserialize, PartialEq)]
831#[serde(rename_all = "camelCase")]
832#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
833#[cfg_attr(feature = "typescript", ts(export, rename_all = "camelCase"))]
834#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
835#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
836pub struct Chunk {
837    /// File path relative to the IMP root directory.
838    pub path: String,
839    pub volume_index: u32,
840}
841
842/// OPL XML — Output Profile List (SMPTE ST 2067-100).
843///
844/// Defines output processing instructions for a composition: image scaling,
845/// cropping, pixel encoding, and audio routing/mixing macros. The macro list
846/// is not deserialized (it uses `xsi:type` polymorphism with vendor-specific
847/// extension types).
848#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
849#[derive(Debug, Serialize, Deserialize, PartialEq)]
850#[serde(rename_all = "camelCase")]
851pub struct OutputProfileList {
852    pub id: ImfUuid,
853    pub annotation: Option<String>,
854    /// ISO 8601 issue date.
855    pub issue_date: String,
856    pub issuer: Option<String>,
857    pub creator: Option<String>,
858    /// The CPL that this OPL targets.
859    pub composition_playlist_id: ImfUuid,
860}
861
862impl OutputProfileList {
863    fn from_raw(raw: raw::OutputProfileList) -> Result<Self, AssetMapParseError> {
864        Ok(Self {
865            id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
866                field: "Id",
867                source,
868            })?,
869            annotation: raw.annotation,
870            issue_date: raw.issue_date,
871            issuer: raw.issuer,
872            creator: raw.creator,
873            composition_playlist_id: ImfUuid::parse(&raw.composition_playlist_id).map_err(
874                |source| AssetMapParseError::Field {
875                    field: "CompositionPlaylistId",
876                    source,
877                },
878            )?,
879        })
880    }
881}
882
883/// PKL XML — Packing List (SMPTE ST 2067-2 §9).
884///
885/// Assets carry SHA-1 (default) or SHA-256 checksums. The algorithm is
886/// determined by the optional `<HashAlgorithm>` element on each asset.
887#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
888#[derive(Debug, Serialize, Deserialize, PartialEq)]
889#[serde(rename_all = "camelCase")]
890pub struct PackingList {
891    /// The SMPTE spec version detected from the root xmlns.
892    #[serde(skip)]
893    pub namespace: PklNamespace,
894    pub id: ImfUuid,
895    pub annotation_text: Option<String>,
896    /// ISO 8601 issue date.
897    pub issue_date: String,
898    pub issuer: Option<String>,
899    pub creator: Option<String>,
900    /// Optional group identifier for partial deliveries (SMPTE ST 2067-2 §9).
901    pub group_id: Option<ImfUuid>,
902    pub asset_list: PklAssetList,
903}
904
905impl PackingList {
906    fn from_raw(
907        raw: raw::PackingList,
908        namespace: PklNamespace,
909    ) -> Result<Self, AssetMapParseError> {
910        let group_id = raw
911            .group_id
912            .map(|s| ImfUuid::parse(&s))
913            .transpose()
914            .map_err(|source| AssetMapParseError::Field {
915                field: "GroupId",
916                source,
917            })?;
918
919        Ok(Self {
920            namespace,
921            id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
922                field: "Id",
923                source,
924            })?,
925            annotation_text: raw.annotation_text,
926            issue_date: raw.issue_date,
927            issuer: raw.issuer,
928            creator: raw.creator,
929            group_id,
930            asset_list: PklAssetList::from_raw(raw.asset_list)?,
931        })
932    }
933}
934
935/// The `<AssetList>` element in a PKL.
936#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
937#[derive(Debug, Serialize, Deserialize, PartialEq)]
938#[serde(rename_all = "camelCase")]
939pub struct PklAssetList {
940    pub assets: Vec<PklAsset>,
941}
942
943impl PklAssetList {
944    fn from_raw(raw: raw::PklAssetList) -> Result<Self, AssetMapParseError> {
945        let assets = raw
946            .assets
947            .into_iter()
948            .map(PklAsset::from_raw)
949            .collect::<Result<Vec<_>, _>>()?;
950        Ok(Self { assets })
951    }
952}
953
954/// A single asset entry in a PKL.
955#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
956#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
957#[serde(rename_all = "camelCase")]
958pub struct PklAsset {
959    pub id: ImfUuid,
960    pub annotation_text: Option<String>,
961    /// SHA-1 digest decoded from the base64 `<Hash>` element (SMPTE ST 2067-2 §9.3).
962    pub hash: AssetHash,
963    /// File size in bytes.
964    pub size: u64,
965    /// MIME type of the asset file (SMPTE ST 2067-2 §9.4).
966    pub mime_type: MimeType,
967    pub original_file_name: Option<String>,
968}
969
970impl PklAsset {
971    fn from_raw(raw: raw::PklAsset) -> Result<Self, AssetMapParseError> {
972        // Determine hash algorithm from <HashAlgorithm Algorithm="..."/> element.
973        // Per ST 2067-2 §9, SHA-1 is the default when the element is absent.
974        let algorithm = match &raw.hash_algorithm {
975            Some(dm) => {
976                HashAlgorithm::from_uri(&dm.algorithm).ok_or_else(|| AssetMapParseError::Field {
977                    field: "HashAlgorithm",
978                    source: ImfTypeError::InvalidHash(format!(
979                        "unsupported hash algorithm URI: {}",
980                        dm.algorithm
981                    )),
982                })?
983            }
984            None => HashAlgorithm::Sha1,
985        };
986
987        Ok(Self {
988            id: ImfUuid::parse(&raw.id).map_err(|source| AssetMapParseError::Field {
989                field: "Id",
990                source,
991            })?,
992            annotation_text: raw.annotation_text,
993            hash: AssetHash::from_base64(&raw.hash, algorithm).map_err(|source| {
994                AssetMapParseError::Field {
995                    field: "Hash",
996                    source,
997                }
998            })?,
999            size: raw.size,
1000            mime_type: MimeType::parse(&raw.mime_type),
1001            original_file_name: raw.original_file_name,
1002        })
1003    }
1004}
1005
1006// ─── Parse functions ──────────────────────────────────────────────────────────
1007
1008// parse_volindex is re-exported from the volindex module at the top of this file.
1009
1010/// Parse ASSETMAP.xml (ST 429-9 §6).
1011///
1012/// Detects the SMPTE spec version from the root `xmlns` attribute and stores it
1013/// on the returned `AssetMap.namespace` field.
1014pub fn parse_assetmap(xml_content: &str) -> Result<AssetMap, AssetMapParseError> {
1015    let namespace = detect_root_namespace(xml_content)
1016        .map(|uri| AssetMapNamespace::from_uri(&uri))
1017        .unwrap_or_default();
1018    let raw: raw::AssetMap = quick_xml::de::from_str(xml_content)?;
1019    AssetMap::from_raw(raw, namespace)
1020}
1021
1022/// Parse PKL XML (SMPTE ST 2067-2 §9).
1023///
1024/// Detects the SMPTE spec version from the root `xmlns` attribute and stores it
1025/// on the returned `PackingList.namespace` field.
1026pub fn parse_pkl(xml_content: &str) -> Result<PackingList, AssetMapParseError> {
1027    let namespace = detect_root_namespace(xml_content)
1028        .map(|uri| PklNamespace::from_uri(&uri))
1029        .unwrap_or_default();
1030    let raw: raw::PackingList = quick_xml::de::from_str(xml_content)?;
1031    PackingList::from_raw(raw, namespace)
1032}
1033
1034/// Parse OPL XML (SMPTE ST 2067-100).
1035///
1036/// Extracts the core metadata (Id, Annotation, IssueDate, Issuer, Creator,
1037/// CompositionPlaylistId). The MacroList is not deserialized because it uses
1038/// `xsi:type` polymorphism with vendor-specific extension namespaces.
1039pub fn parse_opl(xml_content: &str) -> Result<OutputProfileList, AssetMapParseError> {
1040    let raw: raw::OutputProfileList = quick_xml::de::from_str(xml_content)?;
1041    OutputProfileList::from_raw(raw)
1042}
1043
1044// ─── Tests ────────────────────────────────────────────────────────────────────
1045
1046#[cfg(test)]
1047mod tests {
1048    use super::*;
1049    use pretty_assertions::assert_eq;
1050    use std::path::PathBuf;
1051
1052    fn test_data(name: &str) -> PathBuf {
1053        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1054            .join("../../test-data")
1055            .join(name)
1056    }
1057
1058    // ── ImfUuid ──────────────────────────────────────────────────────────────
1059
1060    /// SMPTE ST 2067-2 §7: UUIDs are serialized as urn:uuid: URNs in XML.
1061    #[test]
1062    fn uuid_parse_urn_form() {
1063        let id = ImfUuid::parse("urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85").unwrap();
1064        assert_eq!(id.to_string(), "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
1065        assert_eq!(id.to_urn(), "urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
1066    }
1067
1068    #[test]
1069    fn uuid_parse_bare_form() {
1070        let id = ImfUuid::parse("0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85").unwrap();
1071        assert_eq!(id.to_string(), "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
1072    }
1073
1074    #[test]
1075    fn uuid_parse_invalid() {
1076        assert!(ImfUuid::parse("not-a-uuid").is_err());
1077        assert!(ImfUuid::parse("").is_err());
1078        assert!(ImfUuid::parse("urn:uuid:not-valid").is_err());
1079    }
1080
1081    #[test]
1082    fn uuid_roundtrip_serde() {
1083        let id = ImfUuid::parse("urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85").unwrap();
1084        let json = serde_json::to_string(&id).unwrap();
1085        // Serializes as bare UUID (no urn: prefix) for JSON
1086        assert_eq!(json, r#""0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85""#);
1087        let back: ImfUuid = serde_json::from_str(&json).unwrap();
1088        assert_eq!(id, back);
1089    }
1090
1091    #[test]
1092    fn uuid_deserialize_urn_from_json() {
1093        // Deserializer must accept urn: form too
1094        let back: ImfUuid =
1095            serde_json::from_str(r#""urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85""#).unwrap();
1096        assert_eq!(back.to_string(), "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85");
1097    }
1098
1099    // ── SmpteUl ──────────────────────────────────────────────────────────────
1100
1101    /// ST 298M: Byte 8 (registry version) must be ignored for semantic identity.
1102    #[test]
1103    fn smpte_ul_parse_4group() {
1104        let ul = SmpteUl::parse("060e2b34.04010106.04010101.03030000").unwrap();
1105        assert_eq!(ul.0[0], 0x06);
1106        assert_eq!(ul.0[7], 0x06); // byte 8 = version
1107        assert_eq!(ul.0[12], 0x03);
1108    }
1109
1110    #[test]
1111    fn smpte_ul_parse_urn_form() {
1112        let ul = SmpteUl::parse("urn:smpte:ul:060e2b34.04010106.04010101.03030000").unwrap();
1113        assert_eq!(ul.0[0], 0x06);
1114    }
1115
1116    #[test]
1117    fn smpte_ul_parse_5group_variant() {
1118        // Variant format from some test data
1119        let ul = SmpteUl::parse("urn:smpte:ul:060e2b34.0401.0101.04010101.01020000").unwrap();
1120        assert_eq!(ul.0[4], 0x04);
1121        assert_eq!(ul.0[5], 0x01);
1122    }
1123
1124    #[test]
1125    fn smpte_ul_version_agnostic_equality() {
1126        // Same UL at different registry versions
1127        let v1 = SmpteUl::parse("060e2b34.04010101.04010101.03030000").unwrap();
1128        let v6 = SmpteUl::parse("060e2b34.04010106.04010101.03030000").unwrap();
1129        let vd = SmpteUl::parse("060e2b34.0401010d.04010101.03030000").unwrap();
1130        assert_eq!(v1, v6, "version 01 == version 06");
1131        assert_eq!(v6, vd, "version 06 == version 0d");
1132    }
1133
1134    #[test]
1135    fn smpte_ul_different_items_not_equal() {
1136        let a = SmpteUl::parse("060e2b34.04010106.04010101.03030000").unwrap();
1137        let b = SmpteUl::parse("060e2b34.04010106.04010101.03040000").unwrap();
1138        assert_ne!(a, b);
1139    }
1140
1141    #[test]
1142    fn smpte_ul_display_roundtrip() {
1143        let ul = SmpteUl::parse("urn:smpte:ul:060e2b34.04010106.04010101.03030000").unwrap();
1144        let s = ul.to_string();
1145        assert!(s.starts_with("urn:smpte:ul:"));
1146        let ul2 = SmpteUl::parse(&s).unwrap();
1147        assert_eq!(ul, ul2);
1148    }
1149
1150    #[test]
1151    fn smpte_ul_parse_invalid() {
1152        assert!(SmpteUl::parse("not-a-ul").is_err());
1153        assert!(SmpteUl::parse("060e2b34.04010106").is_err()); // too short
1154    }
1155
1156    // ── AssetHash ────────────────────────────────────────────────────────────
1157
1158    /// SMPTE ST 2067-2 §9: PKL assets carry base64-encoded SHA-1 digests.
1159    #[test]
1160    fn asset_hash_sha1_roundtrip() {
1161        // SHA-1 of empty bytes
1162        let b64 = "2jmj7l5rSw0yVb/vlWAYkK/YBwk=";
1163        let h = AssetHash::from_base64_sha1(b64).unwrap();
1164        assert_eq!(h.algorithm, HashAlgorithm::Sha1);
1165        assert_eq!(h.bytes.len(), 20);
1166        assert_eq!(h.to_base64(), b64);
1167    }
1168
1169    /// SMPTE ST 2067-2 §9: SHA-1 digest must be exactly 20 bytes.
1170    #[test]
1171    fn asset_hash_sha1_wrong_length_rejected() {
1172        let err = AssetHash::from_base64_sha1("AAAA").unwrap_err();
1173        assert!(err.to_string().contains("20 bytes"));
1174    }
1175
1176    #[test]
1177    fn asset_hash_sha256_roundtrip() {
1178        let b64 = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=";
1179        let h = AssetHash::from_base64_sha256(b64).unwrap();
1180        assert_eq!(h.algorithm, HashAlgorithm::Sha256);
1181        assert_eq!(h.bytes.len(), 32);
1182        assert_eq!(h.to_base64(), b64);
1183    }
1184
1185    #[test]
1186    fn asset_hash_sha256_wrong_length_rejected() {
1187        let err = AssetHash::from_base64_sha256("2jmj7l5rSw0yVb/vlWAYkK/YBwk=").unwrap_err();
1188        assert!(err.to_string().contains("32 bytes"));
1189    }
1190
1191    #[test]
1192    fn asset_hash_from_base64_routes_correctly() {
1193        let sha1_b64 = "2jmj7l5rSw0yVb/vlWAYkK/YBwk=";
1194        let sha256_b64 = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=";
1195
1196        let h1 = AssetHash::from_base64(sha1_b64, HashAlgorithm::Sha1).unwrap();
1197        assert_eq!(h1.algorithm, HashAlgorithm::Sha1);
1198
1199        let h2 = AssetHash::from_base64(sha256_b64, HashAlgorithm::Sha256).unwrap();
1200        assert_eq!(h2.algorithm, HashAlgorithm::Sha256);
1201    }
1202
1203    #[test]
1204    fn asset_hash_invalid_base64() {
1205        assert!(AssetHash::from_base64_sha1("not-valid-base64!!!").is_err());
1206    }
1207
1208    // ── HashAlgorithm ─────────────────────────────────────────────────────────
1209
1210    /// SMPTE ST 2067-2 §9: HashAlgorithm URI parsing.
1211    #[test]
1212    fn hash_algorithm_from_uri() {
1213        assert_eq!(
1214            HashAlgorithm::from_uri("http://www.w3.org/2000/09/xmldsig#sha1"),
1215            Some(HashAlgorithm::Sha1)
1216        );
1217        assert_eq!(
1218            HashAlgorithm::from_uri("http://www.w3.org/2001/04/xmlenc#sha256"),
1219            Some(HashAlgorithm::Sha256)
1220        );
1221        assert_eq!(HashAlgorithm::from_uri("http://example.com/unknown"), None);
1222    }
1223
1224    #[test]
1225    fn hash_algorithm_digest_len() {
1226        assert_eq!(HashAlgorithm::Sha1.digest_len(), 20);
1227        assert_eq!(HashAlgorithm::Sha256.digest_len(), 32);
1228    }
1229
1230    #[test]
1231    fn hash_algorithm_display() {
1232        assert_eq!(HashAlgorithm::Sha1.to_string(), "SHA-1");
1233        assert_eq!(HashAlgorithm::Sha256.to_string(), "SHA-256");
1234    }
1235
1236    // ── VOLINDEX ──────────────────────────────────────────────────────────────
1237
1238    /// ST 429-9 §5: VOLINDEX.xml contains a single <Index> element.
1239    #[test]
1240    fn volindex_parses_index_element() {
1241        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
1242<VolumeIndex xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
1243    <Index>1</Index>
1244</VolumeIndex>"#;
1245        let result = parse_volindex(xml).unwrap();
1246        assert_eq!(result.index, 1);
1247    }
1248
1249    // ── ASSETMAP ──────────────────────────────────────────────────────────────
1250
1251    /// ST 429-9 §6.2: AssetMap Id must be a valid UUID URN.
1252    #[test]
1253    fn assetmap_id_is_imf_uuid() {
1254        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
1255<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
1256    <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1257    <AnnotationText>MERIDIAN</AnnotationText>
1258    <Creator>Clipster 6.1.0.0 Beta (build 111500)</Creator>
1259    <VolumeCount>1</VolumeCount>
1260    <IssueDate>2016-10-06T08:35:02-00:00</IssueDate>
1261    <Issuer>R&amp;S</Issuer>
1262    <AssetList>
1263        <Asset>
1264            <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1265            <ChunkList>
1266                <Chunk>
1267                    <Path>CPL_0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85.xml</Path>
1268                    <VolumeIndex>1</VolumeIndex>
1269                </Chunk>
1270            </ChunkList>
1271        </Asset>
1272        <Asset>
1273            <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1274            <PackingList>true</PackingList>
1275            <ChunkList>
1276                <Chunk>
1277                    <Path>PKL_f5e93462-aed2-44ad-a4ba-2adb65823e7c.xml</Path>
1278                    <VolumeIndex>1</VolumeIndex>
1279                </Chunk>
1280            </ChunkList>
1281        </Asset>
1282    </AssetList>
1283</AssetMap>"#;
1284
1285        let result = parse_assetmap(xml).unwrap();
1286        assert_eq!(
1287            result.id,
1288            ImfUuid::parse("urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7").unwrap()
1289        );
1290        assert_eq!(result.annotation_text, Some("MERIDIAN".to_string()));
1291        assert_eq!(result.volume_count, 1);
1292        assert_eq!(result.asset_list.assets.len(), 2);
1293
1294        // ST 429-9 §6.3: Asset entries carry UUID references to package files.
1295        let cpl_asset = &result.asset_list.assets[0];
1296        assert_eq!(
1297            cpl_asset.id,
1298            ImfUuid::parse("urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85").unwrap()
1299        );
1300        assert_eq!(cpl_asset.packing_list, None);
1301        assert_eq!(
1302            cpl_asset.chunk_list.chunks[0].path,
1303            "CPL_0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85.xml"
1304        );
1305
1306        // ST 429-9 §6.3: PackingList flag marks the PKL asset.
1307        let pkl_asset = &result.asset_list.assets[1];
1308        assert_eq!(pkl_asset.packing_list, Some(true));
1309        assert_eq!(
1310            pkl_asset.chunk_list.chunks[0].path,
1311            "PKL_f5e93462-aed2-44ad-a4ba-2adb65823e7c.xml"
1312        );
1313    }
1314
1315    /// ST 429-9 §6.2: Invalid UUID in AssetMap <Id> yields a typed error.
1316    #[test]
1317    fn assetmap_invalid_uuid_returns_field_error() {
1318        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1319<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
1320    <Id>not-a-valid-uuid</Id>
1321    <VolumeCount>1</VolumeCount>
1322    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1323    <AssetList><Asset>
1324        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1325        <ChunkList><Chunk><Path>foo.xml</Path></Chunk></ChunkList>
1326    </Asset></AssetList>
1327</AssetMap>"#;
1328        let err = parse_assetmap(xml).unwrap_err();
1329        assert!(
1330            matches!(err, AssetMapParseError::Field { field: "Id", .. }),
1331            "expected Field error for Id, got: {err}"
1332        );
1333    }
1334
1335    // ── PKL ───────────────────────────────────────────────────────────────────
1336
1337    /// SMPTE ST 2067-2 §9: PKL carries SHA-1 hashes, sizes, and MIME types.
1338    #[test]
1339    fn pkl_parses_assets_with_strong_types() {
1340        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
1341<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1342    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1343    <AnnotationText>MERIDIAN</AnnotationText>
1344    <IssueDate>2016-10-06T08:35:02-00:00</IssueDate>
1345    <Issuer>R&amp;S</Issuer>
1346    <Creator>Clipster 6.1.0.0 Beta (build 111500)</Creator>
1347    <AssetList>
1348        <Asset>
1349            <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1350            <AnnotationText>Meridian UHD 5994P</AnnotationText>
1351            <Hash>IW0J5IZBsAxLMCCmWtHvfHhjVUw=</Hash>
1352            <Size>15214</Size>
1353            <Type>text/xml</Type>
1354            <OriginalFileName>CPL_0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85.xml</OriginalFileName>
1355        </Asset>
1356        <Asset>
1357            <Id>urn:uuid:61d91654-2650-4abf-abbc-ad2c7f640bf8</Id>
1358            <Hash>fL7SnTeNskm71I4otXqr/T0D5LQ=</Hash>
1359            <Size>79486353</Size>
1360            <Type>application/mxf</Type>
1361            <OriginalFileName>MERIDIAN_Netflix_Photon_161006_00.mxf</OriginalFileName>
1362        </Asset>
1363    </AssetList>
1364</PackingList>"#;
1365
1366        let result = parse_pkl(xml).unwrap();
1367        assert_eq!(
1368            result.id,
1369            ImfUuid::parse("urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c").unwrap()
1370        );
1371        assert_eq!(result.annotation_text, Some("MERIDIAN".to_string()));
1372        assert_eq!(result.issuer, Some("R&S".to_string()));
1373        assert_eq!(result.asset_list.assets.len(), 2);
1374
1375        let cpl_asset = &result.asset_list.assets[0];
1376        assert_eq!(cpl_asset.hash.algorithm, HashAlgorithm::Sha1);
1377        assert_eq!(cpl_asset.hash.bytes.len(), 20);
1378        assert_eq!(cpl_asset.hash.to_base64(), "IW0J5IZBsAxLMCCmWtHvfHhjVUw=");
1379        assert_eq!(cpl_asset.size, 15214);
1380        assert_eq!(cpl_asset.mime_type, MimeType::TextXml);
1381        assert!(cpl_asset.mime_type.is_xml());
1382
1383        let mxf_asset = &result.asset_list.assets[1];
1384        assert_eq!(mxf_asset.mime_type, MimeType::ApplicationMxf);
1385        assert!(mxf_asset.mime_type.is_mxf());
1386    }
1387
1388    /// SMPTE ST 2067-2 §9: PKL with explicit SHA-1 <HashAlgorithm> element.
1389    #[test]
1390    fn pkl_explicit_sha1_hash_algorithm() {
1391        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1392<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1393    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1394    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1395    <AssetList><Asset>
1396        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1397        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1398        <Size>1024</Size>
1399        <Type>application/mxf</Type>
1400        <HashAlgorithm Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
1401    </Asset></AssetList>
1402</PackingList>"#;
1403        let result = parse_pkl(xml).unwrap();
1404        assert_eq!(
1405            result.asset_list.assets[0].hash.algorithm,
1406            HashAlgorithm::Sha1
1407        );
1408    }
1409
1410    /// SMPTE ST 2067-2 §9: PKL with SHA-256 <HashAlgorithm>.
1411    #[test]
1412    fn pkl_sha256_hash_algorithm() {
1413        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1414<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1415    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1416    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1417    <AssetList><Asset>
1418        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1419        <Hash>47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=</Hash>
1420        <Size>1024</Size>
1421        <Type>application/mxf</Type>
1422        <HashAlgorithm Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
1423    </Asset></AssetList>
1424</PackingList>"#;
1425        let result = parse_pkl(xml).unwrap();
1426        assert_eq!(
1427            result.asset_list.assets[0].hash.algorithm,
1428            HashAlgorithm::Sha256
1429        );
1430        assert_eq!(result.asset_list.assets[0].hash.bytes.len(), 32);
1431    }
1432
1433    /// SMPTE ST 2067-2 §9: PKL without <HashAlgorithm> defaults to SHA-1.
1434    #[test]
1435    fn pkl_missing_hash_algorithm_defaults_to_sha1() {
1436        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1437<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1438    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1439    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1440    <AssetList><Asset>
1441        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1442        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1443        <Size>1024</Size>
1444        <Type>application/mxf</Type>
1445    </Asset></AssetList>
1446</PackingList>"#;
1447        let result = parse_pkl(xml).unwrap();
1448        assert_eq!(
1449            result.asset_list.assets[0].hash.algorithm,
1450            HashAlgorithm::Sha1
1451        );
1452    }
1453
1454    /// SMPTE ST 2067-2 §9: PKL with <GroupId> for partial deliveries.
1455    #[test]
1456    fn pkl_with_group_id() {
1457        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1458<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1459    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1460    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1461    <GroupId>urn:uuid:aabbccdd-1122-3344-5566-778899aabbcc</GroupId>
1462    <AssetList><Asset>
1463        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1464        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1465        <Size>1024</Size>
1466        <Type>application/mxf</Type>
1467    </Asset></AssetList>
1468</PackingList>"#;
1469        let result = parse_pkl(xml).unwrap();
1470        assert_eq!(
1471            result.group_id,
1472            Some(ImfUuid::parse("urn:uuid:aabbccdd-1122-3344-5566-778899aabbcc").unwrap())
1473        );
1474    }
1475
1476    /// SMPTE ST 2067-2 §9: Unrecognised MIME type is preserved as MimeType::Other.
1477    #[test]
1478    fn pkl_unknown_mime_type_preserved() {
1479        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1480<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1481    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1482    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1483    <AssetList><Asset>
1484        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1485        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1486        <Size>512</Size>
1487        <Type>application/octet-stream</Type>
1488    </Asset></AssetList>
1489</PackingList>"#;
1490        let result = parse_pkl(xml).unwrap();
1491        assert_eq!(
1492            result.asset_list.assets[0].mime_type,
1493            MimeType::Other("application/octet-stream".to_string())
1494        );
1495    }
1496
1497    // ── Namespace compatibility ──────────────────────────────────────────────
1498
1499    /// ST 2067-2: PKL namespace versions must all parse identically.
1500    #[test]
1501    fn pkl_parses_with_2067_2_2016_namespace() {
1502        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1503<PackingList xmlns="http://www.smpte-ra.org/schemas/2067-2/2016">
1504    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1505    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1506    <AssetList><Asset>
1507        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1508        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1509        <Size>1024</Size>
1510        <Type>application/mxf</Type>
1511    </Asset></AssetList>
1512</PackingList>"#;
1513        let result = parse_pkl(xml).unwrap();
1514        assert_eq!(result.asset_list.assets.len(), 1);
1515        assert_eq!(result.namespace, PklNamespace::Smpte2067_2_2016);
1516        assert_eq!(result.namespace.spec_id(), "ST 2067-2:2016");
1517    }
1518
1519    #[test]
1520    fn pkl_parses_with_2067_2_2020_namespace() {
1521        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1522<PackingList xmlns="http://www.smpte-ra.org/ns/2067-2/2020">
1523    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1524    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1525    <AssetList><Asset>
1526        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1527        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1528        <Size>1024</Size>
1529        <Type>application/mxf</Type>
1530    </Asset></AssetList>
1531</PackingList>"#;
1532        let result = parse_pkl(xml).unwrap();
1533        assert_eq!(result.namespace, PklNamespace::Smpte2067_2_2020);
1534    }
1535
1536    #[test]
1537    fn pkl_detects_dci_429_8_namespace() {
1538        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1539<PackingList xmlns="http://www.smpte-ra.org/schemas/429-8/2007/PKL">
1540    <Id>urn:uuid:f5e93462-aed2-44ad-a4ba-2adb65823e7c</Id>
1541    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1542    <AssetList><Asset>
1543        <Id>urn:uuid:00000000-0000-0000-0000-000000000001</Id>
1544        <Hash>2jmj7l5rSw0yVb/vlWAYkK/YBwk=</Hash>
1545        <Size>1024</Size>
1546        <Type>application/mxf</Type>
1547    </Asset></AssetList>
1548</PackingList>"#;
1549        let result = parse_pkl(xml).unwrap();
1550        assert_eq!(result.namespace, PklNamespace::Dci429_8);
1551    }
1552
1553    #[test]
1554    fn assetmap_parses_with_2067_9_namespace() {
1555        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1556<AssetMap xmlns="http://www.smpte-ra.org/schemas/2067-9/2016">
1557    <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1558    <VolumeCount>1</VolumeCount>
1559    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1560    <AssetList>
1561        <Asset>
1562            <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1563            <ChunkList><Chunk><Path>test.xml</Path></Chunk></ChunkList>
1564        </Asset>
1565    </AssetList>
1566</AssetMap>"#;
1567        let result = parse_assetmap(xml).unwrap();
1568        assert_eq!(result.asset_list.assets.len(), 1);
1569        assert_eq!(result.namespace, AssetMapNamespace::Smpte2067_9_2016);
1570    }
1571
1572    #[test]
1573    fn assetmap_parses_with_2020_namespace() {
1574        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1575<AssetMap xmlns="http://www.smpte-ra.org/ns/2067-9/2020">
1576    <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1577    <VolumeCount>1</VolumeCount>
1578    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1579    <AssetList>
1580        <Asset>
1581            <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1582            <ChunkList><Chunk><Path>test.xml</Path></Chunk></ChunkList>
1583        </Asset>
1584    </AssetList>
1585</AssetMap>"#;
1586        let result = parse_assetmap(xml).unwrap();
1587        assert_eq!(result.namespace, AssetMapNamespace::Smpte2067_9_2020);
1588    }
1589
1590    #[test]
1591    fn assetmap_detects_dci_429_9_namespace() {
1592        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1593<AssetMap xmlns="http://www.smpte-ra.org/schemas/429-9/2007/AM">
1594    <Id>urn:uuid:75864667-c65e-4aae-a5b2-fa5ea5fe31b7</Id>
1595    <VolumeCount>1</VolumeCount>
1596    <IssueDate>2024-01-01T00:00:00Z</IssueDate>
1597    <AssetList>
1598        <Asset>
1599            <Id>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</Id>
1600            <ChunkList><Chunk><Path>test.xml</Path></Chunk></ChunkList>
1601        </Asset>
1602    </AssetList>
1603</AssetMap>"#;
1604        let result = parse_assetmap(xml).unwrap();
1605        assert_eq!(result.namespace, AssetMapNamespace::Dci429_9);
1606    }
1607
1608    // ── OPL ──────────────────────────────────────────────────────────────────
1609
1610    /// SMPTE ST 2067-100: OPL metadata fields are parsed correctly.
1611    #[test]
1612    fn opl_parses_core_metadata() {
1613        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1614<OutputProfileList xmlns="http://www.smpte-ra.org/schemas/2067-100/2014">
1615    <Id>urn:uuid:8cf83c32-4949-4f00-b081-01e12b18932f</Id>
1616    <Annotation>OPL Example</Annotation>
1617    <IssueDate>2016-06-14T19:22:37-00:00</IssueDate>
1618    <Issuer>Clipster</Issuer>
1619    <Creator>Clipster 5.9.3.7</Creator>
1620    <CompositionPlaylistId>urn:uuid:0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85</CompositionPlaylistId>
1621    <AliasList/>
1622    <MacroList/>
1623</OutputProfileList>"#;
1624        let result = parse_opl(xml).unwrap();
1625        assert_eq!(
1626            result.id.to_string(),
1627            "8cf83c32-4949-4f00-b081-01e12b18932f"
1628        );
1629        assert_eq!(result.annotation.as_deref(), Some("OPL Example"));
1630        assert_eq!(result.issuer.as_deref(), Some("Clipster"));
1631        assert_eq!(result.creator.as_deref(), Some("Clipster 5.9.3.7"));
1632        assert_eq!(
1633            result.composition_playlist_id.to_string(),
1634            "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85"
1635        );
1636    }
1637
1638    /// SMPTE ST 2067-100: OPL with complex macros parses without error.
1639    #[test]
1640    fn opl_parses_real_test_file() {
1641        let xml = std::fs::read_to_string(test_data(
1642            "OPL/OPL_8cf83c32-4949-4f00-b081-01e12b18932f.xml",
1643        ))
1644        .unwrap();
1645        let result = parse_opl(&xml).unwrap();
1646        assert_eq!(
1647            result.id.to_string(),
1648            "8cf83c32-4949-4f00-b081-01e12b18932f"
1649        );
1650        assert_eq!(
1651            result.composition_playlist_id.to_string(),
1652            "0eb3d1b9-b77b-4d3f-bbe5-7c69b15dca85"
1653        );
1654    }
1655
1656    /// SMPTE ST 2067-100: OPL with simple preset macro from ISXD test data.
1657    #[test]
1658    fn opl_parses_isxd_test_file() {
1659        let xml = std::fs::read_to_string(test_data(
1660            "ISXD/CompleteIMP/OPL_af6b288d-27e8-441f-9a36-2c4ab9025d19.xml",
1661        ))
1662        .unwrap();
1663        let result = parse_opl(&xml).unwrap();
1664        assert_eq!(
1665            result.id.to_string(),
1666            "af6b288d-27e8-441f-9a36-2c4ab9025d19"
1667        );
1668        assert_eq!(
1669            result.composition_playlist_id.to_string(),
1670            "b2d74f92-1990-41e0-869f-2179a50f7090"
1671        );
1672    }
1673}