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    /// Create an empty option bag (same as `CodecOptions::default()`).
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Builder-style setter, useful for one-liners.
49    /// `CodecOptions::new().set("interlace", "true")`.
50    pub fn set(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
51        self.insert(k, v);
52        self
53    }
54
55    /// Mutating insert. Overwrites any existing entry with the same
56    /// key.
57    pub fn insert(&mut self, k: impl Into<String>, v: impl Into<String>) {
58        let k = k.into();
59        let v = v.into();
60        if let Some(existing) = self.entries.iter_mut().find(|(kk, _)| kk == &k) {
61            existing.1 = v;
62        } else {
63            self.entries.push((k, v));
64        }
65    }
66
67    /// Look up the value for key `k`, or `None` if the key was never
68    /// set.
69    pub fn get(&self, k: &str) -> Option<&str> {
70        self.entries
71            .iter()
72            .find(|(kk, _)| kk == k)
73            .map(|(_, v)| v.as_str())
74    }
75
76    /// `true` when the bag contains no entries.
77    pub fn is_empty(&self) -> bool {
78        self.entries.is_empty()
79    }
80
81    /// Number of entries in the bag.
82    pub fn len(&self) -> usize {
83        self.entries.len()
84    }
85
86    /// Iterate over `(key, value)` pairs in insertion order.
87    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
88        self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
89    }
90
91    /// Build a bag from a JSON object. Scalar values (bool / number /
92    /// string) are stringified into the bag; arrays and nested objects
93    /// are rejected — keys with structured values don't map into the
94    /// flat string bag.
95    pub fn from_json(s: &str) -> Result<Self> {
96        let v: serde_json::Value =
97            serde_json::from_str(s).map_err(|e| Error::invalid(format!("options json: {e}")))?;
98        Self::from_json_value(&v)
99    }
100
101    /// As [`from_json`](Self::from_json) but takes a pre-parsed value
102    /// (the shape pipelines already use — `TrackSpec.codec_params`).
103    pub fn from_json_value(v: &serde_json::Value) -> Result<Self> {
104        use serde_json::Value;
105        let obj = match v {
106            Value::Null => return Ok(Self::default()),
107            Value::Object(m) => m,
108            other => {
109                return Err(Error::invalid(format!(
110                    "options json: expected object, got {}",
111                    json_type_name(other)
112                )))
113            }
114        };
115        let mut out = Self::default();
116        for (k, val) in obj {
117            let s = match val {
118                Value::Bool(b) => b.to_string(),
119                Value::Number(n) => n.to_string(),
120                Value::String(s) => s.clone(),
121                Value::Null => continue, // null = "leave default"
122                other => {
123                    return Err(Error::invalid(format!(
124                        "option '{k}': structured values ({}) are not supported",
125                        json_type_name(other)
126                    )))
127                }
128            };
129            out.insert(k.clone(), s);
130        }
131        Ok(out)
132    }
133}
134
135fn json_type_name(v: &serde_json::Value) -> &'static str {
136    use serde_json::Value;
137    match v {
138        Value::Null => "null",
139        Value::Bool(_) => "bool",
140        Value::Number(_) => "number",
141        Value::String(_) => "string",
142        Value::Array(_) => "array",
143        Value::Object(_) => "object",
144    }
145}
146
147/// Declared type of a single option. Used at parse time to coerce a
148/// raw string (or JSON scalar) into a typed [`OptionValue`] and to
149/// reject malformed values up front.
150#[derive(Clone, Copy, Debug)]
151pub enum OptionKind {
152    /// Boolean; accepts `true`/`1`/`yes`/`on` and `false`/`0`/`no`/`off`.
153    Bool,
154    /// Unsigned 32-bit integer.
155    U32,
156    /// Signed 32-bit integer.
157    I32,
158    /// 32-bit floating point.
159    F32,
160    /// Free-form string; any value is accepted verbatim.
161    String,
162    /// Enumeration: the only accepted values are the strings in this
163    /// slice. Matching is case-sensitive.
164    Enum(&'static [&'static str]),
165}
166
167/// Coerced value handed to a codec's `apply` method. Codec code
168/// chooses the appropriate `as_*` accessor based on the field name.
169#[derive(Clone, Debug)]
170pub enum OptionValue {
171    /// A coerced boolean value.
172    Bool(bool),
173    /// A coerced unsigned 32-bit integer.
174    U32(u32),
175    /// A coerced signed 32-bit integer.
176    I32(i32),
177    /// A coerced 32-bit float.
178    F32(f32),
179    /// A string value (also used for [`OptionKind::Enum`] matches).
180    String(String),
181}
182
183impl OptionValue {
184    /// Extract the boolean, or [`Error::InvalidData`] when the value is
185    /// a different kind.
186    pub fn as_bool(&self) -> Result<bool> {
187        match self {
188            OptionValue::Bool(b) => Ok(*b),
189            other => Err(Error::invalid(format!("expected bool, got {other:?}"))),
190        }
191    }
192    /// Extract the `u32`, or [`Error::InvalidData`] when the value is a
193    /// different kind.
194    pub fn as_u32(&self) -> Result<u32> {
195        match self {
196            OptionValue::U32(n) => Ok(*n),
197            other => Err(Error::invalid(format!("expected u32, got {other:?}"))),
198        }
199    }
200    /// Extract the `i32`, or [`Error::InvalidData`] when the value is a
201    /// different kind.
202    pub fn as_i32(&self) -> Result<i32> {
203        match self {
204            OptionValue::I32(n) => Ok(*n),
205            other => Err(Error::invalid(format!("expected i32, got {other:?}"))),
206        }
207    }
208    /// Extract the `f32`, or [`Error::InvalidData`] when the value is a
209    /// different kind.
210    pub fn as_f32(&self) -> Result<f32> {
211        match self {
212            OptionValue::F32(n) => Ok(*n),
213            other => Err(Error::invalid(format!("expected f32, got {other:?}"))),
214        }
215    }
216    /// Extract the string (also the shape of `Enum` matches), or
217    /// [`Error::InvalidData`] when the value is a different kind.
218    pub fn as_str(&self) -> Result<&str> {
219        match self {
220            OptionValue::String(s) => Ok(s.as_str()),
221            other => Err(Error::invalid(format!("expected string, got {other:?}"))),
222        }
223    }
224}
225
226/// Schema entry describing one recognised option. Codec crates declare
227/// a `&'static [OptionField]` listing every key their options struct
228/// consumes.
229#[derive(Debug)]
230pub struct OptionField {
231    /// Option key as it appears in the [`CodecOptions`] bag.
232    pub name: &'static str,
233    /// Declared type used to coerce and validate the raw string value.
234    pub kind: OptionKind,
235    /// Value used when the key is absent from the bag (documentation /
236    /// introspection — the actual default lives in the struct's
237    /// `Default` impl).
238    pub default: OptionValue,
239    /// One-line human-readable description for `--help`-style listings.
240    pub help: &'static str,
241}
242
243/// Trait implemented by each codec's typed options struct.
244///
245/// Typical hand-written implementation:
246///
247/// ```ignore
248/// impl CodecOptionsStruct for PngEncoderOptions {
249///     const SCHEMA: &'static [OptionField] = &[
250///         OptionField {
251///             name: "interlace",
252///             kind: OptionKind::Bool,
253///             default: OptionValue::Bool(false),
254///             help: "Adam7 interlaced encode",
255///         },
256///     ];
257///     fn apply(&mut self, key: &str, v: &OptionValue) -> Result<()> {
258///         match key {
259///             "interlace" => self.interlace = v.as_bool()?,
260///             _ => unreachable!("guarded by SCHEMA"),
261///         }
262///         Ok(())
263///     }
264/// }
265/// ```
266pub trait CodecOptionsStruct: Default + 'static {
267    /// Static schema listing every option key this struct consumes,
268    /// with its declared kind, default, and help text.
269    const SCHEMA: &'static [OptionField];
270    /// Write one coerced value into the struct. `key` is guaranteed to
271    /// be present in [`SCHEMA`](Self::SCHEMA) and `value` to match the
272    /// declared [`OptionKind`] when called via [`parse_options`].
273    fn apply(&mut self, key: &str, value: &OptionValue) -> Result<()>;
274}
275
276/// Parse a [`CodecOptions`] bag into a typed options struct.
277///
278/// Strict: unknown keys return [`Error::InvalidData`]; malformed values
279/// do the same. The returned struct is seeded from
280/// `T::default()` — any key not set in the bag keeps the struct's
281/// default value.
282pub fn parse_options<T: CodecOptionsStruct>(opts: &CodecOptions) -> Result<T> {
283    let mut out = T::default();
284    for (k, v_str) in opts.iter() {
285        let field = T::SCHEMA
286            .iter()
287            .find(|f| f.name == k)
288            .ok_or_else(|| Error::invalid(format!("unknown option '{k}'")))?;
289        let v = coerce(k, field.kind, v_str)?;
290        out.apply(k, &v)?;
291    }
292    Ok(out)
293}
294
295/// Shorthand: parse straight from a JSON-object source.
296pub fn parse_options_json<T: CodecOptionsStruct>(s: &str) -> Result<T> {
297    parse_options::<T>(&CodecOptions::from_json(s)?)
298}
299
300fn coerce(name: &str, kind: OptionKind, raw: &str) -> Result<OptionValue> {
301    match kind {
302        OptionKind::Bool => match raw {
303            "true" | "1" | "yes" | "on" => Ok(OptionValue::Bool(true)),
304            "false" | "0" | "no" | "off" => Ok(OptionValue::Bool(false)),
305            other => Err(Error::invalid(format!(
306                "option '{name}' expects bool, got {other:?}"
307            ))),
308        },
309        OptionKind::U32 => raw
310            .parse::<u32>()
311            .map(OptionValue::U32)
312            .map_err(|_| Error::invalid(format!("option '{name}' expects u32, got {raw:?}"))),
313        OptionKind::I32 => raw
314            .parse::<i32>()
315            .map(OptionValue::I32)
316            .map_err(|_| Error::invalid(format!("option '{name}' expects i32, got {raw:?}"))),
317        OptionKind::F32 => raw
318            .parse::<f32>()
319            .map(OptionValue::F32)
320            .map_err(|_| Error::invalid(format!("option '{name}' expects f32, got {raw:?}"))),
321        OptionKind::String => Ok(OptionValue::String(raw.to_owned())),
322        OptionKind::Enum(allowed) => {
323            if allowed.contains(&raw) {
324                Ok(OptionValue::String(raw.to_owned()))
325            } else {
326                Err(Error::invalid(format!(
327                    "option '{name}' must be one of {:?}, got {raw:?}",
328                    allowed
329                )))
330            }
331        }
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[derive(Default, Debug, PartialEq)]
340    struct Demo {
341        interlace: bool,
342        level: u32,
343        mode: String,
344    }
345
346    impl CodecOptionsStruct for Demo {
347        const SCHEMA: &'static [OptionField] = &[
348            OptionField {
349                name: "interlace",
350                kind: OptionKind::Bool,
351                default: OptionValue::Bool(false),
352                help: "",
353            },
354            OptionField {
355                name: "level",
356                kind: OptionKind::U32,
357                default: OptionValue::U32(6),
358                help: "",
359            },
360            OptionField {
361                name: "mode",
362                kind: OptionKind::Enum(&["fast", "slow"]),
363                default: OptionValue::String(String::new()),
364                help: "",
365            },
366        ];
367        fn apply(&mut self, key: &str, v: &OptionValue) -> Result<()> {
368            match key {
369                "interlace" => self.interlace = v.as_bool()?,
370                "level" => self.level = v.as_u32()?,
371                "mode" => self.mode = v.as_str()?.to_owned(),
372                _ => unreachable!("guarded by SCHEMA"),
373            }
374            Ok(())
375        }
376    }
377
378    #[test]
379    fn bag_preserves_order_and_overwrites() {
380        let opts = CodecOptions::new()
381            .set("a", "1")
382            .set("b", "2")
383            .set("a", "3");
384        assert_eq!(opts.get("a"), Some("3"));
385        let collected: Vec<_> = opts.iter().collect();
386        assert_eq!(collected, vec![("a", "3"), ("b", "2")]);
387    }
388
389    #[test]
390    fn parse_empty_returns_default() {
391        let opts = CodecOptions::new();
392        let d = parse_options::<Demo>(&opts).unwrap();
393        assert_eq!(d, Demo::default());
394    }
395
396    #[test]
397    fn parse_typed_values() {
398        let opts = CodecOptions::new()
399            .set("interlace", "true")
400            .set("level", "9")
401            .set("mode", "fast");
402        let d = parse_options::<Demo>(&opts).unwrap();
403        assert!(d.interlace);
404        assert_eq!(d.level, 9);
405        assert_eq!(d.mode, "fast");
406    }
407
408    #[test]
409    fn parse_rejects_unknown_key() {
410        let opts = CodecOptions::new().set("nope", "1");
411        let err = parse_options::<Demo>(&opts).unwrap_err();
412        assert!(matches!(err, Error::InvalidData(ref s) if s.contains("unknown option 'nope'")));
413    }
414
415    #[test]
416    fn parse_rejects_bad_bool() {
417        let opts = CodecOptions::new().set("interlace", "maybe");
418        let err = parse_options::<Demo>(&opts).unwrap_err();
419        assert!(matches!(err, Error::InvalidData(ref s) if s.contains("expects bool")));
420    }
421
422    #[test]
423    fn parse_rejects_bad_u32() {
424        let opts = CodecOptions::new().set("level", "-1");
425        assert!(parse_options::<Demo>(&opts).is_err());
426    }
427
428    #[test]
429    fn parse_rejects_enum_miss() {
430        let opts = CodecOptions::new().set("mode", "medium");
431        let err = parse_options::<Demo>(&opts).unwrap_err();
432        assert!(matches!(err, Error::InvalidData(ref s) if s.contains("must be one of")));
433    }
434
435    #[test]
436    fn bool_accepts_common_synonyms() {
437        for (raw, want) in [
438            ("true", true),
439            ("1", true),
440            ("yes", true),
441            ("on", true),
442            ("false", false),
443            ("0", false),
444            ("no", false),
445            ("off", false),
446        ] {
447            let opts = CodecOptions::new().set("interlace", raw);
448            let d = parse_options::<Demo>(&opts).unwrap();
449            assert_eq!(d.interlace, want, "raw = {raw}");
450        }
451    }
452
453    #[test]
454    fn from_json_object() {
455        let bag =
456            CodecOptions::from_json(r#"{"interlace": true, "level": 9, "mode": "fast"}"#).unwrap();
457        let d = parse_options::<Demo>(&bag).unwrap();
458        assert!(d.interlace);
459        assert_eq!(d.level, 9);
460        assert_eq!(d.mode, "fast");
461    }
462
463    #[test]
464    fn from_json_null_is_empty() {
465        let bag = CodecOptions::from_json("null").unwrap();
466        assert!(bag.is_empty());
467    }
468
469    #[test]
470    fn from_json_rejects_nested() {
471        let err = CodecOptions::from_json(r#"{"k": [1, 2]}"#).unwrap_err();
472        assert!(matches!(err, Error::InvalidData(ref s) if s.contains("structured")));
473    }
474
475    #[test]
476    fn parse_options_json_shortcut() {
477        let d = parse_options_json::<Demo>(r#"{"level": 3}"#).unwrap();
478        assert_eq!(d.level, 3);
479    }
480}