Skip to main content

fresh/view/settings/
schema.rs

1//! JSON Schema parsing for settings UI
2//!
3//! Parses the config JSON Schema to build the settings UI structure.
4//!
5//! # Extensible Enums with `x-enum-values`
6//!
7//! This module supports a custom JSON Schema extension called `x-enum-values` that allows
8//! enum values to be defined separately from type definitions. This enables extensibility -
9//! new enum values can be added without modifying the type schema.
10//!
11//! ## How it works
12//!
13//! 1. Define a type in `$defs` (without hardcoded enum values):
14//!    ```json
15//!    "$defs": {
16//!      "ThemeOptions": {
17//!        "type": "string"
18//!      }
19//!    }
20//!    ```
21//!
22//! 2. Reference the type in properties:
23//!    ```json
24//!    "properties": {
25//!      "theme": {
26//!        "$ref": "#/$defs/ThemeOptions",
27//!        "default": "dark"
28//!      }
29//!    }
30//!    ```
31//!
32//! 3. Define enum values separately - each value declares which type it extends:
33//!    ```json
34//!    "x-enum-values": [
35//!      { "ref": "#/$defs/ThemeOptions", "name": "Dark", "value": "dark" },
36//!      { "ref": "#/$defs/ThemeOptions", "name": "Light", "value": "light" },
37//!      { "ref": "#/$defs/ThemeOptions", "name": "High Contrast", "value": "high-contrast" }
38//!    ]
39//!    ```
40//!
41//! ## Entry structure
42//!
43//! Each entry in `x-enum-values` has:
44//! - `ref` (required): JSON pointer to the type being extended (e.g., `#/$defs/ThemeOptions`)
45//! - `value` (required): The actual value, must match the referenced type
46//! - `name` (optional): Human-friendly display name, defaults to `value` if not provided
47//!
48//! ## Benefits
49//!
50//! - **Extensibility**: Add new values without changing the schema structure
51//! - **Self-describing**: Values declare which type they belong to
52//! - **Plugin-friendly**: External sources can contribute enum values
53//! - **Type-safe**: Values are validated against their referenced type
54
55use rust_i18n::t;
56use serde::Deserialize;
57use std::collections::HashMap;
58
59/// A property/setting from the schema
60#[derive(Debug, Clone)]
61pub struct SettingSchema {
62    /// JSON pointer path (e.g., "/editor/tab_size")
63    pub path: String,
64    /// Human-readable name derived from property name
65    pub name: String,
66    /// Description from schema
67    pub description: Option<String>,
68    /// The type of this setting
69    pub setting_type: SettingType,
70    /// Default value (as JSON)
71    pub default: Option<serde_json::Value>,
72    /// Whether this field is read-only (cannot be edited by user)
73    pub read_only: bool,
74    /// Section/group within the category (from x-section)
75    pub section: Option<String>,
76    /// Sort order override (from x-order). Lower values sort first.
77    /// When set, overrides alphabetical sorting.
78    pub order: Option<i32>,
79    /// Whether this setting accepts null (i.e., can be "unset" to inherit).
80    /// Derived from JSON Schema `"type": ["<type>", "null"]`.
81    pub nullable: bool,
82    /// Dynamic enum source path: derive dropdown options from the keys of
83    /// another config property at runtime (e.g., "/languages").
84    pub enum_from: Option<String>,
85    /// Path to the sibling dual-list setting (for cross-exclusion)
86    pub dual_list_sibling: Option<String>,
87}
88
89/// Type of a setting, determines which control to render
90#[derive(Debug, Clone)]
91pub enum SettingType {
92    /// Boolean toggle
93    Boolean,
94    /// Integer number with optional min/max
95    Integer {
96        minimum: Option<i64>,
97        maximum: Option<i64>,
98    },
99    /// Floating point number
100    Number {
101        minimum: Option<f64>,
102        maximum: Option<f64>,
103    },
104    /// Free-form string
105    String,
106    /// String with enumerated options (display name, value)
107    Enum { options: Vec<EnumOption> },
108    /// Array of strings
109    StringArray,
110    /// Array of integers (rendered as TextList, values parsed as numbers)
111    IntegerArray,
112    /// Array of objects with a schema (for keybindings, etc.)
113    ObjectArray {
114        item_schema: Box<SettingSchema>,
115        /// JSON pointer to field within item to display as preview (e.g., "/action")
116        display_field: Option<String>,
117    },
118    /// Nested object (category)
119    Object { properties: Vec<SettingSchema> },
120    /// Map with string keys (for languages, lsp configs)
121    Map {
122        value_schema: Box<SettingSchema>,
123        /// JSON pointer to field within value to display as preview (e.g., "/command")
124        display_field: Option<String>,
125        /// Whether to disallow adding new entries (entries are auto-managed)
126        no_add: bool,
127    },
128    /// Dual-list: ordered subset of a fixed set of options with sibling cross-exclusion
129    DualList {
130        options: Vec<EnumOption>,
131        sibling_path: Option<String>,
132    },
133    /// Complex type we can't edit directly
134    Complex,
135}
136
137/// An option in an enum type
138#[derive(Debug, Clone)]
139pub struct EnumOption {
140    /// Display name shown in UI
141    pub name: String,
142    /// Actual value stored in config
143    pub value: String,
144}
145
146/// A category in the settings tree
147#[derive(Debug, Clone)]
148pub struct SettingCategory {
149    /// Category name (e.g., "Editor", "File Explorer")
150    pub name: String,
151    /// JSON path prefix for this category
152    pub path: String,
153    /// Description of this category
154    pub description: Option<String>,
155    /// Whether this category is nullable (e.g., `Option<LanguageConfig>`)
156    /// and can be cleared as a whole.
157    pub nullable: bool,
158    /// Settings in this category
159    pub settings: Vec<SettingSchema>,
160    /// Subcategories
161    pub subcategories: Vec<SettingCategory>,
162}
163
164/// Raw JSON Schema structure for deserialization
165#[derive(Debug, Deserialize)]
166struct RawSchema {
167    #[serde(rename = "type")]
168    schema_type: Option<SchemaType>,
169    description: Option<String>,
170    default: Option<serde_json::Value>,
171    properties: Option<HashMap<String, RawSchema>>,
172    items: Option<Box<RawSchema>>,
173    #[serde(rename = "enum")]
174    enum_values: Option<Vec<serde_json::Value>>,
175    minimum: Option<serde_json::Number>,
176    maximum: Option<serde_json::Number>,
177    #[serde(rename = "$ref")]
178    ref_path: Option<String>,
179    #[serde(rename = "$defs")]
180    defs: Option<HashMap<String, RawSchema>>,
181    #[serde(rename = "additionalProperties")]
182    additional_properties: Option<AdditionalProperties>,
183    /// Extensible enum values - see module docs for details
184    #[serde(rename = "x-enum-values", default)]
185    extensible_enum_values: Vec<EnumValueEntry>,
186    /// Custom extension: field to display as preview in Map/ObjectArray entries
187    /// e.g., "/command" for OnSaveAction, "/action" for Keybinding
188    #[serde(rename = "x-display-field")]
189    display_field: Option<String>,
190    /// Whether this field is read-only
191    #[serde(rename = "readOnly", default)]
192    read_only: bool,
193    /// Whether this Map-type property should be rendered as its own category
194    #[serde(rename = "x-standalone-category", default)]
195    standalone_category: bool,
196    /// Whether this Map should disallow adding new entries (entries are auto-managed)
197    #[serde(rename = "x-no-add", default)]
198    no_add: bool,
199    /// Section/group within the category for organizing related settings
200    #[serde(rename = "x-section")]
201    section: Option<String>,
202    /// Sort order override for field ordering in entry dialogs
203    #[serde(rename = "x-order")]
204    order: Option<i32>,
205    /// anyOf combinator (used by schemars for Option<T> where T is a struct)
206    #[serde(rename = "anyOf")]
207    any_of: Option<Vec<RawSchema>>,
208    /// Dynamic enum: derive dropdown options from the keys of another config
209    /// property at runtime (e.g., `"x-enum-from": "/languages"` populates
210    /// the dropdown with keys from the `languages` HashMap).
211    #[serde(rename = "x-enum-from")]
212    enum_from: Option<String>,
213    /// Dual-list options defined on the item schema (array of {value, name})
214    #[serde(rename = "x-dual-list-options", default)]
215    dual_list_options: Vec<DualListOptionEntry>,
216    /// Path to the sibling dual-list setting (for cross-exclusion)
217    #[serde(rename = "x-dual-list-sibling")]
218    dual_list_sibling: Option<String>,
219}
220
221/// An entry in the x-enum-values array
222#[derive(Debug, Deserialize)]
223struct EnumValueEntry {
224    /// JSON pointer to the type being extended (e.g., "#/$defs/ThemeOptions")
225    #[serde(rename = "ref")]
226    ref_path: String,
227    /// Human-friendly display name (optional, defaults to value)
228    name: Option<String>,
229    /// The actual value (must match the referenced type)
230    value: serde_json::Value,
231}
232
233/// An option entry in x-dual-list-options
234#[derive(Debug, Deserialize)]
235struct DualListOptionEntry {
236    /// The actual value (e.g., "{filename}")
237    value: String,
238    /// Human-friendly display name
239    name: Option<String>,
240}
241
242/// additionalProperties can be a boolean or a schema object
243#[derive(Debug, Deserialize)]
244#[serde(untagged)]
245enum AdditionalProperties {
246    Bool(bool),
247    Schema(Box<RawSchema>),
248}
249
250/// JSON Schema type can be a single string or an array of strings
251#[derive(Debug, Deserialize)]
252#[serde(untagged)]
253enum SchemaType {
254    Single(String),
255    Multiple(Vec<String>),
256}
257
258impl SchemaType {
259    /// Get the primary type (first type if array, or the single type)
260    fn primary(&self) -> Option<&str> {
261        match self {
262            Self::Single(s) => Some(s.as_str()),
263            Self::Multiple(v) => v.first().map(|s| s.as_str()),
264        }
265    }
266
267    /// Check if this type includes "null" (i.e., the field is nullable/optional)
268    fn contains_null(&self) -> bool {
269        match self {
270            Self::Single(s) => s == "null",
271            Self::Multiple(v) => v.iter().any(|s| s == "null"),
272        }
273    }
274}
275
276/// Map from $ref paths to their enum options
277type EnumValuesMap = HashMap<String, Vec<EnumOption>>;
278
279/// Parse the JSON Schema and build the category tree
280pub fn parse_schema(schema_json: &str) -> Result<Vec<SettingCategory>, serde_json::Error> {
281    let raw: RawSchema = serde_json::from_str(schema_json)?;
282
283    let defs = raw.defs.unwrap_or_default();
284    let properties = raw.properties.unwrap_or_default();
285
286    // Build enum values map from x-enum-values entries
287    let enum_values_map = build_enum_values_map(&raw.extensible_enum_values);
288
289    let mut categories = Vec::new();
290    let mut top_level_settings = Vec::new();
291
292    // Process each top-level property (sorted for deterministic output)
293    let mut sorted_props: Vec<_> = properties.into_iter().collect();
294    sorted_props.sort_by(|a, b| a.0.cmp(&b.0));
295    for (name, prop) in sorted_props {
296        let path = format!("/{}", name);
297        let display_name = humanize_name(&name);
298
299        // Resolve references
300        let resolved = resolve_ref(&prop, &defs);
301
302        // Detect if this property is nullable (Option<T> generates anyOf with null variant)
303        let is_nullable = prop.any_of.as_ref().is_some_and(|variants| {
304            variants.iter().any(|v| {
305                v.schema_type
306                    .as_ref()
307                    .map(|t| t.primary() == Some("null"))
308                    .unwrap_or(false)
309            })
310        });
311
312        // Check if this property should be a standalone category (for Map types)
313        if prop.standalone_category {
314            // Create a category with the Map setting as its only content
315            let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
316            categories.push(SettingCategory {
317                name: display_name,
318                path: path.clone(),
319                description: prop.description.clone().or(resolved.description.clone()),
320                nullable: is_nullable,
321                settings: vec![setting],
322                subcategories: Vec::new(),
323            });
324        } else if let Some(ref inner_props) = resolved.properties {
325            // This is a category with nested settings
326            let settings = parse_properties(inner_props, &path, &defs, &enum_values_map);
327            // Combine the field-level description (from the property's doc comment) with
328            // the resolved struct description, separated by a line break when both exist.
329            let description = match (&prop.description, &resolved.description) {
330                (Some(field_desc), Some(struct_desc)) if field_desc != struct_desc => {
331                    Some(format!("{}\n{}", field_desc, struct_desc))
332                }
333                (Some(d), _) | (_, Some(d)) => Some(d.clone()),
334                _ => None,
335            };
336            categories.push(SettingCategory {
337                name: display_name,
338                path: path.clone(),
339                description,
340                nullable: is_nullable,
341                settings,
342                subcategories: Vec::new(),
343            });
344        } else {
345            // This is a top-level setting
346            let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
347            top_level_settings.push(setting);
348        }
349    }
350
351    // If there are top-level settings, create a "General" category for them
352    if !top_level_settings.is_empty() {
353        // Sort top-level settings alphabetically
354        top_level_settings.sort_by(|a, b| a.name.cmp(&b.name));
355        categories.insert(
356            0,
357            SettingCategory {
358                name: "General".to_string(),
359                path: String::new(),
360                description: Some("General settings".to_string()),
361                nullable: false,
362                settings: top_level_settings,
363                subcategories: Vec::new(),
364            },
365        );
366    }
367
368    // Sort categories alphabetically, but keep General first
369    categories.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
370        ("General", _) => std::cmp::Ordering::Less,
371        (_, "General") => std::cmp::Ordering::Greater,
372        (a, b) => a.cmp(b),
373    });
374
375    Ok(categories)
376}
377
378/// Build a map from $ref paths to their enum options
379fn build_enum_values_map(entries: &[EnumValueEntry]) -> EnumValuesMap {
380    let mut map: EnumValuesMap = HashMap::new();
381
382    for entry in entries {
383        let value_str = match &entry.value {
384            serde_json::Value::String(s) => s.clone(),
385            other => other.to_string(),
386        };
387
388        let option = EnumOption {
389            name: entry.name.clone().unwrap_or_else(|| value_str.clone()),
390            value: value_str,
391        };
392
393        map.entry(entry.ref_path.clone()).or_default().push(option);
394    }
395
396    map
397}
398
399/// Parse properties into settings
400fn parse_properties(
401    properties: &HashMap<String, RawSchema>,
402    parent_path: &str,
403    defs: &HashMap<String, RawSchema>,
404    enum_values_map: &EnumValuesMap,
405) -> Vec<SettingSchema> {
406    let mut settings = Vec::new();
407
408    for (name, prop) in properties {
409        let path = format!("{}/{}", parent_path, name);
410        let setting = parse_setting(name, &path, prop, defs, enum_values_map);
411
412        settings.push(setting);
413    }
414
415    // Sort settings: by x-order (if set) first, then alphabetically by name.
416    // Settings with x-order come before those without.
417    settings.sort_by(|a, b| match (a.order, b.order) {
418        (Some(a_ord), Some(b_ord)) => a_ord.cmp(&b_ord).then_with(|| a.name.cmp(&b.name)),
419        (Some(_), None) => std::cmp::Ordering::Less,
420        (None, Some(_)) => std::cmp::Ordering::Greater,
421        (None, None) => a.name.cmp(&b.name),
422    });
423
424    settings
425}
426
427/// Parse a single setting from its schema
428fn parse_setting(
429    name: &str,
430    path: &str,
431    schema: &RawSchema,
432    defs: &HashMap<String, RawSchema>,
433    enum_values_map: &EnumValuesMap,
434) -> SettingSchema {
435    let setting_type = determine_type(schema, defs, enum_values_map);
436
437    // Get description from resolved ref if not present on schema
438    let resolved = resolve_ref(schema, defs);
439    let description = schema
440        .description
441        .clone()
442        .or_else(|| resolved.description.clone());
443
444    // Check for readOnly flag on schema or resolved ref
445    let read_only = schema.read_only || resolved.read_only;
446
447    // Get section from schema or resolved ref
448    let section = schema.section.clone().or_else(|| resolved.section.clone());
449
450    // Get order from schema or resolved ref
451    let order = schema.order.or(resolved.order);
452
453    // Detect nullability from type array containing "null" or anyOf containing a null variant
454    let nullable = resolved
455        .schema_type
456        .as_ref()
457        .map(|t| t.contains_null())
458        .unwrap_or(false)
459        || schema.any_of.as_ref().is_some_and(|variants| {
460            variants.iter().any(|v| {
461                v.schema_type
462                    .as_ref()
463                    .map(|t| t.primary() == Some("null"))
464                    .unwrap_or(false)
465            })
466        });
467
468    SettingSchema {
469        path: path.to_string(),
470        name: i18n_name(path, name),
471        description,
472        setting_type,
473        default: schema.default.clone(),
474        read_only,
475        section,
476        order,
477        nullable,
478        enum_from: schema
479            .enum_from
480            .clone()
481            .or_else(|| resolved.enum_from.clone()),
482        dual_list_sibling: schema
483            .dual_list_sibling
484            .clone()
485            .or_else(|| resolved.dual_list_sibling.clone()),
486    }
487}
488
489/// Determine the SettingType from a schema
490fn determine_type(
491    schema: &RawSchema,
492    defs: &HashMap<String, RawSchema>,
493    enum_values_map: &EnumValuesMap,
494) -> SettingType {
495    // Check for extensible enum values via $ref
496    if let Some(ref ref_path) = schema.ref_path {
497        if let Some(options) = enum_values_map.get(ref_path) {
498            if !options.is_empty() {
499                return SettingType::Enum {
500                    options: options.clone(),
501                };
502            }
503        }
504    }
505
506    // Resolve ref for type checking
507    let resolved = resolve_ref(schema, defs);
508
509    // Check for inline enum values (on original schema or resolved ref)
510    let enum_values = schema
511        .enum_values
512        .as_ref()
513        .or(resolved.enum_values.as_ref());
514    if let Some(values) = enum_values {
515        let options: Vec<EnumOption> = values
516            .iter()
517            .filter_map(|v| {
518                if v.is_null() {
519                    // null in enum represents "auto-detect" or "default"
520                    Some(EnumOption {
521                        name: "Auto-detect".to_string(),
522                        value: String::new(), // Empty string represents null
523                    })
524                } else {
525                    v.as_str().map(|s| EnumOption {
526                        name: s.to_string(),
527                        value: s.to_string(),
528                    })
529                }
530            })
531            .collect();
532        if !options.is_empty() {
533            return SettingType::Enum { options };
534        }
535    }
536
537    // Check type field
538    match resolved.schema_type.as_ref().and_then(|t| t.primary()) {
539        Some("boolean") => SettingType::Boolean,
540        Some("integer") => {
541            let minimum = resolved.minimum.as_ref().and_then(|n| n.as_i64());
542            let maximum = resolved.maximum.as_ref().and_then(|n| n.as_i64());
543            SettingType::Integer { minimum, maximum }
544        }
545        Some("number") => {
546            let minimum = resolved.minimum.as_ref().and_then(|n| n.as_f64());
547            let maximum = resolved.maximum.as_ref().and_then(|n| n.as_f64());
548            SettingType::Number { minimum, maximum }
549        }
550        Some("string") => SettingType::String,
551        Some("array") => {
552            // Check if it's an array of strings, integers, or objects
553            if let Some(ref items) = resolved.items {
554                let item_resolved = resolve_ref(items, defs);
555                // Check for dual-list options on the item schema
556                if !item_resolved.dual_list_options.is_empty() {
557                    let options = item_resolved
558                        .dual_list_options
559                        .iter()
560                        .map(|entry| EnumOption {
561                            name: entry.name.clone().unwrap_or_else(|| entry.value.clone()),
562                            value: entry.value.clone(),
563                        })
564                        .collect();
565                    return SettingType::DualList {
566                        options,
567                        sibling_path: schema
568                            .dual_list_sibling
569                            .clone()
570                            .or_else(|| resolved.dual_list_sibling.clone()),
571                    };
572                }
573                let item_type = item_resolved.schema_type.as_ref().and_then(|t| t.primary());
574                if item_type == Some("string") {
575                    return SettingType::StringArray;
576                }
577                if item_type == Some("integer") || item_type == Some("number") {
578                    return SettingType::IntegerArray;
579                }
580                // Check if items reference an object type
581                if items.ref_path.is_some() {
582                    // Parse the item schema from the referenced definition
583                    let item_schema =
584                        parse_setting("item", "", item_resolved, defs, enum_values_map);
585
586                    // Only create ObjectArray if the item is an object with properties
587                    if matches!(item_schema.setting_type, SettingType::Object { .. }) {
588                        // Get display_field from x-display-field in the referenced schema
589                        let display_field = item_resolved.display_field.clone();
590                        return SettingType::ObjectArray {
591                            item_schema: Box::new(item_schema),
592                            display_field,
593                        };
594                    }
595                }
596            }
597            SettingType::Complex
598        }
599        Some("object") => {
600            // Check for additionalProperties (map type)
601            if let Some(ref add_props) = resolved.additional_properties {
602                match add_props {
603                    AdditionalProperties::Schema(schema_box) => {
604                        let inner_resolved = resolve_ref(schema_box, defs);
605                        let value_schema =
606                            parse_setting("value", "", inner_resolved, defs, enum_values_map);
607
608                        // Get display_field from x-display-field in the referenced schema.
609                        // If the value schema is an array, also check the array items for display_field.
610                        let display_field = inner_resolved.display_field.clone().or_else(|| {
611                            inner_resolved.items.as_ref().and_then(|items| {
612                                let items_resolved = resolve_ref(items, defs);
613                                items_resolved.display_field.clone()
614                            })
615                        });
616
617                        // Get no_add from the parent schema (resolved)
618                        let no_add = resolved.no_add;
619
620                        return SettingType::Map {
621                            value_schema: Box::new(value_schema),
622                            display_field,
623                            no_add,
624                        };
625                    }
626                    AdditionalProperties::Bool(true) => {
627                        // additionalProperties: true means any value is allowed
628                        return SettingType::Complex;
629                    }
630                    AdditionalProperties::Bool(false) => {
631                        // additionalProperties: false means no additional properties
632                        // Fall through to check for fixed properties
633                    }
634                }
635            }
636            // Regular object with fixed properties
637            if let Some(ref props) = resolved.properties {
638                let properties = parse_properties(props, "", defs, enum_values_map);
639                return SettingType::Object { properties };
640            }
641            SettingType::Complex
642        }
643        _ => SettingType::Complex,
644    }
645}
646
647/// Resolve a $ref to its definition.
648///
649/// Also resolves through `anyOf` patterns generated by schemars for `Option<T>`:
650///   `anyOf: [{ "$ref": "#/$defs/Foo" }, { "type": "null" }]`
651/// In this case, the non-null `$ref` variant is resolved.
652fn resolve_ref<'a>(schema: &'a RawSchema, defs: &'a HashMap<String, RawSchema>) -> &'a RawSchema {
653    // Direct $ref
654    if let Some(ref ref_path) = schema.ref_path {
655        if let Some(def_name) = ref_path.strip_prefix("#/$defs/") {
656            if let Some(def) = defs.get(def_name) {
657                return def;
658            }
659        }
660    }
661    // anyOf: find the non-null variant and resolve it
662    if let Some(ref variants) = schema.any_of {
663        for variant in variants {
664            let is_null = variant
665                .schema_type
666                .as_ref()
667                .map(|t| t.primary() == Some("null"))
668                .unwrap_or(false);
669            if !is_null {
670                return resolve_ref(variant, defs);
671            }
672        }
673    }
674    schema
675}
676
677/// Look up an i18n translation for a settings field, falling back to humanized name.
678///
679/// Derives a translation key from the schema path, e.g. `/editor/whitespace_show`
680/// becomes `settings.field.editor.whitespace_show`. If no translation is found,
681/// falls back to `humanize_name()`.
682fn i18n_name(path: &str, fallback_name: &str) -> String {
683    let key = format!("settings.field{}", path.replace('/', "."));
684    let translated = t!(&key);
685    if *translated == key {
686        humanize_name(fallback_name)
687    } else {
688        translated.to_string()
689    }
690}
691
692/// Convert snake_case to Title Case
693fn humanize_name(name: &str) -> String {
694    name.split('_')
695        .map(|word| {
696            let mut chars = word.chars();
697            match chars.next() {
698                None => String::new(),
699                Some(first) => first.to_uppercase().chain(chars).collect(),
700            }
701        })
702        .collect::<Vec<_>>()
703        .join(" ")
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709
710    const SAMPLE_SCHEMA: &str = r##"
711{
712  "$schema": "https://json-schema.org/draft/2020-12/schema",
713  "title": "Config",
714  "type": "object",
715  "properties": {
716    "theme": {
717      "description": "Color theme name",
718      "type": "string",
719      "default": "high-contrast"
720    },
721    "check_for_updates": {
722      "description": "Check for new versions on quit",
723      "type": "boolean",
724      "default": true
725    },
726    "editor": {
727      "description": "Editor settings",
728      "$ref": "#/$defs/EditorConfig"
729    }
730  },
731  "$defs": {
732    "EditorConfig": {
733      "description": "Editor behavior configuration",
734      "type": "object",
735      "properties": {
736        "tab_size": {
737          "description": "Number of spaces per tab",
738          "type": "integer",
739          "minimum": 1,
740          "maximum": 16,
741          "default": 4
742        },
743        "line_numbers": {
744          "description": "Show line numbers",
745          "type": "boolean",
746          "default": true
747        }
748      }
749    }
750  }
751}
752"##;
753
754    #[test]
755    fn test_parse_schema() {
756        let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
757
758        // Should have General and Editor categories
759        assert_eq!(categories.len(), 2);
760        assert_eq!(categories[0].name, "General");
761        assert_eq!(categories[1].name, "Editor");
762    }
763
764    #[test]
765    fn test_general_category() {
766        let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
767        let general = &categories[0];
768
769        // General should have theme and check_for_updates
770        assert_eq!(general.settings.len(), 2);
771
772        let theme = general
773            .settings
774            .iter()
775            .find(|s| s.path == "/theme")
776            .unwrap();
777        assert!(matches!(theme.setting_type, SettingType::String));
778
779        let updates = general
780            .settings
781            .iter()
782            .find(|s| s.path == "/check_for_updates")
783            .unwrap();
784        assert!(matches!(updates.setting_type, SettingType::Boolean));
785    }
786
787    #[test]
788    fn test_editor_category() {
789        let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
790        let editor = &categories[1];
791
792        assert_eq!(editor.path, "/editor");
793        assert_eq!(editor.settings.len(), 2);
794
795        let tab_size = editor
796            .settings
797            .iter()
798            .find(|s| s.name == "Tab Size")
799            .unwrap();
800        if let SettingType::Integer { minimum, maximum } = &tab_size.setting_type {
801            assert_eq!(*minimum, Some(1));
802            assert_eq!(*maximum, Some(16));
803        } else {
804            panic!("Expected integer type");
805        }
806    }
807
808    #[test]
809    fn test_any_of_nullable_object() {
810        // Tests that anyOf: [{$ref: "..."}, {type: "null"}] resolves to an Object type
811        // and is marked as nullable. This is the pattern schemars generates for Option<T>.
812        let schema_json = r##"
813{
814  "$schema": "https://json-schema.org/draft/2020-12/schema",
815  "title": "Config",
816  "type": "object",
817  "properties": {
818    "fallback": {
819      "description": "Fallback language config",
820      "anyOf": [
821        { "$ref": "#/$defs/LanguageConfig" },
822        { "type": "null" }
823      ],
824      "default": null
825    }
826  },
827  "$defs": {
828    "LanguageConfig": {
829      "description": "Language-specific configuration",
830      "type": "object",
831      "properties": {
832        "grammar": {
833          "description": "Grammar name",
834          "type": "string",
835          "default": ""
836        },
837        "comment_prefix": {
838          "description": "Comment prefix",
839          "type": ["string", "null"],
840          "default": null
841        },
842        "auto_indent": {
843          "description": "Enable auto-indent",
844          "type": "boolean",
845          "default": false
846        }
847      }
848    }
849  }
850}
851"##;
852        let categories = parse_schema(schema_json).unwrap();
853
854        // anyOf with $ref should resolve through to LanguageConfig's properties,
855        // creating a category (like "Editor") with individual sub-field controls
856        let fallback_cat = categories
857            .iter()
858            .find(|c| c.path == "/fallback")
859            .expect("fallback should be a category");
860        assert_eq!(fallback_cat.settings.len(), 3);
861
862        // Verify sub-fields are properly typed
863        let grammar = fallback_cat
864            .settings
865            .iter()
866            .find(|s| s.name == "Grammar")
867            .unwrap();
868        assert!(matches!(grammar.setting_type, SettingType::String));
869
870        let auto_indent = fallback_cat
871            .settings
872            .iter()
873            .find(|s| s.name == "Auto Indent")
874            .unwrap();
875        assert!(matches!(auto_indent.setting_type, SettingType::Boolean));
876    }
877
878    #[test]
879    fn test_humanize_name() {
880        assert_eq!(humanize_name("tab_size"), "Tab Size");
881        assert_eq!(humanize_name("line_numbers"), "Line Numbers");
882        assert_eq!(humanize_name("check_for_updates"), "Check For Updates");
883        assert_eq!(humanize_name("lsp"), "Lsp");
884    }
885
886    #[test]
887    fn test_enum_from_parsed_from_schema() {
888        let schema_json = r##"{
889            "type": "object",
890            "properties": {
891                "default_language": {
892                    "type": ["string", "null"],
893                    "x-enum-from": "/languages"
894                },
895                "theme": {
896                    "type": "string"
897                }
898            }
899        }"##;
900
901        let categories = parse_schema(schema_json).unwrap();
902        let general = &categories[0];
903        let default_lang = general
904            .settings
905            .iter()
906            .find(|s| s.name == "Default Language")
907            .expect("should have Default Language setting");
908
909        assert_eq!(
910            default_lang.enum_from.as_deref(),
911            Some("/languages"),
912            "enum_from should be parsed from x-enum-from"
913        );
914        assert!(default_lang.nullable, "should be nullable");
915
916        // theme should not have enum_from
917        let theme = general
918            .settings
919            .iter()
920            .find(|s| s.name == "Theme")
921            .expect("should have Theme setting");
922        assert!(theme.enum_from.is_none());
923    }
924
925    #[test]
926    fn test_dual_list_parsed_from_schema() {
927        let schema_json = r##"{
928            "type": "object",
929            "properties": {
930                "tags": {
931                    "type": "array",
932                    "items": {
933                        "type": "string",
934                        "x-dual-list-options": [
935                            {"value": "red", "name": "Red"},
936                            {"value": "green", "name": "Green"},
937                            {"value": "blue", "name": "Blue"}
938                        ]
939                    },
940                    "x-dual-list-sibling": "/other_tags"
941                }
942            }
943        }"##;
944        let categories = parse_schema(schema_json).unwrap();
945        let general = &categories[0];
946        let tags = general
947            .settings
948            .iter()
949            .find(|s| s.path == "/tags")
950            .expect("tags setting");
951
952        match &tags.setting_type {
953            SettingType::DualList {
954                options,
955                sibling_path,
956            } => {
957                assert_eq!(options.len(), 3);
958                assert_eq!(options[0].value, "red");
959                assert_eq!(options[0].name, "Red");
960                assert_eq!(sibling_path.as_deref(), Some("/other_tags"));
961            }
962            other => panic!("expected DualList, got {:?}", other),
963        }
964        assert_eq!(tags.dual_list_sibling.as_deref(), Some("/other_tags"));
965    }
966}