Skip to main content

pixelflow_core/
metadata.rs

1//! Typed frame metadata values and schema enforcement.
2
3use std::collections::BTreeMap;
4use std::sync::Arc;
5
6use crate::{ErrorCategory, ErrorCode, PixelFlowError, Result};
7
8const CORE_KEYS: [(&str, MetadataKind); 10] = [
9    ("core:matrix", MetadataKind::String),
10    ("core:transfer", MetadataKind::String),
11    ("core:primaries", MetadataKind::String),
12    ("core:range", MetadataKind::String),
13    ("core:chroma_siting", MetadataKind::String),
14    ("core:field_order", MetadataKind::String),
15    ("core:frame_number", MetadataKind::Int),
16    ("core:duration", MetadataKind::Rational),
17    ("core:timecode", MetadataKind::String),
18    ("core:source_path", MetadataKind::String),
19];
20
21/// Rational metadata value.
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub struct Rational {
24    /// Numerator.
25    pub numerator: i64,
26    /// Denominator.
27    pub denominator: i64,
28}
29
30/// Typed metadata value.
31#[derive(Clone, Debug, PartialEq)]
32pub enum MetadataValue {
33    /// Non-applicable value.
34    None,
35    /// Boolean value.
36    Bool(bool),
37    /// Integer value.
38    Int(i64),
39    /// Floating point value.
40    Float(f64),
41    /// String value.
42    String(String),
43    /// Nested array value.
44    Array(Vec<MetadataValue>),
45    /// Rational value.
46    Rational(Rational),
47    /// Binary blob value.
48    Blob(Arc<[u8]>),
49}
50
51impl MetadataValue {
52    /// Returns metadata kind for non-None values.
53    #[must_use]
54    pub const fn kind(&self) -> Option<MetadataKind> {
55        match self {
56            Self::None => None,
57            Self::Bool(_) => Some(MetadataKind::Bool),
58            Self::Int(_) => Some(MetadataKind::Int),
59            Self::Float(_) => Some(MetadataKind::Float),
60            Self::String(_) => Some(MetadataKind::String),
61            Self::Array(_) => Some(MetadataKind::Array),
62            Self::Rational(_) => Some(MetadataKind::Rational),
63            Self::Blob(_) => Some(MetadataKind::Blob),
64        }
65    }
66}
67
68/// Declared metadata type for schema entries.
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub enum MetadataKind {
71    /// Boolean value.
72    Bool,
73    /// Integer value.
74    Int,
75    /// Floating point value.
76    Float,
77    /// String value.
78    String,
79    /// Array value.
80    Array,
81    /// Rational value.
82    Rational,
83    /// Binary blob value.
84    Blob,
85}
86
87/// Metadata schema for core keys and plugin extension keys.
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct MetadataSchema {
90    core: BTreeMap<String, MetadataKind>,
91    plugin: BTreeMap<String, MetadataKind>,
92}
93
94impl MetadataSchema {
95    /// Creates schema containing all `core:*` keys.
96    #[must_use]
97    pub fn core() -> Self {
98        let core = CORE_KEYS
99            .into_iter()
100            .map(|(key, kind)| (key.to_owned(), kind))
101            .collect();
102        Self {
103            core,
104            plugin: BTreeMap::new(),
105        }
106    }
107
108    /// Registers a plugin metadata key in `publisher/plugin:key` format.
109    pub fn register_plugin_key(&mut self, key: &str, kind: MetadataKind) -> Result<()> {
110        if !is_plugin_key(key) {
111            return Err(PixelFlowError::new(
112                ErrorCategory::Plugin,
113                ErrorCode::new("metadata.invalid_plugin_key"),
114                format!("invalid plugin metadata key '{key}'"),
115            ));
116        }
117
118        self.plugin.insert(key.to_owned(), kind);
119        Ok(())
120    }
121
122    /// Returns true when key is registered in schema.
123    #[must_use]
124    pub fn contains_key(&self, key: &str) -> bool {
125        self.kind_for(key).is_some()
126    }
127
128    /// Returns declared metadata kind for registered key.
129    #[must_use]
130    pub fn kind(&self, key: &str) -> Option<MetadataKind> {
131        self.kind_for(key).map(|(kind, _is_core)| kind)
132    }
133
134    /// Returns true when key is one of built-in `core:*` keys.
135    #[must_use]
136    pub fn is_core_key(&self, key: &str) -> bool {
137        self.core.contains_key(key)
138    }
139
140    /// Validates that key is registered and value matches declared kind.
141    pub fn validate_value(&self, key: &str, value: &MetadataValue) -> Result<()> {
142        let Some((expected_kind, is_core)) = self.kind_for(key) else {
143            return Err(PixelFlowError::new(
144                ErrorCategory::Plugin,
145                ErrorCode::new("metadata.unregistered_key"),
146                format!("metadata key '{key}' is not registered"),
147            ));
148        };
149
150        if let Some(actual_kind) = value.kind()
151            && actual_kind != expected_kind
152        {
153            let category = if is_core {
154                ErrorCategory::Core
155            } else {
156                ErrorCategory::Plugin
157            };
158            return Err(PixelFlowError::new(
159                category,
160                ErrorCode::new("metadata.type_mismatch"),
161                format!(
162                    "metadata key '{key}' expects {:?}, got {:?}",
163                    expected_kind, actual_kind
164                ),
165            ));
166        }
167
168        Ok(())
169    }
170
171    pub(crate) fn kind_for(&self, key: &str) -> Option<(MetadataKind, bool)> {
172        if let Some(kind) = self.core.get(key).copied() {
173            return Some((kind, true));
174        }
175        self.plugin.get(key).copied().map(|kind| (kind, false))
176    }
177
178    pub(crate) fn core_keys(&self) -> impl Iterator<Item = &str> {
179        self.core.keys().map(String::as_str)
180    }
181}
182
183/// Typed metadata map validated against a [`MetadataSchema`].
184#[derive(Clone, Debug, PartialEq)]
185pub struct Metadata {
186    values: BTreeMap<String, MetadataValue>,
187}
188
189impl Metadata {
190    /// Creates metadata map with every core key pre-populated as `None`.
191    #[must_use]
192    pub fn new(schema: &MetadataSchema) -> Self {
193        let values = schema
194            .core_keys()
195            .map(|key| (key.to_owned(), MetadataValue::None))
196            .collect();
197        Self { values }
198    }
199
200    /// Returns metadata value by key.
201    #[must_use]
202    pub fn get(&self, key: &str) -> Option<&MetadataValue> {
203        self.values.get(key)
204    }
205
206    /// Clears registered metadata key by writing `None`.
207    pub fn clear(&mut self, schema: &MetadataSchema, key: &str) -> Result<()> {
208        self.set(schema, key, MetadataValue::None)
209    }
210
211    /// Iterates metadata entries in deterministic key order.
212    pub fn iter(&self) -> impl Iterator<Item = (&str, &MetadataValue)> + '_ {
213        self.values.iter().map(|(key, value)| (key.as_str(), value))
214    }
215
216    /// Writes metadata value after registration and type validation.
217    pub fn set(&mut self, schema: &MetadataSchema, key: &str, value: MetadataValue) -> Result<()> {
218        schema.validate_value(key, &value)?;
219        self.values.insert(key.to_owned(), value);
220        Ok(())
221    }
222}
223
224fn is_plugin_key(key: &str) -> bool {
225    let Some((namespace, field)) = key.split_once(':') else {
226        return false;
227    };
228    let Some((publisher, plugin)) = namespace.split_once('/') else {
229        return false;
230    };
231    is_key_component(publisher) && is_key_component(plugin) && is_key_component(field)
232}
233
234fn is_key_component(component: &str) -> bool {
235    !component.is_empty()
236        && component
237            .bytes()
238            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
239}
240
241#[cfg(test)]
242mod tests {
243    use crate::{ErrorCategory, ErrorCode};
244
245    use super::{Metadata, MetadataKind, MetadataSchema, MetadataValue, Rational};
246
247    #[test]
248    fn core_metadata_keys_are_always_present_as_none() {
249        let schema = MetadataSchema::core();
250        let metadata = Metadata::new(&schema);
251
252        for key in [
253            "core:matrix",
254            "core:transfer",
255            "core:primaries",
256            "core:range",
257            "core:chroma_siting",
258            "core:field_order",
259            "core:frame_number",
260            "core:duration",
261            "core:timecode",
262            "core:source_path",
263        ] {
264            assert_eq!(metadata.get(key), Some(&MetadataValue::None));
265        }
266    }
267
268    #[test]
269    fn plugin_metadata_write_requires_registered_key() {
270        let schema = MetadataSchema::core();
271        let mut metadata = Metadata::new(&schema);
272
273        let error = metadata
274            .set(&schema, "acme/filter:strength", MetadataValue::Float(0.5))
275            .expect_err("unregistered plugin key should fail");
276
277        assert_eq!(error.category(), ErrorCategory::Plugin);
278        assert_eq!(error.code(), ErrorCode::new("metadata.unregistered_key"));
279    }
280
281    #[test]
282    fn plugin_metadata_write_accepts_registered_key_and_type() {
283        let mut schema = MetadataSchema::core();
284        schema
285            .register_plugin_key("acme/filter:strength", MetadataKind::Float)
286            .expect("plugin key should register");
287        let mut metadata = Metadata::new(&schema);
288
289        metadata
290            .set(&schema, "acme/filter:strength", MetadataValue::Float(0.5))
291            .expect("registered key should accept matching value");
292
293        assert_eq!(
294            metadata.get("acme/filter:strength"),
295            Some(&MetadataValue::Float(0.5))
296        );
297    }
298
299    #[test]
300    fn mismatched_metadata_type_returns_structured_error() {
301        let schema = MetadataSchema::core();
302        let mut metadata = Metadata::new(&schema);
303
304        let error = metadata
305            .set(
306                &schema,
307                "core:frame_number",
308                MetadataValue::String("zero".to_owned()),
309            )
310            .expect_err("wrong type should fail");
311
312        assert_eq!(error.category(), ErrorCategory::Core);
313        assert_eq!(error.code(), ErrorCode::new("metadata.type_mismatch"));
314    }
315
316    #[test]
317    fn metadata_supports_rational_array_and_blob_values() {
318        let mut schema = MetadataSchema::core();
319        schema
320            .register_plugin_key("acme/filter:ratios", MetadataKind::Array)
321            .expect("array key should register");
322        schema
323            .register_plugin_key("acme/filter:payload", MetadataKind::Blob)
324            .expect("blob key should register");
325        let mut metadata = Metadata::new(&schema);
326
327        metadata
328            .set(
329                &schema,
330                "acme/filter:ratios",
331                MetadataValue::Array(vec![MetadataValue::Rational(Rational {
332                    numerator: 1,
333                    denominator: 2,
334                })]),
335            )
336            .expect("array value should be accepted");
337        metadata
338            .set(
339                &schema,
340                "acme/filter:payload",
341                MetadataValue::Blob(vec![1_u8, 2, 3].into()),
342            )
343            .expect("blob value should be accepted");
344
345        assert!(matches!(
346            metadata.get("acme/filter:ratios"),
347            Some(MetadataValue::Array(_))
348        ));
349        assert!(matches!(
350            metadata.get("acme/filter:payload"),
351            Some(MetadataValue::Blob(_))
352        ));
353    }
354
355    #[test]
356    fn metadata_schema_exposes_registered_kind_and_namespace() {
357        let mut schema = MetadataSchema::core();
358        schema
359            .register_plugin_key("acme/filter:enabled", MetadataKind::Bool)
360            .expect("plugin key should register");
361
362        assert_eq!(schema.kind("core:frame_number"), Some(MetadataKind::Int));
363        assert_eq!(schema.kind("acme/filter:enabled"), Some(MetadataKind::Bool));
364        assert!(schema.is_core_key("core:frame_number"));
365        assert!(!schema.is_core_key("acme/filter:enabled"));
366        assert_eq!(schema.kind("missing"), None);
367    }
368
369    #[test]
370    fn metadata_clear_sets_registered_key_to_none() {
371        let mut schema = MetadataSchema::core();
372        schema
373            .register_plugin_key("acme/filter:enabled", MetadataKind::Bool)
374            .expect("plugin key should register");
375        let mut metadata = Metadata::new(&schema);
376        metadata
377            .set(&schema, "acme/filter:enabled", MetadataValue::Bool(true))
378            .expect("registered key should set");
379
380        metadata
381            .clear(&schema, "acme/filter:enabled")
382            .expect("registered key should clear");
383
384        assert_eq!(
385            metadata.get("acme/filter:enabled"),
386            Some(&MetadataValue::None)
387        );
388    }
389
390    #[test]
391    fn metadata_iter_is_deterministic_and_sorted() {
392        let mut schema = MetadataSchema::core();
393        schema
394            .register_plugin_key("acme/filter:enabled", MetadataKind::Bool)
395            .expect("plugin key should register");
396        let mut metadata = Metadata::new(&schema);
397        metadata
398            .set(&schema, "acme/filter:enabled", MetadataValue::Bool(true))
399            .expect("registered key should set");
400
401        let keys = metadata.iter().map(|(key, _value)| key).collect::<Vec<_>>();
402
403        assert_eq!(keys.first().copied(), Some("acme/filter:enabled"));
404        assert!(
405            keys.windows(2)
406                .all(|pair| matches!(pair, [left, right] if left < right))
407        );
408    }
409}