1use crate::config::{DetailActionDef, FieldAction};
2use once_cell::sync::Lazy;
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
7use std::sync::Mutex;
8
9static REGEX_CACHE: Lazy<Mutex<HashMap<String, Regex>>> = Lazy::new(|| Mutex::new(HashMap::new()));
12pub const SETTINGS_SCHEMA_VERSION: u32 = 1;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
16pub enum CommitPolicy {
17 #[default]
19 #[serde(rename = "staged")]
20 Staged,
21 #[serde(rename = "immediateAllowed")]
23 ImmediateAllowed,
24}
25
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
28pub enum PathMode {
29 #[serde(rename = "file")]
31 File,
32 #[serde(rename = "directory")]
34 Directory,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(tag = "kind")]
43pub enum WidgetHint {
44 #[serde(rename = "text")]
46 Text,
47 #[serde(rename = "textarea")]
49 Textarea,
50 #[serde(rename = "number")]
52 Number,
53 #[serde(rename = "toggle")]
55 Toggle,
56 #[serde(rename = "select")]
58 Select,
59 #[serde(rename = "multiselect")]
61 MultiSelect,
62 #[serde(rename = "path")]
64 Path {
65 #[serde(rename = "mode")]
67 mode: PathMode,
68 },
69 #[serde(rename = "color")]
71 Color,
72 #[serde(rename = "image")]
74 Image {
75 #[serde(rename = "accept")]
77 accept: Vec<String>,
78 #[serde(rename = "maxSize")]
80 #[serde(default)]
81 max_size: Option<u64>,
82 },
83 #[serde(rename = "font")]
85 Font {
86 #[serde(rename = "action")]
88 action: String,
89 #[serde(rename = "component", default)]
91 component: Option<String>,
92 },
93 #[serde(rename = "hotkey")]
95 Hotkey,
96 #[serde(rename = "list")]
98 List,
99 #[serde(rename = "tags")]
101 Tags,
102 #[serde(rename = "table")]
104 Table,
105 #[serde(rename = "cards")]
107 Cards,
108 #[serde(rename = "masterDetail")]
110 MasterDetail,
111 #[serde(rename = "searchTable")]
113 SearchTable,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
121#[serde(tag = "type")]
122pub enum SchemaKind {
123 #[serde(rename = "string")]
125 String {
126 #[serde(rename = "enum", default)]
128 enum_values: Vec<String>,
129 #[serde(rename = "enumLabels", default)]
132 enum_labels: Vec<String>,
133 #[serde(rename = "minLength", default)]
135 min_length: Option<usize>,
136 #[serde(rename = "maxLength", default)]
138 max_length: Option<usize>,
139 #[serde(rename = "pattern", default)]
141 pattern: Option<String>,
142 },
143 #[serde(rename = "number")]
145 Number {
146 #[serde(rename = "minimum", default)]
148 minimum: Option<f64>,
149 #[serde(rename = "maximum", default)]
151 maximum: Option<f64>,
152 #[serde(rename = "multipleOf", default)]
154 multiple_of: Option<f64>,
155 },
156 #[serde(rename = "integer")]
158 Integer {
159 #[serde(rename = "minimum", default)]
161 minimum: Option<i64>,
162 #[serde(rename = "maximum", default)]
164 maximum: Option<i64>,
165 #[serde(rename = "multipleOf", default)]
167 multiple_of: Option<i64>,
168 },
169 #[serde(rename = "boolean")]
171 Boolean,
172 #[serde(rename = "array")]
174 Array {
175 #[serde(rename = "items")]
177 items: Box<SchemaNode>,
178 #[serde(rename = "itemWidget", default)]
180 item_widget: Option<WidgetHint>,
181 #[serde(rename = "minItems", default)]
183 min_items: Option<usize>,
184 #[serde(rename = "maxItems", default)]
186 max_items: Option<usize>,
187 },
188 #[serde(rename = "object")]
190 Object {
191 #[serde(rename = "properties")]
193 properties: BTreeMap<String, SchemaNode>,
194 #[serde(rename = "ui", default)]
196 ui: Vec<FieldUiMetadata>,
197 #[serde(rename = "required", default)]
199 required: BTreeSet<String>,
200 },
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct SchemaNode {
206 #[serde(flatten)]
208 pub kind: SchemaKind,
209 #[serde(rename = "default", default)]
211 pub default: Option<Value>,
212}
213
214impl SchemaNode {
215 pub fn string() -> Self {
217 Self {
218 kind: SchemaKind::String {
219 enum_values: Vec::new(),
220 enum_labels: Vec::new(),
221 min_length: None,
222 max_length: None,
223 pattern: None,
224 },
225 default: None,
226 }
227 }
228
229 pub fn number() -> Self {
231 Self {
232 kind: SchemaKind::Number {
233 minimum: None,
234 maximum: None,
235 multiple_of: None,
236 },
237 default: None,
238 }
239 }
240
241 pub fn integer() -> Self {
243 Self {
244 kind: SchemaKind::Integer {
245 minimum: None,
246 maximum: None,
247 multiple_of: None,
248 },
249 default: None,
250 }
251 }
252
253 pub fn boolean() -> Self {
255 Self {
256 kind: SchemaKind::Boolean,
257 default: None,
258 }
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
267pub struct VisibleWhen {
268 #[serde(rename = "field")]
270 pub field: String,
271 #[serde(rename = "value")]
273 pub value: Value,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct FieldUiMetadata {
281 #[serde(rename = "pointer")]
283 pub pointer: String,
284 #[serde(rename = "label")]
286 pub label: String,
287 #[serde(rename = "description", default)]
289 pub description: String,
290 #[serde(rename = "note", default)]
293 pub note: Option<String>,
294 #[serde(rename = "group", default)]
296 pub group: Option<String>,
297 #[serde(rename = "order", default)]
299 pub order: u32,
300 #[serde(rename = "visible", default = "default_true")]
302 pub visible: bool,
303 #[serde(rename = "readOnly", default)]
305 pub read_only: bool,
306 #[serde(rename = "sensitive", default)]
309 pub sensitive: bool,
310 #[serde(rename = "visibleWhen", default)]
312 pub visible_when: Option<VisibleWhen>,
313 #[serde(rename = "widget", default)]
315 pub widget: Option<WidgetHint>,
316 #[serde(rename = "action", default)]
318 pub action: Option<FieldAction>,
319 #[serde(rename = "detailAction", default)]
324 pub detail_action: Option<DetailActionDef>,
325}
326
327fn default_true() -> bool {
328 true
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct SettingDefinition {
337 #[serde(rename = "key")]
339 pub key: String,
340 #[serde(rename = "schema")]
342 pub schema: SchemaNode,
343 #[serde(rename = "ui")]
345 pub ui: FieldUiMetadata,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct SettingsContribution {
354 #[serde(rename = "schemaVersion")]
356 pub schema_version: u32,
357 #[serde(rename = "properties")]
359 pub properties: BTreeMap<String, SchemaNode>,
360 #[serde(rename = "ui")]
362 pub ui: Vec<FieldUiMetadata>,
363 #[serde(rename = "commitPolicy", default)]
365 pub commit_policy: CommitPolicy,
366}
367
368impl SettingsContribution {
369 pub fn from_entries(entries: Vec<SettingDefinition>) -> Result<Self, String> {
373 validate_setting_definitions(&entries)?;
374 let mut properties = BTreeMap::new();
375 let mut ui = Vec::with_capacity(entries.len());
376 for entry in entries {
377 properties.insert(entry.key, entry.schema);
378 ui.push(entry.ui);
379 }
380 ui.sort_by_key(|field| field.order);
381 Ok(Self {
382 schema_version: SETTINGS_SCHEMA_VERSION,
383 properties,
384 ui,
385 commit_policy: CommitPolicy::Staged,
386 })
387 }
388
389 pub fn empty() -> Self {
391 Self {
392 schema_version: SETTINGS_SCHEMA_VERSION,
393 properties: BTreeMap::new(),
394 ui: Vec::new(),
395 commit_policy: CommitPolicy::Staged,
396 }
397 }
398
399 pub fn default_settings(&self) -> Value {
401 let values = self
402 .properties
403 .iter()
404 .filter_map(|(key, node)| {
405 let value = node.default.as_ref()?;
406 if value.is_null() {
407 None
408 } else {
409 Some((key.clone(), value.clone()))
410 }
411 })
412 .collect();
413 Value::Object(values)
414 }
415
416 pub fn validate_values(&self, value: &Value) -> Result<(), String> {
418 let object = value
419 .as_object()
420 .ok_or_else(|| "settings root must be an object".to_string())?;
421 for key in object.keys() {
422 if !self.properties.contains_key(key) {
423 return Err(format!("unknown setting key: {}", key));
424 }
425 }
426 for (key, node) in &self.properties {
427 if let Some(value) = object.get(key) {
428 validate_node(node, value, &format!("/{}", escape_pointer(key)), 0)?;
429 }
430 }
431 Ok(())
432 }
433}
434
435pub fn validate_setting_definitions(entries: &[SettingDefinition]) -> Result<(), String> {
439 if entries.len() > 128 {
440 return Err("too many top-level settings (max 128)".to_string());
441 }
442 let mut keys = HashSet::new();
443 let top_keys: HashSet<&str> = entries.iter().map(|e| e.key.as_str()).collect();
444 for entry in entries {
445 if entry.key.is_empty() || entry.key.len() > 128 {
446 return Err("setting key length is invalid".to_string());
447 }
448 if !entry
449 .key
450 .bytes()
451 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
452 {
453 return Err(format!("invalid setting key: {}", entry.key));
454 }
455 if !keys.insert(entry.key.clone()) {
456 return Err(format!("duplicate setting key: {}", entry.key));
457 }
458 let expected_pointer = format!("/{}", escape_pointer(&entry.key));
459 if entry.ui.pointer != expected_pointer {
460 return Err(format!(
461 "UI pointer '{}' does not match key '{}'",
462 entry.ui.pointer, entry.key
463 ));
464 }
465 validate_ui(&entry.ui)?;
466 check_visible_when(&entry.ui, &top_keys)?;
467 let mut node_count = 0usize;
468 validate_schema_node(&entry.schema, 0, &mut node_count)?;
469 if let Some(default) = &entry.schema.default {
470 validate_node(&entry.schema, default, &expected_pointer, 0)?;
471 }
472 }
473 Ok(())
474}
475
476fn validate_schema_node(
478 node: &SchemaNode,
479 depth: usize,
480 node_count: &mut usize,
481) -> Result<(), String> {
482 if depth > 4 {
483 return Err("schema nesting exceeds limit (max 4)".to_string());
484 }
485 *node_count += 1;
486 if *node_count > 512 {
487 return Err("schema node count exceeds limit".to_string());
488 }
489 match &node.kind {
490 SchemaKind::String {
491 enum_values,
492 min_length,
493 max_length,
494 pattern,
495 ..
496 } => {
497 if enum_values.len() > 256 || enum_values.iter().any(|v| v.len() > 4096) {
498 return Err("string enum exceeds limit".to_string());
499 }
500 if let (Some(min), Some(max)) = (min_length, max_length) {
501 if min > max {
502 return Err("minLength cannot exceed maxLength".to_string());
503 }
504 }
505 if pattern.as_ref().is_some_and(|p| p.len() > 512) {
506 return Err("pattern exceeds limit".to_string());
507 }
508 if let Some(pattern) = pattern {
509 if regex::Regex::new(pattern).is_err() {
510 return Err("pattern is not a valid regular expression".to_string());
511 }
512 }
513 }
514 SchemaKind::Number {
515 minimum,
516 maximum,
517 multiple_of,
518 } => {
519 if let (Some(min), Some(max)) = (minimum, maximum) {
520 if min > max {
521 return Err("minimum cannot exceed maximum".to_string());
522 }
523 }
524 if multiple_of.is_some_and(|v| v <= 0.0 || !v.is_finite()) {
525 return Err("multipleOf must be finite and positive".to_string());
526 }
527 }
528 SchemaKind::Integer {
529 minimum,
530 maximum,
531 multiple_of,
532 } => {
533 if let (Some(min), Some(max)) = (minimum, maximum) {
534 if min > max {
535 return Err("minimum cannot exceed maximum".to_string());
536 }
537 }
538 if multiple_of.is_some_and(|v| v <= 0) {
539 return Err("multipleOf must be positive".to_string());
540 }
541 }
542 SchemaKind::Array {
544 items,
545 min_items,
546 max_items,
547 ..
548 } => {
549 if max_items.unwrap_or(1024) > 1024 {
550 return Err("maxItems exceeds limit".to_string());
551 }
552 if let (Some(min), Some(max)) = (min_items, max_items) {
553 if min > max {
554 return Err("minItems cannot exceed maxItems".to_string());
555 }
556 }
557 validate_schema_node(items, depth + 1, node_count)?;
558 }
559 SchemaKind::Object {
560 properties,
561 ui,
562 required,
563 } => {
564 validate_object_ui(properties, ui)?;
565 if properties.len() > 128 {
566 return Err("object property count exceeds limit".to_string());
567 }
568 for key in required {
569 if !properties.contains_key(key) {
570 return Err(format!("required property does not exist: {}", key));
571 }
572 }
573 for (key, child) in properties {
574 if key.is_empty() || key.len() > 128 {
575 return Err("invalid property key".to_string());
576 }
577 validate_schema_node(child, depth + 1, node_count)?;
578 }
579 }
580 SchemaKind::Boolean => {}
581 }
582 Ok(())
583}
584fn validate_node(
585 node: &SchemaNode,
586 value: &Value,
587 pointer: &str,
588 depth: usize,
589) -> Result<(), String> {
590 if depth > 4 {
591 return Err(format!("{} exceeds nesting limit", pointer));
592 }
593 match &node.kind {
594 SchemaKind::String {
595 enum_values,
596 min_length,
597 max_length,
598 pattern,
599 ..
600 } => {
601 let text = value
602 .as_str()
603 .ok_or_else(|| format!("{} must be a string", pointer))?;
604 let len = text.chars().count();
605 if let Some(min) = min_length {
606 if len < *min {
607 return Err(format!("{} is too short (min {})", pointer, min));
608 }
609 }
610 if let Some(max) = max_length {
611 if len > *max {
612 return Err(format!("{} is too long (max {})", pointer, max));
613 }
614 }
615 if !enum_values.is_empty() && !enum_values.iter().any(|v| v == text) {
616 return Err(format!("{} is not an allowed value", pointer));
617 }
618 if let Some(pattern) = pattern {
619 let mut cache = REGEX_CACHE.lock().unwrap();
620 let regex = cache.entry(pattern.clone()).or_insert_with(|| {
621 Regex::new(pattern)
622 .expect("pattern 已在 schema 构建时通过 validate_schema_node 校验")
623 });
624 if !regex.is_match(text) {
625 return Err(format!("{} does not match pattern", pointer));
626 }
627 }
628 }
629 SchemaKind::Number {
630 minimum,
631 maximum,
632 multiple_of,
633 } => {
634 let number = value
635 .as_f64()
636 .filter(|n| n.is_finite())
637 .ok_or_else(|| format!("{} must be a finite number", pointer))?;
638 if let Some(min) = minimum {
639 if number < *min {
640 return Err(format!("{} is below minimum {}", pointer, min));
641 }
642 }
643 if let Some(max) = maximum {
644 if number > *max {
645 return Err(format!("{} is above maximum {}", pointer, max));
646 }
647 }
648 if let Some(step) = multiple_of {
649 let quotient = number / step;
650 if (quotient - quotient.round()).abs() > 1e-6 {
652 return Err(format!("{} is not a multiple of {}", pointer, step));
653 }
654 }
655 }
656 SchemaKind::Integer {
657 minimum,
658 maximum,
659 multiple_of,
660 } => {
661 let number = value
662 .as_f64()
663 .filter(|v| v.is_finite() && v.fract() == 0.0)
664 .filter(|v| *v >= i64::MIN as f64 && *v <= i64::MAX as f64)
665 .map(|v| v as i64)
666 .ok_or_else(|| format!("{} must be an integer", pointer))?;
667 if let Some(min) = minimum {
668 if number < *min {
669 return Err(format!("{} is below minimum {}", pointer, min));
670 }
671 }
672 if let Some(max) = maximum {
673 if number > *max {
674 return Err(format!("{} is above maximum {}", pointer, max));
675 }
676 }
677 if let Some(step) = multiple_of {
678 if number % step != 0 {
679 return Err(format!("{} is not a multiple of {}", pointer, step));
680 }
681 }
682 }
683 SchemaKind::Boolean => {
684 if !value.is_boolean() {
685 return Err(format!("{} must be a boolean", pointer));
686 }
687 }
688 SchemaKind::Array {
690 items,
691 min_items,
692 max_items,
693 ..
694 } => {
695 let array = value
696 .as_array()
697 .ok_or_else(|| format!("{} must be an array", pointer))?;
698 if let Some(min) = min_items {
699 if array.len() < *min {
700 return Err(format!("{} has too few items (min {})", pointer, min));
701 }
702 }
703 if let Some(max) = max_items {
704 if array.len() > *max {
705 return Err(format!("{} has too many items (max {})", pointer, max));
706 }
707 }
708 for (index, item) in array.iter().enumerate() {
709 validate_node(items, item, &format!("{}/{}", pointer, index), depth + 1)?;
710 }
711 }
712 SchemaKind::Object {
713 properties,
714 required,
715 ..
716 } => {
717 let object = value
718 .as_object()
719 .ok_or_else(|| format!("{} must be an object", pointer))?;
720 for key in required {
721 if !object.contains_key(key) {
722 return Err(format!("{}/{} is required", pointer, escape_pointer(key)));
723 }
724 }
725 for key in object.keys() {
726 if !properties.contains_key(key) {
727 return Err(format!(
728 "unknown property: {}/{}",
729 pointer,
730 escape_pointer(key)
731 ));
732 }
733 }
734 for (key, child) in properties {
735 if let Some(child_value) = object.get(key) {
736 validate_node(
737 child,
738 child_value,
739 &format!("{}/{}", pointer, escape_pointer(key)),
740 depth + 1,
741 )?;
742 }
743 }
744 }
745 }
746 Ok(())
747}
748fn validate_ui(ui: &FieldUiMetadata) -> Result<(), String> {
749 if ui.label.is_empty() || ui.label.len() > 512 || ui.description.len() > 4096 {
750 return Err(format!("invalid UI metadata at {}", ui.pointer));
751 }
752 if ui.group.as_ref().is_some_and(|g| g.len() > 512) {
753 return Err(format!("UI group exceeds limit at {}", ui.pointer));
754 }
755 if ui
757 .note
758 .as_ref()
759 .is_some_and(|note| note.is_empty() || note.len() > 4096)
760 {
761 return Err(format!("invalid UI note at {}", ui.pointer));
762 }
763 Ok(())
764}
765
766fn check_visible_when(ui: &FieldUiMetadata, sibling_keys: &HashSet<&str>) -> Result<(), String> {
768 if let Some(cond) = &ui.visible_when {
769 if cond.field.is_empty() || cond.field.len() > 128 {
770 return Err(format!("invalid visibleWhen field at {}", ui.pointer));
771 }
772 if !sibling_keys.contains(cond.field.as_str()) {
773 return Err(format!(
774 "visibleWhen field '{}' does not exist in the same scope (pointer {})",
775 cond.field, ui.pointer
776 ));
777 }
778 }
779 Ok(())
780}
781fn validate_object_ui(
783 properties: &BTreeMap<String, SchemaNode>,
784 ui: &[FieldUiMetadata],
785) -> Result<(), String> {
786 if ui.len() != properties.len() {
787 return Err("object UI metadata must match properties".to_string());
788 }
789 let mut seen = HashSet::new();
790 let sibling_keys: HashSet<&str> = properties.keys().map(|k| k.as_str()).collect();
791 for metadata in ui {
792 validate_ui(metadata)?;
793 check_visible_when(metadata, &sibling_keys)?;
794 if !seen.insert(metadata.pointer.clone()) {
795 return Err(format!("duplicate object UI pointer: {}", metadata.pointer));
796 }
797 let matches_property = properties
798 .keys()
799 .any(|key| format!("/{}", escape_pointer(key)) == metadata.pointer);
800 if !matches_property {
801 return Err(format!(
802 "object UI pointer does not match properties: {}",
803 metadata.pointer
804 ));
805 }
806 }
807 Ok(())
808}
809
810fn escape_pointer(value: &str) -> String {
811 value.replace('~', "~0").replace('/', "~1")
812}
813
814#[derive(Debug, Clone)]
825pub enum PrimitiveType {
826 Text,
828 Number {
830 min: Option<f64>,
832 max: Option<f64>,
834 step: Option<f64>,
836 },
837 Integer {
839 min: Option<i64>,
841 max: Option<i64>,
843 step: Option<i64>,
845 },
846 Boolean,
848 Select {
850 options: Vec<String>,
852 },
853 Path {
855 mode: PathMode,
857 },
858 Color,
860}
861
862#[cfg(test)]
863mod tests {
864 use super::*;
865
866 #[test]
867 fn rejects_unknown_settings() {
868 let contribution = SettingsContribution::from_entries(vec![SettingDefinition {
869 key: "enabled".into(),
870 schema: SchemaNode {
871 kind: SchemaKind::Boolean,
872 default: Some(Value::Bool(true)),
873 },
874 ui: FieldUiMetadata {
875 pointer: "/enabled".into(),
876 label: "Enabled".into(),
877 description: String::new(),
878 note: None,
879 group: None,
880 order: 0,
881 visible: true,
882 read_only: false,
883 sensitive: false,
884 visible_when: None,
885 widget: None,
886 action: None,
887 detail_action: None,
888 },
889 }])
890 .unwrap();
891 assert!(contribution
892 .validate_values(&serde_json::json!({"other": true}))
893 .is_err());
894 }
895
896 #[test]
897 fn rejects_mismatched_ui_pointer() {
898 let result = SettingsContribution::from_entries(vec![SettingDefinition {
899 key: "enabled".into(),
900 schema: SchemaNode::boolean(),
901 ui: FieldUiMetadata {
902 pointer: "/other".into(),
903 label: "Enabled".into(),
904 description: String::new(),
905 note: None,
906 group: None,
907 order: 0,
908 visible: true,
909 read_only: false,
910 sensitive: false,
911 visible_when: None,
912 widget: None,
913 action: None,
914 detail_action: None,
915 },
916 }]);
917 assert!(result.is_err());
918 }
919
920 #[test]
921 fn collects_defaults() {
922 let contribution = SettingsContribution::from_entries(vec![SettingDefinition {
923 key: "theme".into(),
924 schema: SchemaNode {
925 kind: SchemaKind::String {
926 enum_values: vec!["light".into(), "dark".into()],
927 enum_labels: Vec::new(),
928 min_length: None,
929 max_length: None,
930 pattern: None,
931 },
932 default: Some(Value::String("light".into())),
933 },
934 ui: FieldUiMetadata {
935 pointer: "/theme".into(),
936 label: "Theme".into(),
937 description: String::new(),
938 note: None,
939 group: None,
940 order: 0,
941 visible: true,
942 read_only: false,
943 sensitive: false,
944 visible_when: None,
945 widget: None,
946 action: None,
947 detail_action: None,
948 },
949 }])
950 .unwrap();
951 let defaults = contribution.default_settings();
952 assert_eq!(defaults, serde_json::json!({"theme": "light"}));
953 }
954 fn test_ui(pointer: &str) -> FieldUiMetadata {
956 FieldUiMetadata {
957 pointer: pointer.into(),
958 label: pointer.trim_start_matches('/').into(),
959 description: String::new(),
960 note: None,
961 group: None,
962 order: 0,
963 visible: true,
964 read_only: false,
965 sensitive: false,
966 visible_when: None,
967 widget: None,
968 action: None,
969 detail_action: None,
970 }
971 }
972
973 #[test]
975 fn validates_visible_when_sibling_scope() {
976 let kind = SettingDefinition {
977 key: "kind".into(),
978 schema: SchemaNode::string(),
979 ui: test_ui("/kind"),
980 };
981 let mut temp = SettingDefinition {
982 key: "temp".into(),
983 schema: SchemaNode::number(),
984 ui: test_ui("/temp"),
985 };
986 temp.ui.visible_when = Some(VisibleWhen {
987 field: "kind".into(),
988 value: Value::String("chat".into()),
989 });
990 assert!(SettingsContribution::from_entries(vec![kind.clone(), temp.clone()]).is_ok());
991
992 temp.ui.visible_when = Some(VisibleWhen {
993 field: "missing".into(),
994 value: Value::String("chat".into()),
995 });
996 assert!(SettingsContribution::from_entries(vec![kind, temp]).is_err());
997 }
998
999 #[test]
1000 fn validates_nested_object_schema() {
1001 let mut properties = BTreeMap::new();
1002 properties.insert("name".into(), SchemaNode::string());
1003 let schema = SchemaNode {
1004 kind: SchemaKind::Object {
1005 properties,
1006 ui: vec![test_ui("/name")],
1007 required: BTreeSet::from(["name".into()]),
1008 },
1009 default: Some(serde_json::json!({"name": "default"})),
1010 };
1011 let contribution = SettingsContribution::from_entries(vec![SettingDefinition {
1012 key: "profile".into(),
1013 schema,
1014 ui: test_ui("/profile"),
1015 }])
1016 .unwrap();
1017 assert!(contribution
1018 .validate_values(&serde_json::json!({
1019 "profile": {"name": "value"}
1020 }))
1021 .is_ok());
1022 }
1023
1024 #[test]
1026 fn rejects_nested_object_without_ui_metadata() {
1027 let mut properties = BTreeMap::new();
1028 properties.insert("name".into(), SchemaNode::string());
1029 let result = SettingsContribution::from_entries(vec![SettingDefinition {
1030 key: "profile".into(),
1031 schema: SchemaNode {
1032 kind: SchemaKind::Object {
1033 properties,
1034 ui: Vec::new(),
1035 required: BTreeSet::new(),
1036 },
1037 default: None,
1038 },
1039 ui: test_ui("/profile"),
1040 }]);
1041 assert!(result.is_err());
1042 }
1043}