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