1use rust_i18n::t;
56use serde::Deserialize;
57use std::collections::HashMap;
58
59#[derive(Debug, Clone)]
61pub struct SettingSchema {
62 pub path: String,
64 pub name: String,
66 pub description: Option<String>,
68 pub setting_type: SettingType,
70 pub default: Option<serde_json::Value>,
72 pub read_only: bool,
74 pub section: Option<String>,
76 pub order: Option<i32>,
79 pub nullable: bool,
82 pub enum_from: Option<String>,
85 pub dual_list_sibling: Option<String>,
87 pub dynamically_extendable_status_bar_elements: bool,
89}
90
91#[derive(Debug, Clone)]
93pub enum SettingType {
94 Boolean,
96 Integer {
98 minimum: Option<i64>,
99 maximum: Option<i64>,
100 },
101 Number {
103 minimum: Option<f64>,
104 maximum: Option<f64>,
105 },
106 String,
108 Enum { options: Vec<EnumOption> },
110 StringArray,
112 IntegerArray,
114 ObjectArray {
116 item_schema: Box<SettingSchema>,
117 display_field: Option<String>,
119 },
120 Object { properties: Vec<SettingSchema> },
122 Map {
124 value_schema: Box<SettingSchema>,
125 display_field: Option<String>,
127 no_add: bool,
129 },
130 DualList {
132 options: Vec<EnumOption>,
133 sibling_path: Option<String>,
134 },
135 Complex,
137}
138
139#[derive(Debug, Clone)]
141pub struct EnumOption {
142 pub name: String,
144 pub value: String,
146}
147
148#[derive(Debug, Clone)]
150pub struct SettingCategory {
151 pub name: String,
153 pub path: String,
155 pub description: Option<String>,
157 pub nullable: bool,
160 pub settings: Vec<SettingSchema>,
162 pub subcategories: Vec<SettingCategory>,
164}
165
166#[derive(Debug, Deserialize)]
168struct RawSchema {
169 #[serde(rename = "type")]
170 schema_type: Option<SchemaType>,
171 description: Option<String>,
172 default: Option<serde_json::Value>,
173 properties: Option<HashMap<String, RawSchema>>,
174 items: Option<Box<RawSchema>>,
175 #[serde(rename = "enum")]
176 enum_values: Option<Vec<serde_json::Value>>,
177 minimum: Option<serde_json::Number>,
178 maximum: Option<serde_json::Number>,
179 #[serde(rename = "$ref")]
180 ref_path: Option<String>,
181 #[serde(rename = "$defs")]
182 defs: Option<HashMap<String, RawSchema>>,
183 #[serde(rename = "additionalProperties")]
184 additional_properties: Option<AdditionalProperties>,
185 #[serde(rename = "x-enum-values", default)]
187 extensible_enum_values: Vec<EnumValueEntry>,
188 #[serde(rename = "x-display-field")]
191 display_field: Option<String>,
192 #[serde(rename = "readOnly", default)]
194 read_only: bool,
195 #[serde(rename = "x-standalone-category", default)]
197 standalone_category: bool,
198 #[serde(rename = "x-no-add", default)]
200 no_add: bool,
201 #[serde(rename = "x-section")]
203 section: Option<String>,
204 #[serde(rename = "x-order")]
206 order: Option<i32>,
207 #[serde(rename = "anyOf")]
209 any_of: Option<Vec<RawSchema>>,
210 #[serde(rename = "x-enum-from")]
214 enum_from: Option<String>,
215 #[serde(rename = "x-dual-list-options", default)]
217 dual_list_options: Vec<DualListOptionEntry>,
218 #[serde(rename = "x-dual-list-sibling")]
220 dual_list_sibling: Option<String>,
221 #[serde(rename = "x-dynamically-extendable-status-bar-elements", default)]
223 dynamically_extendable_status_bar_elements: bool,
224}
225
226#[derive(Debug, Deserialize)]
228struct EnumValueEntry {
229 #[serde(rename = "ref")]
231 ref_path: String,
232 name: Option<String>,
234 value: serde_json::Value,
236}
237
238#[derive(Debug, Deserialize)]
240struct DualListOptionEntry {
241 value: String,
243 name: Option<String>,
245}
246
247#[derive(Debug, Deserialize)]
249#[serde(untagged)]
250enum AdditionalProperties {
251 Bool(bool),
252 Schema(Box<RawSchema>),
253}
254
255#[derive(Debug, Deserialize)]
257#[serde(untagged)]
258enum SchemaType {
259 Single(String),
260 Multiple(Vec<String>),
261}
262
263impl SchemaType {
264 fn primary(&self) -> Option<&str> {
266 match self {
267 Self::Single(s) => Some(s.as_str()),
268 Self::Multiple(v) => v.first().map(|s| s.as_str()),
269 }
270 }
271
272 fn contains_null(&self) -> bool {
274 match self {
275 Self::Single(s) => s == "null",
276 Self::Multiple(v) => v.iter().any(|s| s == "null"),
277 }
278 }
279}
280
281type EnumValuesMap = HashMap<String, Vec<EnumOption>>;
283
284pub fn parse_schema(schema_json: &str) -> Result<Vec<SettingCategory>, serde_json::Error> {
286 let raw: RawSchema = serde_json::from_str(schema_json)?;
287
288 let defs = raw.defs.unwrap_or_default();
289 let properties = raw.properties.unwrap_or_default();
290
291 let enum_values_map = build_enum_values_map(&raw.extensible_enum_values);
293
294 let mut categories = Vec::new();
295 let mut top_level_settings = Vec::new();
296
297 let mut sorted_props: Vec<_> = properties.into_iter().collect();
299 sorted_props.sort_by(|a, b| a.0.cmp(&b.0));
300 for (name, prop) in sorted_props {
301 let path = format!("/{}", name);
302 let display_name = humanize_name(&name);
303
304 let resolved = resolve_ref(&prop, &defs);
306
307 let is_nullable = prop.any_of.as_ref().is_some_and(|variants| {
309 variants.iter().any(|v| {
310 v.schema_type
311 .as_ref()
312 .map(|t| t.primary() == Some("null"))
313 .unwrap_or(false)
314 })
315 });
316
317 if prop.standalone_category {
319 let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
321 categories.push(SettingCategory {
322 name: display_name,
323 path: path.clone(),
324 description: prop.description.clone().or(resolved.description.clone()),
325 nullable: is_nullable,
326 settings: vec![setting],
327 subcategories: Vec::new(),
328 });
329 } else if let Some(ref inner_props) = resolved.properties {
330 let settings = parse_properties(inner_props, &path, &defs, &enum_values_map);
332 let description = prop
338 .description
339 .clone()
340 .or_else(|| resolved.description.clone());
341 categories.push(SettingCategory {
342 name: display_name,
343 path: path.clone(),
344 description,
345 nullable: is_nullable,
346 settings,
347 subcategories: Vec::new(),
348 });
349 } else {
350 let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
352 top_level_settings.push(setting);
353 }
354 }
355
356 if !top_level_settings.is_empty() {
358 top_level_settings.sort_by(|a, b| a.name.cmp(&b.name));
360 categories.insert(
361 0,
362 SettingCategory {
363 name: "General".to_string(),
364 path: String::new(),
365 description: Some("General settings".to_string()),
366 nullable: false,
367 settings: top_level_settings,
368 subcategories: Vec::new(),
369 },
370 );
371 }
372
373 categories.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
375 ("General", _) => std::cmp::Ordering::Less,
376 (_, "General") => std::cmp::Ordering::Greater,
377 (a, b) => a.cmp(b),
378 });
379
380 Ok(categories)
381}
382
383pub fn append_plugin_settings_category(
391 categories: &mut Vec<SettingCategory>,
392 plugin_schemas: &HashMap<String, serde_json::Value>,
393 enabled_plugins_with_schema: &[String],
394) {
395 if enabled_plugins_with_schema.is_empty() {
396 return;
397 }
398
399 let mut added = 0;
403 for name in enabled_plugins_with_schema {
404 let Some(schema_value) = plugin_schemas.get(name) else {
405 continue;
406 };
407 let Some(mut category) = plugin_schema_to_category(name, schema_value) else {
408 continue;
409 };
410 category.name = format!("Plugin: {}", name);
411 categories.push(category);
412 added += 1;
413 }
414
415 if added == 0 {
416 return;
417 }
418
419 categories.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
424 ("General", _) => std::cmp::Ordering::Less,
425 (_, "General") => std::cmp::Ordering::Greater,
426 (a, b) => match (a.starts_with("Plugin: "), b.starts_with("Plugin: ")) {
427 (true, false) => std::cmp::Ordering::Greater,
428 (false, true) => std::cmp::Ordering::Less,
429 _ => a.cmp(b),
430 },
431 });
432}
433
434fn plugin_schema_to_category(
435 plugin_name: &str,
436 schema_value: &serde_json::Value,
437) -> Option<SettingCategory> {
438 let raw: RawSchema = serde_json::from_value(schema_value.clone()).ok()?;
442 let defs = HashMap::new();
443 let enum_values_map = HashMap::new();
444 let base_path = format!("/plugins/{}/settings", plugin_name);
445
446 let properties = raw.properties.as_ref()?;
447 let settings = parse_properties(properties, &base_path, &defs, &enum_values_map);
448 if settings.is_empty() {
449 return None;
450 }
451
452 Some(SettingCategory {
453 name: plugin_name.to_string(),
454 path: base_path,
455 description: raw.description.clone(),
456 nullable: false,
457 settings,
458 subcategories: Vec::new(),
459 })
460}
461
462fn build_enum_values_map(entries: &[EnumValueEntry]) -> EnumValuesMap {
464 let mut map: EnumValuesMap = HashMap::new();
465
466 for entry in entries {
467 let value_str = match &entry.value {
468 serde_json::Value::String(s) => s.clone(),
469 other => other.to_string(),
470 };
471
472 let option = EnumOption {
473 name: entry.name.clone().unwrap_or_else(|| value_str.clone()),
474 value: value_str,
475 };
476
477 map.entry(entry.ref_path.clone()).or_default().push(option);
478 }
479
480 map
481}
482
483fn parse_properties(
485 properties: &HashMap<String, RawSchema>,
486 parent_path: &str,
487 defs: &HashMap<String, RawSchema>,
488 enum_values_map: &EnumValuesMap,
489) -> Vec<SettingSchema> {
490 let mut settings = Vec::new();
491
492 for (name, prop) in properties {
493 let path = format!("{}/{}", parent_path, name);
494 let setting = parse_setting(name, &path, prop, defs, enum_values_map);
495
496 settings.push(setting);
497 }
498
499 settings.sort_by(|a, b| match (a.order, b.order) {
502 (Some(a_ord), Some(b_ord)) => a_ord.cmp(&b_ord).then_with(|| a.name.cmp(&b.name)),
503 (Some(_), None) => std::cmp::Ordering::Less,
504 (None, Some(_)) => std::cmp::Ordering::Greater,
505 (None, None) => a.name.cmp(&b.name),
506 });
507
508 settings
509}
510
511fn parse_setting(
513 name: &str,
514 path: &str,
515 schema: &RawSchema,
516 defs: &HashMap<String, RawSchema>,
517 enum_values_map: &EnumValuesMap,
518) -> SettingSchema {
519 let setting_type = determine_type(schema, defs, enum_values_map);
520
521 let resolved = resolve_ref(schema, defs);
523 let description = schema
524 .description
525 .clone()
526 .or_else(|| resolved.description.clone());
527
528 let read_only = schema.read_only || resolved.read_only;
530
531 let section = schema.section.clone().or_else(|| resolved.section.clone());
533
534 let order = schema.order.or(resolved.order);
536
537 let nullable = resolved
539 .schema_type
540 .as_ref()
541 .map(|t| t.contains_null())
542 .unwrap_or(false)
543 || schema.any_of.as_ref().is_some_and(|variants| {
544 variants.iter().any(|v| {
545 v.schema_type
546 .as_ref()
547 .map(|t| t.primary() == Some("null"))
548 .unwrap_or(false)
549 })
550 });
551
552 SettingSchema {
553 path: path.to_string(),
554 name: i18n_name(path, name),
555 description,
556 setting_type,
557 default: schema.default.clone(),
558 read_only,
559 section,
560 order,
561 nullable,
562 enum_from: schema
563 .enum_from
564 .clone()
565 .or_else(|| resolved.enum_from.clone()),
566 dual_list_sibling: schema
567 .dual_list_sibling
568 .clone()
569 .or_else(|| resolved.dual_list_sibling.clone()),
570 dynamically_extendable_status_bar_elements: schema
571 .dynamically_extendable_status_bar_elements
572 || resolved.dynamically_extendable_status_bar_elements,
573 }
574}
575
576fn determine_type(
578 schema: &RawSchema,
579 defs: &HashMap<String, RawSchema>,
580 enum_values_map: &EnumValuesMap,
581) -> SettingType {
582 if let Some(ref ref_path) = schema.ref_path {
584 if let Some(options) = enum_values_map.get(ref_path) {
585 if !options.is_empty() {
586 return SettingType::Enum {
587 options: options.clone(),
588 };
589 }
590 }
591 }
592
593 let resolved = resolve_ref(schema, defs);
595
596 let enum_values = schema
598 .enum_values
599 .as_ref()
600 .or(resolved.enum_values.as_ref());
601 if let Some(values) = enum_values {
602 let options: Vec<EnumOption> = values
603 .iter()
604 .filter_map(|v| {
605 if v.is_null() {
606 Some(EnumOption {
608 name: "Auto-detect".to_string(),
609 value: String::new(), })
611 } else {
612 v.as_str().map(|s| EnumOption {
613 name: s.to_string(),
614 value: s.to_string(),
615 })
616 }
617 })
618 .collect();
619 if !options.is_empty() {
620 return SettingType::Enum { options };
621 }
622 }
623
624 match resolved.schema_type.as_ref().and_then(|t| t.primary()) {
626 Some("boolean") => SettingType::Boolean,
627 Some("integer") => {
628 let minimum = resolved.minimum.as_ref().and_then(|n| n.as_i64());
629 let maximum = resolved.maximum.as_ref().and_then(|n| n.as_i64());
630 SettingType::Integer { minimum, maximum }
631 }
632 Some("number") => {
633 let minimum = resolved.minimum.as_ref().and_then(|n| n.as_f64());
634 let maximum = resolved.maximum.as_ref().and_then(|n| n.as_f64());
635 SettingType::Number { minimum, maximum }
636 }
637 Some("string") => SettingType::String,
638 Some("array") => {
639 if let Some(ref items) = resolved.items {
641 let item_resolved = resolve_ref(items, defs);
642 if !item_resolved.dual_list_options.is_empty() {
644 let options = item_resolved
645 .dual_list_options
646 .iter()
647 .map(|entry| EnumOption {
648 name: entry.name.clone().unwrap_or_else(|| entry.value.clone()),
649 value: entry.value.clone(),
650 })
651 .collect();
652 return SettingType::DualList {
653 options,
654 sibling_path: schema
655 .dual_list_sibling
656 .clone()
657 .or_else(|| resolved.dual_list_sibling.clone()),
658 };
659 }
660 let item_type = item_resolved.schema_type.as_ref().and_then(|t| t.primary());
661 if item_type == Some("string") {
662 return SettingType::StringArray;
663 }
664 if item_type == Some("integer") || item_type == Some("number") {
665 return SettingType::IntegerArray;
666 }
667 if items.ref_path.is_some() {
669 let item_schema =
671 parse_setting("item", "", item_resolved, defs, enum_values_map);
672
673 if matches!(item_schema.setting_type, SettingType::Object { .. }) {
675 let display_field = item_resolved.display_field.clone();
677 return SettingType::ObjectArray {
678 item_schema: Box::new(item_schema),
679 display_field,
680 };
681 }
682 }
683 }
684 SettingType::Complex
685 }
686 Some("object") => {
687 if let Some(ref add_props) = resolved.additional_properties {
689 match add_props {
690 AdditionalProperties::Schema(schema_box) => {
691 let inner_resolved = resolve_ref(schema_box, defs);
692 let value_schema =
693 parse_setting("value", "", inner_resolved, defs, enum_values_map);
694
695 let display_field = inner_resolved.display_field.clone().or_else(|| {
698 inner_resolved.items.as_ref().and_then(|items| {
699 let items_resolved = resolve_ref(items, defs);
700 items_resolved.display_field.clone()
701 })
702 });
703
704 let no_add = resolved.no_add;
706
707 return SettingType::Map {
708 value_schema: Box::new(value_schema),
709 display_field,
710 no_add,
711 };
712 }
713 AdditionalProperties::Bool(true) => {
714 return SettingType::Complex;
716 }
717 AdditionalProperties::Bool(false) => {
718 }
721 }
722 }
723 if let Some(ref props) = resolved.properties {
725 let properties = parse_properties(props, "", defs, enum_values_map);
726 return SettingType::Object { properties };
727 }
728 SettingType::Complex
729 }
730 _ => SettingType::Complex,
731 }
732}
733
734fn resolve_ref<'a>(schema: &'a RawSchema, defs: &'a HashMap<String, RawSchema>) -> &'a RawSchema {
740 if let Some(ref ref_path) = schema.ref_path {
742 if let Some(def_name) = ref_path.strip_prefix("#/$defs/") {
743 if let Some(def) = defs.get(def_name) {
744 return def;
745 }
746 }
747 }
748 if let Some(ref variants) = schema.any_of {
750 for variant in variants {
751 let is_null = variant
752 .schema_type
753 .as_ref()
754 .map(|t| t.primary() == Some("null"))
755 .unwrap_or(false);
756 if !is_null {
757 return resolve_ref(variant, defs);
758 }
759 }
760 }
761 schema
762}
763
764fn i18n_name(path: &str, fallback_name: &str) -> String {
770 let key = format!("settings.field{}", path.replace('/', "."));
771 let translated = t!(&key);
772 if *translated == key {
773 humanize_name(fallback_name)
774 } else {
775 translated.to_string()
776 }
777}
778
779fn humanize_name(name: &str) -> String {
781 name.split('_')
782 .map(|word| {
783 let mut chars = word.chars();
784 match chars.next() {
785 None => String::new(),
786 Some(first) => first.to_uppercase().chain(chars).collect(),
787 }
788 })
789 .collect::<Vec<_>>()
790 .join(" ")
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796
797 const SAMPLE_SCHEMA: &str = r##"
798{
799 "$schema": "https://json-schema.org/draft/2020-12/schema",
800 "title": "Config",
801 "type": "object",
802 "properties": {
803 "theme": {
804 "description": "Color theme name",
805 "type": "string",
806 "default": "high-contrast"
807 },
808 "check_for_updates": {
809 "description": "Check for new versions on quit",
810 "type": "boolean",
811 "default": true
812 },
813 "editor": {
814 "description": "Editor settings",
815 "$ref": "#/$defs/EditorConfig"
816 }
817 },
818 "$defs": {
819 "EditorConfig": {
820 "description": "Editor behavior configuration",
821 "type": "object",
822 "properties": {
823 "tab_size": {
824 "description": "Number of spaces per tab",
825 "type": "integer",
826 "minimum": 1,
827 "maximum": 16,
828 "default": 4
829 },
830 "line_numbers": {
831 "description": "Show line numbers",
832 "type": "boolean",
833 "default": true
834 }
835 }
836 }
837 }
838}
839"##;
840
841 #[test]
842 fn test_parse_schema() {
843 let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
844
845 assert_eq!(categories.len(), 2);
847 assert_eq!(categories[0].name, "General");
848 assert_eq!(categories[1].name, "Editor");
849 }
850
851 #[test]
852 fn test_general_category() {
853 let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
854 let general = &categories[0];
855
856 assert_eq!(general.settings.len(), 2);
858
859 let theme = general
860 .settings
861 .iter()
862 .find(|s| s.path == "/theme")
863 .unwrap();
864 assert!(matches!(theme.setting_type, SettingType::String));
865
866 let updates = general
867 .settings
868 .iter()
869 .find(|s| s.path == "/check_for_updates")
870 .unwrap();
871 assert!(matches!(updates.setting_type, SettingType::Boolean));
872 }
873
874 #[test]
875 fn test_editor_category() {
876 let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
877 let editor = &categories[1];
878
879 assert_eq!(editor.path, "/editor");
880 assert_eq!(editor.settings.len(), 2);
881
882 let tab_size = editor
883 .settings
884 .iter()
885 .find(|s| s.name == "Tab Size")
886 .unwrap();
887 if let SettingType::Integer { minimum, maximum } = &tab_size.setting_type {
888 assert_eq!(*minimum, Some(1));
889 assert_eq!(*maximum, Some(16));
890 } else {
891 panic!("Expected integer type");
892 }
893 }
894
895 #[test]
896 fn test_any_of_nullable_object() {
897 let schema_json = r##"
900{
901 "$schema": "https://json-schema.org/draft/2020-12/schema",
902 "title": "Config",
903 "type": "object",
904 "properties": {
905 "fallback": {
906 "description": "Fallback language config",
907 "anyOf": [
908 { "$ref": "#/$defs/LanguageConfig" },
909 { "type": "null" }
910 ],
911 "default": null
912 }
913 },
914 "$defs": {
915 "LanguageConfig": {
916 "description": "Language-specific configuration",
917 "type": "object",
918 "properties": {
919 "grammar": {
920 "description": "Grammar name",
921 "type": "string",
922 "default": ""
923 },
924 "comment_prefix": {
925 "description": "Comment prefix",
926 "type": ["string", "null"],
927 "default": null
928 },
929 "auto_indent": {
930 "description": "Enable auto-indent",
931 "type": "boolean",
932 "default": false
933 }
934 }
935 }
936 }
937}
938"##;
939 let categories = parse_schema(schema_json).unwrap();
940
941 let fallback_cat = categories
944 .iter()
945 .find(|c| c.path == "/fallback")
946 .expect("fallback should be a category");
947 assert_eq!(fallback_cat.settings.len(), 3);
948
949 let grammar = fallback_cat
951 .settings
952 .iter()
953 .find(|s| s.name == "Grammar")
954 .unwrap();
955 assert!(matches!(grammar.setting_type, SettingType::String));
956
957 let auto_indent = fallback_cat
958 .settings
959 .iter()
960 .find(|s| s.name == "Auto Indent")
961 .unwrap();
962 assert!(matches!(auto_indent.setting_type, SettingType::Boolean));
963 }
964
965 #[test]
966 fn test_humanize_name() {
967 assert_eq!(humanize_name("tab_size"), "Tab Size");
968 assert_eq!(humanize_name("line_numbers"), "Line Numbers");
969 assert_eq!(humanize_name("check_for_updates"), "Check For Updates");
970 assert_eq!(humanize_name("lsp"), "Lsp");
971 }
972
973 #[test]
974 fn test_enum_from_parsed_from_schema() {
975 let schema_json = r##"{
976 "type": "object",
977 "properties": {
978 "default_language": {
979 "type": ["string", "null"],
980 "x-enum-from": "/languages"
981 },
982 "theme": {
983 "type": "string"
984 }
985 }
986 }"##;
987
988 let categories = parse_schema(schema_json).unwrap();
989 let general = &categories[0];
990 let default_lang = general
991 .settings
992 .iter()
993 .find(|s| s.name == "Default Language")
994 .expect("should have Default Language setting");
995
996 assert_eq!(
997 default_lang.enum_from.as_deref(),
998 Some("/languages"),
999 "enum_from should be parsed from x-enum-from"
1000 );
1001 assert!(default_lang.nullable, "should be nullable");
1002
1003 let theme = general
1005 .settings
1006 .iter()
1007 .find(|s| s.name == "Theme")
1008 .expect("should have Theme setting");
1009 assert!(theme.enum_from.is_none());
1010 }
1011
1012 #[test]
1013 fn test_dual_list_parsed_from_schema() {
1014 let schema_json = r##"{
1015 "type": "object",
1016 "properties": {
1017 "tags": {
1018 "type": "array",
1019 "items": {
1020 "type": "string",
1021 "x-dual-list-options": [
1022 {"value": "red", "name": "Red"},
1023 {"value": "green", "name": "Green"},
1024 {"value": "blue", "name": "Blue"}
1025 ]
1026 },
1027 "x-dual-list-sibling": "/other_tags"
1028 }
1029 }
1030 }"##;
1031 let categories = parse_schema(schema_json).unwrap();
1032 let general = &categories[0];
1033 let tags = general
1034 .settings
1035 .iter()
1036 .find(|s| s.path == "/tags")
1037 .expect("tags setting");
1038
1039 match &tags.setting_type {
1040 SettingType::DualList {
1041 options,
1042 sibling_path,
1043 } => {
1044 assert_eq!(options.len(), 3);
1045 assert_eq!(options[0].value, "red");
1046 assert_eq!(options[0].name, "Red");
1047 assert_eq!(sibling_path.as_deref(), Some("/other_tags"));
1048 }
1049 other => panic!("expected DualList, got {:?}", other),
1050 }
1051 assert_eq!(tags.dual_list_sibling.as_deref(), Some("/other_tags"));
1052 }
1053}