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