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 = "requireModifier", default)]
99 require_modifier: bool,
100 },
101 #[serde(rename = "list")]
103 List,
104 #[serde(rename = "tags")]
106 Tags,
107 #[serde(rename = "table")]
109 Table,
110 #[serde(rename = "cards")]
112 Cards,
113 #[serde(rename = "masterDetail")]
115 MasterDetail,
116 #[serde(rename = "searchTable")]
118 SearchTable,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(tag = "type")]
127pub enum SchemaKind {
128 #[serde(rename = "string")]
130 String {
131 #[serde(rename = "enum", default)]
133 enum_values: Vec<String>,
134 #[serde(rename = "enumLabels", default)]
137 enum_labels: Vec<String>,
138 #[serde(rename = "minLength", default)]
140 min_length: Option<usize>,
141 #[serde(rename = "maxLength", default)]
143 max_length: Option<usize>,
144 #[serde(rename = "pattern", default)]
146 pattern: Option<String>,
147 },
148 #[serde(rename = "number")]
150 Number {
151 #[serde(rename = "minimum", default)]
153 minimum: Option<f64>,
154 #[serde(rename = "maximum", default)]
156 maximum: Option<f64>,
157 #[serde(rename = "multipleOf", default)]
159 multiple_of: Option<f64>,
160 },
161 #[serde(rename = "integer")]
163 Integer {
164 #[serde(rename = "minimum", default)]
166 minimum: Option<i64>,
167 #[serde(rename = "maximum", default)]
169 maximum: Option<i64>,
170 #[serde(rename = "multipleOf", default)]
172 multiple_of: Option<i64>,
173 },
174 #[serde(rename = "boolean")]
176 Boolean,
177 #[serde(rename = "array")]
179 Array {
180 #[serde(rename = "items")]
182 items: Box<SchemaNode>,
183 #[serde(rename = "itemWidget", default)]
185 item_widget: Option<WidgetHint>,
186 #[serde(rename = "minItems", default)]
188 min_items: Option<usize>,
189 #[serde(rename = "maxItems", default)]
191 max_items: Option<usize>,
192 },
193 #[serde(rename = "object")]
195 Object {
196 #[serde(rename = "properties")]
198 properties: BTreeMap<String, SchemaNode>,
199 #[serde(rename = "ui", default)]
201 ui: Vec<FieldUiMetadata>,
202 #[serde(rename = "required", default)]
204 required: BTreeSet<String>,
205 },
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct SchemaNode {
211 #[serde(flatten)]
213 pub kind: SchemaKind,
214 #[serde(rename = "default", default)]
216 pub default: Option<Value>,
217}
218
219impl SchemaNode {
220 pub fn string() -> Self {
222 Self {
223 kind: SchemaKind::String {
224 enum_values: Vec::new(),
225 enum_labels: Vec::new(),
226 min_length: None,
227 max_length: None,
228 pattern: None,
229 },
230 default: None,
231 }
232 }
233
234 pub fn number() -> Self {
236 Self {
237 kind: SchemaKind::Number {
238 minimum: None,
239 maximum: None,
240 multiple_of: None,
241 },
242 default: None,
243 }
244 }
245
246 pub fn integer() -> Self {
248 Self {
249 kind: SchemaKind::Integer {
250 minimum: None,
251 maximum: None,
252 multiple_of: None,
253 },
254 default: None,
255 }
256 }
257
258 pub fn boolean() -> Self {
260 Self {
261 kind: SchemaKind::Boolean,
262 default: None,
263 }
264 }
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
272pub struct VisibleWhen {
273 #[serde(rename = "field")]
275 pub field: String,
276 #[serde(rename = "value")]
278 pub value: Value,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
286pub struct DropSpec {
287 #[serde(rename = "pathField", default)]
290 pub path_field: String,
291 #[serde(rename = "patternField", default)]
294 pub pattern_field: Option<String>,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct FieldUiMetadata {
302 #[serde(rename = "pointer")]
304 pub pointer: String,
305 #[serde(rename = "label")]
307 pub label: String,
308 #[serde(rename = "description", default)]
310 pub description: String,
311 #[serde(rename = "note", default)]
314 pub note: Option<String>,
315 #[serde(rename = "group", default)]
317 pub group: Option<String>,
318 #[serde(rename = "order", default)]
320 pub order: u32,
321 #[serde(rename = "visible", default = "default_true")]
323 pub visible: bool,
324 #[serde(rename = "readOnly", default)]
326 pub read_only: bool,
327 #[serde(rename = "sensitive", default)]
330 pub sensitive: bool,
331 #[serde(rename = "visibleWhen", default)]
333 pub visible_when: Option<VisibleWhen>,
334 #[serde(rename = "widget", default)]
336 pub widget: Option<WidgetHint>,
337 #[serde(rename = "action", default)]
339 pub action: Option<FieldAction>,
340 #[serde(rename = "detailAction", default)]
345 pub detail_action: Option<DetailActionDef>,
346 #[serde(rename = "drop", default)]
348 pub drop: Option<DropSpec>,
349}
350
351fn default_true() -> bool {
352 true
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct SettingDefinition {
361 #[serde(rename = "key")]
363 pub key: String,
364 #[serde(rename = "schema")]
366 pub schema: SchemaNode,
367 #[serde(rename = "ui")]
369 pub ui: FieldUiMetadata,
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
377pub struct SettingsContribution {
378 #[serde(rename = "schemaVersion")]
380 pub schema_version: u32,
381 #[serde(rename = "properties")]
383 pub properties: BTreeMap<String, SchemaNode>,
384 #[serde(rename = "ui")]
386 pub ui: Vec<FieldUiMetadata>,
387 #[serde(rename = "commitPolicy", default)]
389 pub commit_policy: CommitPolicy,
390}
391
392impl SettingsContribution {
393 pub fn from_entries(entries: Vec<SettingDefinition>) -> Result<Self, String> {
397 validate_setting_definitions(&entries)?;
398 let mut properties = BTreeMap::new();
399 let mut ui = Vec::with_capacity(entries.len());
400 for entry in entries {
401 properties.insert(entry.key, entry.schema);
402 ui.push(entry.ui);
403 }
404 ui.sort_by_key(|field| field.order);
405 Ok(Self {
406 schema_version: SETTINGS_SCHEMA_VERSION,
407 properties,
408 ui,
409 commit_policy: CommitPolicy::Staged,
410 })
411 }
412
413 pub fn empty() -> Self {
415 Self {
416 schema_version: SETTINGS_SCHEMA_VERSION,
417 properties: BTreeMap::new(),
418 ui: Vec::new(),
419 commit_policy: CommitPolicy::Staged,
420 }
421 }
422
423 pub fn default_settings(&self) -> Value {
425 let values = self
426 .properties
427 .iter()
428 .filter_map(|(key, node)| {
429 let value = node.default.as_ref()?;
430 if value.is_null() {
431 None
432 } else {
433 Some((key.clone(), value.clone()))
434 }
435 })
436 .collect();
437 Value::Object(values)
438 }
439
440 pub fn validate_values(&self, value: &Value) -> Result<(), String> {
442 let object = value
443 .as_object()
444 .ok_or_else(|| "settings root must be an object".to_string())?;
445 for key in object.keys() {
446 if !self.properties.contains_key(key) {
447 return Err(format!("unknown setting key: {}", key));
448 }
449 }
450 for (key, node) in &self.properties {
451 if let Some(value) = object.get(key) {
452 validate_node(node, value, &format!("/{}", escape_pointer(key)), 0)?;
453 }
454 }
455 Ok(())
456 }
457}
458
459pub fn validate_setting_definitions(entries: &[SettingDefinition]) -> Result<(), String> {
463 if entries.len() > 128 {
464 return Err("too many top-level settings (max 128)".to_string());
465 }
466 let mut keys = HashSet::new();
467 let top_keys: HashSet<&str> = entries.iter().map(|e| e.key.as_str()).collect();
468 for entry in entries {
469 if entry.key.is_empty() || entry.key.len() > 128 {
470 return Err("setting key length is invalid".to_string());
471 }
472 if !entry
473 .key
474 .bytes()
475 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
476 {
477 return Err(format!("invalid setting key: {}", entry.key));
478 }
479 if !keys.insert(entry.key.clone()) {
480 return Err(format!("duplicate setting key: {}", entry.key));
481 }
482 let expected_pointer = format!("/{}", escape_pointer(&entry.key));
483 if entry.ui.pointer != expected_pointer {
484 return Err(format!(
485 "UI pointer '{}' does not match key '{}'",
486 entry.ui.pointer, entry.key
487 ));
488 }
489 validate_ui(&entry.ui)?;
490 check_visible_when(&entry.ui, &top_keys)?;
491 let mut node_count = 0usize;
492 validate_schema_node(&entry.schema, 0, &mut node_count)?;
493 if let Some(default) = &entry.schema.default {
494 validate_node(&entry.schema, default, &expected_pointer, 0)?;
495 }
496 }
497 Ok(())
498}
499
500fn validate_schema_node(
502 node: &SchemaNode,
503 depth: usize,
504 node_count: &mut usize,
505) -> Result<(), String> {
506 if depth > 4 {
507 return Err("schema nesting exceeds limit (max 4)".to_string());
508 }
509 *node_count += 1;
510 if *node_count > 512 {
511 return Err("schema node count exceeds limit".to_string());
512 }
513 match &node.kind {
514 SchemaKind::String {
515 enum_values,
516 min_length,
517 max_length,
518 pattern,
519 ..
520 } => {
521 if enum_values.len() > 256 || enum_values.iter().any(|v| v.len() > 4096) {
522 return Err("string enum exceeds limit".to_string());
523 }
524 if let (Some(min), Some(max)) = (min_length, max_length) {
525 if min > max {
526 return Err("minLength cannot exceed maxLength".to_string());
527 }
528 }
529 if pattern.as_ref().is_some_and(|p| p.len() > 512) {
530 return Err("pattern exceeds limit".to_string());
531 }
532 if let Some(pattern) = pattern {
533 if regex::Regex::new(pattern).is_err() {
534 return Err("pattern is not a valid regular expression".to_string());
535 }
536 }
537 }
538 SchemaKind::Number {
539 minimum,
540 maximum,
541 multiple_of,
542 } => {
543 if let (Some(min), Some(max)) = (minimum, maximum) {
544 if min > max {
545 return Err("minimum cannot exceed maximum".to_string());
546 }
547 }
548 if multiple_of.is_some_and(|v| v <= 0.0 || !v.is_finite()) {
549 return Err("multipleOf must be finite and positive".to_string());
550 }
551 }
552 SchemaKind::Integer {
553 minimum,
554 maximum,
555 multiple_of,
556 } => {
557 if let (Some(min), Some(max)) = (minimum, maximum) {
558 if min > max {
559 return Err("minimum cannot exceed maximum".to_string());
560 }
561 }
562 if multiple_of.is_some_and(|v| v <= 0) {
563 return Err("multipleOf must be positive".to_string());
564 }
565 }
566 SchemaKind::Array {
568 items,
569 min_items,
570 max_items,
571 ..
572 } => {
573 if max_items.unwrap_or(1024) > 1024 {
574 return Err("maxItems exceeds limit".to_string());
575 }
576 if let (Some(min), Some(max)) = (min_items, max_items) {
577 if min > max {
578 return Err("minItems cannot exceed maxItems".to_string());
579 }
580 }
581 validate_schema_node(items, depth + 1, node_count)?;
582 }
583 SchemaKind::Object {
584 properties,
585 ui,
586 required,
587 } => {
588 validate_object_ui(properties, ui)?;
589 if properties.len() > 128 {
590 return Err("object property count exceeds limit".to_string());
591 }
592 for key in required {
593 if !properties.contains_key(key) {
594 return Err(format!("required property does not exist: {}", key));
595 }
596 }
597 for (key, child) in properties {
598 if key.is_empty() || key.len() > 128 {
599 return Err("invalid property key".to_string());
600 }
601 validate_schema_node(child, depth + 1, node_count)?;
602 }
603 }
604 SchemaKind::Boolean => {}
605 }
606 Ok(())
607}
608fn validate_node(
609 node: &SchemaNode,
610 value: &Value,
611 pointer: &str,
612 depth: usize,
613) -> Result<(), String> {
614 if depth > 4 {
615 return Err(format!("{} exceeds nesting limit", pointer));
616 }
617 match &node.kind {
618 SchemaKind::String {
619 enum_values,
620 min_length,
621 max_length,
622 pattern,
623 ..
624 } => {
625 let text = value
626 .as_str()
627 .ok_or_else(|| format!("{} must be a string", pointer))?;
628 let len = text.chars().count();
629 if let Some(min) = min_length {
630 if len < *min {
631 return Err(format!("{} is too short (min {})", pointer, min));
632 }
633 }
634 if let Some(max) = max_length {
635 if len > *max {
636 return Err(format!("{} is too long (max {})", pointer, max));
637 }
638 }
639 if !enum_values.is_empty() && !enum_values.iter().any(|v| v == text) {
640 return Err(format!("{} is not an allowed value", pointer));
641 }
642 if let Some(pattern) = pattern {
643 let mut cache = REGEX_CACHE.lock().unwrap();
644 let regex = cache.entry(pattern.clone()).or_insert_with(|| {
645 Regex::new(pattern)
646 .expect("pattern 已在 schema 构建时通过 validate_schema_node 校验")
647 });
648 if !regex.is_match(text) {
649 return Err(format!("{} does not match pattern", pointer));
650 }
651 }
652 }
653 SchemaKind::Number {
654 minimum,
655 maximum,
656 multiple_of,
657 } => {
658 let number = value
659 .as_f64()
660 .filter(|n| n.is_finite())
661 .ok_or_else(|| format!("{} must be a finite number", pointer))?;
662 if let Some(min) = minimum {
663 if number < *min {
664 return Err(format!("{} is below minimum {}", pointer, min));
665 }
666 }
667 if let Some(max) = maximum {
668 if number > *max {
669 return Err(format!("{} is above maximum {}", pointer, max));
670 }
671 }
672 if let Some(step) = multiple_of {
673 let quotient = number / step;
674 if (quotient - quotient.round()).abs() > 1e-6 {
676 return Err(format!("{} is not a multiple of {}", pointer, step));
677 }
678 }
679 }
680 SchemaKind::Integer {
681 minimum,
682 maximum,
683 multiple_of,
684 } => {
685 let number = value
686 .as_f64()
687 .filter(|v| v.is_finite() && v.fract() == 0.0)
688 .filter(|v| *v >= i64::MIN as f64 && *v <= i64::MAX as f64)
689 .map(|v| v as i64)
690 .ok_or_else(|| format!("{} must be an integer", pointer))?;
691 if let Some(min) = minimum {
692 if number < *min {
693 return Err(format!("{} is below minimum {}", pointer, min));
694 }
695 }
696 if let Some(max) = maximum {
697 if number > *max {
698 return Err(format!("{} is above maximum {}", pointer, max));
699 }
700 }
701 if let Some(step) = multiple_of {
702 if number % step != 0 {
703 return Err(format!("{} is not a multiple of {}", pointer, step));
704 }
705 }
706 }
707 SchemaKind::Boolean => {
708 if !value.is_boolean() {
709 return Err(format!("{} must be a boolean", pointer));
710 }
711 }
712 SchemaKind::Array {
714 items,
715 min_items,
716 max_items,
717 ..
718 } => {
719 let array = value
720 .as_array()
721 .ok_or_else(|| format!("{} must be an array", pointer))?;
722 if let Some(min) = min_items {
723 if array.len() < *min {
724 return Err(format!("{} has too few items (min {})", pointer, min));
725 }
726 }
727 if let Some(max) = max_items {
728 if array.len() > *max {
729 return Err(format!("{} has too many items (max {})", pointer, max));
730 }
731 }
732 for (index, item) in array.iter().enumerate() {
733 validate_node(items, item, &format!("{}/{}", pointer, index), depth + 1)?;
734 }
735 }
736 SchemaKind::Object {
737 properties,
738 required,
739 ..
740 } => {
741 let object = value
742 .as_object()
743 .ok_or_else(|| format!("{} must be an object", pointer))?;
744 for key in required {
745 if !object.contains_key(key) {
746 return Err(format!("{}/{} is required", pointer, escape_pointer(key)));
747 }
748 }
749 for key in object.keys() {
750 if !properties.contains_key(key) {
751 return Err(format!(
752 "unknown property: {}/{}",
753 pointer,
754 escape_pointer(key)
755 ));
756 }
757 }
758 for (key, child) in properties {
759 if let Some(child_value) = object.get(key) {
760 validate_node(
761 child,
762 child_value,
763 &format!("{}/{}", pointer, escape_pointer(key)),
764 depth + 1,
765 )?;
766 }
767 }
768 }
769 }
770 Ok(())
771}
772fn validate_ui(ui: &FieldUiMetadata) -> Result<(), String> {
773 if ui.label.is_empty() || ui.label.len() > 512 || ui.description.len() > 4096 {
774 return Err(format!("invalid UI metadata at {}", ui.pointer));
775 }
776 if ui.group.as_ref().is_some_and(|g| g.len() > 512) {
777 return Err(format!("UI group exceeds limit at {}", ui.pointer));
778 }
779 if ui
781 .note
782 .as_ref()
783 .is_some_and(|note| note.is_empty() || note.len() > 4096)
784 {
785 return Err(format!("invalid UI note at {}", ui.pointer));
786 }
787 Ok(())
788}
789
790fn check_visible_when(ui: &FieldUiMetadata, sibling_keys: &HashSet<&str>) -> Result<(), String> {
792 if let Some(cond) = &ui.visible_when {
793 if cond.field.is_empty() || cond.field.len() > 128 {
794 return Err(format!("invalid visibleWhen field at {}", ui.pointer));
795 }
796 if !sibling_keys.contains(cond.field.as_str()) {
797 return Err(format!(
798 "visibleWhen field '{}' does not exist in the same scope (pointer {})",
799 cond.field, ui.pointer
800 ));
801 }
802 }
803 Ok(())
804}
805fn validate_object_ui(
807 properties: &BTreeMap<String, SchemaNode>,
808 ui: &[FieldUiMetadata],
809) -> Result<(), String> {
810 if ui.len() != properties.len() {
811 return Err("object UI metadata must match properties".to_string());
812 }
813 let mut seen = HashSet::new();
814 let sibling_keys: HashSet<&str> = properties.keys().map(|k| k.as_str()).collect();
815 for metadata in ui {
816 validate_ui(metadata)?;
817 check_visible_when(metadata, &sibling_keys)?;
818 if !seen.insert(metadata.pointer.clone()) {
819 return Err(format!("duplicate object UI pointer: {}", metadata.pointer));
820 }
821 let matches_property = properties
822 .keys()
823 .any(|key| format!("/{}", escape_pointer(key)) == metadata.pointer);
824 if !matches_property {
825 return Err(format!(
826 "object UI pointer does not match properties: {}",
827 metadata.pointer
828 ));
829 }
830 }
831 Ok(())
832}
833
834fn escape_pointer(value: &str) -> String {
835 value.replace('~', "~0").replace('/', "~1")
836}
837
838#[derive(Debug, Clone)]
849pub enum PrimitiveType {
850 Text,
852 Number {
854 min: Option<f64>,
856 max: Option<f64>,
858 step: Option<f64>,
860 },
861 Integer {
863 min: Option<i64>,
865 max: Option<i64>,
867 step: Option<i64>,
869 },
870 Boolean,
872 Select {
874 options: Vec<String>,
876 },
877 Path {
879 mode: PathMode,
881 },
882 Color,
884}
885
886#[cfg(test)]
887mod tests {
888 use super::*;
889
890 #[test]
891 fn rejects_unknown_settings() {
892 let contribution = SettingsContribution::from_entries(vec![SettingDefinition {
893 key: "enabled".into(),
894 schema: SchemaNode {
895 kind: SchemaKind::Boolean,
896 default: Some(Value::Bool(true)),
897 },
898 ui: FieldUiMetadata {
899 pointer: "/enabled".into(),
900 label: "Enabled".into(),
901 description: String::new(),
902 note: None,
903 group: None,
904 order: 0,
905 visible: true,
906 read_only: false,
907 sensitive: false,
908 visible_when: None,
909 widget: None,
910 action: None,
911 detail_action: None,
912 drop: None,
913 },
914 }])
915 .unwrap();
916 assert!(contribution
917 .validate_values(&serde_json::json!({"other": true}))
918 .is_err());
919 }
920
921 #[test]
922 fn rejects_mismatched_ui_pointer() {
923 let result = SettingsContribution::from_entries(vec![SettingDefinition {
924 key: "enabled".into(),
925 schema: SchemaNode::boolean(),
926 ui: FieldUiMetadata {
927 pointer: "/other".into(),
928 label: "Enabled".into(),
929 description: String::new(),
930 note: None,
931 group: None,
932 order: 0,
933 visible: true,
934 read_only: false,
935 sensitive: false,
936 visible_when: None,
937 widget: None,
938 action: None,
939 detail_action: None,
940 drop: None,
941 },
942 }]);
943 assert!(result.is_err());
944 }
945
946 #[test]
947 fn collects_defaults() {
948 let contribution = SettingsContribution::from_entries(vec![SettingDefinition {
949 key: "theme".into(),
950 schema: SchemaNode {
951 kind: SchemaKind::String {
952 enum_values: vec!["light".into(), "dark".into()],
953 enum_labels: Vec::new(),
954 min_length: None,
955 max_length: None,
956 pattern: None,
957 },
958 default: Some(Value::String("light".into())),
959 },
960 ui: FieldUiMetadata {
961 pointer: "/theme".into(),
962 label: "Theme".into(),
963 description: String::new(),
964 note: None,
965 group: None,
966 order: 0,
967 visible: true,
968 read_only: false,
969 sensitive: false,
970 visible_when: None,
971 widget: None,
972 action: None,
973 detail_action: None,
974 drop: None,
975 },
976 }])
977 .unwrap();
978 let defaults = contribution.default_settings();
979 assert_eq!(defaults, serde_json::json!({"theme": "light"}));
980 }
981 fn test_ui(pointer: &str) -> FieldUiMetadata {
983 FieldUiMetadata {
984 pointer: pointer.into(),
985 label: pointer.trim_start_matches('/').into(),
986 description: String::new(),
987 note: None,
988 group: None,
989 order: 0,
990 visible: true,
991 read_only: false,
992 sensitive: false,
993 visible_when: None,
994 widget: None,
995 action: None,
996 detail_action: None,
997 drop: None,
998 }
999 }
1000
1001 #[test]
1003 fn validates_visible_when_sibling_scope() {
1004 let kind = SettingDefinition {
1005 key: "kind".into(),
1006 schema: SchemaNode::string(),
1007 ui: test_ui("/kind"),
1008 };
1009 let mut temp = SettingDefinition {
1010 key: "temp".into(),
1011 schema: SchemaNode::number(),
1012 ui: test_ui("/temp"),
1013 };
1014 temp.ui.visible_when = Some(VisibleWhen {
1015 field: "kind".into(),
1016 value: Value::String("chat".into()),
1017 });
1018 assert!(SettingsContribution::from_entries(vec![kind.clone(), temp.clone()]).is_ok());
1019
1020 temp.ui.visible_when = Some(VisibleWhen {
1021 field: "missing".into(),
1022 value: Value::String("chat".into()),
1023 });
1024 assert!(SettingsContribution::from_entries(vec![kind, temp]).is_err());
1025 }
1026
1027 #[test]
1028 fn validates_nested_object_schema() {
1029 let mut properties = BTreeMap::new();
1030 properties.insert("name".into(), SchemaNode::string());
1031 let schema = SchemaNode {
1032 kind: SchemaKind::Object {
1033 properties,
1034 ui: vec![test_ui("/name")],
1035 required: BTreeSet::from(["name".into()]),
1036 },
1037 default: Some(serde_json::json!({"name": "default"})),
1038 };
1039 let contribution = SettingsContribution::from_entries(vec![SettingDefinition {
1040 key: "profile".into(),
1041 schema,
1042 ui: test_ui("/profile"),
1043 }])
1044 .unwrap();
1045 assert!(contribution
1046 .validate_values(&serde_json::json!({
1047 "profile": {"name": "value"}
1048 }))
1049 .is_ok());
1050 }
1051
1052 #[test]
1054 fn rejects_nested_object_without_ui_metadata() {
1055 let mut properties = BTreeMap::new();
1056 properties.insert("name".into(), SchemaNode::string());
1057 let result = SettingsContribution::from_entries(vec![SettingDefinition {
1058 key: "profile".into(),
1059 schema: SchemaNode {
1060 kind: SchemaKind::Object {
1061 properties,
1062 ui: Vec::new(),
1063 required: BTreeSet::new(),
1064 },
1065 default: None,
1066 },
1067 ui: test_ui("/profile"),
1068 }]);
1069 assert!(result.is_err());
1070 }
1071}