Skip to main content

libobs_wrapper/
settings.rs

1//! Generic, plugin-agnostic settings schemas built from OBS property metadata.
2//!
3//! This module is the safe bridge between runtime-discovered [`crate::capabilities::PropertyMetadata`]
4//! and [`crate::data::ObsData`]. It deliberately models values independently from concrete OBS plugins,
5//! so applications can build configuration UIs or remote-control surfaces without hard-coding plugin IDs.
6//!
7//! Scalar values, combo lists, frame-rate values/options, editable lists, fonts, colors, and checkable
8//! groups are represented as [`PropertyValue`]. Button properties are intentionally metadata/actions,
9//! not data values; triggering a property button requires the concrete OBS object that owns the action.
10//! Use [`SettingsSnapshot`] when a UI needs dynamic metadata plus current/default values together.
11
12use std::ffi::CStr;
13
14use crate::{
15    capabilities::{
16        FrameRate, GroupType, ListFormat, ListItem, ListType, ListValue, PropertyKind,
17        PropertyMetadata,
18    },
19    data::{ImmutableObsData, ObsData, ObsDataGetters, ObsDataPointers, ObsDataSetters},
20    run_with_obs,
21    utils::{ObsError, ObsString},
22};
23
24/// A generic value that can be read from or applied to an OBS property.
25#[derive(Clone, Debug, PartialEq)]
26pub enum PropertyValue {
27    Boolean(bool),
28    Integer(i64),
29    Float(f64),
30    String(String),
31    FrameRate(FrameRateSetting),
32    EditableList(Vec<EditableListEntry>),
33    Font(FontSetting),
34}
35
36impl PropertyValue {
37    pub fn kind_name(&self) -> &'static str {
38        match self {
39            Self::Boolean(_) => "boolean",
40            Self::Integer(_) => "integer",
41            Self::Float(_) => "float",
42            Self::String(_) => "string",
43            Self::FrameRate(_) => "frame-rate",
44            Self::EditableList(_) => "editable-list",
45            Self::Font(_) => "font",
46        }
47    }
48}
49
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct FrameRateSetting {
52    pub frame_rate: FrameRate,
53    pub option: Option<String>,
54}
55
56impl From<FrameRate> for PropertyValue {
57    fn from(frame_rate: FrameRate) -> Self {
58        Self::FrameRate(FrameRateSetting {
59            frame_rate,
60            option: None,
61        })
62    }
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct EditableListEntry {
67    pub value: String,
68    pub uuid: Option<String>,
69    pub selected: bool,
70    pub hidden: bool,
71}
72
73impl EditableListEntry {
74    pub fn new(value: impl Into<String>) -> Self {
75        Self {
76            value: value.into(),
77            uuid: None,
78            selected: false,
79            hidden: false,
80        }
81    }
82}
83
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct FontSetting {
86    pub face: String,
87    pub style: String,
88    pub size: i64,
89    pub flags: u32,
90}
91
92fn list_value_to_property_value(value: &ListValue) -> Option<PropertyValue> {
93    match value {
94        ListValue::String(value) => Some(PropertyValue::String(value.clone())),
95        ListValue::Int(value) => Some(PropertyValue::Integer(*value)),
96        ListValue::Float(value) => Some(PropertyValue::Float(*value)),
97        ListValue::Bool(value) => Some(PropertyValue::Boolean(*value)),
98        ListValue::UnknownFormat(_) => None,
99    }
100}
101
102/// Current/default values associated with one runtime-discovered property. Action-only properties
103/// such as buttons intentionally have no data value while retaining their metadata.
104#[derive(Clone, Debug, PartialEq)]
105pub struct PropertyState {
106    pub metadata: PropertyMetadata,
107    pub current_value: Option<PropertyValue>,
108    pub default_value: Option<PropertyValue>,
109}
110
111/// A form-ready settings view containing dynamic metadata plus current/default values.
112#[derive(Clone, Debug, PartialEq)]
113pub struct SettingsSnapshot {
114    schema: SettingsSchema,
115    states: Vec<PropertyState>,
116}
117
118impl SettingsSnapshot {
119    pub fn schema(&self) -> &SettingsSchema {
120        &self.schema
121    }
122
123    pub fn states(&self) -> &[PropertyState] {
124        &self.states
125    }
126
127    pub fn state(&self, name: &str) -> Option<&PropertyState> {
128        self.states.iter().find(|state| state.metadata.name == name)
129    }
130}
131
132/// An owned, recursively searchable snapshot of an OBS property tree.
133#[derive(Clone, Debug, Default, PartialEq)]
134pub struct SettingsSchema {
135    properties: Vec<PropertyMetadata>,
136}
137
138impl SettingsSchema {
139    pub fn new(properties: Vec<PropertyMetadata>) -> Self {
140        Self { properties }
141    }
142
143    pub fn properties(&self) -> &[PropertyMetadata] {
144        &self.properties
145    }
146
147    /// Finds a property by its OBS name, recursively descending through property groups.
148    pub fn property(&self, name: &str) -> Option<&PropertyMetadata> {
149        find_property(&self.properties, name)
150    }
151
152    /// Validates a value against the runtime-discovered metadata without mutating OBS data.
153    pub fn validate(&self, name: &str, value: &PropertyValue) -> Result<(), ObsError> {
154        let property = self
155            .property(name)
156            .ok_or_else(|| ObsError::PropertyNotFound {
157                name: name.to_owned(),
158            })?;
159        validate_property_value(property, value)
160    }
161
162    /// Validates and applies one scalar property value to an [`ObsData`] object.
163    pub fn set(
164        &self,
165        settings: &mut ObsData,
166        name: &str,
167        value: PropertyValue,
168    ) -> Result<(), ObsError> {
169        self.validate(name, &value)?;
170        match value {
171            PropertyValue::Boolean(value) => {
172                settings.set_bool(name, value)?;
173            }
174            PropertyValue::Integer(value) => {
175                settings.set_int(name, value)?;
176            }
177            PropertyValue::Float(value) => {
178                settings.set_double(name, value)?;
179            }
180            PropertyValue::String(value) => {
181                settings.set_string(name, value)?;
182            }
183            PropertyValue::FrameRate(value) => {
184                set_frame_rate(settings, name, value)?;
185            }
186            PropertyValue::EditableList(entries) => {
187                set_editable_list(settings, name, entries)?;
188            }
189            PropertyValue::Font(font) => {
190                set_font(settings, name, font)?;
191            }
192        }
193        Ok(())
194    }
195
196    /// Reads a scalar property using the value category declared by this schema.
197    pub fn value(&self, settings: &ObsData, name: &str) -> Result<Option<PropertyValue>, ObsError> {
198        let property = self
199            .property(name)
200            .ok_or_else(|| ObsError::PropertyNotFound {
201                name: name.to_owned(),
202            })?;
203        read_property_value(settings, property)
204    }
205
206    /// Joins this dynamic schema with current and default values into one form-ready snapshot.
207    pub fn snapshot(
208        &self,
209        current: &ObsData,
210        defaults: Option<&ImmutableObsData>,
211    ) -> Result<SettingsSnapshot, ObsError> {
212        if let Some(defaults) = defaults {
213            current.runtime().ensure_same_runtime(defaults.runtime())?;
214        }
215        let mut states = Vec::new();
216        collect_property_states(&self.properties, current, defaults, &mut states)?;
217        Ok(SettingsSnapshot {
218            schema: self.clone(),
219            states,
220        })
221    }
222}
223
224impl PropertyMetadata {
225    /// Returns all enabled values advertised by a list property in their generic typed form.
226    pub fn enabled_list_values(&self) -> Vec<PropertyValue> {
227        match &self.kind {
228            PropertyKind::List { items, .. } => items
229                .iter()
230                .filter(|item| !item.disabled)
231                .filter_map(|item| list_value_to_property_value(&item.value))
232                .collect(),
233            _ => Vec::new(),
234        }
235    }
236}
237
238fn collect_property_states(
239    properties: &[PropertyMetadata],
240    current: &ObsData,
241    defaults: Option<&ImmutableObsData>,
242    states: &mut Vec<PropertyState>,
243) -> Result<(), ObsError> {
244    for property in properties {
245        states.push(PropertyState {
246            metadata: property.clone(),
247            current_value: read_property_value(current, property)?,
248            default_value: match defaults {
249                Some(defaults) => read_property_value(defaults, property)?,
250                None => None,
251            },
252        });
253        if let PropertyKind::Group { properties, .. } = &property.kind {
254            collect_property_states(properties, current, defaults, states)?;
255        }
256    }
257    Ok(())
258}
259
260fn find_property<'a>(
261    properties: &'a [PropertyMetadata],
262    name: &str,
263) -> Option<&'a PropertyMetadata> {
264    for property in properties {
265        if property.name == name {
266            return Some(property);
267        }
268        if let PropertyKind::Group { properties, .. } = &property.kind {
269            if let Some(found) = find_property(properties, name) {
270                return Some(found);
271            }
272        }
273    }
274    None
275}
276
277fn expected_kind_name(kind: &PropertyKind) -> &'static str {
278    match kind {
279        PropertyKind::Bool => "boolean",
280        PropertyKind::Integer { .. } | PropertyKind::Color | PropertyKind::ColorAlpha => "integer",
281        PropertyKind::Float { .. } => "float",
282        PropertyKind::Text { .. } | PropertyKind::Path { .. } => "string",
283        PropertyKind::List { format, .. } => match format {
284            ListFormat::String => "string",
285            ListFormat::Int => "integer",
286            ListFormat::Float => "float",
287            ListFormat::Bool => "boolean",
288            _ => "list value",
289        },
290        PropertyKind::FrameRate { .. } => "frame-rate",
291        PropertyKind::EditableList { .. } => "editable-list",
292        PropertyKind::Font => "font",
293        PropertyKind::Group {
294            group_type: GroupType::Checkable,
295            ..
296        } => "boolean",
297        PropertyKind::Group { .. } => "non-value/structural",
298        PropertyKind::Invalid | PropertyKind::Button { .. } | PropertyKind::Unknown(_) => {
299            "non-value/action"
300        }
301    }
302}
303
304fn type_mismatch(property: &PropertyMetadata, value: &PropertyValue) -> ObsError {
305    ObsError::PropertyValueTypeMismatch {
306        name: property.name.clone(),
307        expected: expected_kind_name(&property.kind).to_owned(),
308        actual: value.kind_name().to_owned(),
309    }
310}
311
312fn validate_property_value(
313    property: &PropertyMetadata,
314    value: &PropertyValue,
315) -> Result<(), ObsError> {
316    match (&property.kind, value) {
317        (PropertyKind::Bool, PropertyValue::Boolean(_))
318        | (
319            PropertyKind::Group {
320                group_type: GroupType::Checkable,
321                ..
322            },
323            PropertyValue::Boolean(_),
324        )
325        | (PropertyKind::Text { .. }, PropertyValue::String(_))
326        | (PropertyKind::Path { .. }, PropertyValue::String(_))
327        | (PropertyKind::Color, PropertyValue::Integer(_))
328        | (PropertyKind::ColorAlpha, PropertyValue::Integer(_)) => Ok(()),
329        (PropertyKind::Integer { min, max, step, .. }, PropertyValue::Integer(value)) => {
330            let min = i64::from(*min);
331            let max = i64::from(*max);
332            if *value < min || *value > max {
333                return Err(ObsError::PropertyValueOutOfRange {
334                    name: property.name.clone(),
335                    value: value.to_string(),
336                    min: min.to_string(),
337                    max: max.to_string(),
338                });
339            }
340            if *step > 0 && (*value - min) % i64::from(*step) != 0 {
341                return Err(ObsError::PropertyValueNotAllowed {
342                    name: property.name.clone(),
343                    value: value.to_string(),
344                });
345            }
346            Ok(())
347        }
348        (PropertyKind::Float { min, max, .. }, PropertyValue::Float(value)) => {
349            if !value.is_finite() || *value < *min || *value > *max {
350                return Err(ObsError::PropertyValueOutOfRange {
351                    name: property.name.clone(),
352                    value: value.to_string(),
353                    min: min.to_string(),
354                    max: max.to_string(),
355                });
356            }
357            Ok(())
358        }
359        (
360            PropertyKind::List {
361                list_type,
362                format,
363                items,
364            },
365            value,
366        ) => validate_list_value(property, *list_type, *format, items, value),
367        (PropertyKind::FrameRate { options, ranges }, PropertyValue::FrameRate(value)) => {
368            if let Some(option) = value.option.as_deref() {
369                if options
370                    .iter()
371                    .any(|candidate| candidate.name.as_deref() == Some(option))
372                {
373                    return Ok(());
374                }
375                return Err(ObsError::PropertyValueNotAllowed {
376                    name: property.name.clone(),
377                    value: option.to_owned(),
378                });
379            }
380
381            let fps = value.frame_rate;
382            if fps.denominator == 0 {
383                return Err(ObsError::PropertyValueNotAllowed {
384                    name: property.name.clone(),
385                    value: format!("{}/{}", fps.numerator, fps.denominator),
386                });
387            }
388            if ranges.is_empty()
389                || ranges
390                    .iter()
391                    .any(|range| frame_rate_in_range(fps, range.min, range.max))
392            {
393                Ok(())
394            } else {
395                Err(ObsError::PropertyValueNotAllowed {
396                    name: property.name.clone(),
397                    value: format!("{}/{}", fps.numerator, fps.denominator),
398                })
399            }
400        }
401        (PropertyKind::EditableList { .. }, PropertyValue::EditableList(_))
402        | (PropertyKind::Font, PropertyValue::Font(_)) => Ok(()),
403        (
404            PropertyKind::Invalid
405            | PropertyKind::Button { .. }
406            | PropertyKind::Group { .. }
407            | PropertyKind::Unknown(_),
408            _,
409        ) => Err(ObsError::PropertyValueUnsupported {
410            name: property.name.clone(),
411            property_type: format!("{:?}", property.kind),
412        }),
413        _ => Err(type_mismatch(property, value)),
414    }
415}
416
417fn validate_list_value(
418    property: &PropertyMetadata,
419    list_type: ListType,
420    format: ListFormat,
421    items: &[ListItem],
422    value: &PropertyValue,
423) -> Result<(), ObsError> {
424    let type_matches = matches!(
425        (format, value),
426        (ListFormat::String, PropertyValue::String(_))
427            | (ListFormat::Int, PropertyValue::Integer(_))
428            | (ListFormat::Float, PropertyValue::Float(_))
429            | (ListFormat::Bool, PropertyValue::Boolean(_))
430    );
431    if !type_matches {
432        return Err(type_mismatch(property, value));
433    }
434
435    if matches!(list_type, ListType::Editable) {
436        return Ok(());
437    }
438    if items
439        .iter()
440        .filter(|item| !item.disabled)
441        .filter_map(|item| list_value_to_property_value(&item.value))
442        .any(|item| item == *value)
443    {
444        Ok(())
445    } else {
446        Err(ObsError::PropertyValueNotAllowed {
447            name: property.name.clone(),
448            value: format!("{value:?}"),
449        })
450    }
451}
452
453fn frame_rate_in_range(value: FrameRate, min: FrameRate, max: FrameRate) -> bool {
454    let value = value.numerator as f64 / value.denominator as f64;
455    let min = min.numerator as f64 / min.denominator.max(1) as f64;
456    let max = max.numerator as f64 / max.denominator.max(1) as f64;
457    value >= min && value <= max
458}
459
460fn set_frame_rate(settings: &ObsData, name: &str, value: FrameRateSetting) -> Result<(), ObsError> {
461    let name = ObsString::new(name);
462    let data = settings.as_ptr();
463    run_with_obs!(settings.runtime(), (name, data, value), move || unsafe {
464        // Safety: managed data/name stay alive for the actor call; libobs copies the rate and
465        // optional string synchronously.
466        let fps = libobs::media_frames_per_second {
467            numerator: value.frame_rate.numerator,
468            denominator: value.frame_rate.denominator,
469        };
470        let option = value.option.as_deref().map(ObsString::new);
471        libobs::obs_data_set_frames_per_second(
472            data.get_ptr(),
473            name.as_ptr().0,
474            fps,
475            option
476                .as_ref()
477                .map_or(std::ptr::null(), |option| option.as_ptr().0),
478        );
479    })
480}
481
482fn set_editable_list(
483    settings: &ObsData,
484    name: &str,
485    entries: Vec<EditableListEntry>,
486) -> Result<(), ObsError> {
487    let name = ObsString::new(name);
488    let data = settings.as_ptr();
489    run_with_obs!(settings.runtime(), (name, data, entries), move || unsafe {
490        // Safety: all temporary obs_data/array objects are owned within this actor command. The
491        // destination data object takes its own reference when the array is assigned.
492        let array = libobs::obs_data_array_create();
493        if array.is_null() {
494            return Err(ObsError::NullPointer(Some(
495                "creating editable-list data array".into(),
496            )));
497        }
498        let value_key = ObsString::new("value");
499        let uuid_key = ObsString::new("uuid");
500        let selected_key = ObsString::new("selected");
501        let hidden_key = ObsString::new("hidden");
502        for entry in entries {
503            let item = libobs::obs_data_create();
504            if item.is_null() {
505                libobs::obs_data_array_release(array);
506                return Err(ObsError::NullPointer(Some(
507                    "creating editable-list item".into(),
508                )));
509            }
510            let value = ObsString::new(entry.value);
511            libobs::obs_data_set_string(item, value_key.as_ptr().0, value.as_ptr().0);
512            if let Some(uuid) = entry.uuid {
513                let uuid = ObsString::new(uuid);
514                libobs::obs_data_set_string(item, uuid_key.as_ptr().0, uuid.as_ptr().0);
515            }
516            libobs::obs_data_set_bool(item, selected_key.as_ptr().0, entry.selected);
517            libobs::obs_data_set_bool(item, hidden_key.as_ptr().0, entry.hidden);
518            libobs::obs_data_array_push_back(array, item);
519            libobs::obs_data_release(item);
520        }
521        libobs::obs_data_set_array(data.get_ptr(), name.as_ptr().0, array);
522        libobs::obs_data_array_release(array);
523        Ok(())
524    })?
525}
526
527fn set_font(settings: &ObsData, name: &str, font: FontSetting) -> Result<(), ObsError> {
528    let name = ObsString::new(name);
529    let data = settings.as_ptr();
530    run_with_obs!(settings.runtime(), (name, data, font), move || unsafe {
531        // Safety: the temporary font object lives until `obs_data_set_obj` takes its own reference.
532        let object = libobs::obs_data_create();
533        if object.is_null() {
534            return Err(ObsError::NullPointer(Some(
535                "creating font settings object".into(),
536            )));
537        }
538        let face_key = ObsString::new("face");
539        let style_key = ObsString::new("style");
540        let size_key = ObsString::new("size");
541        let flags_key = ObsString::new("flags");
542        let face = ObsString::new(font.face);
543        let style = ObsString::new(font.style);
544        libobs::obs_data_set_string(object, face_key.as_ptr().0, face.as_ptr().0);
545        libobs::obs_data_set_string(object, style_key.as_ptr().0, style.as_ptr().0);
546        libobs::obs_data_set_int(object, size_key.as_ptr().0, font.size);
547        libobs::obs_data_set_int(object, flags_key.as_ptr().0, i64::from(font.flags));
548        libobs::obs_data_set_obj(data.get_ptr(), name.as_ptr().0, object);
549        libobs::obs_data_release(object);
550        Ok(())
551    })?
552}
553
554fn read_frame_rate<T>(settings: &T, name: &str) -> Result<Option<PropertyValue>, ObsError>
555where
556    T: ObsDataPointers,
557{
558    let name = ObsString::new(name);
559    let data = settings.as_ptr();
560    run_with_obs!(settings.runtime(), (name, data), move || unsafe {
561        // Safety: libobs writes only stack-local output values while the managed data handle lives.
562        let mut fps = libobs::media_frames_per_second {
563            numerator: 0,
564            denominator: 0,
565        };
566        let mut option = std::ptr::null();
567        if !libobs::obs_data_get_frames_per_second(
568            data.get_ptr(),
569            name.as_ptr().0,
570            &mut fps,
571            &mut option,
572        ) {
573            return None;
574        }
575        let option = if option.is_null() {
576            None
577        } else {
578            Some(CStr::from_ptr(option).to_string_lossy().into_owned())
579        };
580        Some(PropertyValue::FrameRate(FrameRateSetting {
581            frame_rate: FrameRate {
582                numerator: fps.numerator,
583                denominator: fps.denominator,
584            },
585            option,
586        }))
587    })
588}
589
590fn read_editable_list<T>(settings: &T, name: &str) -> Result<Option<PropertyValue>, ObsError>
591where
592    T: ObsDataPointers,
593{
594    let name = ObsString::new(name);
595    let data = settings.as_ptr();
596    run_with_obs!(settings.runtime(), (name, data), move || unsafe {
597        // Safety: `obs_data_get_array`/`obs_data_array_item` return owned references which are
598        // released before leaving the actor command. All strings are copied into Rust values.
599        let array = libobs::obs_data_get_array(data.get_ptr(), name.as_ptr().0);
600        if array.is_null() {
601            return None;
602        }
603        let value_key = ObsString::new("value");
604        let uuid_key = ObsString::new("uuid");
605        let selected_key = ObsString::new("selected");
606        let hidden_key = ObsString::new("hidden");
607        let mut entries = Vec::with_capacity(libobs::obs_data_array_count(array));
608        for index in 0..libobs::obs_data_array_count(array) {
609            let item = libobs::obs_data_array_item(array, index);
610            if item.is_null() {
611                continue;
612            }
613            let value_ptr = libobs::obs_data_get_string(item, value_key.as_ptr().0);
614            let uuid_ptr = libobs::obs_data_get_string(item, uuid_key.as_ptr().0);
615            let value = if value_ptr.is_null() {
616                String::new()
617            } else {
618                CStr::from_ptr(value_ptr).to_string_lossy().into_owned()
619            };
620            let uuid = if uuid_ptr.is_null() {
621                None
622            } else {
623                let uuid = CStr::from_ptr(uuid_ptr).to_string_lossy().into_owned();
624                (!uuid.is_empty()).then_some(uuid)
625            };
626            entries.push(EditableListEntry {
627                value,
628                uuid,
629                selected: libobs::obs_data_get_bool(item, selected_key.as_ptr().0),
630                hidden: libobs::obs_data_get_bool(item, hidden_key.as_ptr().0),
631            });
632            libobs::obs_data_release(item);
633        }
634        libobs::obs_data_array_release(array);
635        Some(PropertyValue::EditableList(entries))
636    })
637}
638
639fn read_font<T>(settings: &T, name: &str) -> Result<Option<PropertyValue>, ObsError>
640where
641    T: ObsDataPointers,
642{
643    let name = ObsString::new(name);
644    let data = settings.as_ptr();
645    run_with_obs!(settings.runtime(), (name, data), move || unsafe {
646        // Safety: `obs_data_get_obj` returns an owned reference; fields are copied before release.
647        let object = libobs::obs_data_get_obj(data.get_ptr(), name.as_ptr().0);
648        if object.is_null() {
649            return None;
650        }
651        let face_key = ObsString::new("face");
652        let style_key = ObsString::new("style");
653        let size_key = ObsString::new("size");
654        let flags_key = ObsString::new("flags");
655        let face_ptr = libobs::obs_data_get_string(object, face_key.as_ptr().0);
656        let style_ptr = libobs::obs_data_get_string(object, style_key.as_ptr().0);
657        let face = if face_ptr.is_null() {
658            String::new()
659        } else {
660            CStr::from_ptr(face_ptr).to_string_lossy().into_owned()
661        };
662        let style = if style_ptr.is_null() {
663            String::new()
664        } else {
665            CStr::from_ptr(style_ptr).to_string_lossy().into_owned()
666        };
667        let result = PropertyValue::Font(FontSetting {
668            face,
669            style,
670            size: libobs::obs_data_get_int(object, size_key.as_ptr().0),
671            flags: libobs::obs_data_get_int(object, flags_key.as_ptr().0) as u32,
672        });
673        libobs::obs_data_release(object);
674        Some(result)
675    })
676}
677
678fn read_property_value<T>(
679    settings: &T,
680    property: &PropertyMetadata,
681) -> Result<Option<PropertyValue>, ObsError>
682where
683    T: ObsDataGetters + ObsDataPointers,
684{
685    match &property.kind {
686        PropertyKind::Bool
687        | PropertyKind::Group {
688            group_type: GroupType::Checkable,
689            ..
690        } => settings
691            .get_bool(property.name.as_str())
692            .map(|value| value.map(PropertyValue::Boolean)),
693        PropertyKind::Integer { .. } | PropertyKind::Color | PropertyKind::ColorAlpha => settings
694            .get_int(property.name.as_str())
695            .map(|value| value.map(PropertyValue::Integer)),
696        PropertyKind::Float { .. } => settings
697            .get_double(property.name.as_str())
698            .map(|value| value.map(PropertyValue::Float)),
699        PropertyKind::Text { .. } | PropertyKind::Path { .. } => settings
700            .get_string(property.name.as_str())
701            .map(|value| value.map(PropertyValue::String)),
702        PropertyKind::List { format, .. } => match format {
703            ListFormat::String => settings
704                .get_string(property.name.as_str())
705                .map(|value| value.map(PropertyValue::String)),
706            ListFormat::Int => settings
707                .get_int(property.name.as_str())
708                .map(|value| value.map(PropertyValue::Integer)),
709            ListFormat::Float => settings
710                .get_double(property.name.as_str())
711                .map(|value| value.map(PropertyValue::Float)),
712            ListFormat::Bool => settings
713                .get_bool(property.name.as_str())
714                .map(|value| value.map(PropertyValue::Boolean)),
715            _ => Ok(None),
716        },
717        PropertyKind::FrameRate { .. } => read_frame_rate(settings, property.name.as_str()),
718        PropertyKind::EditableList { .. } => read_editable_list(settings, property.name.as_str()),
719        PropertyKind::Font => read_font(settings, property.name.as_str()),
720        _ => Ok(None),
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use crate::capabilities::NumberControl;
728
729    fn integer_property() -> PropertyMetadata {
730        PropertyMetadata {
731            name: "bitrate".into(),
732            description: None,
733            long_description: None,
734            enabled: true,
735            visible: true,
736            kind: PropertyKind::Integer {
737                min: 100,
738                max: 10_000,
739                step: 50,
740                suffix: None,
741                control: NumberControl::Scroller,
742            },
743        }
744    }
745
746    #[test]
747    fn integer_validation_enforces_range_and_step() {
748        let schema = SettingsSchema::new(vec![integer_property()]);
749        assert!(schema
750            .validate("bitrate", &PropertyValue::Integer(6_000))
751            .is_ok());
752        assert!(matches!(
753            schema.validate("bitrate", &PropertyValue::Integer(6_025)),
754            Err(ObsError::PropertyValueNotAllowed { .. })
755        ));
756        assert!(matches!(
757            schema.validate("bitrate", &PropertyValue::Integer(20_000)),
758            Err(ObsError::PropertyValueOutOfRange { .. })
759        ));
760    }
761
762    #[test]
763    fn only_checkable_groups_have_boolean_values() {
764        let structural = PropertyMetadata {
765            name: "advanced".into(),
766            description: None,
767            long_description: None,
768            enabled: true,
769            visible: true,
770            kind: PropertyKind::Group {
771                group_type: GroupType::Normal,
772                properties: Vec::new(),
773            },
774        };
775        let checkable = PropertyMetadata {
776            name: "enabled_group".into(),
777            description: None,
778            long_description: None,
779            enabled: true,
780            visible: true,
781            kind: PropertyKind::Group {
782                group_type: GroupType::Checkable,
783                properties: Vec::new(),
784            },
785        };
786        let schema = SettingsSchema::new(vec![structural, checkable]);
787        assert!(matches!(
788            schema.validate("advanced", &PropertyValue::Boolean(true)),
789            Err(ObsError::PropertyValueUnsupported { .. })
790        ));
791        assert!(schema
792            .validate("enabled_group", &PropertyValue::Boolean(true))
793            .is_ok());
794    }
795
796    #[test]
797    fn frame_rate_named_options_must_be_advertised_by_obs() {
798        let schema = SettingsSchema::new(vec![PropertyMetadata {
799            name: "fps".into(),
800            description: None,
801            long_description: None,
802            enabled: true,
803            visible: true,
804            kind: PropertyKind::FrameRate {
805                options: vec![crate::capabilities::FrameRateOption {
806                    name: Some("match-output".into()),
807                    description: None,
808                }],
809                ranges: Vec::new(),
810            },
811        }]);
812        let named = PropertyValue::FrameRate(FrameRateSetting {
813            frame_rate: FrameRate {
814                numerator: 0,
815                denominator: 0,
816            },
817            option: Some("match-output".into()),
818        });
819        assert!(schema.validate("fps", &named).is_ok());
820
821        let invalid = PropertyValue::FrameRate(FrameRateSetting {
822            frame_rate: FrameRate {
823                numerator: 0,
824                denominator: 0,
825            },
826            option: Some("not-advertised".into()),
827        });
828        assert!(matches!(
829            schema.validate("fps", &invalid),
830            Err(ObsError::PropertyValueNotAllowed { .. })
831        ));
832    }
833}