Skip to main content

oxideav_core/
options.rs

1//! Generic, schema-validated option bag for codec (and container) init.
2//!
3//! The over-the-wire form is an untyped string→string bag
4//! ([`CodecOptions`]). Each codec defines a typed struct implementing
5//! [`CodecOptionsStruct`], which declares a static [`OptionField`]
6//! schema and an [`apply`](CodecOptionsStruct::apply) method that
7//! writes one coerced value into the struct. [`parse_options`] drives
8//! the whole thing: it walks the bag, looks up every key in the
9//! schema, coerces the string to the declared [`OptionKind`], and
10//! hands the resulting [`OptionValue`] to `apply`.
11//!
12//! Strict at init: unknown keys and malformed values return
13//! [`Error::InvalidData`]. Consumers that want "ignore unknown keys"
14//! should pre-filter the bag before calling [`parse_options`].
15//!
16//! All parsing happens once, at encoder/decoder construction — the
17//! hot path never touches this module.
18//!
19//! Consumers have two entry points:
20//! - **Dynamic / JSON** — build a [`CodecOptions`] via `.set(k, v)` or
21//!   [`CodecOptions::from_json`] (feature `json-options`) and attach
22//!   it to `CodecParameters::options`.
23//! - **Typed** — skip the bag entirely: build the codec's options
24//!   struct directly and pass it to a codec-specific typed entry point
25//!   (e.g. `encode_single_with_options`). The bag only exists for
26//!   consumers who can't know the typed struct at compile time.
27
28use crate::error::{Error, Result};
29
30/// Untyped string → string bag. The over-the-wire shape of options
31/// as they travel from the caller (CLI / pipeline JSON / FFI) to a
32/// codec factory.
33///
34/// Insertion order is preserved and [`iter`](Self::iter) walks keys in
35/// the order they were set. Duplicate keys overwrite (last writer
36/// wins).
37#[derive(Debug, Clone, Default)]
38pub struct CodecOptions {
39    entries: Vec<(String, String)>,
40}
41
42impl CodecOptions {
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Builder-style setter, useful for one-liners.
48    /// `CodecOptions::new().set("interlace", "true")`.
49    pub fn set(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
50        self.insert(k, v);
51        self
52    }
53
54    /// Mutating insert. Overwrites any existing entry with the same
55    /// key.
56    pub fn insert(&mut self, k: impl Into<String>, v: impl Into<String>) {
57        let k = k.into();
58        let v = v.into();
59        if let Some(existing) = self.entries.iter_mut().find(|(kk, _)| kk == &k) {
60            existing.1 = v;
61        } else {
62            self.entries.push((k, v));
63        }
64    }
65
66    pub fn get(&self, k: &str) -> Option<&str> {
67        self.entries
68            .iter()
69            .find(|(kk, _)| kk == k)
70            .map(|(_, v)| v.as_str())
71    }
72
73    pub fn is_empty(&self) -> bool {
74        self.entries.is_empty()
75    }
76
77    pub fn len(&self) -> usize {
78        self.entries.len()
79    }
80
81    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
82        self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
83    }
84
85    /// Build a bag from a JSON object. Scalar values (bool / number /
86    /// string) are stringified into the bag; arrays and nested objects
87    /// are rejected — keys with structured values don't map into the
88    /// flat string bag.
89    #[cfg(feature = "json-options")]
90    pub fn from_json(s: &str) -> Result<Self> {
91        let v: serde_json::Value =
92            serde_json::from_str(s).map_err(|e| Error::invalid(format!("options json: {e}")))?;
93        Self::from_json_value(&v)
94    }
95
96    /// As [`from_json`](Self::from_json) but takes a pre-parsed value
97    /// (the shape pipelines already use — `TrackSpec.codec_params`).
98    #[cfg(feature = "json-options")]
99    pub fn from_json_value(v: &serde_json::Value) -> Result<Self> {
100        use serde_json::Value;
101        let obj = match v {
102            Value::Null => return Ok(Self::default()),
103            Value::Object(m) => m,
104            other => {
105                return Err(Error::invalid(format!(
106                    "options json: expected object, got {}",
107                    json_type_name(other)
108                )))
109            }
110        };
111        let mut out = Self::default();
112        for (k, val) in obj {
113            let s = match val {
114                Value::Bool(b) => b.to_string(),
115                Value::Number(n) => n.to_string(),
116                Value::String(s) => s.clone(),
117                Value::Null => continue, // null = "leave default"
118                other => {
119                    return Err(Error::invalid(format!(
120                        "option '{k}': structured values ({}) are not supported",
121                        json_type_name(other)
122                    )))
123                }
124            };
125            out.insert(k.clone(), s);
126        }
127        Ok(out)
128    }
129}
130
131#[cfg(feature = "json-options")]
132fn json_type_name(v: &serde_json::Value) -> &'static str {
133    use serde_json::Value;
134    match v {
135        Value::Null => "null",
136        Value::Bool(_) => "bool",
137        Value::Number(_) => "number",
138        Value::String(_) => "string",
139        Value::Array(_) => "array",
140        Value::Object(_) => "object",
141    }
142}
143
144/// Declared type of a single option. Used at parse time to coerce a
145/// raw string (or JSON scalar) into a typed [`OptionValue`] and to
146/// reject malformed values up front.
147#[derive(Clone, Copy, Debug)]
148pub enum OptionKind {
149    Bool,
150    U32,
151    I32,
152    F32,
153    String,
154    /// Enumeration: the only accepted values are the strings in this
155    /// slice. Matching is case-sensitive.
156    Enum(&'static [&'static str]),
157}
158
159/// Coerced value handed to a codec's `apply` method. Codec code
160/// chooses the appropriate `as_*` accessor based on the field name.
161#[derive(Clone, Debug)]
162pub enum OptionValue {
163    Bool(bool),
164    U32(u32),
165    I32(i32),
166    F32(f32),
167    String(String),
168}
169
170impl OptionValue {
171    pub fn as_bool(&self) -> Result<bool> {
172        match self {
173            OptionValue::Bool(b) => Ok(*b),
174            other => Err(Error::invalid(format!("expected bool, got {other:?}"))),
175        }
176    }
177    pub fn as_u32(&self) -> Result<u32> {
178        match self {
179            OptionValue::U32(n) => Ok(*n),
180            other => Err(Error::invalid(format!("expected u32, got {other:?}"))),
181        }
182    }
183    pub fn as_i32(&self) -> Result<i32> {
184        match self {
185            OptionValue::I32(n) => Ok(*n),
186            other => Err(Error::invalid(format!("expected i32, got {other:?}"))),
187        }
188    }
189    pub fn as_f32(&self) -> Result<f32> {
190        match self {
191            OptionValue::F32(n) => Ok(*n),
192            other => Err(Error::invalid(format!("expected f32, got {other:?}"))),
193        }
194    }
195    pub fn as_str(&self) -> Result<&str> {
196        match self {
197            OptionValue::String(s) => Ok(s.as_str()),
198            other => Err(Error::invalid(format!("expected string, got {other:?}"))),
199        }
200    }
201}
202
203/// Schema entry describing one recognised option. Codec crates declare
204/// a `&'static [OptionField]` listing every key their options struct
205/// consumes.
206#[derive(Debug)]
207pub struct OptionField {
208    pub name: &'static str,
209    pub kind: OptionKind,
210    pub default: OptionValue,
211    pub help: &'static str,
212}
213
214/// Trait implemented by each codec's typed options struct.
215///
216/// Typical hand-written implementation:
217///
218/// ```ignore
219/// impl CodecOptionsStruct for PngEncoderOptions {
220///     const SCHEMA: &'static [OptionField] = &[
221///         OptionField {
222///             name: "interlace",
223///             kind: OptionKind::Bool,
224///             default: OptionValue::Bool(false),
225///             help: "Adam7 interlaced encode",
226///         },
227///     ];
228///     fn apply(&mut self, key: &str, v: &OptionValue) -> Result<()> {
229///         match key {
230///             "interlace" => self.interlace = v.as_bool()?,
231///             _ => unreachable!("guarded by SCHEMA"),
232///         }
233///         Ok(())
234///     }
235/// }
236/// ```
237pub trait CodecOptionsStruct: Default + 'static {
238    const SCHEMA: &'static [OptionField];
239    fn apply(&mut self, key: &str, value: &OptionValue) -> Result<()>;
240}
241
242/// Parse a [`CodecOptions`] bag into a typed options struct.
243///
244/// Strict: unknown keys return [`Error::InvalidData`]; malformed values
245/// do the same. The returned struct is seeded from
246/// `T::default()` — any key not set in the bag keeps the struct's
247/// default value.
248pub fn parse_options<T: CodecOptionsStruct>(opts: &CodecOptions) -> Result<T> {
249    let mut out = T::default();
250    for (k, v_str) in opts.iter() {
251        let field = T::SCHEMA
252            .iter()
253            .find(|f| f.name == k)
254            .ok_or_else(|| Error::invalid(format!("unknown option '{k}'")))?;
255        let v = coerce(k, field.kind, v_str)?;
256        out.apply(k, &v)?;
257    }
258    Ok(out)
259}
260
261/// Shorthand: parse straight from a JSON-object source. Feature-gated.
262#[cfg(feature = "json-options")]
263pub fn parse_options_json<T: CodecOptionsStruct>(s: &str) -> Result<T> {
264    parse_options::<T>(&CodecOptions::from_json(s)?)
265}
266
267fn coerce(name: &str, kind: OptionKind, raw: &str) -> Result<OptionValue> {
268    match kind {
269        OptionKind::Bool => match raw {
270            "true" | "1" | "yes" | "on" => Ok(OptionValue::Bool(true)),
271            "false" | "0" | "no" | "off" => Ok(OptionValue::Bool(false)),
272            other => Err(Error::invalid(format!(
273                "option '{name}' expects bool, got {other:?}"
274            ))),
275        },
276        OptionKind::U32 => raw
277            .parse::<u32>()
278            .map(OptionValue::U32)
279            .map_err(|_| Error::invalid(format!("option '{name}' expects u32, got {raw:?}"))),
280        OptionKind::I32 => raw
281            .parse::<i32>()
282            .map(OptionValue::I32)
283            .map_err(|_| Error::invalid(format!("option '{name}' expects i32, got {raw:?}"))),
284        OptionKind::F32 => raw
285            .parse::<f32>()
286            .map(OptionValue::F32)
287            .map_err(|_| Error::invalid(format!("option '{name}' expects f32, got {raw:?}"))),
288        OptionKind::String => Ok(OptionValue::String(raw.to_owned())),
289        OptionKind::Enum(allowed) => {
290            if allowed.contains(&raw) {
291                Ok(OptionValue::String(raw.to_owned()))
292            } else {
293                Err(Error::invalid(format!(
294                    "option '{name}' must be one of {:?}, got {raw:?}",
295                    allowed
296                )))
297            }
298        }
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[derive(Default, Debug, PartialEq)]
307    struct Demo {
308        interlace: bool,
309        level: u32,
310        mode: String,
311    }
312
313    impl CodecOptionsStruct for Demo {
314        const SCHEMA: &'static [OptionField] = &[
315            OptionField {
316                name: "interlace",
317                kind: OptionKind::Bool,
318                default: OptionValue::Bool(false),
319                help: "",
320            },
321            OptionField {
322                name: "level",
323                kind: OptionKind::U32,
324                default: OptionValue::U32(6),
325                help: "",
326            },
327            OptionField {
328                name: "mode",
329                kind: OptionKind::Enum(&["fast", "slow"]),
330                default: OptionValue::String(String::new()),
331                help: "",
332            },
333        ];
334        fn apply(&mut self, key: &str, v: &OptionValue) -> Result<()> {
335            match key {
336                "interlace" => self.interlace = v.as_bool()?,
337                "level" => self.level = v.as_u32()?,
338                "mode" => self.mode = v.as_str()?.to_owned(),
339                _ => unreachable!("guarded by SCHEMA"),
340            }
341            Ok(())
342        }
343    }
344
345    #[test]
346    fn bag_preserves_order_and_overwrites() {
347        let opts = CodecOptions::new()
348            .set("a", "1")
349            .set("b", "2")
350            .set("a", "3");
351        assert_eq!(opts.get("a"), Some("3"));
352        let collected: Vec<_> = opts.iter().collect();
353        assert_eq!(collected, vec![("a", "3"), ("b", "2")]);
354    }
355
356    #[test]
357    fn parse_empty_returns_default() {
358        let opts = CodecOptions::new();
359        let d = parse_options::<Demo>(&opts).unwrap();
360        assert_eq!(d, Demo::default());
361    }
362
363    #[test]
364    fn parse_typed_values() {
365        let opts = CodecOptions::new()
366            .set("interlace", "true")
367            .set("level", "9")
368            .set("mode", "fast");
369        let d = parse_options::<Demo>(&opts).unwrap();
370        assert!(d.interlace);
371        assert_eq!(d.level, 9);
372        assert_eq!(d.mode, "fast");
373    }
374
375    #[test]
376    fn parse_rejects_unknown_key() {
377        let opts = CodecOptions::new().set("nope", "1");
378        let err = parse_options::<Demo>(&opts).unwrap_err();
379        assert!(matches!(err, Error::InvalidData(ref s) if s.contains("unknown option 'nope'")));
380    }
381
382    #[test]
383    fn parse_rejects_bad_bool() {
384        let opts = CodecOptions::new().set("interlace", "maybe");
385        let err = parse_options::<Demo>(&opts).unwrap_err();
386        assert!(matches!(err, Error::InvalidData(ref s) if s.contains("expects bool")));
387    }
388
389    #[test]
390    fn parse_rejects_bad_u32() {
391        let opts = CodecOptions::new().set("level", "-1");
392        assert!(parse_options::<Demo>(&opts).is_err());
393    }
394
395    #[test]
396    fn parse_rejects_enum_miss() {
397        let opts = CodecOptions::new().set("mode", "medium");
398        let err = parse_options::<Demo>(&opts).unwrap_err();
399        assert!(matches!(err, Error::InvalidData(ref s) if s.contains("must be one of")));
400    }
401
402    #[test]
403    fn bool_accepts_common_synonyms() {
404        for (raw, want) in [
405            ("true", true),
406            ("1", true),
407            ("yes", true),
408            ("on", true),
409            ("false", false),
410            ("0", false),
411            ("no", false),
412            ("off", false),
413        ] {
414            let opts = CodecOptions::new().set("interlace", raw);
415            let d = parse_options::<Demo>(&opts).unwrap();
416            assert_eq!(d.interlace, want, "raw = {raw}");
417        }
418    }
419
420    #[cfg(feature = "json-options")]
421    #[test]
422    fn from_json_object() {
423        let bag =
424            CodecOptions::from_json(r#"{"interlace": true, "level": 9, "mode": "fast"}"#).unwrap();
425        let d = parse_options::<Demo>(&bag).unwrap();
426        assert!(d.interlace);
427        assert_eq!(d.level, 9);
428        assert_eq!(d.mode, "fast");
429    }
430
431    #[cfg(feature = "json-options")]
432    #[test]
433    fn from_json_null_is_empty() {
434        let bag = CodecOptions::from_json("null").unwrap();
435        assert!(bag.is_empty());
436    }
437
438    #[cfg(feature = "json-options")]
439    #[test]
440    fn from_json_rejects_nested() {
441        let err = CodecOptions::from_json(r#"{"k": [1, 2]}"#).unwrap_err();
442        assert!(matches!(err, Error::InvalidData(ref s) if s.contains("structured")));
443    }
444
445    #[cfg(feature = "json-options")]
446    #[test]
447    fn parse_options_json_shortcut() {
448        let d = parse_options_json::<Demo>(r#"{"level": 3}"#).unwrap();
449        assert_eq!(d.level, 3);
450    }
451}