use crate::config::{DetailActionDef, FieldAction};
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::sync::Mutex;
static REGEX_CACHE: Lazy<Mutex<HashMap<String, Regex>>> = Lazy::new(|| Mutex::new(HashMap::new()));
pub const SETTINGS_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum CommitPolicy {
#[default]
#[serde(rename = "staged")]
Staged,
#[serde(rename = "immediateAllowed")]
ImmediateAllowed,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum PathMode {
#[serde(rename = "file")]
File,
#[serde(rename = "directory")]
Directory,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind")]
pub enum WidgetHint {
#[serde(rename = "text")]
Text,
#[serde(rename = "textarea")]
Textarea,
#[serde(rename = "number")]
Number,
#[serde(rename = "toggle")]
Toggle,
#[serde(rename = "select")]
Select,
#[serde(rename = "multiselect")]
MultiSelect,
#[serde(rename = "path")]
Path {
#[serde(rename = "mode")]
mode: PathMode,
},
#[serde(rename = "color")]
Color,
#[serde(rename = "image")]
Image {
#[serde(rename = "accept")]
accept: Vec<String>,
#[serde(rename = "maxSize")]
#[serde(default)]
max_size: Option<u64>,
},
#[serde(rename = "font")]
Font {
#[serde(rename = "action")]
action: String,
#[serde(rename = "component", default)]
component: Option<String>,
},
#[serde(rename = "hotkey")]
Hotkey,
#[serde(rename = "list")]
List,
#[serde(rename = "tags")]
Tags,
#[serde(rename = "table")]
Table,
#[serde(rename = "cards")]
Cards,
#[serde(rename = "masterDetail")]
MasterDetail,
#[serde(rename = "searchTable")]
SearchTable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SchemaKind {
#[serde(rename = "string")]
String {
#[serde(rename = "enum", default)]
enum_values: Vec<String>,
#[serde(rename = "enumLabels", default)]
enum_labels: Vec<String>,
#[serde(rename = "minLength", default)]
min_length: Option<usize>,
#[serde(rename = "maxLength", default)]
max_length: Option<usize>,
#[serde(rename = "pattern", default)]
pattern: Option<String>,
},
#[serde(rename = "number")]
Number {
#[serde(rename = "minimum", default)]
minimum: Option<f64>,
#[serde(rename = "maximum", default)]
maximum: Option<f64>,
#[serde(rename = "multipleOf", default)]
multiple_of: Option<f64>,
},
#[serde(rename = "integer")]
Integer {
#[serde(rename = "minimum", default)]
minimum: Option<i64>,
#[serde(rename = "maximum", default)]
maximum: Option<i64>,
#[serde(rename = "multipleOf", default)]
multiple_of: Option<i64>,
},
#[serde(rename = "boolean")]
Boolean,
#[serde(rename = "array")]
Array {
#[serde(rename = "items")]
items: Box<SchemaNode>,
#[serde(rename = "itemWidget", default)]
item_widget: Option<WidgetHint>,
#[serde(rename = "minItems", default)]
min_items: Option<usize>,
#[serde(rename = "maxItems", default)]
max_items: Option<usize>,
},
#[serde(rename = "object")]
Object {
#[serde(rename = "properties")]
properties: BTreeMap<String, SchemaNode>,
#[serde(rename = "ui", default)]
ui: Vec<FieldUiMetadata>,
#[serde(rename = "required", default)]
required: BTreeSet<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaNode {
#[serde(flatten)]
pub kind: SchemaKind,
#[serde(rename = "default", default)]
pub default: Option<Value>,
}
impl SchemaNode {
pub fn string() -> Self {
Self {
kind: SchemaKind::String {
enum_values: Vec::new(),
enum_labels: Vec::new(),
min_length: None,
max_length: None,
pattern: None,
},
default: None,
}
}
pub fn number() -> Self {
Self {
kind: SchemaKind::Number {
minimum: None,
maximum: None,
multiple_of: None,
},
default: None,
}
}
pub fn integer() -> Self {
Self {
kind: SchemaKind::Integer {
minimum: None,
maximum: None,
multiple_of: None,
},
default: None,
}
}
pub fn boolean() -> Self {
Self {
kind: SchemaKind::Boolean,
default: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VisibleWhen {
#[serde(rename = "field")]
pub field: String,
#[serde(rename = "value")]
pub value: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldUiMetadata {
#[serde(rename = "pointer")]
pub pointer: String,
#[serde(rename = "label")]
pub label: String,
#[serde(rename = "description", default)]
pub description: String,
#[serde(rename = "group", default)]
pub group: Option<String>,
#[serde(rename = "order", default)]
pub order: u32,
#[serde(rename = "visible", default = "default_true")]
pub visible: bool,
#[serde(rename = "readOnly", default)]
pub read_only: bool,
#[serde(rename = "visibleWhen", default)]
pub visible_when: Option<VisibleWhen>,
#[serde(rename = "widget", default)]
pub widget: Option<WidgetHint>,
#[serde(rename = "action", default)]
pub action: Option<FieldAction>,
#[serde(rename = "detailAction", default)]
pub detail_action: Option<DetailActionDef>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingDefinition {
#[serde(rename = "key")]
pub key: String,
#[serde(rename = "schema")]
pub schema: SchemaNode,
#[serde(rename = "ui")]
pub ui: FieldUiMetadata,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingsContribution {
#[serde(rename = "schemaVersion")]
pub schema_version: u32,
#[serde(rename = "properties")]
pub properties: BTreeMap<String, SchemaNode>,
#[serde(rename = "ui")]
pub ui: Vec<FieldUiMetadata>,
#[serde(rename = "commitPolicy", default)]
pub commit_policy: CommitPolicy,
}
impl SettingsContribution {
pub fn from_entries(entries: Vec<SettingDefinition>) -> Result<Self, String> {
validate_setting_definitions(&entries)?;
let mut properties = BTreeMap::new();
let mut ui = Vec::with_capacity(entries.len());
for entry in entries {
properties.insert(entry.key, entry.schema);
ui.push(entry.ui);
}
ui.sort_by_key(|field| field.order);
Ok(Self {
schema_version: SETTINGS_SCHEMA_VERSION,
properties,
ui,
commit_policy: CommitPolicy::Staged,
})
}
pub fn empty() -> Self {
Self {
schema_version: SETTINGS_SCHEMA_VERSION,
properties: BTreeMap::new(),
ui: Vec::new(),
commit_policy: CommitPolicy::Staged,
}
}
pub fn default_settings(&self) -> Value {
let values = self
.properties
.iter()
.filter_map(|(key, node)| {
let value = node.default.as_ref()?;
if value.is_null() {
None
} else {
Some((key.clone(), value.clone()))
}
})
.collect();
Value::Object(values)
}
pub fn validate_values(&self, value: &Value) -> Result<(), String> {
let object = value
.as_object()
.ok_or_else(|| "settings root must be an object".to_string())?;
for key in object.keys() {
if !self.properties.contains_key(key) {
return Err(format!("unknown setting key: {}", key));
}
}
for (key, node) in &self.properties {
if let Some(value) = object.get(key) {
validate_node(node, value, &format!("/{}", escape_pointer(key)), 0)?;
}
}
Ok(())
}
}
pub fn validate_setting_definitions(entries: &[SettingDefinition]) -> Result<(), String> {
if entries.len() > 128 {
return Err("too many top-level settings (max 128)".to_string());
}
let mut keys = HashSet::new();
let top_keys: HashSet<&str> = entries.iter().map(|e| e.key.as_str()).collect();
for entry in entries {
if entry.key.is_empty() || entry.key.len() > 128 {
return Err("setting key length is invalid".to_string());
}
if !entry
.key
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
{
return Err(format!("invalid setting key: {}", entry.key));
}
if !keys.insert(entry.key.clone()) {
return Err(format!("duplicate setting key: {}", entry.key));
}
let expected_pointer = format!("/{}", escape_pointer(&entry.key));
if entry.ui.pointer != expected_pointer {
return Err(format!(
"UI pointer '{}' does not match key '{}'",
entry.ui.pointer, entry.key
));
}
validate_ui(&entry.ui)?;
check_visible_when(&entry.ui, &top_keys)?;
let mut node_count = 0usize;
validate_schema_node(&entry.schema, 0, &mut node_count)?;
if let Some(default) = &entry.schema.default {
validate_node(&entry.schema, default, &expected_pointer, 0)?;
}
}
Ok(())
}
fn validate_schema_node(
node: &SchemaNode,
depth: usize,
node_count: &mut usize,
) -> Result<(), String> {
if depth > 4 {
return Err("schema nesting exceeds limit (max 4)".to_string());
}
*node_count += 1;
if *node_count > 512 {
return Err("schema node count exceeds limit".to_string());
}
match &node.kind {
SchemaKind::String {
enum_values,
min_length,
max_length,
pattern,
..
} => {
if enum_values.len() > 256 || enum_values.iter().any(|v| v.len() > 4096) {
return Err("string enum exceeds limit".to_string());
}
if let (Some(min), Some(max)) = (min_length, max_length) {
if min > max {
return Err("minLength cannot exceed maxLength".to_string());
}
}
if pattern.as_ref().is_some_and(|p| p.len() > 512) {
return Err("pattern exceeds limit".to_string());
}
if let Some(pattern) = pattern {
if regex::Regex::new(pattern).is_err() {
return Err("pattern is not a valid regular expression".to_string());
}
}
}
SchemaKind::Number {
minimum,
maximum,
multiple_of,
} => {
if let (Some(min), Some(max)) = (minimum, maximum) {
if min > max {
return Err("minimum cannot exceed maximum".to_string());
}
}
if multiple_of.is_some_and(|v| v <= 0.0 || !v.is_finite()) {
return Err("multipleOf must be finite and positive".to_string());
}
}
SchemaKind::Integer {
minimum,
maximum,
multiple_of,
} => {
if let (Some(min), Some(max)) = (minimum, maximum) {
if min > max {
return Err("minimum cannot exceed maximum".to_string());
}
}
if multiple_of.is_some_and(|v| v <= 0) {
return Err("multipleOf must be positive".to_string());
}
}
SchemaKind::Array {
items,
min_items,
max_items,
..
} => {
if max_items.unwrap_or(1024) > 1024 {
return Err("maxItems exceeds limit".to_string());
}
if let (Some(min), Some(max)) = (min_items, max_items) {
if min > max {
return Err("minItems cannot exceed maxItems".to_string());
}
}
validate_schema_node(items, depth + 1, node_count)?;
}
SchemaKind::Object {
properties,
ui,
required,
} => {
validate_object_ui(properties, ui)?;
if properties.len() > 128 {
return Err("object property count exceeds limit".to_string());
}
for key in required {
if !properties.contains_key(key) {
return Err(format!("required property does not exist: {}", key));
}
}
for (key, child) in properties {
if key.is_empty() || key.len() > 128 {
return Err("invalid property key".to_string());
}
validate_schema_node(child, depth + 1, node_count)?;
}
}
SchemaKind::Boolean => {}
}
Ok(())
}
fn validate_node(
node: &SchemaNode,
value: &Value,
pointer: &str,
depth: usize,
) -> Result<(), String> {
if depth > 4 {
return Err(format!("{} exceeds nesting limit", pointer));
}
match &node.kind {
SchemaKind::String {
enum_values,
min_length,
max_length,
pattern,
..
} => {
let text = value
.as_str()
.ok_or_else(|| format!("{} must be a string", pointer))?;
let len = text.chars().count();
if let Some(min) = min_length {
if len < *min {
return Err(format!("{} is too short (min {})", pointer, min));
}
}
if let Some(max) = max_length {
if len > *max {
return Err(format!("{} is too long (max {})", pointer, max));
}
}
if !enum_values.is_empty() && !enum_values.iter().any(|v| v == text) {
return Err(format!("{} is not an allowed value", pointer));
}
if let Some(pattern) = pattern {
let mut cache = REGEX_CACHE.lock().unwrap();
let regex = cache.entry(pattern.clone()).or_insert_with(|| {
Regex::new(pattern)
.expect("pattern 已在 schema 构建时通过 validate_schema_node 校验")
});
if !regex.is_match(text) {
return Err(format!("{} does not match pattern", pointer));
}
}
}
SchemaKind::Number {
minimum,
maximum,
multiple_of,
} => {
let number = value
.as_f64()
.filter(|n| n.is_finite())
.ok_or_else(|| format!("{} must be a finite number", pointer))?;
if let Some(min) = minimum {
if number < *min {
return Err(format!("{} is below minimum {}", pointer, min));
}
}
if let Some(max) = maximum {
if number > *max {
return Err(format!("{} is above maximum {}", pointer, max));
}
}
if let Some(step) = multiple_of {
let quotient = number / step;
if (quotient - quotient.round()).abs() > 1e-6 {
return Err(format!("{} is not a multiple of {}", pointer, step));
}
}
}
SchemaKind::Integer {
minimum,
maximum,
multiple_of,
} => {
let number = value
.as_f64()
.filter(|v| v.is_finite() && v.fract() == 0.0)
.filter(|v| *v >= i64::MIN as f64 && *v <= i64::MAX as f64)
.map(|v| v as i64)
.ok_or_else(|| format!("{} must be an integer", pointer))?;
if let Some(min) = minimum {
if number < *min {
return Err(format!("{} is below minimum {}", pointer, min));
}
}
if let Some(max) = maximum {
if number > *max {
return Err(format!("{} is above maximum {}", pointer, max));
}
}
if let Some(step) = multiple_of {
if number % step != 0 {
return Err(format!("{} is not a multiple of {}", pointer, step));
}
}
}
SchemaKind::Boolean => {
if !value.is_boolean() {
return Err(format!("{} must be a boolean", pointer));
}
}
SchemaKind::Array {
items,
min_items,
max_items,
..
} => {
let array = value
.as_array()
.ok_or_else(|| format!("{} must be an array", pointer))?;
if let Some(min) = min_items {
if array.len() < *min {
return Err(format!("{} has too few items (min {})", pointer, min));
}
}
if let Some(max) = max_items {
if array.len() > *max {
return Err(format!("{} has too many items (max {})", pointer, max));
}
}
for (index, item) in array.iter().enumerate() {
validate_node(items, item, &format!("{}/{}", pointer, index), depth + 1)?;
}
}
SchemaKind::Object {
properties,
required,
..
} => {
let object = value
.as_object()
.ok_or_else(|| format!("{} must be an object", pointer))?;
for key in required {
if !object.contains_key(key) {
return Err(format!("{}/{} is required", pointer, escape_pointer(key)));
}
}
for key in object.keys() {
if !properties.contains_key(key) {
return Err(format!(
"unknown property: {}/{}",
pointer,
escape_pointer(key)
));
}
}
for (key, child) in properties {
if let Some(child_value) = object.get(key) {
validate_node(
child,
child_value,
&format!("{}/{}", pointer, escape_pointer(key)),
depth + 1,
)?;
}
}
}
}
Ok(())
}
fn validate_ui(ui: &FieldUiMetadata) -> Result<(), String> {
if ui.label.is_empty() || ui.label.len() > 512 || ui.description.len() > 4096 {
return Err(format!("invalid UI metadata at {}", ui.pointer));
}
if ui.group.as_ref().is_some_and(|g| g.len() > 512) {
return Err(format!("UI group exceeds limit at {}", ui.pointer));
}
Ok(())
}
fn check_visible_when(ui: &FieldUiMetadata, sibling_keys: &HashSet<&str>) -> Result<(), String> {
if let Some(cond) = &ui.visible_when {
if cond.field.is_empty() || cond.field.len() > 128 {
return Err(format!("invalid visibleWhen field at {}", ui.pointer));
}
if !sibling_keys.contains(cond.field.as_str()) {
return Err(format!(
"visibleWhen field '{}' does not exist in the same scope (pointer {})",
cond.field, ui.pointer
));
}
}
Ok(())
}
fn validate_object_ui(
properties: &BTreeMap<String, SchemaNode>,
ui: &[FieldUiMetadata],
) -> Result<(), String> {
if ui.len() != properties.len() {
return Err("object UI metadata must match properties".to_string());
}
let mut seen = HashSet::new();
let sibling_keys: HashSet<&str> = properties.keys().map(|k| k.as_str()).collect();
for metadata in ui {
validate_ui(metadata)?;
check_visible_when(metadata, &sibling_keys)?;
if !seen.insert(metadata.pointer.clone()) {
return Err(format!("duplicate object UI pointer: {}", metadata.pointer));
}
let matches_property = properties
.keys()
.any(|key| format!("/{}", escape_pointer(key)) == metadata.pointer);
if !matches_property {
return Err(format!(
"object UI pointer does not match properties: {}",
metadata.pointer
));
}
}
Ok(())
}
fn escape_pointer(value: &str) -> String {
value.replace('~', "~0").replace('/', "~1")
}
#[derive(Debug, Clone)]
pub enum PrimitiveType {
Text,
Number {
min: Option<f64>,
max: Option<f64>,
step: Option<f64>,
},
Integer {
min: Option<i64>,
max: Option<i64>,
step: Option<i64>,
},
Boolean,
Select {
options: Vec<String>,
},
Path {
mode: PathMode,
},
Color,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_unknown_settings() {
let contribution = SettingsContribution::from_entries(vec![SettingDefinition {
key: "enabled".into(),
schema: SchemaNode {
kind: SchemaKind::Boolean,
default: Some(Value::Bool(true)),
},
ui: FieldUiMetadata {
pointer: "/enabled".into(),
label: "Enabled".into(),
description: String::new(),
group: None,
order: 0,
visible: true,
read_only: false,
visible_when: None,
widget: None,
action: None,
detail_action: None,
},
}])
.unwrap();
assert!(contribution
.validate_values(&serde_json::json!({"other": true}))
.is_err());
}
#[test]
fn rejects_mismatched_ui_pointer() {
let result = SettingsContribution::from_entries(vec![SettingDefinition {
key: "enabled".into(),
schema: SchemaNode::boolean(),
ui: FieldUiMetadata {
pointer: "/other".into(),
label: "Enabled".into(),
description: String::new(),
group: None,
order: 0,
visible: true,
read_only: false,
visible_when: None,
widget: None,
action: None,
detail_action: None,
},
}]);
assert!(result.is_err());
}
#[test]
fn collects_defaults() {
let contribution = SettingsContribution::from_entries(vec![SettingDefinition {
key: "theme".into(),
schema: SchemaNode {
kind: SchemaKind::String {
enum_values: vec!["light".into(), "dark".into()],
enum_labels: Vec::new(),
min_length: None,
max_length: None,
pattern: None,
},
default: Some(Value::String("light".into())),
},
ui: FieldUiMetadata {
pointer: "/theme".into(),
label: "Theme".into(),
description: String::new(),
group: None,
order: 0,
visible: true,
read_only: false,
visible_when: None,
widget: None,
action: None,
detail_action: None,
},
}])
.unwrap();
let defaults = contribution.default_settings();
assert_eq!(defaults, serde_json::json!({"theme": "light"}));
}
fn test_ui(pointer: &str) -> FieldUiMetadata {
FieldUiMetadata {
pointer: pointer.into(),
label: pointer.trim_start_matches('/').into(),
description: String::new(),
group: None,
order: 0,
visible: true,
read_only: false,
visible_when: None,
widget: None,
action: None,
detail_action: None,
}
}
#[test]
fn validates_visible_when_sibling_scope() {
let kind = SettingDefinition {
key: "kind".into(),
schema: SchemaNode::string(),
ui: test_ui("/kind"),
};
let mut temp = SettingDefinition {
key: "temp".into(),
schema: SchemaNode::number(),
ui: test_ui("/temp"),
};
temp.ui.visible_when = Some(VisibleWhen {
field: "kind".into(),
value: Value::String("chat".into()),
});
assert!(SettingsContribution::from_entries(vec![kind.clone(), temp.clone()]).is_ok());
temp.ui.visible_when = Some(VisibleWhen {
field: "missing".into(),
value: Value::String("chat".into()),
});
assert!(SettingsContribution::from_entries(vec![kind, temp]).is_err());
}
#[test]
fn validates_nested_object_schema() {
let mut properties = BTreeMap::new();
properties.insert("name".into(), SchemaNode::string());
let schema = SchemaNode {
kind: SchemaKind::Object {
properties,
ui: vec![test_ui("/name")],
required: BTreeSet::from(["name".into()]),
},
default: Some(serde_json::json!({"name": "default"})),
};
let contribution = SettingsContribution::from_entries(vec![SettingDefinition {
key: "profile".into(),
schema,
ui: test_ui("/profile"),
}])
.unwrap();
assert!(contribution
.validate_values(&serde_json::json!({
"profile": {"name": "value"}
}))
.is_ok());
}
#[test]
fn rejects_nested_object_without_ui_metadata() {
let mut properties = BTreeMap::new();
properties.insert("name".into(), SchemaNode::string());
let result = SettingsContribution::from_entries(vec![SettingDefinition {
key: "profile".into(),
schema: SchemaNode {
kind: SchemaKind::Object {
properties,
ui: Vec::new(),
required: BTreeSet::new(),
},
default: None,
},
ui: test_ui("/profile"),
}]);
assert!(result.is_err());
}
}