Skip to main content

gpui_rhai/
schema.rs

1use std::cmp::Ordering;
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt;
4
5use rhai::{Array, Dynamic, FLOAT, FnPtr, INT, ImmutableString, Map};
6use serde::{Deserialize, Serialize};
7
8use crate::{AssetId, ElementRef, Length, NativeSignal, OpaqueHandle, Style, UiNode, UiValue};
9
10#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11#[serde(tag = "type", rename_all = "snake_case")]
12pub enum ValueSchema {
13    Null,
14    Bool,
15    Integer {
16        #[serde(default, skip_serializing_if = "Option::is_none")]
17        min: Option<INT>,
18        #[serde(default, skip_serializing_if = "Option::is_none")]
19        max: Option<INT>,
20    },
21    Float {
22        #[serde(default, skip_serializing_if = "Option::is_none")]
23        min: Option<FLOAT>,
24        #[serde(default, skip_serializing_if = "Option::is_none")]
25        max: Option<FLOAT>,
26        #[serde(default, skip_serializing_if = "Option::is_none")]
27        exclusive_min: Option<FLOAT>,
28        #[serde(default, skip_serializing_if = "Option::is_none")]
29        exclusive_max: Option<FLOAT>,
30    },
31    Number {
32        #[serde(default, skip_serializing_if = "Option::is_none")]
33        min: Option<FLOAT>,
34        #[serde(default, skip_serializing_if = "Option::is_none")]
35        max: Option<FLOAT>,
36        #[serde(default, skip_serializing_if = "Option::is_none")]
37        exclusive_min: Option<FLOAT>,
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        exclusive_max: Option<FLOAT>,
40    },
41    String {
42        #[serde(default, skip_serializing_if = "Vec::is_empty")]
43        allowed: Vec<String>,
44    },
45    Array {
46        items: Box<Self>,
47        #[serde(default, skip_serializing_if = "Option::is_none")]
48        max_items: Option<usize>,
49    },
50    Map {
51        values: Box<Self>,
52    },
53    Object {
54        fields: BTreeMap<String, ObjectField>,
55        #[serde(default)]
56        allow_unknown: bool,
57    },
58    Optional {
59        value: Box<Self>,
60    },
61    OneOf {
62        variants: Vec<Self>,
63    },
64    Node,
65    Callback,
66    Style,
67    Length,
68    UiValue,
69    Asset,
70    Signal,
71    Collection,
72    Document,
73    #[cfg(feature = "charts")]
74    ChartData,
75    Ref,
76    Handle {
77        kind: String,
78    },
79}
80
81impl ValueSchema {
82    #[must_use]
83    pub fn string() -> Self {
84        Self::String {
85            allowed: Vec::new(),
86        }
87    }
88
89    #[must_use]
90    pub const fn integer() -> Self {
91        Self::Integer {
92            min: None,
93            max: None,
94        }
95    }
96
97    #[must_use]
98    pub const fn bounded_integer(min: Option<INT>, max: Option<INT>) -> Self {
99        Self::Integer { min, max }
100    }
101
102    #[must_use]
103    pub const fn float() -> Self {
104        Self::Float {
105            min: None,
106            max: None,
107            exclusive_min: None,
108            exclusive_max: None,
109        }
110    }
111
112    #[must_use]
113    pub const fn bounded_float(min: Option<FLOAT>, max: Option<FLOAT>) -> Self {
114        Self::Float {
115            min,
116            max,
117            exclusive_min: None,
118            exclusive_max: None,
119        }
120    }
121
122    #[must_use]
123    pub const fn number() -> Self {
124        Self::Number {
125            min: None,
126            max: None,
127            exclusive_min: None,
128            exclusive_max: None,
129        }
130    }
131
132    #[must_use]
133    pub const fn bounded_number(min: Option<FLOAT>, max: Option<FLOAT>) -> Self {
134        Self::Number {
135            min,
136            max,
137            exclusive_min: None,
138            exclusive_max: None,
139        }
140    }
141
142    #[must_use]
143    pub const fn positive_number() -> Self {
144        Self::Number {
145            min: None,
146            max: None,
147            exclusive_min: Some(0.0),
148            exclusive_max: None,
149        }
150    }
151
152    #[must_use]
153    pub fn enumeration(values: impl IntoIterator<Item = impl Into<String>>) -> Self {
154        Self::String {
155            allowed: values.into_iter().map(Into::into).collect(),
156        }
157    }
158
159    #[must_use]
160    pub fn optional(value: Self) -> Self {
161        Self::Optional {
162            value: Box::new(value),
163        }
164    }
165
166    #[must_use]
167    pub fn one_of(variants: impl IntoIterator<Item = Self>) -> Self {
168        Self::OneOf {
169            variants: variants.into_iter().collect(),
170        }
171    }
172
173    #[must_use]
174    pub fn object(fields: BTreeMap<String, ObjectField>) -> Self {
175        Self::Object {
176            fields,
177            allow_unknown: false,
178        }
179    }
180
181    /// Validate the schema itself before it is used for props or events.
182    ///
183    /// # Errors
184    ///
185    /// Returns [`SchemaDefinitionError`] for inverted/non-finite bounds, an
186    /// empty union, duplicate enum values, or an invalid nested definition.
187    pub fn validate_definition(&self) -> Result<(), SchemaDefinitionError> {
188        self.validate_definition_at("$")
189    }
190
191    fn validate_definition_at(&self, path: &str) -> Result<(), SchemaDefinitionError> {
192        match self {
193            Self::Integer { min, max } => validate_integer_bounds(*min, *max, path),
194            Self::Float {
195                min,
196                max,
197                exclusive_min,
198                exclusive_max,
199            }
200            | Self::Number {
201                min,
202                max,
203                exclusive_min,
204                exclusive_max,
205            } => validate_float_bounds(*min, *max, *exclusive_min, *exclusive_max, path),
206            Self::String { allowed } => {
207                let mut unique = BTreeSet::new();
208                if let Some(duplicate) = allowed.iter().find(|value| !unique.insert(*value)) {
209                    return Err(SchemaDefinitionError::new(
210                        path,
211                        format!("allowed string value `{duplicate}` is duplicated"),
212                    ));
213                }
214                Ok(())
215            }
216            Self::Array { items, .. } => items.validate_definition_at(&format!("{path}.items")),
217            Self::Map { values } => values.validate_definition_at(&format!("{path}.values")),
218            Self::Object { fields, .. } => {
219                for (name, field) in fields {
220                    field
221                        .schema
222                        .validate_definition_at(&format!("{path}.fields.{name}"))?;
223                }
224                Ok(())
225            }
226            Self::Optional { value } => value.validate_definition_at(&format!("{path}.value")),
227            Self::OneOf { variants } => {
228                if variants.is_empty() {
229                    return Err(SchemaDefinitionError::new(
230                        path,
231                        "one_of must contain at least one variant",
232                    ));
233                }
234                for (index, variant) in variants.iter().enumerate() {
235                    variant.validate_definition_at(&format!("{path}.variants[{index}]"))?;
236                }
237                Ok(())
238            }
239            Self::Handle { kind } if kind.trim().is_empty() => Err(SchemaDefinitionError::new(
240                path,
241                "handle kind cannot be empty",
242            )),
243            Self::Null
244            | Self::Bool
245            | Self::Node
246            | Self::Callback
247            | Self::Style
248            | Self::Length
249            | Self::UiValue
250            | Self::Asset
251            | Self::Signal
252            | Self::Collection
253            | Self::Document
254            | Self::Ref
255            | Self::Handle { .. } => Ok(()),
256            #[cfg(feature = "charts")]
257            Self::ChartData => Ok(()),
258        }
259    }
260
261    /// Validate a Rhai runtime value and collect all detectable schema issues.
262    ///
263    /// # Errors
264    ///
265    /// Returns [`SchemaValidationError`] with precise value paths when one or
266    /// more constraints fail.
267    pub fn validate(&self, value: &Dynamic) -> Result<(), SchemaValidationError> {
268        let mut issues = Vec::new();
269        self.validate_at(value, "$", &mut issues);
270        if issues.is_empty() {
271            Ok(())
272        } else {
273            Err(SchemaValidationError { issues })
274        }
275    }
276
277    /// Validate a durable host value before converting it back into Rhai.
278    ///
279    /// # Errors
280    ///
281    /// Returns all detected durable-domain or schema violations.
282    pub fn validate_ui_value(&self, value: &UiValue) -> Result<(), SchemaValidationError> {
283        value.validate().map_err(|error| SchemaValidationError {
284            issues: vec![SchemaIssue::new("$", error.to_string())],
285        })?;
286        self.validate(&value.clone().into_dynamic())
287    }
288
289    fn validate_at(&self, value: &Dynamic, path: &str, issues: &mut Vec<SchemaIssue>) {
290        match self {
291            Self::Null => expect_type(value.is_unit(), value, path, "null", issues),
292            Self::Bool => expect_type(value.is::<bool>(), value, path, "bool", issues),
293            Self::Integer { min, max } => validate_integer(value, *min, *max, path, issues),
294            Self::Float {
295                min,
296                max,
297                exclusive_min,
298                exclusive_max,
299            } => validate_float(
300                value,
301                *min,
302                *max,
303                *exclusive_min,
304                *exclusive_max,
305                path,
306                issues,
307            ),
308            Self::Number {
309                min,
310                max,
311                exclusive_min,
312                exclusive_max,
313            } => validate_number(
314                value,
315                *min,
316                *max,
317                *exclusive_min,
318                *exclusive_max,
319                path,
320                issues,
321            ),
322            Self::String { allowed } => validate_string(value, allowed, path, issues),
323            Self::Array { items, max_items } => {
324                validate_array(value, items, *max_items, path, issues);
325            }
326            Self::Map { values } => validate_map(value, values, path, issues),
327            Self::Object {
328                fields,
329                allow_unknown,
330            } => validate_object_value(value, fields, *allow_unknown, path, issues),
331            Self::Optional { value: schema } => {
332                if !value.is_unit() {
333                    schema.validate_at(value, path, issues);
334                }
335            }
336            Self::OneOf { variants } => validate_one_of(value, variants, path, issues),
337            Self::Node => expect_type(value.is::<UiNode>(), value, path, "UiNode", issues),
338            Self::Callback => expect_type(
339                value.is::<FnPtr>() || value.is::<crate::NativeHandlerRef>(),
340                value,
341                path,
342                "callback or NativeHandlerRef",
343                issues,
344            ),
345            Self::Style => expect_type(value.is::<Style>(), value, path, "Style", issues),
346            Self::Length => validate_length(value, path, issues),
347            Self::UiValue => {
348                if let Err(error) = UiValue::from_dynamic(value.clone()) {
349                    issues.push(SchemaIssue::new(path, error.to_string()));
350                }
351            }
352            Self::Asset => expect_type(value.is::<AssetId>(), value, path, "AssetId", issues),
353            Self::Signal => expect_type(
354                value.is::<NativeSignal>(),
355                value,
356                path,
357                "NativeSignal",
358                issues,
359            ),
360            Self::Collection => expect_type(
361                value.is::<crate::NativeCollection>(),
362                value,
363                path,
364                "NativeCollection",
365                issues,
366            ),
367            Self::Document => expect_type(
368                value.is::<crate::NativeTextDocument>(),
369                value,
370                path,
371                "NativeTextDocument",
372                issues,
373            ),
374            #[cfg(feature = "charts")]
375            Self::ChartData => expect_type(
376                value.is::<crate::NativeChartData>(),
377                value,
378                path,
379                "NativeChartData",
380                issues,
381            ),
382            Self::Ref => expect_type(value.is::<ElementRef>(), value, path, "ElementRef", issues),
383            Self::Handle { kind } => validate_handle(value, kind, path, issues),
384        }
385    }
386}
387
388fn validate_integer(
389    value: &Dynamic,
390    min: Option<INT>,
391    max: Option<INT>,
392    path: &str,
393    issues: &mut Vec<SchemaIssue>,
394) {
395    if value.is::<INT>() {
396        validate_integer_value(value.clone_cast::<INT>(), min, max, path, issues);
397    } else {
398        expect_type(false, value, path, "integer", issues);
399    }
400}
401
402fn validate_float(
403    value: &Dynamic,
404    min: Option<FLOAT>,
405    max: Option<FLOAT>,
406    exclusive_min: Option<FLOAT>,
407    exclusive_max: Option<FLOAT>,
408    path: &str,
409    issues: &mut Vec<SchemaIssue>,
410) {
411    if value.is::<FLOAT>() {
412        validate_float_value(
413            value.clone_cast::<FLOAT>(),
414            min,
415            max,
416            exclusive_min,
417            exclusive_max,
418            path,
419            issues,
420        );
421    } else {
422        expect_type(false, value, path, "float", issues);
423    }
424}
425
426fn validate_number(
427    value: &Dynamic,
428    min: Option<FLOAT>,
429    max: Option<FLOAT>,
430    exclusive_min: Option<FLOAT>,
431    exclusive_max: Option<FLOAT>,
432    path: &str,
433    issues: &mut Vec<SchemaIssue>,
434) {
435    let number = if value.is::<INT>() {
436        Some(integer_as_float(value.clone_cast::<INT>()))
437    } else if value.is::<FLOAT>() {
438        Some(value.clone_cast::<FLOAT>())
439    } else {
440        None
441    };
442    if let Some(number) = number {
443        validate_float_value(number, min, max, exclusive_min, exclusive_max, path, issues);
444    } else {
445        expect_type(false, value, path, "number", issues);
446    }
447}
448
449fn validate_string(value: &Dynamic, allowed: &[String], path: &str, issues: &mut Vec<SchemaIssue>) {
450    if value.is::<ImmutableString>() {
451        let actual = value.clone_cast::<ImmutableString>();
452        if !allowed.is_empty() && !allowed.iter().any(|allowed| allowed == actual.as_str()) {
453            issues.push(SchemaIssue::new(
454                path,
455                format!("expected one of [{}], got `{actual}`", allowed.join(", ")),
456            ));
457        }
458    } else {
459        expect_type(false, value, path, "string", issues);
460    }
461}
462
463fn validate_array(
464    value: &Dynamic,
465    items: &ValueSchema,
466    max_items: Option<usize>,
467    path: &str,
468    issues: &mut Vec<SchemaIssue>,
469) {
470    if !value.is::<Array>() {
471        expect_type(false, value, path, "array", issues);
472        return;
473    }
474    let values = value.clone_cast::<Array>();
475    if let Some(max_items) = max_items
476        && values.len() > max_items
477    {
478        issues.push(SchemaIssue::new(
479            path,
480            format!("expected at most {max_items} items, got {}", values.len()),
481        ));
482    }
483    for (index, item) in values.iter().enumerate() {
484        items.validate_at(item, &format!("{path}[{index}]"), issues);
485    }
486}
487
488fn validate_map(value: &Dynamic, values: &ValueSchema, path: &str, issues: &mut Vec<SchemaIssue>) {
489    if !value.is::<Map>() {
490        expect_type(false, value, path, "map", issues);
491        return;
492    }
493    for (key, item) in value.clone_cast::<Map>() {
494        values.validate_at(&item, &format!("{path}.{key}"), issues);
495    }
496}
497
498fn validate_object_value(
499    value: &Dynamic,
500    fields: &BTreeMap<String, ObjectField>,
501    allow_unknown: bool,
502    path: &str,
503    issues: &mut Vec<SchemaIssue>,
504) {
505    if value.is::<Map>() {
506        validate_object(
507            &value.clone_cast::<Map>(),
508            fields,
509            allow_unknown,
510            path,
511            issues,
512        );
513    } else {
514        expect_type(false, value, path, "object", issues);
515    }
516}
517
518fn validate_one_of(
519    value: &Dynamic,
520    variants: &[ValueSchema],
521    path: &str,
522    issues: &mut Vec<SchemaIssue>,
523) {
524    let mut branch_issues = Vec::with_capacity(variants.len());
525    for variant in variants {
526        let mut candidate = Vec::new();
527        variant.validate_at(value, path, &mut candidate);
528        if candidate.is_empty() {
529            return;
530        }
531        branch_issues.push(candidate);
532    }
533    let summary = branch_issues
534        .iter()
535        .enumerate()
536        .map(|(index, candidate)| {
537            let messages = candidate
538                .iter()
539                .map(|issue| issue.message.as_str())
540                .collect::<Vec<_>>()
541                .join(", ");
542            format!("variant {index}: {messages}")
543        })
544        .collect::<Vec<_>>()
545        .join("; ");
546    issues.push(SchemaIssue::new(
547        path,
548        format!("value did not match any one_of variant ({summary})"),
549    ));
550}
551
552fn validate_length(value: &Dynamic, path: &str, issues: &mut Vec<SchemaIssue>) {
553    if value.is::<Length>() {
554        let length = value.clone_cast::<Length>();
555        if let Err(error) = length.validate() {
556            issues.push(SchemaIssue::new(path, error.to_string()));
557        }
558    } else {
559        expect_type(false, value, path, "Length", issues);
560    }
561}
562
563fn validate_handle(value: &Dynamic, kind: &str, path: &str, issues: &mut Vec<SchemaIssue>) {
564    if value.is::<OpaqueHandle>() {
565        let handle = value.clone_cast::<OpaqueHandle>();
566        if handle.kind() != kind {
567            issues.push(SchemaIssue::new(
568                path,
569                format!("expected `{kind}` handle, got `{}` handle", handle.kind()),
570            ));
571        }
572    } else {
573        expect_type(false, value, path, &format!("{kind} handle"), issues);
574    }
575}
576
577fn validate_integer_bounds(
578    min: Option<INT>,
579    max: Option<INT>,
580    path: &str,
581) -> Result<(), SchemaDefinitionError> {
582    if min.zip(max).is_some_and(|(min, max)| min > max) {
583        Err(SchemaDefinitionError::new(
584            path,
585            format!("integer minimum {min:?} exceeds maximum {max:?}"),
586        ))
587    } else {
588        Ok(())
589    }
590}
591
592fn validate_float_bounds(
593    min: Option<FLOAT>,
594    max: Option<FLOAT>,
595    exclusive_min: Option<FLOAT>,
596    exclusive_max: Option<FLOAT>,
597    path: &str,
598) -> Result<(), SchemaDefinitionError> {
599    if min.is_some_and(|value| !value.is_finite())
600        || max.is_some_and(|value| !value.is_finite())
601        || exclusive_min.is_some_and(|value| !value.is_finite())
602        || exclusive_max.is_some_and(|value| !value.is_finite())
603    {
604        return Err(SchemaDefinitionError::new(
605            path,
606            "numeric bounds must be finite",
607        ));
608    }
609    if min.is_some() && exclusive_min.is_some() {
610        return Err(SchemaDefinitionError::new(
611            path,
612            "numeric schema cannot define both min and exclusive_min",
613        ));
614    }
615    if max.is_some() && exclusive_max.is_some() {
616        return Err(SchemaDefinitionError::new(
617            path,
618            "numeric schema cannot define both max and exclusive_max",
619        ));
620    }
621    let lower = min.or(exclusive_min);
622    let upper = max.or(exclusive_max);
623    let empty = lower.zip(upper).is_some_and(|(lower, upper)| {
624        matches!(lower.total_cmp(&upper), Ordering::Greater)
625            || (matches!(lower.total_cmp(&upper), Ordering::Equal)
626                && (exclusive_min.is_some() || exclusive_max.is_some()))
627    });
628    if empty {
629        Err(SchemaDefinitionError::new(
630            path,
631            format!("numeric lower bound {lower:?} does not precede upper bound {upper:?}"),
632        ))
633    } else {
634        Ok(())
635    }
636}
637
638fn validate_integer_value(
639    value: INT,
640    min: Option<INT>,
641    max: Option<INT>,
642    path: &str,
643    issues: &mut Vec<SchemaIssue>,
644) {
645    if let Some(min) = min
646        && value < min
647    {
648        issues.push(SchemaIssue::new(
649            path,
650            format!("expected integer >= {min}, got {value}"),
651        ));
652    }
653    if let Some(max) = max
654        && value > max
655    {
656        issues.push(SchemaIssue::new(
657            path,
658            format!("expected integer <= {max}, got {value}"),
659        ));
660    }
661}
662
663fn validate_float_value(
664    value: FLOAT,
665    min: Option<FLOAT>,
666    max: Option<FLOAT>,
667    exclusive_min: Option<FLOAT>,
668    exclusive_max: Option<FLOAT>,
669    path: &str,
670    issues: &mut Vec<SchemaIssue>,
671) {
672    if !value.is_finite() {
673        issues.push(SchemaIssue::new(path, "number must be finite"));
674        return;
675    }
676    if let Some(min) = min
677        && value < min
678    {
679        issues.push(SchemaIssue::new(
680            path,
681            format!("expected number >= {min}, got {value}"),
682        ));
683    }
684    if let Some(max) = max
685        && value > max
686    {
687        issues.push(SchemaIssue::new(
688            path,
689            format!("expected number <= {max}, got {value}"),
690        ));
691    }
692    if let Some(min) = exclusive_min
693        && value <= min
694    {
695        issues.push(SchemaIssue::new(
696            path,
697            format!("expected number > {min}, got {value}"),
698        ));
699    }
700    if let Some(max) = exclusive_max
701        && value >= max
702    {
703        issues.push(SchemaIssue::new(
704            path,
705            format!("expected number < {max}, got {value}"),
706        ));
707    }
708}
709
710fn integer_as_float(value: INT) -> FLOAT {
711    value
712        .to_string()
713        .parse()
714        .expect("an integer always has a finite float representation")
715}
716
717#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
718pub struct ObjectField {
719    pub schema: ValueSchema,
720    #[serde(default)]
721    pub required: bool,
722    #[serde(default)]
723    pub sensitive: bool,
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub default: Option<crate::UiValue>,
726}
727
728impl ObjectField {
729    #[must_use]
730    pub fn required(schema: ValueSchema) -> Self {
731        Self {
732            schema,
733            required: true,
734            sensitive: false,
735            default: None,
736        }
737    }
738
739    #[must_use]
740    pub fn optional(schema: ValueSchema) -> Self {
741        Self {
742            schema,
743            required: false,
744            sensitive: false,
745            default: None,
746        }
747    }
748
749    #[must_use]
750    pub fn sensitive(mut self) -> Self {
751        self.sensitive = true;
752        self
753    }
754
755    #[must_use]
756    pub fn with_default(mut self, default: crate::UiValue) -> Self {
757        self.default = Some(default);
758        self
759    }
760}
761
762fn validate_object(
763    value: &Map,
764    fields: &BTreeMap<String, ObjectField>,
765    allow_unknown: bool,
766    path: &str,
767    issues: &mut Vec<SchemaIssue>,
768) {
769    let actual_keys = value
770        .keys()
771        .map(ToString::to_string)
772        .collect::<BTreeSet<_>>();
773
774    for (name, field) in fields {
775        match value.get(name.as_str()) {
776            Some(value) => field
777                .schema
778                .validate_at(value, &format!("{path}.{name}"), issues),
779            None if field.required => issues.push(SchemaIssue::new(
780                format!("{path}.{name}"),
781                "required field is missing",
782            )),
783            None => {}
784        }
785    }
786
787    if !allow_unknown {
788        for unknown in actual_keys.difference(&fields.keys().cloned().collect()) {
789            issues.push(SchemaIssue::new(
790                format!("{path}.{unknown}"),
791                "unknown field",
792            ));
793        }
794    }
795}
796
797fn expect_type(
798    valid: bool,
799    value: &Dynamic,
800    path: &str,
801    expected: &str,
802    issues: &mut Vec<SchemaIssue>,
803) {
804    if !valid {
805        issues.push(SchemaIssue::new(
806            path,
807            format!("expected {expected}, got {}", value.type_name()),
808        ));
809    }
810}
811
812#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
813pub struct SchemaIssue {
814    pub path: String,
815    pub message: String,
816}
817
818impl SchemaIssue {
819    #[must_use]
820    pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
821        Self {
822            path: path.into(),
823            message: message.into(),
824        }
825    }
826}
827
828#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
829pub struct SchemaValidationError {
830    pub issues: Vec<SchemaIssue>,
831}
832
833impl fmt::Display for SchemaValidationError {
834    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
835        for (index, issue) in self.issues.iter().enumerate() {
836            if index > 0 {
837                formatter.write_str("; ")?;
838            }
839            write!(formatter, "{}: {}", issue.path, issue.message)?;
840        }
841        Ok(())
842    }
843}
844
845impl std::error::Error for SchemaValidationError {}
846
847#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
848pub struct SchemaDefinitionError {
849    pub path: String,
850    pub message: String,
851}
852
853impl SchemaDefinitionError {
854    #[must_use]
855    pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
856        Self {
857            path: path.into(),
858            message: message.into(),
859        }
860    }
861}
862
863impl fmt::Display for SchemaDefinitionError {
864    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
865        write!(formatter, "{}: {}", self.path, self.message)
866    }
867}
868
869impl std::error::Error for SchemaDefinitionError {}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874
875    fn button_schema() -> ValueSchema {
876        ValueSchema::object(BTreeMap::from([
877            (
878                "text".to_owned(),
879                ObjectField::required(ValueSchema::string()),
880            ),
881            (
882                "variant".to_owned(),
883                ObjectField::optional(ValueSchema::enumeration(["primary", "secondary"])),
884            ),
885            (
886                "on_click".to_owned(),
887                ObjectField::optional(ValueSchema::Callback),
888            ),
889        ]))
890    }
891
892    #[test]
893    fn objects_report_all_precise_paths() {
894        let value = Dynamic::from_map(Map::from_iter([
895            ("variant".into(), Dynamic::from("danger")),
896            ("lable".into(), Dynamic::from("Save")),
897        ]));
898        let error = button_schema().validate(&value).unwrap_err();
899
900        assert_eq!(
901            error.issues,
902            vec![
903                SchemaIssue::new("$.text", "required field is missing"),
904                SchemaIssue::new(
905                    "$.variant",
906                    "expected one of [primary, secondary], got `danger`",
907                ),
908                SchemaIssue::new("$.lable", "unknown field"),
909            ]
910        );
911    }
912
913    #[test]
914    fn node_callback_and_handle_types_are_distinct() {
915        ValueSchema::Node
916            .validate(&Dynamic::from(UiNode::text("content")))
917            .unwrap();
918        ValueSchema::Callback
919            .validate(&Dynamic::from(FnPtr::new("clicked").unwrap()))
920            .unwrap();
921        ValueSchema::Handle {
922            kind: "image".to_owned(),
923        }
924        .validate(&Dynamic::from(OpaqueHandle::new("image", 42)))
925        .unwrap();
926
927        let error = ValueSchema::Handle {
928            kind: "image".to_owned(),
929        }
930        .validate(&Dynamic::from(OpaqueHandle::new("task", 42)))
931        .unwrap_err();
932        assert_eq!(error.issues[0].path, "$".to_owned());
933    }
934
935    #[test]
936    fn bounds_and_unions_report_precise_failures() {
937        let schema = ValueSchema::one_of([
938            ValueSchema::bounded_integer(Some(1), Some(3)),
939            ValueSchema::enumeration(["auto"]),
940        ]);
941        schema.validate_definition().unwrap();
942        schema.validate(&Dynamic::from(2_i64)).unwrap();
943        schema.validate(&Dynamic::from("auto")).unwrap();
944
945        let error = schema.validate(&Dynamic::from(9_i64)).unwrap_err();
946        assert_eq!(error.issues.len(), 1);
947        assert!(error.issues[0].message.contains("integer <= 3"));
948        assert!(error.issues[0].message.contains("expected string"));
949    }
950
951    #[test]
952    fn invalid_definitions_are_rejected() {
953        assert!(matches!(
954            ValueSchema::bounded_integer(Some(4), Some(2)).validate_definition(),
955            Err(SchemaDefinitionError { ref path, .. }) if path == "$"
956        ));
957        assert!(ValueSchema::one_of([]).validate_definition().is_err());
958        assert!(
959            ValueSchema::bounded_number(Some(f64::NAN), None)
960                .validate_definition()
961                .is_err()
962        );
963        ValueSchema::positive_number()
964            .validate(&Dynamic::from(0.5_f64))
965            .unwrap();
966        assert!(
967            ValueSchema::positive_number()
968                .validate(&Dynamic::from(0_i64))
969                .is_err()
970        );
971    }
972
973    #[test]
974    fn length_ui_value_and_asset_are_distinct() {
975        ValueSchema::Length
976            .validate(&Dynamic::from(Length::pixels(12.0).unwrap()))
977            .unwrap();
978        ValueSchema::UiValue
979            .validate(&Dynamic::from_map(Map::from_iter([(
980                "nested".into(),
981                Dynamic::from_array(vec![Dynamic::from(1_i64)]),
982            )])))
983            .unwrap();
984        ValueSchema::Asset
985            .validate(&Dynamic::from(AssetId::parse("app/check").unwrap()))
986            .unwrap();
987        assert!(
988            ValueSchema::UiValue
989                .validate(&Dynamic::from(UiNode::text("not data")))
990                .is_err()
991        );
992    }
993}