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    pub fn from_json(s: &str) -> Result<Self> {
90        let v: serde_json::Value =
91            serde_json::from_str(s).map_err(|e| Error::invalid(format!("options json: {e}")))?;
92        Self::from_json_value(&v)
93    }
94
95    /// As [`from_json`](Self::from_json) but takes a pre-parsed value
96    /// (the shape pipelines already use — `TrackSpec.codec_params`).
97    pub fn from_json_value(v: &serde_json::Value) -> Result<Self> {
98        use serde_json::Value;
99        let obj = match v {
100            Value::Null => return Ok(Self::default()),
101            Value::Object(m) => m,
102            other => {
103                return Err(Error::invalid(format!(
104                    "options json: expected object, got {}",
105                    json_type_name(other)
106                )))
107            }
108        };
109        let mut out = Self::default();
110        for (k, val) in obj {
111            let s = match val {
112                Value::Bool(b) => b.to_string(),
113                Value::Number(n) => n.to_string(),
114                Value::String(s) => s.clone(),
115                Value::Null => continue, // null = "leave default"
116                other => {
117                    return Err(Error::invalid(format!(
118                        "option '{k}': structured values ({}) are not supported",
119                        json_type_name(other)
120                    )))
121                }
122            };
123            out.insert(k.clone(), s);
124        }
125        Ok(out)
126    }
127}
128
129fn json_type_name(v: &serde_json::Value) -> &'static str {
130    use serde_json::Value;
131    match v {
132        Value::Null => "null",
133        Value::Bool(_) => "bool",
134        Value::Number(_) => "number",
135        Value::String(_) => "string",
136        Value::Array(_) => "array",
137        Value::Object(_) => "object",
138    }
139}
140
141/// Declared type of a single option. Used at parse time to coerce a
142/// raw string (or JSON scalar) into a typed [`OptionValue`] and to
143/// reject malformed values up front.
144#[derive(Clone, Copy, Debug)]
145pub enum OptionKind {
146    Bool,
147    U32,
148    I32,
149    F32,
150    String,
151    /// Enumeration: the only accepted values are the strings in this
152    /// slice. Matching is case-sensitive.
153    Enum(&'static [&'static str]),
154}
155
156/// Coerced value handed to a codec's `apply` method. Codec code
157/// chooses the appropriate `as_*` accessor based on the field name.
158#[derive(Clone, Debug)]
159pub enum OptionValue {
160    Bool(bool),
161    U32(u32),
162    I32(i32),
163    F32(f32),
164    String(String),
165}
166
167impl OptionValue {
168    pub fn as_bool(&self) -> Result<bool> {
169        match self {
170            OptionValue::Bool(b) => Ok(*b),
171            other => Err(Error::invalid(format!("expected bool, got {other:?}"))),
172        }
173    }
174    pub fn as_u32(&self) -> Result<u32> {
175        match self {
176            OptionValue::U32(n) => Ok(*n),
177            other => Err(Error::invalid(format!("expected u32, got {other:?}"))),
178        }
179    }
180    pub fn as_i32(&self) -> Result<i32> {
181        match self {
182            OptionValue::I32(n) => Ok(*n),
183            other => Err(Error::invalid(format!("expected i32, got {other:?}"))),
184        }
185    }
186    pub fn as_f32(&self) -> Result<f32> {
187        match self {
188            OptionValue::F32(n) => Ok(*n),
189            other => Err(Error::invalid(format!("expected f32, got {other:?}"))),
190        }
191    }
192    pub fn as_str(&self) -> Result<&str> {
193        match self {
194            OptionValue::String(s) => Ok(s.as_str()),
195            other => Err(Error::invalid(format!("expected string, got {other:?}"))),
196        }
197    }
198}
199
200/// Schema entry describing one recognised option. Codec crates declare
201/// a `&'static [OptionField]` listing every key their options struct
202/// consumes.
203#[derive(Debug)]
204pub struct OptionField {
205    pub name: &'static str,
206    pub kind: OptionKind,
207    pub default: OptionValue,
208    pub help: &'static str,
209}
210
211/// Trait implemented by each codec's typed options struct.
212///
213/// Typical hand-written implementation:
214///
215/// ```ignore
216/// impl CodecOptionsStruct for PngEncoderOptions {
217///     const SCHEMA: &'static [OptionField] = &[
218///         OptionField {
219///             name: "interlace",
220///             kind: OptionKind::Bool,
221///             default: OptionValue::Bool(false),
222///             help: "Adam7 interlaced encode",
223///         },
224///     ];
225///     fn apply(&mut self, key: &str, v: &OptionValue) -> Result<()> {
226///         match key {
227///             "interlace" => self.interlace = v.as_bool()?,
228///             _ => unreachable!("guarded by SCHEMA"),
229///         }
230///         Ok(())
231///     }
232/// }
233/// ```
234pub trait CodecOptionsStruct: Default + 'static {
235    const SCHEMA: &'static [OptionField];
236    fn apply(&mut self, key: &str, value: &OptionValue) -> Result<()>;
237}
238
239/// Parse a [`CodecOptions`] bag into a typed options struct.
240///
241/// Strict: unknown keys return [`Error::InvalidData`]; malformed values
242/// do the same. The returned struct is seeded from
243/// `T::default()` — any key not set in the bag keeps the struct's
244/// default value.
245pub fn parse_options<T: CodecOptionsStruct>(opts: &CodecOptions) -> Result<T> {
246    let mut out = T::default();
247    for (k, v_str) in opts.iter() {
248        let field = T::SCHEMA
249            .iter()
250            .find(|f| f.name == k)
251            .ok_or_else(|| Error::invalid(format!("unknown option '{k}'")))?;
252        let v = coerce(k, field.kind, v_str)?;
253        out.apply(k, &v)?;
254    }
255    Ok(out)
256}
257
258/// Shorthand: parse straight from a JSON-object source.
259pub fn parse_options_json<T: CodecOptionsStruct>(s: &str) -> Result<T> {
260    parse_options::<T>(&CodecOptions::from_json(s)?)
261}
262
263fn coerce(name: &str, kind: OptionKind, raw: &str) -> Result<OptionValue> {
264    match kind {
265        OptionKind::Bool => match raw {
266            "true" | "1" | "yes" | "on" => Ok(OptionValue::Bool(true)),
267            "false" | "0" | "no" | "off" => Ok(OptionValue::Bool(false)),
268            other => Err(Error::invalid(format!(
269                "option '{name}' expects bool, got {other:?}"
270            ))),
271        },
272        OptionKind::U32 => raw
273            .parse::<u32>()
274            .map(OptionValue::U32)
275            .map_err(|_| Error::invalid(format!("option '{name}' expects u32, got {raw:?}"))),
276        OptionKind::I32 => raw
277            .parse::<i32>()
278            .map(OptionValue::I32)
279            .map_err(|_| Error::invalid(format!("option '{name}' expects i32, got {raw:?}"))),
280        OptionKind::F32 => raw
281            .parse::<f32>()
282            .map(OptionValue::F32)
283            .map_err(|_| Error::invalid(format!("option '{name}' expects f32, got {raw:?}"))),
284        OptionKind::String => Ok(OptionValue::String(raw.to_owned())),
285        OptionKind::Enum(allowed) => {
286            if allowed.contains(&raw) {
287                Ok(OptionValue::String(raw.to_owned()))
288            } else {
289                Err(Error::invalid(format!(
290                    "option '{name}' must be one of {:?}, got {raw:?}",
291                    allowed
292                )))
293            }
294        }
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[derive(Default, Debug, PartialEq)]
303    struct Demo {
304        interlace: bool,
305        level: u32,
306        mode: String,
307    }
308
309    impl CodecOptionsStruct for Demo {
310        const SCHEMA: &'static [OptionField] = &[
311            OptionField {
312                name: "interlace",
313                kind: OptionKind::Bool,
314                default: OptionValue::Bool(false),
315                help: "",
316            },
317            OptionField {
318                name: "level",
319                kind: OptionKind::U32,
320                default: OptionValue::U32(6),
321                help: "",
322            },
323            OptionField {
324                name: "mode",
325                kind: OptionKind::Enum(&["fast", "slow"]),
326                default: OptionValue::String(String::new()),
327                help: "",
328            },
329        ];
330        fn apply(&mut self, key: &str, v: &OptionValue) -> Result<()> {
331            match key {
332                "interlace" => self.interlace = v.as_bool()?,
333                "level" => self.level = v.as_u32()?,
334                "mode" => self.mode = v.as_str()?.to_owned(),
335                _ => unreachable!("guarded by SCHEMA"),
336            }
337            Ok(())
338        }
339    }
340
341    #[test]
342    fn bag_preserves_order_and_overwrites() {
343        let opts = CodecOptions::new()
344            .set("a", "1")
345            .set("b", "2")
346            .set("a", "3");
347        assert_eq!(opts.get("a"), Some("3"));
348        let collected: Vec<_> = opts.iter().collect();
349        assert_eq!(collected, vec![("a", "3"), ("b", "2")]);
350    }
351
352    #[test]
353    fn parse_empty_returns_default() {
354        let opts = CodecOptions::new();
355        let d = parse_options::<Demo>(&opts).unwrap();
356        assert_eq!(d, Demo::default());
357    }
358
359    #[test]
360    fn parse_typed_values() {
361        let opts = CodecOptions::new()
362            .set("interlace", "true")
363            .set("level", "9")
364            .set("mode", "fast");
365        let d = parse_options::<Demo>(&opts).unwrap();
366        assert!(d.interlace);
367        assert_eq!(d.level, 9);
368        assert_eq!(d.mode, "fast");
369    }
370
371    #[test]
372    fn parse_rejects_unknown_key() {
373        let opts = CodecOptions::new().set("nope", "1");
374        let err = parse_options::<Demo>(&opts).unwrap_err();
375        assert!(matches!(err, Error::InvalidData(ref s) if s.contains("unknown option 'nope'")));
376    }
377
378    #[test]
379    fn parse_rejects_bad_bool() {
380        let opts = CodecOptions::new().set("interlace", "maybe");
381        let err = parse_options::<Demo>(&opts).unwrap_err();
382        assert!(matches!(err, Error::InvalidData(ref s) if s.contains("expects bool")));
383    }
384
385    #[test]
386    fn parse_rejects_bad_u32() {
387        let opts = CodecOptions::new().set("level", "-1");
388        assert!(parse_options::<Demo>(&opts).is_err());
389    }
390
391    #[test]
392    fn parse_rejects_enum_miss() {
393        let opts = CodecOptions::new().set("mode", "medium");
394        let err = parse_options::<Demo>(&opts).unwrap_err();
395        assert!(matches!(err, Error::InvalidData(ref s) if s.contains("must be one of")));
396    }
397
398    #[test]
399    fn bool_accepts_common_synonyms() {
400        for (raw, want) in [
401            ("true", true),
402            ("1", true),
403            ("yes", true),
404            ("on", true),
405            ("false", false),
406            ("0", false),
407            ("no", false),
408            ("off", false),
409        ] {
410            let opts = CodecOptions::new().set("interlace", raw);
411            let d = parse_options::<Demo>(&opts).unwrap();
412            assert_eq!(d.interlace, want, "raw = {raw}");
413        }
414    }
415
416    #[test]
417    fn from_json_object() {
418        let bag =
419            CodecOptions::from_json(r#"{"interlace": true, "level": 9, "mode": "fast"}"#).unwrap();
420        let d = parse_options::<Demo>(&bag).unwrap();
421        assert!(d.interlace);
422        assert_eq!(d.level, 9);
423        assert_eq!(d.mode, "fast");
424    }
425
426    #[test]
427    fn from_json_null_is_empty() {
428        let bag = CodecOptions::from_json("null").unwrap();
429        assert!(bag.is_empty());
430    }
431
432    #[test]
433    fn from_json_rejects_nested() {
434        let err = CodecOptions::from_json(r#"{"k": [1, 2]}"#).unwrap_err();
435        assert!(matches!(err, Error::InvalidData(ref s) if s.contains("structured")));
436    }
437
438    #[test]
439    fn parse_options_json_shortcut() {
440        let d = parse_options_json::<Demo>(r#"{"level": 3}"#).unwrap();
441        assert_eq!(d.level, 3);
442    }
443}