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    /// Validate an online durable payload without constructing an unbounded
290    /// issue list or converting the complete value back into `Dynamic`.
291    pub(crate) fn validate_ui_value_first(
292        &self,
293        value: &UiValue,
294    ) -> Result<(), SchemaValidationError> {
295        value.validate().map_err(|error| SchemaValidationError {
296            issues: vec![SchemaIssue::new("$", error.to_string())],
297        })?;
298        self.validate_ui_value_at_first(value, "$")
299            .map_or(Ok(()), |issue| {
300                Err(SchemaValidationError {
301                    issues: vec![issue],
302                })
303            })
304    }
305
306    #[allow(clippy::too_many_lines)]
307    fn validate_ui_value_at_first(&self, value: &UiValue, path: &str) -> Option<SchemaIssue> {
308        let expected = |name: &str| {
309            Some(SchemaIssue::new(
310                path,
311                format!("expected {name}, got {}", ui_value_type_name(value)),
312            ))
313        };
314        match self {
315            Self::Null => (!matches!(value, UiValue::Null))
316                .then(|| expected("null"))
317                .flatten(),
318            Self::Bool => (!matches!(value, UiValue::Bool(_)))
319                .then(|| expected("bool"))
320                .flatten(),
321            Self::Integer { min, max } => match value {
322                UiValue::Integer(value) => {
323                    if min.is_some_and(|min| *value < min) {
324                        Some(SchemaIssue::new(
325                            path,
326                            format!("expected integer >= {}, got {value}", min.unwrap()),
327                        ))
328                    } else if max.is_some_and(|max| *value > max) {
329                        Some(SchemaIssue::new(
330                            path,
331                            format!("expected integer <= {}, got {value}", max.unwrap()),
332                        ))
333                    } else {
334                        None
335                    }
336                }
337                _ => expected("integer"),
338            },
339            Self::Float {
340                min,
341                max,
342                exclusive_min,
343                exclusive_max,
344            } => match value {
345                UiValue::Float(value) => {
346                    first_float_issue(*value, *min, *max, *exclusive_min, *exclusive_max, path)
347                }
348                _ => expected("float"),
349            },
350            Self::Number {
351                min,
352                max,
353                exclusive_min,
354                exclusive_max,
355            } => match value {
356                UiValue::Float(value) => {
357                    first_float_issue(*value, *min, *max, *exclusive_min, *exclusive_max, path)
358                }
359                UiValue::Integer(value) => first_float_issue(
360                    integer_as_float(*value),
361                    *min,
362                    *max,
363                    *exclusive_min,
364                    *exclusive_max,
365                    path,
366                ),
367                _ => expected("number"),
368            },
369            Self::String { allowed } => match value {
370                UiValue::String(value)
371                    if !allowed.is_empty() && !allowed.iter().any(|item| item == value) =>
372                {
373                    Some(SchemaIssue::new(
374                        path,
375                        format!("expected one of [{}], got `{value}`", allowed.join(", ")),
376                    ))
377                }
378                UiValue::String(_) => None,
379                _ => expected("string"),
380            },
381            Self::Array { items, max_items } => match value {
382                UiValue::Array(values) => {
383                    if max_items.is_some_and(|max| values.len() > max) {
384                        Some(SchemaIssue::new(
385                            path,
386                            format!(
387                                "expected at most {} items, got {}",
388                                max_items.unwrap(),
389                                values.len()
390                            ),
391                        ))
392                    } else {
393                        values.iter().enumerate().find_map(|(index, value)| {
394                            items.validate_ui_value_at_first(value, &format!("{path}[{index}]"))
395                        })
396                    }
397                }
398                _ => expected("array"),
399            },
400            Self::Map { values } => match value {
401                UiValue::Map(items) => items.iter().find_map(|(key, value)| {
402                    values.validate_ui_value_at_first(value, &format!("{path}.{key}"))
403                }),
404                _ => expected("map"),
405            },
406            Self::Object {
407                fields,
408                allow_unknown,
409            } => match value {
410                UiValue::Map(values) => {
411                    let field_issue = fields.iter().find_map(|(name, field)| {
412                        values.get(name).map_or_else(
413                            || {
414                                field.required.then(|| {
415                                    SchemaIssue::new(
416                                        format!("{path}.{name}"),
417                                        "required field is missing",
418                                    )
419                                })
420                            },
421                            |value| {
422                                field
423                                    .schema
424                                    .validate_ui_value_at_first(value, &format!("{path}.{name}"))
425                            },
426                        )
427                    });
428                    field_issue.or_else(|| {
429                        (!*allow_unknown).then(|| {
430                            values
431                                .keys()
432                                .find(|key| !fields.contains_key(*key))
433                                .map(|key| {
434                                    SchemaIssue::new(format!("{path}.{key}"), "unknown field")
435                                })
436                        })?
437                    })
438                }
439                _ => expected("object"),
440            },
441            Self::Optional { value: schema } => {
442                if matches!(value, UiValue::Null) {
443                    None
444                } else {
445                    schema.validate_ui_value_at_first(value, path)
446                }
447            }
448            Self::OneOf { variants } => {
449                let failures = variants
450                    .iter()
451                    .map(|variant| variant.validate_ui_value_at_first(value, path))
452                    .collect::<Vec<_>>();
453                if failures.iter().any(Option::is_none) {
454                    None
455                } else {
456                    Some(SchemaIssue::new(
457                        path,
458                        format!(
459                            "value did not match any one_of variant ({})",
460                            failures
461                                .into_iter()
462                                .enumerate()
463                                .filter_map(|(index, issue)| issue
464                                    .map(|issue| format!("variant {index}: {}", issue.message)))
465                                .collect::<Vec<_>>()
466                                .join("; ")
467                        ),
468                    ))
469                }
470            }
471            Self::UiValue => None,
472            Self::Handle { kind } => match value {
473                UiValue::Handle(handle) if handle.kind() == kind => None,
474                UiValue::Handle(handle) => Some(SchemaIssue::new(
475                    path,
476                    format!("expected `{kind}` handle, got `{}` handle", handle.kind()),
477                )),
478                _ => expected(&format!("{kind} handle")),
479            },
480            Self::Node => expected("UiNode"),
481            Self::Callback => expected("callback or NativeHandlerRef"),
482            Self::Style => expected("Style"),
483            Self::Length => expected("Length"),
484            Self::Asset => expected("AssetId"),
485            Self::Signal => expected("NativeSignal"),
486            Self::Collection => expected("NativeCollection"),
487            Self::Document => expected("NativeTextDocument"),
488            #[cfg(feature = "charts")]
489            Self::ChartData => expected("NativeChartData"),
490            Self::Ref => expected("ElementRef"),
491        }
492    }
493
494    fn validate_at(&self, value: &Dynamic, path: &str, issues: &mut Vec<SchemaIssue>) {
495        match self {
496            Self::Null => expect_type(value.is_unit(), value, path, "null", issues),
497            Self::Bool => expect_type(value.is::<bool>(), value, path, "bool", issues),
498            Self::Integer { min, max } => validate_integer(value, *min, *max, path, issues),
499            Self::Float {
500                min,
501                max,
502                exclusive_min,
503                exclusive_max,
504            } => validate_float(
505                value,
506                *min,
507                *max,
508                *exclusive_min,
509                *exclusive_max,
510                path,
511                issues,
512            ),
513            Self::Number {
514                min,
515                max,
516                exclusive_min,
517                exclusive_max,
518            } => validate_number(
519                value,
520                *min,
521                *max,
522                *exclusive_min,
523                *exclusive_max,
524                path,
525                issues,
526            ),
527            Self::String { allowed } => validate_string(value, allowed, path, issues),
528            Self::Array { items, max_items } => {
529                validate_array(value, items, *max_items, path, issues);
530            }
531            Self::Map { values } => validate_map(value, values, path, issues),
532            Self::Object {
533                fields,
534                allow_unknown,
535            } => validate_object_value(value, fields, *allow_unknown, path, issues),
536            Self::Optional { value: schema } => {
537                if !value.is_unit() {
538                    schema.validate_at(value, path, issues);
539                }
540            }
541            Self::OneOf { variants } => validate_one_of(value, variants, path, issues),
542            Self::Node => expect_type(value.is::<UiNode>(), value, path, "UiNode", issues),
543            Self::Callback => expect_type(
544                value.is::<FnPtr>() || value.is::<crate::NativeHandlerRef>(),
545                value,
546                path,
547                "callback or NativeHandlerRef",
548                issues,
549            ),
550            Self::Style => expect_type(value.is::<Style>(), value, path, "Style", issues),
551            Self::Length => validate_length(value, path, issues),
552            Self::UiValue => {
553                if let Err(error) = UiValue::from_dynamic(value.clone()) {
554                    issues.push(SchemaIssue::new(path, error.to_string()));
555                }
556            }
557            Self::Asset => expect_type(value.is::<AssetId>(), value, path, "AssetId", issues),
558            Self::Signal => expect_type(
559                value.is::<NativeSignal>(),
560                value,
561                path,
562                "NativeSignal",
563                issues,
564            ),
565            Self::Collection => expect_type(
566                value.is::<crate::NativeCollection>(),
567                value,
568                path,
569                "NativeCollection",
570                issues,
571            ),
572            Self::Document => expect_type(
573                value.is::<crate::NativeTextDocument>(),
574                value,
575                path,
576                "NativeTextDocument",
577                issues,
578            ),
579            #[cfg(feature = "charts")]
580            Self::ChartData => expect_type(
581                value.is::<crate::NativeChartData>(),
582                value,
583                path,
584                "NativeChartData",
585                issues,
586            ),
587            Self::Ref => expect_type(value.is::<ElementRef>(), value, path, "ElementRef", issues),
588            Self::Handle { kind } => validate_handle(value, kind, path, issues),
589        }
590    }
591}
592
593fn ui_value_type_name(value: &UiValue) -> &'static str {
594    match value {
595        UiValue::Null => "null",
596        UiValue::Bool(_) => "bool",
597        UiValue::Integer(_) => "integer",
598        UiValue::Float(_) => "float",
599        UiValue::String(_) => "string",
600        UiValue::Array(_) => "array",
601        UiValue::Map(_) => "map",
602        UiValue::Handle(_) => "handle",
603    }
604}
605
606fn first_float_issue(
607    value: FLOAT,
608    min: Option<FLOAT>,
609    max: Option<FLOAT>,
610    exclusive_min: Option<FLOAT>,
611    exclusive_max: Option<FLOAT>,
612    path: &str,
613) -> Option<SchemaIssue> {
614    if !value.is_finite() {
615        Some(SchemaIssue::new(path, "number must be finite"))
616    } else if min.is_some_and(|min| value < min) {
617        Some(SchemaIssue::new(
618            path,
619            format!("expected number >= {}, got {value}", min.unwrap()),
620        ))
621    } else if max.is_some_and(|max| value > max) {
622        Some(SchemaIssue::new(
623            path,
624            format!("expected number <= {}, got {value}", max.unwrap()),
625        ))
626    } else if exclusive_min.is_some_and(|min| value <= min) {
627        Some(SchemaIssue::new(
628            path,
629            format!("expected number > {}, got {value}", exclusive_min.unwrap()),
630        ))
631    } else if exclusive_max.is_some_and(|max| value >= max) {
632        Some(SchemaIssue::new(
633            path,
634            format!("expected number < {}, got {value}", exclusive_max.unwrap()),
635        ))
636    } else {
637        None
638    }
639}
640
641fn validate_integer(
642    value: &Dynamic,
643    min: Option<INT>,
644    max: Option<INT>,
645    path: &str,
646    issues: &mut Vec<SchemaIssue>,
647) {
648    if value.is::<INT>() {
649        validate_integer_value(value.clone_cast::<INT>(), min, max, path, issues);
650    } else {
651        expect_type(false, value, path, "integer", issues);
652    }
653}
654
655fn validate_float(
656    value: &Dynamic,
657    min: Option<FLOAT>,
658    max: Option<FLOAT>,
659    exclusive_min: Option<FLOAT>,
660    exclusive_max: Option<FLOAT>,
661    path: &str,
662    issues: &mut Vec<SchemaIssue>,
663) {
664    if value.is::<FLOAT>() {
665        validate_float_value(
666            value.clone_cast::<FLOAT>(),
667            min,
668            max,
669            exclusive_min,
670            exclusive_max,
671            path,
672            issues,
673        );
674    } else {
675        expect_type(false, value, path, "float", issues);
676    }
677}
678
679fn validate_number(
680    value: &Dynamic,
681    min: Option<FLOAT>,
682    max: Option<FLOAT>,
683    exclusive_min: Option<FLOAT>,
684    exclusive_max: Option<FLOAT>,
685    path: &str,
686    issues: &mut Vec<SchemaIssue>,
687) {
688    let number = if value.is::<INT>() {
689        Some(integer_as_float(value.clone_cast::<INT>()))
690    } else if value.is::<FLOAT>() {
691        Some(value.clone_cast::<FLOAT>())
692    } else {
693        None
694    };
695    if let Some(number) = number {
696        validate_float_value(number, min, max, exclusive_min, exclusive_max, path, issues);
697    } else {
698        expect_type(false, value, path, "number", issues);
699    }
700}
701
702fn validate_string(value: &Dynamic, allowed: &[String], path: &str, issues: &mut Vec<SchemaIssue>) {
703    if value.is::<ImmutableString>() {
704        let actual = value.clone_cast::<ImmutableString>();
705        if !allowed.is_empty() && !allowed.iter().any(|allowed| allowed == actual.as_str()) {
706            issues.push(SchemaIssue::new(
707                path,
708                format!("expected one of [{}], got `{actual}`", allowed.join(", ")),
709            ));
710        }
711    } else {
712        expect_type(false, value, path, "string", issues);
713    }
714}
715
716fn validate_array(
717    value: &Dynamic,
718    items: &ValueSchema,
719    max_items: Option<usize>,
720    path: &str,
721    issues: &mut Vec<SchemaIssue>,
722) {
723    if !value.is::<Array>() {
724        expect_type(false, value, path, "array", issues);
725        return;
726    }
727    let values = value.clone_cast::<Array>();
728    if let Some(max_items) = max_items
729        && values.len() > max_items
730    {
731        issues.push(SchemaIssue::new(
732            path,
733            format!("expected at most {max_items} items, got {}", values.len()),
734        ));
735    }
736    for (index, item) in values.iter().enumerate() {
737        items.validate_at(item, &format!("{path}[{index}]"), issues);
738    }
739}
740
741fn validate_map(value: &Dynamic, values: &ValueSchema, path: &str, issues: &mut Vec<SchemaIssue>) {
742    if !value.is::<Map>() {
743        expect_type(false, value, path, "map", issues);
744        return;
745    }
746    for (key, item) in value.clone_cast::<Map>() {
747        values.validate_at(&item, &format!("{path}.{key}"), issues);
748    }
749}
750
751fn validate_object_value(
752    value: &Dynamic,
753    fields: &BTreeMap<String, ObjectField>,
754    allow_unknown: bool,
755    path: &str,
756    issues: &mut Vec<SchemaIssue>,
757) {
758    if value.is::<Map>() {
759        validate_object(
760            &value.clone_cast::<Map>(),
761            fields,
762            allow_unknown,
763            path,
764            issues,
765        );
766    } else {
767        expect_type(false, value, path, "object", issues);
768    }
769}
770
771fn validate_one_of(
772    value: &Dynamic,
773    variants: &[ValueSchema],
774    path: &str,
775    issues: &mut Vec<SchemaIssue>,
776) {
777    let mut branch_issues = Vec::with_capacity(variants.len());
778    for variant in variants {
779        let mut candidate = Vec::new();
780        variant.validate_at(value, path, &mut candidate);
781        if candidate.is_empty() {
782            return;
783        }
784        branch_issues.push(candidate);
785    }
786    let summary = branch_issues
787        .iter()
788        .enumerate()
789        .map(|(index, candidate)| {
790            let messages = candidate
791                .iter()
792                .map(|issue| issue.message.as_str())
793                .collect::<Vec<_>>()
794                .join(", ");
795            format!("variant {index}: {messages}")
796        })
797        .collect::<Vec<_>>()
798        .join("; ");
799    issues.push(SchemaIssue::new(
800        path,
801        format!("value did not match any one_of variant ({summary})"),
802    ));
803}
804
805fn validate_length(value: &Dynamic, path: &str, issues: &mut Vec<SchemaIssue>) {
806    if value.is::<Length>() {
807        let length = value.clone_cast::<Length>();
808        if let Err(error) = length.validate() {
809            issues.push(SchemaIssue::new(path, error.to_string()));
810        }
811    } else {
812        expect_type(false, value, path, "Length", issues);
813    }
814}
815
816fn validate_handle(value: &Dynamic, kind: &str, path: &str, issues: &mut Vec<SchemaIssue>) {
817    if value.is::<OpaqueHandle>() {
818        let handle = value.clone_cast::<OpaqueHandle>();
819        if handle.kind() != kind {
820            issues.push(SchemaIssue::new(
821                path,
822                format!("expected `{kind}` handle, got `{}` handle", handle.kind()),
823            ));
824        }
825    } else {
826        expect_type(false, value, path, &format!("{kind} handle"), issues);
827    }
828}
829
830fn validate_integer_bounds(
831    min: Option<INT>,
832    max: Option<INT>,
833    path: &str,
834) -> Result<(), SchemaDefinitionError> {
835    if min.zip(max).is_some_and(|(min, max)| min > max) {
836        Err(SchemaDefinitionError::new(
837            path,
838            format!("integer minimum {min:?} exceeds maximum {max:?}"),
839        ))
840    } else {
841        Ok(())
842    }
843}
844
845fn validate_float_bounds(
846    min: Option<FLOAT>,
847    max: Option<FLOAT>,
848    exclusive_min: Option<FLOAT>,
849    exclusive_max: Option<FLOAT>,
850    path: &str,
851) -> Result<(), SchemaDefinitionError> {
852    if min.is_some_and(|value| !value.is_finite())
853        || max.is_some_and(|value| !value.is_finite())
854        || exclusive_min.is_some_and(|value| !value.is_finite())
855        || exclusive_max.is_some_and(|value| !value.is_finite())
856    {
857        return Err(SchemaDefinitionError::new(
858            path,
859            "numeric bounds must be finite",
860        ));
861    }
862    if min.is_some() && exclusive_min.is_some() {
863        return Err(SchemaDefinitionError::new(
864            path,
865            "numeric schema cannot define both min and exclusive_min",
866        ));
867    }
868    if max.is_some() && exclusive_max.is_some() {
869        return Err(SchemaDefinitionError::new(
870            path,
871            "numeric schema cannot define both max and exclusive_max",
872        ));
873    }
874    let lower = min.or(exclusive_min);
875    let upper = max.or(exclusive_max);
876    let empty = lower.zip(upper).is_some_and(|(lower, upper)| {
877        matches!(lower.total_cmp(&upper), Ordering::Greater)
878            || (matches!(lower.total_cmp(&upper), Ordering::Equal)
879                && (exclusive_min.is_some() || exclusive_max.is_some()))
880    });
881    if empty {
882        Err(SchemaDefinitionError::new(
883            path,
884            format!("numeric lower bound {lower:?} does not precede upper bound {upper:?}"),
885        ))
886    } else {
887        Ok(())
888    }
889}
890
891fn validate_integer_value(
892    value: INT,
893    min: Option<INT>,
894    max: Option<INT>,
895    path: &str,
896    issues: &mut Vec<SchemaIssue>,
897) {
898    if let Some(min) = min
899        && value < min
900    {
901        issues.push(SchemaIssue::new(
902            path,
903            format!("expected integer >= {min}, got {value}"),
904        ));
905    }
906    if let Some(max) = max
907        && value > max
908    {
909        issues.push(SchemaIssue::new(
910            path,
911            format!("expected integer <= {max}, got {value}"),
912        ));
913    }
914}
915
916fn validate_float_value(
917    value: FLOAT,
918    min: Option<FLOAT>,
919    max: Option<FLOAT>,
920    exclusive_min: Option<FLOAT>,
921    exclusive_max: Option<FLOAT>,
922    path: &str,
923    issues: &mut Vec<SchemaIssue>,
924) {
925    if !value.is_finite() {
926        issues.push(SchemaIssue::new(path, "number must be finite"));
927        return;
928    }
929    if let Some(min) = min
930        && value < min
931    {
932        issues.push(SchemaIssue::new(
933            path,
934            format!("expected number >= {min}, got {value}"),
935        ));
936    }
937    if let Some(max) = max
938        && value > max
939    {
940        issues.push(SchemaIssue::new(
941            path,
942            format!("expected number <= {max}, got {value}"),
943        ));
944    }
945    if let Some(min) = exclusive_min
946        && value <= min
947    {
948        issues.push(SchemaIssue::new(
949            path,
950            format!("expected number > {min}, got {value}"),
951        ));
952    }
953    if let Some(max) = exclusive_max
954        && value >= max
955    {
956        issues.push(SchemaIssue::new(
957            path,
958            format!("expected number < {max}, got {value}"),
959        ));
960    }
961}
962
963fn integer_as_float(value: INT) -> FLOAT {
964    value
965        .to_string()
966        .parse()
967        .expect("an integer always has a finite float representation")
968}
969
970#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
971pub struct ObjectField {
972    pub schema: ValueSchema,
973    #[serde(default)]
974    pub required: bool,
975    #[serde(default)]
976    pub sensitive: bool,
977    #[serde(default, skip_serializing_if = "Option::is_none")]
978    pub default: Option<crate::UiValue>,
979}
980
981impl ObjectField {
982    #[must_use]
983    pub fn required(schema: ValueSchema) -> Self {
984        Self {
985            schema,
986            required: true,
987            sensitive: false,
988            default: None,
989        }
990    }
991
992    #[must_use]
993    pub fn optional(schema: ValueSchema) -> Self {
994        Self {
995            schema,
996            required: false,
997            sensitive: false,
998            default: None,
999        }
1000    }
1001
1002    #[must_use]
1003    pub fn sensitive(mut self) -> Self {
1004        self.sensitive = true;
1005        self
1006    }
1007
1008    #[must_use]
1009    pub fn with_default(mut self, default: crate::UiValue) -> Self {
1010        self.default = Some(default);
1011        self
1012    }
1013}
1014
1015fn validate_object(
1016    value: &Map,
1017    fields: &BTreeMap<String, ObjectField>,
1018    allow_unknown: bool,
1019    path: &str,
1020    issues: &mut Vec<SchemaIssue>,
1021) {
1022    let actual_keys = value
1023        .keys()
1024        .map(ToString::to_string)
1025        .collect::<BTreeSet<_>>();
1026
1027    for (name, field) in fields {
1028        match value.get(name.as_str()) {
1029            Some(value) => field
1030                .schema
1031                .validate_at(value, &format!("{path}.{name}"), issues),
1032            None if field.required => issues.push(SchemaIssue::new(
1033                format!("{path}.{name}"),
1034                "required field is missing",
1035            )),
1036            None => {}
1037        }
1038    }
1039
1040    if !allow_unknown {
1041        for unknown in actual_keys.difference(&fields.keys().cloned().collect()) {
1042            issues.push(SchemaIssue::new(
1043                format!("{path}.{unknown}"),
1044                "unknown field",
1045            ));
1046        }
1047    }
1048}
1049
1050fn expect_type(
1051    valid: bool,
1052    value: &Dynamic,
1053    path: &str,
1054    expected: &str,
1055    issues: &mut Vec<SchemaIssue>,
1056) {
1057    if !valid {
1058        issues.push(SchemaIssue::new(
1059            path,
1060            format!("expected {expected}, got {}", value.type_name()),
1061        ));
1062    }
1063}
1064
1065#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1066pub struct SchemaIssue {
1067    pub path: String,
1068    pub message: String,
1069}
1070
1071impl SchemaIssue {
1072    #[must_use]
1073    pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
1074        Self {
1075            path: path.into(),
1076            message: message.into(),
1077        }
1078    }
1079}
1080
1081#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1082pub struct SchemaValidationError {
1083    pub issues: Vec<SchemaIssue>,
1084}
1085
1086impl fmt::Display for SchemaValidationError {
1087    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1088        for (index, issue) in self.issues.iter().enumerate() {
1089            if index > 0 {
1090                formatter.write_str("; ")?;
1091            }
1092            write!(formatter, "{}: {}", issue.path, issue.message)?;
1093        }
1094        Ok(())
1095    }
1096}
1097
1098impl std::error::Error for SchemaValidationError {}
1099
1100#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1101pub struct SchemaDefinitionError {
1102    pub path: String,
1103    pub message: String,
1104}
1105
1106impl SchemaDefinitionError {
1107    #[must_use]
1108    pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
1109        Self {
1110            path: path.into(),
1111            message: message.into(),
1112        }
1113    }
1114}
1115
1116impl fmt::Display for SchemaDefinitionError {
1117    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1118        write!(formatter, "{}: {}", self.path, self.message)
1119    }
1120}
1121
1122impl std::error::Error for SchemaDefinitionError {}
1123
1124#[cfg(test)]
1125mod tests {
1126    use super::*;
1127
1128    fn button_schema() -> ValueSchema {
1129        ValueSchema::object(BTreeMap::from([
1130            (
1131                "text".to_owned(),
1132                ObjectField::required(ValueSchema::string()),
1133            ),
1134            (
1135                "variant".to_owned(),
1136                ObjectField::optional(ValueSchema::enumeration(["primary", "secondary"])),
1137            ),
1138            (
1139                "on_click".to_owned(),
1140                ObjectField::optional(ValueSchema::Callback),
1141            ),
1142        ]))
1143    }
1144
1145    #[test]
1146    fn objects_report_all_precise_paths() {
1147        let value = Dynamic::from_map(Map::from_iter([
1148            ("variant".into(), Dynamic::from("danger")),
1149            ("lable".into(), Dynamic::from("Save")),
1150        ]));
1151        let error = button_schema().validate(&value).unwrap_err();
1152
1153        assert_eq!(
1154            error.issues,
1155            vec![
1156                SchemaIssue::new("$.text", "required field is missing"),
1157                SchemaIssue::new(
1158                    "$.variant",
1159                    "expected one of [primary, secondary], got `danger`",
1160                ),
1161                SchemaIssue::new("$.lable", "unknown field"),
1162            ]
1163        );
1164    }
1165
1166    #[test]
1167    fn node_callback_and_handle_types_are_distinct() {
1168        ValueSchema::Node
1169            .validate(&Dynamic::from(UiNode::text("content")))
1170            .unwrap();
1171        ValueSchema::Callback
1172            .validate(&Dynamic::from(FnPtr::new("clicked").unwrap()))
1173            .unwrap();
1174        ValueSchema::Handle {
1175            kind: "image".to_owned(),
1176        }
1177        .validate(&Dynamic::from(OpaqueHandle::new("image", 42)))
1178        .unwrap();
1179
1180        let error = ValueSchema::Handle {
1181            kind: "image".to_owned(),
1182        }
1183        .validate(&Dynamic::from(OpaqueHandle::new("task", 42)))
1184        .unwrap_err();
1185        assert_eq!(error.issues[0].path, "$".to_owned());
1186    }
1187
1188    #[test]
1189    fn bounds_and_unions_report_precise_failures() {
1190        let schema = ValueSchema::one_of([
1191            ValueSchema::bounded_integer(Some(1), Some(3)),
1192            ValueSchema::enumeration(["auto"]),
1193        ]);
1194        schema.validate_definition().unwrap();
1195        schema.validate(&Dynamic::from(2_i64)).unwrap();
1196        schema.validate(&Dynamic::from("auto")).unwrap();
1197
1198        let error = schema.validate(&Dynamic::from(9_i64)).unwrap_err();
1199        assert_eq!(error.issues.len(), 1);
1200        assert!(error.issues[0].message.contains("integer <= 3"));
1201        assert!(error.issues[0].message.contains("expected string"));
1202    }
1203
1204    #[test]
1205    fn invalid_definitions_are_rejected() {
1206        assert!(matches!(
1207            ValueSchema::bounded_integer(Some(4), Some(2)).validate_definition(),
1208            Err(SchemaDefinitionError { ref path, .. }) if path == "$"
1209        ));
1210        assert!(ValueSchema::one_of([]).validate_definition().is_err());
1211        assert!(
1212            ValueSchema::bounded_number(Some(f64::NAN), None)
1213                .validate_definition()
1214                .is_err()
1215        );
1216        ValueSchema::positive_number()
1217            .validate(&Dynamic::from(0.5_f64))
1218            .unwrap();
1219        assert!(
1220            ValueSchema::positive_number()
1221                .validate(&Dynamic::from(0_i64))
1222                .is_err()
1223        );
1224    }
1225
1226    #[test]
1227    fn length_ui_value_and_asset_are_distinct() {
1228        ValueSchema::Length
1229            .validate(&Dynamic::from(Length::pixels(12.0).unwrap()))
1230            .unwrap();
1231        ValueSchema::UiValue
1232            .validate(&Dynamic::from_map(Map::from_iter([(
1233                "nested".into(),
1234                Dynamic::from_array(vec![Dynamic::from(1_i64)]),
1235            )])))
1236            .unwrap();
1237        ValueSchema::Asset
1238            .validate(&Dynamic::from(AssetId::parse("app/check").unwrap()))
1239            .unwrap();
1240        assert!(
1241            ValueSchema::UiValue
1242                .validate(&Dynamic::from(UiNode::text("not data")))
1243                .is_err()
1244        );
1245    }
1246
1247    #[test]
1248    fn online_ui_value_validation_stops_after_the_first_issue() {
1249        let value = UiValue::Map(
1250            (0..100_000)
1251                .map(|index| (format!("field-{index}"), UiValue::Null))
1252                .collect(),
1253        );
1254        let error = ValueSchema::Map {
1255            values: Box::new(ValueSchema::integer()),
1256        }
1257        .validate_ui_value_first(&value)
1258        .unwrap_err();
1259        assert_eq!(error.issues.len(), 1);
1260        assert_eq!(error.issues[0].path, "$.field-0");
1261    }
1262
1263    #[test]
1264    fn online_and_complete_durable_validation_agree_on_acceptance() {
1265        let cases = [
1266            (ValueSchema::integer(), UiValue::Integer(2)),
1267            (ValueSchema::integer(), UiValue::Null),
1268            (
1269                ValueSchema::Array {
1270                    items: Box::new(ValueSchema::string()),
1271                    max_items: Some(2),
1272                },
1273                UiValue::Array(vec![UiValue::String("ok".to_owned())]),
1274            ),
1275            (
1276                ValueSchema::Array {
1277                    items: Box::new(ValueSchema::string()),
1278                    max_items: Some(2),
1279                },
1280                UiValue::Array(vec![UiValue::Integer(1)]),
1281            ),
1282            (
1283                button_schema(),
1284                UiValue::Map(BTreeMap::from([(
1285                    "text".to_owned(),
1286                    UiValue::String("Save".to_owned()),
1287                )])),
1288            ),
1289            (
1290                button_schema(),
1291                UiValue::Map(BTreeMap::from([(
1292                    "variant".to_owned(),
1293                    UiValue::String("danger".to_owned()),
1294                )])),
1295            ),
1296        ];
1297        for (schema, value) in cases {
1298            assert_eq!(
1299                schema.validate_ui_value(&value).is_ok(),
1300                schema.validate_ui_value_first(&value).is_ok(),
1301                "schema={schema:?}, value={value:?}"
1302            );
1303        }
1304    }
1305}