1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub struct Rational {
24 pub numerator: i64,
26 pub denominator: i64,
28}
29
30#[derive(Clone, Debug, PartialEq)]
32pub enum MetadataValue {
33 None,
35 Bool(bool),
37 Int(i64),
39 Float(f64),
41 String(String),
43 Array(Vec<MetadataValue>),
45 Rational(Rational),
47 Blob(Arc<[u8]>),
49}
50
51impl MetadataValue {
52 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub enum MetadataKind {
71 Bool,
73 Int,
75 Float,
77 String,
79 Array,
81 Rational,
83 Blob,
85}
86
87#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct MetadataSchema {
90 core: BTreeMap<String, MetadataKind>,
91 plugin: BTreeMap<String, MetadataKind>,
92}
93
94impl MetadataSchema {
95 #[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 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 #[must_use]
124 pub fn contains_key(&self, key: &str) -> bool {
125 self.kind_for(key).is_some()
126 }
127
128 #[must_use]
130 pub fn kind(&self, key: &str) -> Option<MetadataKind> {
131 self.kind_for(key).map(|(kind, _is_core)| kind)
132 }
133
134 #[must_use]
136 pub fn is_core_key(&self, key: &str) -> bool {
137 self.core.contains_key(key)
138 }
139
140 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#[derive(Clone, Debug, PartialEq)]
185pub struct Metadata {
186 values: BTreeMap<String, MetadataValue>,
187}
188
189impl Metadata {
190 #[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 #[must_use]
202 pub fn get(&self, key: &str) -> Option<&MetadataValue> {
203 self.values.get(key)
204 }
205
206 pub fn clear(&mut self, schema: &MetadataSchema, key: &str) -> Result<()> {
208 self.set(schema, key, MetadataValue::None)
209 }
210
211 pub fn iter(&self) -> impl Iterator<Item = (&str, &MetadataValue)> + '_ {
213 self.values.iter().map(|(key, value)| (key.as_str(), value))
214 }
215
216 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}