Skip to main content

g2g_core/
property.rs

1//! Runtime element properties (M104): a name/value bag layered over the
2//! compile-time `with_*` builders, the GObject-property analog.
3//!
4//! The builders (`VideoTestSrc::new().with_pattern(..)`) stay the zero-cost,
5//! type-checked construction path and the only one the `no_std` / RTOS baseline
6//! needs. This module adds the *runtime* face GStreamer tooling expects: set a
7//! property by string name and value, read it back, and enumerate an element's
8//! properties without instantiating tooling-specific code. That runtime face is
9//! what a `gst-launch` text pipeline parser and a `gst-inspect` introspection
10//! dump build on (M105 / M106).
11//!
12//! It costs the baseline nothing: the [`properties`](crate::AsyncElement::properties)
13//! / [`set_property`](crate::AsyncElement::set_property) /
14//! [`get_property`](crate::AsyncElement::get_property) trait methods default to
15//! "no properties", exactly like [`latency`](crate::AsyncElement::latency), so an
16//! element opts in only by overriding them and an RTOS build that never calls
17//! them pays nothing.
18
19use alloc::string::{String, ToString};
20use alloc::vec::Vec;
21
22/// The type of a property value, used in a [`PropertySpec`] (so tooling knows how
23/// to parse a string for it) and to validate a [`PropValue`] on assignment.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum PropKind {
27    Bool,
28    /// Signed integer (`i64`).
29    Int,
30    /// Unsigned integer (`u64`).
31    Uint,
32    /// Floating point (`f64`).
33    Double,
34    /// A `num/den` fraction (e.g. a framerate `30/1`).
35    Fraction,
36    /// UTF-8 string.
37    Str,
38    /// A set of named flags, written `a+b+c` (gst's flags-property syntax, e.g.
39    /// `flags=video+audio`). The accepted nicks are the property's
40    /// [`enum_values`](PropertySpec::enum_values); the element receives them as a
41    /// [`PropValue::Flags`] list, so it never splits the text itself.
42    Flags,
43}
44
45impl PropKind {
46    /// The human label for this kind, as `gst-inspect` names the type
47    /// (`"Boolean"`, `"Unsigned Integer"`, ...). Shared by the text dump and the
48    /// structured [`PropertyDoc`](crate::runtime::PropertyDoc).
49    pub fn label(self) -> &'static str {
50        match self {
51            PropKind::Bool => "Boolean",
52            PropKind::Int => "Integer",
53            PropKind::Uint => "Unsigned Integer",
54            PropKind::Double => "Double",
55            PropKind::Fraction => "Fraction",
56            PropKind::Str => "String",
57            PropKind::Flags => "Flags",
58        }
59    }
60}
61
62/// A runtime property value. The variants mirror [`PropKind`].
63#[derive(Debug, Clone, PartialEq)]
64#[non_exhaustive]
65pub enum PropValue {
66    Bool(bool),
67    Int(i64),
68    Uint(u64),
69    Double(f64),
70    /// `(numerator, denominator)`.
71    Fraction(i32, i32),
72    Str(String),
73    /// The nicks of a set-valued property, in the order they were written.
74    Flags(Vec<String>),
75}
76
77impl PropValue {
78    /// The [`PropKind`] this value holds.
79    pub fn kind(&self) -> PropKind {
80        match self {
81            PropValue::Bool(_) => PropKind::Bool,
82            PropValue::Int(_) => PropKind::Int,
83            PropValue::Uint(_) => PropKind::Uint,
84            PropValue::Double(_) => PropKind::Double,
85            PropValue::Fraction(_, _) => PropKind::Fraction,
86            PropValue::Str(_) => PropKind::Str,
87            PropValue::Flags(_) => PropKind::Flags,
88        }
89    }
90
91    /// Parse a textual value (as it appears in a `gst-launch` pipeline) for the
92    /// given [`PropKind`]. `true`/`false` for bools; `n/d` for fractions; a bare
93    /// integer is also accepted as a fraction `n/1`. The string kind takes the
94    /// text verbatim.
95    pub fn parse(kind: PropKind, text: &str) -> Result<PropValue, PropError> {
96        let t = text.trim();
97        match kind {
98            // Case-insensitive, so a pipeline pasted from gst-launch (which
99            // takes `True` as readily as `true`) parses here too.
100            PropKind::Bool => match t.to_ascii_lowercase().as_str() {
101                "true" | "1" | "yes" => Ok(PropValue::Bool(true)),
102                "false" | "0" | "no" => Ok(PropValue::Bool(false)),
103                _ => Err(PropError::Value),
104            },
105            PropKind::Int => t
106                .parse::<i64>()
107                .map(PropValue::Int)
108                .map_err(|_| PropError::Value),
109            PropKind::Uint => t
110                .parse::<u64>()
111                .map(PropValue::Uint)
112                .map_err(|_| PropError::Value),
113            PropKind::Double => t
114                .parse::<f64>()
115                .map(PropValue::Double)
116                .map_err(|_| PropError::Value),
117            PropKind::Fraction => match t.split_once('/') {
118                Some((n, d)) => {
119                    let n = n.trim().parse::<i32>().map_err(|_| PropError::Value)?;
120                    let d = d.trim().parse::<i32>().map_err(|_| PropError::Value)?;
121                    if d == 0 {
122                        return Err(PropError::Value);
123                    }
124                    Ok(PropValue::Fraction(n, d))
125                }
126                None => {
127                    let n = t.parse::<i32>().map_err(|_| PropError::Value)?;
128                    Ok(PropValue::Fraction(n, 1))
129                }
130            },
131            PropKind::Str => Ok(PropValue::Str(t.to_string())),
132            PropKind::Flags => {
133                let mut set = Vec::new();
134                for nick in t.split('+') {
135                    let nick = nick.trim();
136                    if nick.is_empty() {
137                        return Err(PropError::Value);
138                    }
139                    set.push(nick.to_string());
140                }
141                Ok(PropValue::Flags(set))
142            }
143        }
144    }
145
146    /// Borrow the value as `bool`, if it is one.
147    pub fn as_bool(&self) -> Option<bool> {
148        match self {
149            PropValue::Bool(b) => Some(*b),
150            _ => None,
151        }
152    }
153
154    /// Borrow the value as `i64`, if it is an [`Int`](PropValue::Int).
155    pub fn as_int(&self) -> Option<i64> {
156        match self {
157            PropValue::Int(v) => Some(*v),
158            _ => None,
159        }
160    }
161
162    /// Borrow the value as `u64`, if it is a [`Uint`](PropValue::Uint).
163    pub fn as_uint(&self) -> Option<u64> {
164        match self {
165            PropValue::Uint(v) => Some(*v),
166            _ => None,
167        }
168    }
169
170    /// Borrow the value as `f64`, if it is a [`Double`](PropValue::Double).
171    pub fn as_double(&self) -> Option<f64> {
172        match self {
173            PropValue::Double(v) => Some(*v),
174            _ => None,
175        }
176    }
177
178    /// Borrow the value as a `(num, den)` fraction, if it is one.
179    pub fn as_fraction(&self) -> Option<(i32, i32)> {
180        match self {
181            PropValue::Fraction(n, d) => Some((*n, *d)),
182            _ => None,
183        }
184    }
185
186    /// Borrow the value as `&str`, if it is a [`Str`](PropValue::Str).
187    pub fn as_str(&self) -> Option<&str> {
188        match self {
189            PropValue::Str(s) => Some(s),
190            _ => None,
191        }
192    }
193
194    /// Borrow the value as the nicks of a [`Flags`](PropValue::Flags) set. The
195    /// parser has already split the `a+b` text and (when the property declares
196    /// `enum_values`) checked every nick, so an element only matches them.
197    pub fn as_flags(&self) -> Option<&[String]> {
198        match self {
199            PropValue::Flags(set) => Some(set),
200            _ => None,
201        }
202    }
203
204    /// Whether a [`Flags`](PropValue::Flags) set contains `nick`. `false` for any
205    /// other kind.
206    pub fn has_flag(&self, nick: &str) -> bool {
207        self.as_flags()
208            .is_some_and(|set| set.iter().any(|n| n == nick))
209    }
210}
211
212/// Read/write access flags for a property, the GObject `G_PARAM_READABLE` /
213/// `G_PARAM_WRITABLE` analog shown in a `gst-inspect` dump. Default is
214/// read+write; a derived/computed property is read-only.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub struct PropFlags {
217    pub readable: bool,
218    pub writable: bool,
219}
220
221impl PropFlags {
222    /// Readable and writable (the default).
223    pub const READWRITE: Self = Self {
224        readable: true,
225        writable: true,
226    };
227    /// Readable only (a computed / status property).
228    pub const READ_ONLY: Self = Self {
229        readable: true,
230        writable: false,
231    };
232}
233
234impl Default for PropFlags {
235    fn default() -> Self {
236        Self::READWRITE
237    }
238}
239
240/// Static metadata for one settable property: its name, type, a one-line
241/// description, and (optionally) its default, accepted range, and access flags.
242/// The element type declares these (via
243/// [`properties`](crate::AsyncElement::properties)) so tooling can enumerate and
244/// document them without a live instance carrying the strings. All textual fields
245/// are `&'static str` so the struct stays `Copy` / `const`-declarable.
246///
247/// Build with [`new`](Self::new) (name + kind + blurb) and refine with the
248/// `const` builders ([`with_default`](Self::with_default),
249/// [`with_range`](Self::with_range), [`read_only`](Self::read_only),
250/// [`with_enum_values`](Self::with_enum_values)).
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub struct PropertySpec {
253    /// Property name, as used in a `gst-launch` pipeline (`key=value`).
254    pub name: &'static str,
255    /// The value type, so a textual value can be parsed for it.
256    pub kind: PropKind,
257    /// One-line human description, for a `gst-inspect`-style dump.
258    pub blurb: &'static str,
259    /// Default value as text (parseable via [`PropValue::parse`]), or `None` if
260    /// the property has no meaningful default.
261    pub default: Option<&'static str>,
262    /// Accepted `(min, max)` range as text, for a numeric property.
263    pub range: Option<(&'static str, &'static str)>,
264    /// The named choices of an enum-like string property
265    /// (e.g. `"horizontal-mirror | vertical-mirror | rotate-180"`), `|`
266    /// separated. For a [`Str`](PropKind::Str) or [`Flags`](PropKind::Flags)
267    /// property this is the *closed* set [`parse_value`](Self::parse_value)
268    /// validates against, so every nick the element accepts (aliases included)
269    /// must be listed. On a numeric property it stays a documentation list (the
270    /// entries may carry a note, `"2 (2.5 ms) | 5"`) and is not enforced.
271    pub enum_values: Option<&'static str>,
272    /// Read/write access.
273    pub flags: PropFlags,
274}
275
276/// [`PropertySpec::name`] of the entry an element adds to say it takes
277/// properties beyond the ones it declares.
278///
279/// No pipeline can spell this as a key, so it cannot collide with a real one.
280pub const UNDECLARED_PROPERTIES: &str = "*";
281
282/// Whether these specs let a name none of them declares through.
283pub fn takes_undeclared_properties(specs: &[PropertySpec]) -> bool {
284    specs.iter().any(|s| s.name == UNDECLARED_PROPERTIES)
285}
286
287impl PropertySpec {
288    /// The entry that lets a key none of the other specs names through, as text,
289    /// for whatever does know it to interpret.
290    ///
291    /// For an element whose real property set is not known until something loads
292    /// at run time: a `pyelement` takes whatever the hosted Python class
293    /// declares, so the list cannot be written down here. `blurb` says where the
294    /// rest come from, since a `gst-inspect` dump shows this entry in their place.
295    pub const fn undeclared(blurb: &'static str) -> Self {
296        Self::new(UNDECLARED_PROPERTIES, PropKind::Str, blurb)
297    }
298
299    /// A new spec (a `const fn` so a static `&[PropertySpec]` table is cheap).
300    /// Defaults to no default value, no range, and read+write.
301    pub const fn new(name: &'static str, kind: PropKind, blurb: &'static str) -> Self {
302        Self {
303            name,
304            kind,
305            blurb,
306            default: None,
307            range: None,
308            enum_values: None,
309            flags: PropFlags::READWRITE,
310        }
311    }
312
313    /// Set the textual default value shown by `gst-inspect`.
314    pub const fn with_default(mut self, default: &'static str) -> Self {
315        self.default = Some(default);
316        self
317    }
318
319    /// Set the accepted `(min, max)` numeric range.
320    pub const fn with_range(mut self, min: &'static str, max: &'static str) -> Self {
321        self.range = Some((min, max));
322        self
323    }
324
325    /// Set the named choices of an enum-like string property.
326    pub const fn with_enum_values(mut self, values: &'static str) -> Self {
327        self.enum_values = Some(values);
328        self
329    }
330
331    /// Mark the property read-only (a computed / status value).
332    pub const fn read_only(mut self) -> Self {
333        self.flags = PropFlags::READ_ONLY;
334        self
335    }
336
337    /// The declared nicks of an enum / flag property: [`enum_values`](Self::enum_values)
338    /// split on `|`, each entry's leading word (an entry may carry a trailing
339    /// note, `"2 (2.5 ms)"`). Empty when the property declares none.
340    pub fn enum_nicks(&self) -> impl Iterator<Item = &'static str> {
341        self.enum_values
342            .unwrap_or("")
343            .split('|')
344            .filter_map(|entry| entry.split_whitespace().next())
345    }
346
347    /// Parse a textual value (a `gst-launch` `key=value`) for this property:
348    /// [`PropValue::parse`] for the kind, plus nick validation against
349    /// [`enum_values`](Self::enum_values) for a string / flag set. Validating here
350    /// means a launch parser can name the valid choices in its error instead of
351    /// surfacing a bare [`PropError::Value`] from the element.
352    pub fn parse_value(&self, text: &str) -> Result<PropValue, ValueError> {
353        let value = PropValue::parse(self.kind, text).map_err(|e| {
354            // A flag set parses unless an entry is empty (`a++b`, a trailing
355            // `+`); report the whole text so the error can list the nicks.
356            if self.kind == PropKind::Flags {
357                ValueError::Nick(text.trim().to_string())
358            } else {
359                ValueError::Kind(e)
360            }
361        })?;
362        if self.enum_values.is_none() {
363            return Ok(value);
364        }
365        let nicks: &[String] = match &value {
366            PropValue::Str(s) => core::slice::from_ref(s),
367            PropValue::Flags(set) => set,
368            // A numeric property's enum_values is documentation, not a closed set.
369            _ => return Ok(value),
370        };
371        for nick in nicks {
372            if !self.enum_nicks().any(|d| d == nick) {
373                return Err(ValueError::Nick(nick.clone()));
374            }
375        }
376        Ok(value)
377    }
378}
379
380/// Why [`PropertySpec::parse_value`] rejected a textual property value.
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub enum ValueError {
383    /// The text did not parse for the property's [`PropKind`].
384    Kind(PropError),
385    /// A name that is not one of the property's declared choices. The string is
386    /// the offending nick (one entry of a `+`-joined flag set), or the whole
387    /// value when a flag set was malformed.
388    Nick(String),
389}
390
391impl core::fmt::Display for ValueError {
392    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
393        match self {
394            ValueError::Kind(e) => write!(f, "{e}"),
395            ValueError::Nick(n) => write!(f, "unknown value '{n}'"),
396        }
397    }
398}
399
400/// Static, type-level description of an element for `gst-inspect`-style
401/// introspection (M178), the GStreamer element-class-metadata analog
402/// (`gst_element_class_set_static_metadata`). All `&'static str` so it is
403/// `const`-declarable next to the element and costs a live instance nothing.
404/// An element opts in by overriding `metadata()` (default: empty), exactly like
405/// [`properties`](crate::AsyncElement::properties).
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
407pub struct ElementMetadata {
408    /// Human-readable name, e.g. `"Opus audio encoder"`.
409    pub long_name: &'static str,
410    /// Classification (GStreamer's `klass`), e.g. `"Codec/Encoder/Audio"`.
411    pub klass: &'static str,
412    /// One-paragraph description of what the element does.
413    pub description: &'static str,
414    /// Author / origin, e.g. `"g2g"`.
415    pub author: &'static str,
416}
417
418impl ElementMetadata {
419    /// A new metadata block (a `const fn` for a `const` declaration on the type).
420    pub const fn new(
421        long_name: &'static str,
422        klass: &'static str,
423        description: &'static str,
424        author: &'static str,
425    ) -> Self {
426        Self {
427            long_name,
428            klass,
429            description,
430            author,
431        }
432    }
433
434    /// Whether any field is set (an element that overrode `metadata()`).
435    pub fn is_set(&self) -> bool {
436        !(self.long_name.is_empty()
437            && self.klass.is_empty()
438            && self.description.is_empty()
439            && self.author.is_empty())
440    }
441}
442
443/// Why a [`set_property`](crate::AsyncElement::set_property) (or a value parse)
444/// failed.
445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
446pub enum PropError {
447    /// No property of that name on this element.
448    Unknown,
449    /// The value's [`PropKind`] does not match the property's.
450    Type,
451    /// The value is the right kind but out of the accepted range / not parseable.
452    Value,
453    /// The property exists but is read-only.
454    ReadOnly,
455}
456
457impl core::fmt::Display for PropError {
458    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
459        let s = match self {
460            PropError::Unknown => "unknown property",
461            PropError::Type => "property type mismatch",
462            PropError::Value => "invalid property value",
463            PropError::ReadOnly => "read-only property",
464        };
465        f.write_str(s)
466    }
467}
468
469/// The human label for a [`PropKind`], as `gst-inspect` names the type.
470fn kind_label(kind: PropKind) -> &'static str {
471    kind.label()
472}
473
474/// Format a property spec table the way `gst-inspect` details it: a header line
475/// per property (name + blurb), then indented `flags`, type, range/enum, and
476/// default lines. Used by the registry's introspection dump (M105, enriched
477/// M178).
478pub fn format_specs(specs: &[PropertySpec]) -> String {
479    use core::fmt::Write;
480    let mut out = String::new();
481    for s in specs {
482        let _ = writeln!(out, "  {}: {}", s.name, s.blurb);
483        let flags = match (s.flags.readable, s.flags.writable) {
484            (true, true) => "readable, writable",
485            (true, false) => "readable",
486            (false, true) => "writable",
487            (false, false) => "",
488        };
489        let _ = writeln!(out, "    flags: {flags}");
490        let _ = write!(out, "    {}", kind_label(s.kind));
491        if let Some((min, max)) = s.range {
492            let _ = write!(out, ". Range: {min} - {max}");
493        }
494        if let Some(values) = s.enum_values {
495            let _ = write!(out, ". Values: {values}");
496        }
497        if let Some(default) = s.default {
498            let _ = write!(out, ". Default: {default}");
499        }
500        out.push('\n');
501    }
502    out
503}
504
505/// Format an [`ElementMetadata`] block the way `gst-inspect` opens with its
506/// "Factory Details" section. `name` is the registry/factory name (the element's
507/// `gst-launch` identifier). Empty metadata fields are omitted.
508pub fn format_metadata(name: &str, meta: &ElementMetadata) -> String {
509    use core::fmt::Write;
510    let mut out = String::new();
511    let _ = writeln!(out, "Factory Details:");
512    let _ = writeln!(out, "  Name        {name}");
513    if !meta.long_name.is_empty() {
514        let _ = writeln!(out, "  Long-name   {}", meta.long_name);
515    }
516    if !meta.klass.is_empty() {
517        let _ = writeln!(out, "  Klass       {}", meta.klass);
518    }
519    if !meta.description.is_empty() {
520        let _ = writeln!(out, "  Description {}", meta.description);
521    }
522    if !meta.author.is_empty() {
523        let _ = writeln!(out, "  Author      {}", meta.author);
524    }
525    out
526}
527
528/// Collect the names of a spec table (helper for tests / tooling).
529pub fn spec_names(specs: &[PropertySpec]) -> Vec<&'static str> {
530    specs.iter().map(|s| s.name).collect()
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    #[test]
538    fn parse_matches_kind() {
539        assert_eq!(
540            PropValue::parse(PropKind::Bool, "true").unwrap(),
541            PropValue::Bool(true)
542        );
543        assert_eq!(
544            PropValue::parse(PropKind::Bool, "0").unwrap(),
545            PropValue::Bool(false)
546        );
547        assert_eq!(
548            PropValue::parse(PropKind::Bool, "True").unwrap(),
549            PropValue::Bool(true)
550        );
551        assert_eq!(
552            PropValue::parse(PropKind::Bool, "FALSE").unwrap(),
553            PropValue::Bool(false)
554        );
555        assert_eq!(
556            PropValue::parse(PropKind::Int, "-7").unwrap(),
557            PropValue::Int(-7)
558        );
559        assert_eq!(
560            PropValue::parse(PropKind::Uint, "42").unwrap(),
561            PropValue::Uint(42)
562        );
563        assert_eq!(
564            PropValue::parse(PropKind::Fraction, "30/1").unwrap(),
565            PropValue::Fraction(30, 1)
566        );
567        // A bare integer parses as n/1 for a fraction property.
568        assert_eq!(
569            PropValue::parse(PropKind::Fraction, "25").unwrap(),
570            PropValue::Fraction(25, 1)
571        );
572        assert_eq!(
573            PropValue::parse(PropKind::Str, "file.mp4").unwrap(),
574            PropValue::Str("file.mp4".into())
575        );
576    }
577
578    #[test]
579    fn parse_rejects_bad_values() {
580        assert_eq!(PropValue::parse(PropKind::Int, "x"), Err(PropError::Value));
581        assert_eq!(
582            PropValue::parse(PropKind::Uint, "-1"),
583            Err(PropError::Value)
584        );
585        assert_eq!(
586            PropValue::parse(PropKind::Fraction, "1/0"),
587            Err(PropError::Value)
588        );
589        assert_eq!(
590            PropValue::parse(PropKind::Bool, "maybe"),
591            Err(PropError::Value)
592        );
593    }
594
595    #[test]
596    fn flag_set_parses_into_nicks() {
597        assert_eq!(
598            PropValue::parse(PropKind::Flags, "video+audio").unwrap(),
599            PropValue::Flags(alloc::vec!["video".into(), "audio".into()])
600        );
601        // Whitespace around a nick is trimmed (a quoted `"video + audio"`).
602        assert_eq!(
603            PropValue::parse(PropKind::Flags, "video + audio").unwrap(),
604            PropValue::Flags(alloc::vec!["video".into(), "audio".into()])
605        );
606        let v = PropValue::parse(PropKind::Flags, "video+audio").unwrap();
607        assert!(v.has_flag("audio") && !v.has_flag("text"));
608        assert_eq!(v.as_flags().unwrap().len(), 2);
609    }
610
611    #[test]
612    fn malformed_flag_set_is_rejected() {
613        for bad in ["video+", "+video", "video++audio", ""] {
614            assert_eq!(
615                PropValue::parse(PropKind::Flags, bad),
616                Err(PropError::Value),
617                "{bad} must not parse"
618            );
619        }
620    }
621
622    #[test]
623    fn spec_validates_enum_nicks() {
624        let spec = PropertySpec::new("backend", PropKind::Str, "encoder")
625            .with_enum_values("nvenc | software");
626        assert_eq!(
627            spec.parse_value("software").unwrap(),
628            PropValue::Str("software".into())
629        );
630        assert_eq!(
631            spec.parse_value("nvidia"),
632            Err(ValueError::Nick("nvidia".into()))
633        );
634        // No declared values: any string goes through.
635        let free = PropertySpec::new("location", PropKind::Str, "path");
636        assert!(free.parse_value("anything").is_ok());
637    }
638
639    #[test]
640    fn spec_validates_each_flag_nick() {
641        let spec = PropertySpec::new("protocols", PropKind::Flags, "transports")
642            .with_enum_values("udp | tcp");
643        assert_eq!(
644            spec.parse_value("udp+tcp").unwrap(),
645            PropValue::Flags(alloc::vec!["udp".into(), "tcp".into()])
646        );
647        // The offending nick is named, not the whole value.
648        assert_eq!(
649            spec.parse_value("udp+quic"),
650            Err(ValueError::Nick("quic".into()))
651        );
652        // A malformed set reports the whole text (there is no one bad nick).
653        assert_eq!(
654            spec.parse_value("udp+"),
655            Err(ValueError::Nick("udp+".into()))
656        );
657        assert_eq!(spec.enum_nicks().collect::<Vec<_>>(), ["udp", "tcp"]);
658    }
659
660    #[test]
661    fn numeric_enum_values_stay_documentation() {
662        // `opusenc frame-size` lists annotated numbers; the list documents the
663        // choices but the kind (not the list) decides what parses.
664        let spec = PropertySpec::new("frame-size", PropKind::Uint, "ms")
665            .with_enum_values("2 (2.5 ms) | 5 | 10");
666        assert_eq!(spec.parse_value("20").unwrap(), PropValue::Uint(20));
667        assert_eq!(
668            spec.parse_value("x"),
669            Err(ValueError::Kind(PropError::Value))
670        );
671    }
672
673    #[test]
674    fn kind_round_trips_value() {
675        assert_eq!(PropValue::Int(3).kind(), PropKind::Int);
676        assert_eq!(PropValue::Fraction(30, 1).kind(), PropKind::Fraction);
677        assert_eq!(PropValue::Str("x".into()).kind(), PropKind::Str);
678    }
679
680    #[test]
681    fn format_specs_details_each_property() {
682        let specs = [
683            PropertySpec::new("pattern", PropKind::Str, "test pattern")
684                .with_enum_values("smpte | snow | ball")
685                .with_default("smpte"),
686            PropertySpec::new(
687                "num-buffers",
688                PropKind::Int,
689                "frames then EOS (-1 = forever)",
690            )
691            .with_range("-1", "9223372036854775807")
692            .with_default("-1"),
693        ];
694        let dump = format_specs(&specs);
695        // Header line: name + blurb.
696        assert!(dump.contains("pattern: test pattern"), "got:\n{dump}");
697        // Detail lines: flags, type, enum values, default.
698        assert!(dump.contains("flags: readable, writable"));
699        assert!(dump.contains("String. Values: smpte | snow | ball. Default: smpte"));
700        assert!(dump.contains("Integer. Range: -1 - 9223372036854775807. Default: -1"));
701        assert_eq!(spec_names(&specs), ["pattern", "num-buffers"]);
702    }
703
704    #[test]
705    fn read_only_flag_renders() {
706        let specs = [PropertySpec::new("dropped", PropKind::Uint, "frames dropped").read_only()];
707        assert!(format_specs(&specs).contains("flags: readable\n"));
708    }
709
710    #[test]
711    fn metadata_block_omits_empty_fields() {
712        let meta = ElementMetadata::new("Opus encoder", "Codec/Encoder/Audio", "", "g2g");
713        let dump = format_metadata("opusenc", &meta);
714        assert!(dump.contains("Name        opusenc"));
715        assert!(dump.contains("Long-name   Opus encoder"));
716        assert!(dump.contains("Klass       Codec/Encoder/Audio"));
717        assert!(dump.contains("Author      g2g"));
718        assert!(!dump.contains("Description"), "empty description omitted");
719        assert!(!ElementMetadata::default().is_set());
720        assert!(meta.is_set());
721    }
722}