use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct SettingSchema {
pub path: String,
pub name: String,
pub description: Option<String>,
pub setting_type: SettingType,
pub default: Option<serde_json::Value>,
pub read_only: bool,
pub section: Option<String>,
}
#[derive(Debug, Clone)]
pub enum SettingType {
Boolean,
Integer {
minimum: Option<i64>,
maximum: Option<i64>,
},
Number {
minimum: Option<f64>,
maximum: Option<f64>,
},
String,
Enum { options: Vec<EnumOption> },
StringArray,
ObjectArray {
item_schema: Box<SettingSchema>,
display_field: Option<String>,
},
Object { properties: Vec<SettingSchema> },
Map {
value_schema: Box<SettingSchema>,
display_field: Option<String>,
no_add: bool,
},
Complex,
}
#[derive(Debug, Clone)]
pub struct EnumOption {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone)]
pub struct SettingCategory {
pub name: String,
pub path: String,
pub description: Option<String>,
pub settings: Vec<SettingSchema>,
pub subcategories: Vec<SettingCategory>,
}
#[derive(Debug, Deserialize)]
struct RawSchema {
#[serde(rename = "type")]
schema_type: Option<SchemaType>,
description: Option<String>,
default: Option<serde_json::Value>,
properties: Option<HashMap<String, RawSchema>>,
items: Option<Box<RawSchema>>,
#[serde(rename = "enum")]
enum_values: Option<Vec<serde_json::Value>>,
minimum: Option<serde_json::Number>,
maximum: Option<serde_json::Number>,
#[serde(rename = "$ref")]
ref_path: Option<String>,
#[serde(rename = "$defs")]
defs: Option<HashMap<String, RawSchema>>,
#[serde(rename = "additionalProperties")]
additional_properties: Option<AdditionalProperties>,
#[serde(rename = "x-enum-values", default)]
extensible_enum_values: Vec<EnumValueEntry>,
#[serde(rename = "x-display-field")]
display_field: Option<String>,
#[serde(rename = "readOnly", default)]
read_only: bool,
#[serde(rename = "x-standalone-category", default)]
standalone_category: bool,
#[serde(rename = "x-no-add", default)]
no_add: bool,
#[serde(rename = "x-section")]
section: Option<String>,
}
#[derive(Debug, Deserialize)]
struct EnumValueEntry {
#[serde(rename = "ref")]
ref_path: String,
name: Option<String>,
value: serde_json::Value,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum AdditionalProperties {
Bool(bool),
Schema(Box<RawSchema>),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum SchemaType {
Single(String),
Multiple(Vec<String>),
}
impl SchemaType {
fn primary(&self) -> Option<&str> {
match self {
Self::Single(s) => Some(s.as_str()),
Self::Multiple(v) => v.first().map(|s| s.as_str()),
}
}
}
type EnumValuesMap = HashMap<String, Vec<EnumOption>>;
pub fn parse_schema(schema_json: &str) -> Result<Vec<SettingCategory>, serde_json::Error> {
let raw: RawSchema = serde_json::from_str(schema_json)?;
let defs = raw.defs.unwrap_or_default();
let properties = raw.properties.unwrap_or_default();
let enum_values_map = build_enum_values_map(&raw.extensible_enum_values);
let mut categories = Vec::new();
let mut top_level_settings = Vec::new();
let mut sorted_props: Vec<_> = properties.into_iter().collect();
sorted_props.sort_by(|a, b| a.0.cmp(&b.0));
for (name, prop) in sorted_props {
let path = format!("/{}", name);
let display_name = humanize_name(&name);
let resolved = resolve_ref(&prop, &defs);
if prop.standalone_category {
let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
categories.push(SettingCategory {
name: display_name,
path: path.clone(),
description: prop.description.clone().or(resolved.description.clone()),
settings: vec![setting],
subcategories: Vec::new(),
});
} else if let Some(ref inner_props) = resolved.properties {
let settings = parse_properties(inner_props, &path, &defs, &enum_values_map);
categories.push(SettingCategory {
name: display_name,
path: path.clone(),
description: resolved.description.clone(),
settings,
subcategories: Vec::new(),
});
} else {
let setting = parse_setting(&name, &path, &prop, &defs, &enum_values_map);
top_level_settings.push(setting);
}
}
if !top_level_settings.is_empty() {
top_level_settings.sort_by(|a, b| a.name.cmp(&b.name));
categories.insert(
0,
SettingCategory {
name: "General".to_string(),
path: String::new(),
description: Some("General settings".to_string()),
settings: top_level_settings,
subcategories: Vec::new(),
},
);
}
categories.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
("General", _) => std::cmp::Ordering::Less,
(_, "General") => std::cmp::Ordering::Greater,
(a, b) => a.cmp(b),
});
Ok(categories)
}
fn build_enum_values_map(entries: &[EnumValueEntry]) -> EnumValuesMap {
let mut map: EnumValuesMap = HashMap::new();
for entry in entries {
let value_str = match &entry.value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
let option = EnumOption {
name: entry.name.clone().unwrap_or_else(|| value_str.clone()),
value: value_str,
};
map.entry(entry.ref_path.clone()).or_default().push(option);
}
map
}
fn parse_properties(
properties: &HashMap<String, RawSchema>,
parent_path: &str,
defs: &HashMap<String, RawSchema>,
enum_values_map: &EnumValuesMap,
) -> Vec<SettingSchema> {
let mut settings = Vec::new();
for (name, prop) in properties {
let path = format!("{}/{}", parent_path, name);
let setting = parse_setting(name, &path, prop, defs, enum_values_map);
settings.push(setting);
}
settings.sort_by(|a, b| a.name.cmp(&b.name));
settings
}
fn parse_setting(
name: &str,
path: &str,
schema: &RawSchema,
defs: &HashMap<String, RawSchema>,
enum_values_map: &EnumValuesMap,
) -> SettingSchema {
let setting_type = determine_type(schema, defs, enum_values_map);
let resolved = resolve_ref(schema, defs);
let description = schema
.description
.clone()
.or_else(|| resolved.description.clone());
let read_only = schema.read_only || resolved.read_only;
let section = schema.section.clone().or_else(|| resolved.section.clone());
SettingSchema {
path: path.to_string(),
name: humanize_name(name),
description,
setting_type,
default: schema.default.clone(),
read_only,
section,
}
}
fn determine_type(
schema: &RawSchema,
defs: &HashMap<String, RawSchema>,
enum_values_map: &EnumValuesMap,
) -> SettingType {
if let Some(ref ref_path) = schema.ref_path {
if let Some(options) = enum_values_map.get(ref_path) {
if !options.is_empty() {
return SettingType::Enum {
options: options.clone(),
};
}
}
}
let resolved = resolve_ref(schema, defs);
let enum_values = schema
.enum_values
.as_ref()
.or(resolved.enum_values.as_ref());
if let Some(values) = enum_values {
let options: Vec<EnumOption> = values
.iter()
.filter_map(|v| {
if v.is_null() {
Some(EnumOption {
name: "Auto-detect".to_string(),
value: String::new(), })
} else {
v.as_str().map(|s| EnumOption {
name: s.to_string(),
value: s.to_string(),
})
}
})
.collect();
if !options.is_empty() {
return SettingType::Enum { options };
}
}
match resolved.schema_type.as_ref().and_then(|t| t.primary()) {
Some("boolean") => SettingType::Boolean,
Some("integer") => {
let minimum = resolved.minimum.as_ref().and_then(|n| n.as_i64());
let maximum = resolved.maximum.as_ref().and_then(|n| n.as_i64());
SettingType::Integer { minimum, maximum }
}
Some("number") => {
let minimum = resolved.minimum.as_ref().and_then(|n| n.as_f64());
let maximum = resolved.maximum.as_ref().and_then(|n| n.as_f64());
SettingType::Number { minimum, maximum }
}
Some("string") => SettingType::String,
Some("array") => {
if let Some(ref items) = resolved.items {
let item_resolved = resolve_ref(items, defs);
if item_resolved.schema_type.as_ref().and_then(|t| t.primary()) == Some("string") {
return SettingType::StringArray;
}
if items.ref_path.is_some() {
let item_schema =
parse_setting("item", "", item_resolved, defs, enum_values_map);
if matches!(item_schema.setting_type, SettingType::Object { .. }) {
let display_field = item_resolved.display_field.clone();
return SettingType::ObjectArray {
item_schema: Box::new(item_schema),
display_field,
};
}
}
}
SettingType::Complex
}
Some("object") => {
if let Some(ref add_props) = resolved.additional_properties {
match add_props {
AdditionalProperties::Schema(schema_box) => {
let inner_resolved = resolve_ref(schema_box, defs);
let value_schema =
parse_setting("value", "", inner_resolved, defs, enum_values_map);
let display_field = inner_resolved.display_field.clone();
let no_add = resolved.no_add;
return SettingType::Map {
value_schema: Box::new(value_schema),
display_field,
no_add,
};
}
AdditionalProperties::Bool(true) => {
return SettingType::Complex;
}
AdditionalProperties::Bool(false) => {
}
}
}
if let Some(ref props) = resolved.properties {
let properties = parse_properties(props, "", defs, enum_values_map);
return SettingType::Object { properties };
}
SettingType::Complex
}
_ => SettingType::Complex,
}
}
fn resolve_ref<'a>(schema: &'a RawSchema, defs: &'a HashMap<String, RawSchema>) -> &'a RawSchema {
if let Some(ref ref_path) = schema.ref_path {
if let Some(def_name) = ref_path.strip_prefix("#/$defs/") {
if let Some(def) = defs.get(def_name) {
return def;
}
}
}
schema
}
fn humanize_name(name: &str) -> String {
name.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_SCHEMA: &str = r##"
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Config",
"type": "object",
"properties": {
"theme": {
"description": "Color theme name",
"type": "string",
"default": "high-contrast"
},
"check_for_updates": {
"description": "Check for new versions on quit",
"type": "boolean",
"default": true
},
"editor": {
"description": "Editor settings",
"$ref": "#/$defs/EditorConfig"
}
},
"$defs": {
"EditorConfig": {
"description": "Editor behavior configuration",
"type": "object",
"properties": {
"tab_size": {
"description": "Number of spaces per tab",
"type": "integer",
"minimum": 1,
"maximum": 16,
"default": 4
},
"line_numbers": {
"description": "Show line numbers",
"type": "boolean",
"default": true
}
}
}
}
}
"##;
#[test]
fn test_parse_schema() {
let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
assert_eq!(categories.len(), 2);
assert_eq!(categories[0].name, "General");
assert_eq!(categories[1].name, "Editor");
}
#[test]
fn test_general_category() {
let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
let general = &categories[0];
assert_eq!(general.settings.len(), 2);
let theme = general
.settings
.iter()
.find(|s| s.path == "/theme")
.unwrap();
assert!(matches!(theme.setting_type, SettingType::String));
let updates = general
.settings
.iter()
.find(|s| s.path == "/check_for_updates")
.unwrap();
assert!(matches!(updates.setting_type, SettingType::Boolean));
}
#[test]
fn test_editor_category() {
let categories = parse_schema(SAMPLE_SCHEMA).unwrap();
let editor = &categories[1];
assert_eq!(editor.path, "/editor");
assert_eq!(editor.settings.len(), 2);
let tab_size = editor
.settings
.iter()
.find(|s| s.name == "Tab Size")
.unwrap();
if let SettingType::Integer { minimum, maximum } = &tab_size.setting_type {
assert_eq!(*minimum, Some(1));
assert_eq!(*maximum, Some(16));
} else {
panic!("Expected integer type");
}
}
#[test]
fn test_humanize_name() {
assert_eq!(humanize_name("tab_size"), "Tab Size");
assert_eq!(humanize_name("line_numbers"), "Line Numbers");
assert_eq!(humanize_name("check_for_updates"), "Check For Updates");
assert_eq!(humanize_name("lsp"), "Lsp");
}
}