use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, RwLock};
static REGEX_CACHE: LazyLock<RwLock<HashMap<String, Arc<regex::Regex>>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
pub mod meta {
pub const SECRET: &str = "secret";
pub const ENV_OVERRIDE: &str = "env_override";
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SettingType {
Toggle,
#[default]
Text,
Number,
Select,
Info,
List,
Object,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct NumberConstraints {
#[serde(skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub step: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct TextConstraints {
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ReservedMatchMode {
#[default]
Exact,
PrefixEquals,
PrefixSpace,
CliFlag,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ListConstraints {
#[serde(skip_serializing_if = "Option::is_none")]
pub reserved: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub match_mode: Option<ReservedMatchMode>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct SettingConstraints {
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<Vec<SettingOption>>,
#[serde(flatten)]
pub number: NumberConstraints,
#[serde(flatten)]
pub text: TextConstraints,
#[serde(flatten)]
pub list: ListConstraints,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SettingMetadata {
#[serde(rename = "type")]
pub setting_type: SettingType,
pub default: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<Value>,
#[serde(flatten)]
pub constraints: SettingConstraints,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub metadata: HashMap<String, Value>,
}
impl Default for SettingMetadata {
fn default() -> Self {
Self {
setting_type: SettingType::Text,
default: Value::Null,
value: None,
constraints: SettingConstraints::default(),
metadata: HashMap::new(),
}
}
}
impl SettingMetadata {
pub fn text(default: impl Into<String>) -> Self {
Self {
setting_type: SettingType::Text,
default: Value::String(default.into()),
..Default::default()
}
}
pub fn number(default: impl Into<f64>) -> Self {
Self {
setting_type: SettingType::Number,
default: json!(default.into()),
..Default::default()
}
}
#[must_use]
pub fn toggle(default: bool) -> Self {
Self {
setting_type: SettingType::Toggle,
default: Value::Bool(default),
..Default::default()
}
}
pub fn select(default: impl Into<String>, options: Vec<SettingOption>) -> Self {
Self {
setting_type: SettingType::Select,
default: Value::String(default.into()),
constraints: SettingConstraints {
options: Some(options),
..Default::default()
},
..Default::default()
}
}
#[must_use]
pub fn info(default: Value) -> Self {
Self {
setting_type: SettingType::Info,
default,
..Default::default()
}
}
#[must_use]
pub fn list(default: &[String]) -> Self {
Self {
setting_type: SettingType::List,
default: json!(default),
..Default::default()
}
}
#[must_use]
pub fn object(default: Value) -> Self {
Self {
setting_type: SettingType::Object,
default,
..Default::default()
}
}
#[must_use]
pub fn meta_str(mut self, key: &str, value: impl Into<String>) -> Self {
self.metadata
.insert(key.to_string(), Value::String(value.into()));
self
}
#[must_use]
pub fn meta_bool(mut self, key: &str, value: bool) -> Self {
self.metadata.insert(key.to_string(), Value::Bool(value));
self
}
#[must_use]
pub fn meta_num(mut self, key: &str, value: impl Into<f64>) -> Self {
self.metadata.insert(key.to_string(), json!(value.into()));
self
}
#[must_use]
pub fn meta(mut self, key: &str, value: Value) -> Self {
self.metadata.insert(key.to_string(), value);
self
}
#[must_use]
pub fn get_meta(&self, key: &str) -> Option<&Value> {
self.metadata.get(key)
}
#[must_use]
pub fn get_meta_str(&self, key: &str) -> Option<&str> {
self.metadata.get(key).and_then(|v| v.as_str())
}
#[must_use]
pub fn get_meta_bool(&self, key: &str) -> Option<bool> {
self.metadata.get(key).and_then(Value::as_bool)
}
#[must_use]
pub fn get_meta_num(&self, key: &str) -> Option<f64> {
self.metadata.get(key).and_then(Value::as_f64)
}
#[must_use]
pub fn min(mut self, val: f64) -> Self {
self.constraints.number.min = Some(val);
self
}
#[must_use]
pub fn max(mut self, val: f64) -> Self {
self.constraints.number.max = Some(val);
self
}
#[must_use]
pub fn step(mut self, val: f64) -> Self {
self.constraints.number.step = Some(val);
self
}
#[must_use]
pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
self.constraints.text.pattern = Some(pattern.into());
self
}
#[must_use]
pub fn reserved(mut self, reserved: Vec<String>) -> Self {
self.constraints.list.reserved = Some(reserved);
self
}
#[must_use]
pub fn match_mode(mut self, mode: ReservedMatchMode) -> Self {
self.constraints.list.match_mode = Some(mode);
self
}
#[must_use]
pub fn secret(mut self) -> Self {
self.metadata
.insert(meta::SECRET.to_string(), Value::Bool(true));
self
}
#[must_use]
pub fn is_secret(&self) -> bool {
self.get_meta_bool(meta::SECRET).unwrap_or(false)
}
pub fn validate(&self, value: &Value) -> Result<(), String> {
match self.setting_type {
SettingType::Toggle => Self::validate_toggle(value),
SettingType::Number => self.validate_number(value),
SettingType::Text => self.validate_text(value),
SettingType::Select => self.validate_select(value),
SettingType::List => self.validate_list(value),
SettingType::Info | SettingType::Object => Ok(()), }
}
fn validate_toggle(value: &Value) -> Result<(), String> {
if !value.is_boolean() {
return Err("Value must be a boolean".to_string());
}
Ok(())
}
fn validate_number(&self, value: &Value) -> Result<(), String> {
let num = value
.as_f64()
.ok_or_else(|| "Value must be a number".to_string())?;
if let Some(min) = self.constraints.number.min
&& num < min
{
return Err(format!("Value must be at least {min}"));
}
if let Some(max) = self.constraints.number.max
&& num > max
{
return Err(format!("Value must be at most {max}"));
}
Ok(())
}
fn validate_text(&self, value: &Value) -> Result<(), String> {
if let Some(ref pattern) = self.constraints.text.pattern {
let text = value.as_str().unwrap_or_default();
let re = {
let read_cache = REGEX_CACHE
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(cached) = read_cache.get(pattern) {
Arc::clone(cached)
} else {
drop(read_cache);
let mut write_cache = REGEX_CACHE
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(cached) = write_cache.get(pattern) {
Arc::clone(cached)
} else {
let compiled = Arc::new(
regex::Regex::new(pattern)
.map_err(|e| format!("Invalid regex pattern: {e}"))?,
);
write_cache.insert(pattern.clone(), Arc::clone(&compiled));
compiled
}
}
};
if !re.is_match(text) {
return Err(format!("Value does not match pattern: {pattern}"));
}
}
Ok(())
}
fn validate_select(&self, value: &Value) -> Result<(), String> {
if let Some(ref options) = self.constraints.options {
let is_valid = options.iter().any(|opt| opt.value == *value);
if !is_valid {
return Err("Value must be one of the available options".to_string());
}
}
Ok(())
}
fn validate_list(&self, value: &Value) -> Result<(), String> {
if !value.is_array() {
return Err("Value must be an array".to_string());
}
if let Some(reserved) = &self.constraints.list.reserved {
let mode = self
.constraints
.list
.match_mode
.as_ref()
.unwrap_or(&ReservedMatchMode::Exact);
if let Some(arr) = value.as_array() {
for item in arr {
if let Some(s) = item.as_str() {
Self::check_reserved_item(s, reserved, mode)?;
}
}
}
}
Ok(())
}
fn check_reserved_item(
item: &str,
reserved: &[String],
mode: &ReservedMatchMode,
) -> Result<(), String> {
for r in reserved {
match mode {
ReservedMatchMode::Exact => {
if item == r {
return Err(format!("Value '{item}' is a reserved value"));
}
}
ReservedMatchMode::PrefixEquals => {
if item == r || item.starts_with(&format!("{r}=")) {
return Err(format!("Value '{item}' matches reserved prefix '{r}'"));
}
}
ReservedMatchMode::PrefixSpace => {
if item == r {
return Err(format!("Value '{item}' is a reserved value"));
}
if let Some((key, _)) = item.split_once(' ')
&& key == r
{
return Err(format!("Value '{item}' matches reserved prefix '{r}'"));
}
}
ReservedMatchMode::CliFlag => {
if item == r || item.starts_with(&format!("{r}=")) {
return Err(format!("Value '{item}' matches reserved flag '{r}'"));
}
if let Some((key, _)) = item.split_once(' ')
&& key == r
{
return Err(format!("Value '{item}' matches reserved flag '{r}'"));
}
}
}
}
Ok(())
}
pub fn validate_schema(&self) -> Result<(), String> {
if self.setting_type == SettingType::Select && self.constraints.options.is_none() {
return Err("Select type must have options defined".to_string());
}
if let (Some(min), Some(max)) = (self.constraints.number.min, self.constraints.number.max)
&& min > max
{
return Err(format!("min ({min}) cannot be greater than max ({max})"));
}
if let Some(step) = self.constraints.number.step
&& step <= 0.0
{
return Err(format!("step must be positive, got {step}"));
}
if let Some(ref pattern) = self.constraints.text.pattern {
regex::Regex::new(pattern).map_err(|e| format!("Invalid regex pattern: {e}"))?;
if pattern.is_empty() {
return Err("Pattern cannot be empty string".to_string());
}
}
self.validate(&self.default)
.map_err(|e| format!("Default value is invalid: {e}"))?;
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SettingOption {
pub value: Value,
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl SettingOption {
pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
let value_str = value.into();
Self {
value: Value::String(value_str),
label: label.into(),
description: None,
}
}
pub fn with_description(
value: impl Into<String>,
label: impl Into<String>,
description: impl Into<String>,
) -> Self {
let value_str = value.into();
Self {
value: Value::String(value_str),
label: label.into(),
description: Some(description.into()),
}
}
}
pub trait SettingsSchema: Default + Serialize + for<'de> Deserialize<'de> {
fn get_metadata() -> HashMap<String, SettingMetadata>;
#[must_use]
fn get_categories() -> Vec<String> {
let metadata = Self::get_metadata();
let mut categories: Vec<String> = metadata
.values()
.filter_map(|m| m.get_meta_str("category").map(String::from))
.collect();
categories.sort();
categories.dedup();
categories
}
}
impl SettingsSchema for () {
fn get_metadata() -> HashMap<String, SettingMetadata> {
HashMap::new()
}
}
pub fn opt(value: impl Into<String>, label: impl Into<String>) -> SettingOption {
SettingOption::new(value, label)
}
#[macro_export]
macro_rules! settings {
($($key:expr => $value:expr),* $(,)?) => {{
let mut map = std::collections::HashMap::new();
$(
map.insert($key.to_string(), $value);
)*
map
}};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_setting_metadata_builder() {
let setting = SettingMetadata::toggle(true)
.meta_str("label", "Dark Mode")
.meta_str("description", "Enable dark theme")
.meta_str("category", "appearance")
.meta_num("order", 1.0);
assert_eq!(setting.setting_type, SettingType::Toggle);
assert_eq!(setting.default, Value::Bool(true));
assert_eq!(setting.get_meta_str("label"), Some("Dark Mode"));
assert_eq!(
setting.get_meta_str("description"),
Some("Enable dark theme")
);
assert_eq!(setting.get_meta_str("category"), Some("appearance"));
assert_eq!(setting.get_meta_num("order"), Some(1.0));
}
#[test]
fn test_select_setting() {
let options = vec![
SettingOption::new("en", "English"),
SettingOption::new("tr", "Turkish"),
SettingOption::new("de", "German"),
];
let setting = SettingMetadata::select("en", options);
assert_eq!(setting.setting_type, SettingType::Select);
assert_eq!(setting.constraints.options.as_ref().unwrap().len(), 3);
}
#[test]
fn test_number_setting_with_range() {
let setting = SettingMetadata::number(50.0).min(0.0).max(100.0).step(5.0);
assert_eq!(setting.constraints.number.min, Some(0.0));
assert_eq!(setting.constraints.number.max, Some(100.0));
assert_eq!(setting.constraints.number.step, Some(5.0));
}
#[test]
fn test_number_validation() {
let setting = SettingMetadata::number(8080.0).min(1.0).max(65535.0);
assert!(setting.validate(&Value::from(8080)).is_ok());
assert!(setting.validate(&Value::from(1)).is_ok());
assert!(setting.validate(&Value::from(65535)).is_ok());
assert!(setting.validate(&Value::from(0)).is_err());
assert!(setting.validate(&Value::from(70000)).is_err());
assert!(setting.validate(&Value::from("not a number")).is_err());
}
#[test]
fn test_text_pattern_validation() {
let setting = SettingMetadata::text("").pattern(r"^[\w.-]+@[\w.-]+\.\w+$");
assert!(setting.validate(&Value::from("user@example.com")).is_ok());
assert!(
setting
.validate(&Value::from("test.user@domain.org"))
.is_ok()
);
let result = setting.validate(&Value::from("not-an-email"));
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
r"Value does not match pattern: ^[\w.-]+@[\w.-]+\.\w+$"
);
}
#[test]
fn test_select_validation() {
let options = vec![
SettingOption::new("en", "English"),
SettingOption::new("tr", "Turkish"),
];
let setting = SettingMetadata::select("en", options);
assert!(setting.validate(&Value::from("en")).is_ok());
assert!(setting.validate(&Value::from("tr")).is_ok());
assert!(setting.validate(&Value::from("invalid")).is_err());
}
#[test]
fn test_toggle_validation() {
let setting = SettingMetadata::toggle(false);
assert!(setting.validate(&Value::Bool(true)).is_ok());
assert!(setting.validate(&Value::Bool(false)).is_ok());
assert!(setting.validate(&Value::from("true")).is_err());
}
#[test]
fn test_list_validation() {
let setting = SettingMetadata::list(&["default".to_string()]);
assert!(setting.validate(&json!(["one", "two"])).is_ok());
assert!(setting.validate(&json!([])).is_ok());
assert!(setting.validate(&Value::from("not an array")).is_err());
}
#[test]
fn test_path_setting() {
let setting = SettingMetadata::text("/home/user/.config")
.meta_str("label", "Config Directory")
.meta_str("description", "Directory for configuration files")
.meta_str("input_type", "path");
assert_eq!(setting.setting_type, SettingType::Text);
assert_eq!(setting.default, Value::String("/home/user/.config".into()));
assert_eq!(setting.get_meta_str("label"), Some("Config Directory"));
assert_eq!(setting.get_meta_str("input_type"), Some("path"));
}
#[test]
fn test_file_setting() {
let setting = SettingMetadata::text("/etc/app/config.json")
.meta_str("label", "Config File")
.meta_str("input_type", "file");
assert_eq!(setting.setting_type, SettingType::Text);
assert_eq!(
setting.default,
Value::String("/etc/app/config.json".into())
);
assert_eq!(setting.get_meta_str("input_type"), Some("file"));
}
#[test]
fn test_list_setting() {
let default_items = vec!["item1".to_string(), "item2".to_string()];
let setting = SettingMetadata::list(&default_items)
.meta_str("label", "Tags")
.meta_str("description", "List of tags")
.meta_str("category", "metadata");
assert_eq!(setting.setting_type, SettingType::List);
assert_eq!(setting.default, json!(default_items));
assert_eq!(setting.get_meta_str("label"), Some("Tags"));
}
#[test]
fn test_custom_metadata() {
let setting = SettingMetadata::text("default")
.meta_str("label", "My Setting")
.meta_bool("requires_restart", true)
.meta_bool("advanced", true)
.meta_str("deprecated_since", "2.0")
.meta_num("priority", 10.0)
.meta("custom_obj", json!({"key": "value"}));
assert_eq!(setting.get_meta_str("label"), Some("My Setting"));
assert_eq!(setting.get_meta_bool("requires_restart"), Some(true));
assert_eq!(setting.get_meta_bool("advanced"), Some(true));
assert_eq!(setting.get_meta_str("deprecated_since"), Some("2.0"));
assert_eq!(setting.get_meta_num("priority"), Some(10.0));
assert_eq!(
setting.get_meta("custom_obj"),
Some(&json!({"key": "value"}))
);
}
#[test]
fn test_schema_validation() {
let valid = SettingMetadata::number(50.0).min(0.0).max(100.0);
assert!(valid.validate_schema().is_ok());
let invalid_range = SettingMetadata::number(50.0).min(100.0).max(0.0);
assert!(invalid_range.validate_schema().is_err());
let mut invalid_select = SettingMetadata::text("test");
invalid_select.setting_type = SettingType::Select;
assert!(invalid_select.validate_schema().is_err());
}
#[test]
fn test_serialization() {
let setting = SettingMetadata::number(14.0)
.min(8.0)
.max(32.0)
.meta_str("label", "Font Size")
.meta_str("category", "ui");
let json = serde_json::to_string(&setting).unwrap();
let deserialized: SettingMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(setting.setting_type, deserialized.setting_type);
assert_eq!(setting.default, deserialized.default);
assert_eq!(
setting.constraints.number.min,
deserialized.constraints.number.min
);
assert_eq!(
setting.get_meta_str("label"),
deserialized.get_meta_str("label")
);
}
#[test]
fn test_reserved_list_validation() {
let meta = SettingMetadata::list(&[])
.reserved(vec!["--rc-serve".to_string(), "--log-file".to_string()])
.match_mode(ReservedMatchMode::CliFlag);
assert!(meta.validate(&json!(["--other-flag"])).is_ok());
assert!(meta.validate(&json!(["--rc-something-else"])).is_ok());
assert!(meta.validate(&json!(["--rc-serve"])).is_err());
assert!(meta.validate(&json!(["--log-file"])).is_err());
assert!(meta.validate(&json!(["--rc-serve=true"])).is_err());
assert!(meta.validate(&json!(["--log-file=/tmp/log"])).is_err());
assert!(meta.validate(&json!(["--rc-serve :5572"])).is_err()); assert!(meta.validate(&json!(["--log-file /tmp/log"])).is_err());
let err = meta.validate(&json!(["--rc-serve=true"])).unwrap_err();
assert!(err.contains("Value '--rc-serve=true' matches reserved flag '--rc-serve'"));
}
}