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 serde::Deserialize;
56use std::collections::HashMap;
57
58/// A property/setting from the schema
59#[derive(Debug, Clone)]
60pub struct SettingSchema {
61    /// JSON pointer path (e.g., "/editor/tab_size")
62    pub path: String,
63    /// Human-readable name derived from property name
64    pub name: String,
65    /// Description from schema
66    pub description: Option<String>,
67    /// The type of this setting
68    pub setting_type: SettingType,
69    /// Default value (as JSON)
70    pub default: Option<serde_json::Value>,
71    /// Whether this field is read-only (cannot be edited by user)
72    pub read_only: bool,
73    /// Section/group within the category (from x-section)
74    pub section: Option<String>,
75}
76
77/// Type of a setting, determines which control to render
78#[derive(Debug, Clone)]
79pub enum SettingType {
80    /// Boolean toggle
81    Boolean,
82    /// Integer number with optional min/max
83    Integer {
84        minimum: Option<i64>,
85        maximum: Option<i64>,
86    },
87    /// Floating point number
88    Number {
89        minimum: Option<f64>,
90        maximum: Option<f64>,
91    },
92    /// Free-form string
93    String,
94    /// String with enumerated options (display name, value)
95    Enum { options: Vec<EnumOption> },
96    /// Array of strings
97    StringArray,
98    /// Array of objects with a schema (for keybindings, etc.)
99    ObjectArray {
100        item_schema: Box<SettingSchema>,
101        /// JSON pointer to field within item to display as preview (e.g., "/action")
102        display_field: Option<String>,
103    },
104    /// Nested object (category)
105    Object { properties: Vec<SettingSchema> },
106    /// Map with string keys (for languages, lsp configs)
107    Map {
108        value_schema: Box<SettingSchema>,
109        /// JSON pointer to field within value to display as preview (e.g., "/command")
110        display_field: Option<String>,
111        /// Whether to disallow adding new entries (entries are auto-managed)
112        no_add: bool,
113    },
114    /// Complex type we can't edit directly
115    Complex,
116}
117
118/// An option in an enum type
119#[derive(Debug, Clone)]
120pub struct EnumOption {
121    /// Display name shown in UI
122    pub name: String,
123    /// Actual value stored in config
124    pub value: String,
125}
126
127/// A category in the settings tree
128#[derive(Debug, Clone)]
129pub struct SettingCategory {
130    /// Category name (e.g., "Editor", "File Explorer")
131    pub name: String,
132    /// JSON path prefix for this category
133    pub path: String,
134    /// Description of this category
135    pub description: Option<String>,
136    /// Settings in this category
137    pub settings: Vec<SettingSchema>,
138    /// Subcategories
139    pub subcategories: Vec<SettingCategory>,
140}
141
142/// Raw JSON Schema structure for deserialization
143#[derive(Debug, Deserialize)]
144struct RawSchema {
145    #[serde(rename = "type")]
146    schema_type: Option<SchemaType>,
147    description: Option<String>,
148    default: Option<serde_json::Value>,
149    properties: Option<HashMap<String, RawSchema>>,
150    items: Option<Box<RawSchema>>,
151    #[serde(rename = "enum")]
152    enum_values: Option<Vec<serde_json::Value>>,
153    minimum: Option<serde_json::Number>,
154    maximum: Option<serde_json::Number>,
155    #[serde(rename = "$ref")]
156    ref_path: Option<String>,
157    #[serde(rename = "$defs")]
158    defs: Option<HashMap<String, RawSchema>>,
159    #[serde(rename = "additionalProperties")]
160    additional_properties: Option<AdditionalProperties>,
161    /// Extensible enum values - see module docs for details
162    #[serde(rename = "x-enum-values", default)]
163    extensible_enum_values: Vec<EnumValueEntry>,
164    /// Custom extension: field to display as preview in Map/ObjectArray entries
165    /// e.g., "/command" for OnSaveAction, "/action" for Keybinding
166    #[serde(rename = "x-display-field")]
167    display_field: Option<String>,
168    /// Whether this field is read-only
169    #[serde(rename = "readOnly", default)]
170    read_only: bool,
171    /// Whether this Map-type property should be rendered as its own category
172    #[serde(rename = "x-standalone-category", default)]
173    standalone_category: bool,
174    /// Whether this Map should disallow adding new entries (entries are auto-managed)
175    #[serde(rename = "x-no-add", default)]
176    no_add: bool,
177    /// Section/group within the category for organizing related settings
178    #[serde(rename = "x-section")]
179    section: Option<String>,
180}
181
182/// An entry in the x-enum-values array
183#[derive(Debug, Deserialize)]
184struct EnumValueEntry {
185    /// JSON pointer to the type being extended (e.g., "#/$defs/ThemeOptions")
186    #[serde(rename = "ref")]
187    ref_path: String,
188    /// Human-friendly display name (optional, defaults to value)
189    name: Option<String>,
190    /// The actual value (must match the referenced type)
191    value: serde_json::Value,
192}
193
194/// additionalProperties can be a boolean or a schema object
195#[derive(Debug, Deserialize)]
196#[serde(untagged)]
197enum AdditionalProperties {
198    Bool(bool),
199    Schema(Box<RawSchema>),
200}
201
202/// JSON Schema type can be a single string or an array of strings
203#[derive(Debug, Deserialize)]
204#[serde(untagged)]
205enum SchemaType {
206    Single(String),
207    Multiple(Vec<String>),
208}
209
210impl SchemaType {
211    /// Get the primary type (first type if array, or the single type)
212    fn primary(&self) -> Option<&str> {
213        match self {
214            Self::Single(s) => Some(s.as_str()),
215            Self::Multiple(v) => v.first().map(|s| s.as_str()),
216        }
217    }
218}
219
220/// Map from $ref paths to their enum options
221type EnumValuesMap = HashMap<String, Vec<EnumOption>>;
222
223/// Parse the JSON Schema and build the category tree
224pub fn parse_schema(schema_json: &str) -> Result<Vec<SettingCategory>, serde_json::Error> {
225    let raw: RawSchema = serde_json::from_str(schema_json)?;
226
227    let defs = raw.defs.unwrap_or_default();
228    let properties = raw.properties.unwrap_or_default();
229
230    // Build enum values map from x-enum-values entries
231    let enum_values_map = build_enum_values_map(&raw.extensible_enum_values);
232
233    let mut categories = Vec::new();
234    let mut top_level_settings = Vec::new();
235
236    // Process each top-level property (sorted for deterministic output)
237    let mut sorted_props: Vec<_> = properties.into_iter().collect();
238    sorted_props.sort_by(|a, b| a.0.cmp(&b.0));
239    for (name, prop) in sorted_props {
240        let path = format!("/{}", name);
241        let display_name = humanize_name(&name);
242
243        // Resolve references
244        let resolved = resolve_ref(&prop, &defs);
245
246        // Check if this property should be a standalone category (for Map types)
247        if prop.standalone_category {
248            // Create a category with the Map setting as its only content
249            let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
250            categories.push(SettingCategory {
251                name: display_name,
252                path: path.clone(),
253                description: prop.description.clone().or(resolved.description.clone()),
254                settings: vec![setting],
255                subcategories: Vec::new(),
256            });
257        } else if let Some(ref inner_props) = resolved.properties {
258            // This is a category with nested settings
259            let settings = parse_properties(inner_props, &path, &defs, &enum_values_map);
260            categories.push(SettingCategory {
261                name: display_name,
262                path: path.clone(),
263                description: resolved.description.clone(),
264                settings,
265                subcategories: Vec::new(),
266            });
267        } else {
268            // This is a top-level setting
269            let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
270            top_level_settings.push(setting);
271        }
272    }
273
274    // If there are top-level settings, create a "General" category for them
275    if !top_level_settings.is_empty() {
276        // Sort top-level settings alphabetically
277        top_level_settings.sort_by(|a, b| a.name.cmp(&b.name));
278        categories.insert(
279            0,
280            SettingCategory {
281                name: "General".to_string(),
282                path: String::new(),
283                description: Some("General settings".to_string()),
284                settings: top_level_settings,
285                subcategories: Vec::new(),
286            },
287        );
288    }
289
290    // Sort categories alphabetically, but keep General first
291    categories.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
292        ("General", _) => std::cmp::Ordering::Less,
293        (_, "General") => std::cmp::Ordering::Greater,
294        (a, b) => a.cmp(b),
295    });
296
297    Ok(categories)
298}
299
300/// Build a map from $ref paths to their enum options
301fn build_enum_values_map(entries: &[EnumValueEntry]) -> EnumValuesMap {
302    let mut map: EnumValuesMap = HashMap::new();
303
304    for entry in entries {
305        let value_str = match &entry.value {
306            serde_json::Value::String(s) => s.clone(),
307            other => other.to_string(),
308        };
309
310        let option = EnumOption {
311            name: entry.name.clone().unwrap_or_else(|| value_str.clone()),
312            value: value_str,
313        };
314
315        map.entry(entry.ref_path.clone()).or_default().push(option);
316    }
317
318    map
319}
320
321/// Parse properties into settings
322fn parse_properties(
323    properties: &HashMap<String, RawSchema>,
324    parent_path: &str,
325    defs: &HashMap<String, RawSchema>,
326    enum_values_map: &EnumValuesMap,
327) -> Vec<SettingSchema> {
328    let mut settings = Vec::new();
329
330    for (name, prop) in properties {
331        let path = format!("{}/{}", parent_path, name);
332        let setting = parse_setting(name, &path, prop, defs, enum_values_map);
333        settings.push(setting);
334    }
335
336    // Sort settings alphabetically by name
337    settings.sort_by(|a, b| a.name.cmp(&b.name));
338
339    settings
340}
341
342/// Parse a single setting from its schema
343fn parse_setting(
344    name: &str,
345    path: &str,
346    schema: &RawSchema,
347    defs: &HashMap<String, RawSchema>,
348    enum_values_map: &EnumValuesMap,
349) -> SettingSchema {
350    let setting_type = determine_type(schema, defs, enum_values_map);
351
352    // Get description from resolved ref if not present on schema
353    let resolved = resolve_ref(schema, defs);
354    let description = schema
355        .description
356        .clone()
357        .or_else(|| resolved.description.clone());
358
359    // Check for readOnly flag on schema or resolved ref
360    let read_only = schema.read_only || resolved.read_only;
361
362    // Get section from schema or resolved ref
363    let section = schema.section.clone().or_else(|| resolved.section.clone());
364
365    SettingSchema {
366        path: path.to_string(),
367        name: humanize_name(name),
368        description,
369        setting_type,
370        default: schema.default.clone(),
371        read_only,
372        section,
373    }
374}
375
376/// Determine the SettingType from a schema
377fn determine_type(
378    schema: &RawSchema,
379    defs: &HashMap<String, RawSchema>,
380    enum_values_map: &EnumValuesMap,
381) -> SettingType {
382    // Check for extensible enum values via $ref
383    if let Some(ref ref_path) = schema.ref_path {
384        if let Some(options) = enum_values_map.get(ref_path) {
385            if !options.is_empty() {
386                return SettingType::Enum {
387                    options: options.clone(),
388                };
389            }
390        }
391    }
392
393    // Resolve ref for type checking
394    let resolved = resolve_ref(schema, defs);
395
396    // Check for inline enum values (on original schema or resolved ref)
397    let enum_values = schema
398        .enum_values
399        .as_ref()
400        .or(resolved.enum_values.as_ref());
401    if let Some(values) = enum_values {
402        let options: Vec<EnumOption> = values
403            .iter()
404            .filter_map(|v| {
405                if v.is_null() {
406                    // null in enum represents "auto-detect" or "default"
407                    Some(EnumOption {
408                        name: "Auto-detect".to_string(),
409                        value: String::new(), // Empty string represents null
410                    })
411                } else {
412                    v.as_str().map(|s| EnumOption {
413                        name: s.to_string(),
414                        value: s.to_string(),
415                    })
416                }
417            })
418            .collect();
419        if !options.is_empty() {
420            return SettingType::Enum { options };
421        }
422    }
423
424    // Check type field
425    match resolved.schema_type.as_ref().and_then(|t| t.primary()) {
426        Some("boolean") => SettingType::Boolean,
427        Some("integer") => {
428            let minimum = resolved.minimum.as_ref().and_then(|n| n.as_i64());
429            let maximum = resolved.maximum.as_ref().and_then(|n| n.as_i64());
430            SettingType::Integer { minimum, maximum }
431        }
432        Some("number") => {
433            let minimum = resolved.minimum.as_ref().and_then(|n| n.as_f64());
434            let maximum = resolved.maximum.as_ref().and_then(|n| n.as_f64());
435            SettingType::Number { minimum, maximum }
436        }
437        Some("string") => SettingType::String,
438        Some("array") => {
439            // Check if it's an array of strings or objects
440            if let Some(ref items) = resolved.items {
441                let item_resolved = resolve_ref(items, defs);
442                if item_resolved.schema_type.as_ref().and_then(|t| t.primary()) == Some("string") {
443                    return SettingType::StringArray;
444                }
445                // Check if items reference an object type
446                if items.ref_path.is_some() {
447                    // Parse the item schema from the referenced definition
448                    let item_schema =
449                        parse_setting("item", "", item_resolved, defs, enum_values_map);
450
451                    // Only create ObjectArray if the item is an object with properties
452                    if matches!(item_schema.setting_type, SettingType::Object { .. }) {
453                        // Get display_field from x-display-field in the referenced schema
454                        let display_field = item_resolved.display_field.clone();
455                        return SettingType::ObjectArray {
456                            item_schema: Box::new(item_schema),
457                            display_field,
458                        };
459                    }
460                }
461            }
462            SettingType::Complex
463        }
464        Some("object") => {
465            // Check for additionalProperties (map type)
466            if let Some(ref add_props) = resolved.additional_properties {
467                match add_props {
468                    AdditionalProperties::Schema(schema_box) => {
469                        let inner_resolved = resolve_ref(schema_box, defs);
470                        let value_schema =
471                            parse_setting("value", "", inner_resolved, defs, enum_values_map);
472
473                        // Get display_field from x-display-field in the referenced schema
474                        let display_field = inner_resolved.display_field.clone();
475
476                        // Get no_add from the parent schema (resolved)
477                        let no_add = resolved.no_add;
478
479                        return SettingType::Map {
480                            value_schema: Box::new(value_schema),
481                            display_field,
482                            no_add,
483                        };
484                    }
485                    AdditionalProperties::Bool(true) => {
486                        // additionalProperties: true means any value is allowed
487                        return SettingType::Complex;
488                    }
489                    AdditionalProperties::Bool(false) => {
490                        // additionalProperties: false means no additional properties
491                        // Fall through to check for fixed properties
492                    }
493                }
494            }
495            // Regular object with fixed properties
496            if let Some(ref props) = resolved.properties {
497                let properties = parse_properties(props, "", defs, enum_values_map);
498                return SettingType::Object { properties };
499            }
500            SettingType::Complex
501        }
502        _ => SettingType::Complex,
503    }
504}
505
506/// Resolve a $ref to its definition
507fn resolve_ref<'a>(schema: &'a RawSchema, defs: &'a HashMap<String, RawSchema>) -> &'a RawSchema {
508    if let Some(ref ref_path) = schema.ref_path {
509        // Parse ref path like "#/$defs/EditorConfig"
510        if let Some(def_name) = ref_path.strip_prefix("#/$defs/") {
511            if let Some(def) = defs.get(def_name) {
512                return def;
513            }
514        }
515    }
516    schema
517}
518
519/// Convert snake_case to Title Case
520fn humanize_name(name: &str) -> String {
521    name.split('_')
522        .map(|word| {
523            let mut chars = word.chars();
524            match chars.next() {
525                None => String::new(),
526                Some(first) => first.to_uppercase().chain(chars).collect(),
527            }
528        })
529        .collect::<Vec<_>>()
530        .join(" ")
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    const SAMPLE_SCHEMA: &str = r##"
538{
539  "$schema": "https://json-schema.org/draft/2020-12/schema",
540  "title": "Config",
541  "type": "object",
542  "properties": {
543    "theme": {
544      "description": "Color theme name",
545      "type": "string",
546      "default": "high-contrast"
547    },
548    "check_for_updates": {
549      "description": "Check for new versions on quit",
550      "type": "boolean",
551      "default": true
552    },
553    "editor": {
554      "description": "Editor settings",
555      "$ref": "#/$defs/EditorConfig"
556    }
557  },
558  "$defs": {
559    "EditorConfig": {
560      "description": "Editor behavior configuration",
561      "type": "object",
562      "properties": {
563        "tab_size": {
564          "description": "Number of spaces per tab",
565          "type": "integer",
566          "minimum": 1,
567          "maximum": 16,
568          "default": 4
569        },
570        "line_numbers": {
571          "description": "Show line numbers",
572          "type": "boolean",
573          "default": true
574        }
575      }
576    }
577  }
578}
579"##;
580
581    #[test]
582    fn test_parse_schema() {
583        let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
584
585        // Should have General and Editor categories
586        assert_eq!(categories.len(), 2);
587        assert_eq!(categories[0].name, "General");
588        assert_eq!(categories[1].name, "Editor");
589    }
590
591    #[test]
592    fn test_general_category() {
593        let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
594        let general = &categories[0];
595
596        // General should have theme and check_for_updates
597        assert_eq!(general.settings.len(), 2);
598
599        let theme = general
600            .settings
601            .iter()
602            .find(|s| s.path == "/theme")
603            .unwrap();
604        assert!(matches!(theme.setting_type, SettingType::String));
605
606        let updates = general
607            .settings
608            .iter()
609            .find(|s| s.path == "/check_for_updates")
610            .unwrap();
611        assert!(matches!(updates.setting_type, SettingType::Boolean));
612    }
613
614    #[test]
615    fn test_editor_category() {
616        let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
617        let editor = &categories[1];
618
619        assert_eq!(editor.path, "/editor");
620        assert_eq!(editor.settings.len(), 2);
621
622        let tab_size = editor
623            .settings
624            .iter()
625            .find(|s| s.name == "Tab Size")
626            .unwrap();
627        if let SettingType::Integer { minimum, maximum } = &tab_size.setting_type {
628            assert_eq!(*minimum, Some(1));
629            assert_eq!(*maximum, Some(16));
630        } else {
631            panic!("Expected integer type");
632        }
633    }
634
635    #[test]
636    fn test_humanize_name() {
637        assert_eq!(humanize_name("tab_size"), "Tab Size");
638        assert_eq!(humanize_name("line_numbers"), "Line Numbers");
639        assert_eq!(humanize_name("check_for_updates"), "Check For Updates");
640        assert_eq!(humanize_name("lsp"), "Lsp");
641    }
642}