use serde::{Deserialize, Serialize};
use crate::config::{ConfigError, Scope, Stage};
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Bool,
Integer,
Float,
String,
Array,
Table,
Datetime,
}
impl Kind {
#[must_use]
pub const fn of(value: &toml::Value) -> Self {
match value {
toml::Value::Boolean(_) => Self::Bool,
toml::Value::Integer(_) => Self::Integer,
toml::Value::Float(_) => Self::Float,
toml::Value::String(_) => Self::String,
toml::Value::Array(_) => Self::Array,
toml::Value::Table(_) => Self::Table,
toml::Value::Datetime(_) => Self::Datetime,
}
}
const fn accepts(self, kind: Self) -> bool {
matches!(
(self, kind),
(Self::Bool, Self::Bool)
| (Self::Integer, Self::Integer)
| (Self::Float, Self::Float | Self::Integer)
| (Self::String, Self::String)
| (Self::Array, Self::Array)
| (Self::Table, Self::Table)
| (Self::Datetime, Self::Datetime)
)
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Field {
pub key: String,
pub stage: Stage,
#[serde(default)]
pub scope: Scope,
pub kind: Option<Kind>,
pub default: String,
pub min: Option<f64>,
pub max: Option<f64>,
pub choices: Vec<String>,
pub doc: String,
pub text: String,
#[serde(default, skip_serializing_if = "core::ops::Not::not")]
pub secret: bool,
#[serde(default, skip_serializing_if = "core::ops::Not::not")]
pub synced: bool,
#[serde(default = "first_element", skip_serializing_if = "is_first_element")]
pub first: u32,
}
const fn first_element() -> u32 {
1
}
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "serde passes a reference"
)]
const fn is_first_element(first: &u32) -> bool {
*first == 1
}
impl Field {
#[must_use]
pub fn new<T: Serialize + ?Sized>(key: &str, stage: Stage, default: &T) -> Self {
let value = toml::Value::try_from(default).ok().map(tidy);
Self {
key: key.to_owned(),
stage,
scope: stage.scope(),
kind: value.as_ref().map(Kind::of),
default: value.map(|v| v.to_string()).unwrap_or_default(),
text: format!("config.{key}"),
first: first_element(),
..Self::default()
}
}
#[must_use]
pub const fn with_first(mut self, first: u32) -> Self {
self.first = first;
self
}
#[doc(hidden)]
#[must_use]
pub fn __in_group(mut self, group: &str) -> Self {
let inner = format!("config.{}", self.key);
self.key = format!("{group}.{}", self.key);
if self.text == inner {
self.text = format!("config.{}", self.key);
}
self
}
#[must_use]
pub const fn with_range(mut self, min: Option<f64>, max: Option<f64>) -> Self {
self.min = min;
self.max = max;
self
}
#[must_use]
pub fn with_choices<T: Serialize>(mut self, choices: &[T]) -> Self {
self.choices = choices
.iter()
.filter_map(|choice| toml::Value::try_from(choice).ok())
.map(|value| tidy(value).to_string())
.collect();
self
}
#[must_use]
pub fn with_doc(mut self, doc: &str, text: &str) -> Self {
doc.trim().clone_into(&mut self.doc);
if !text.is_empty() {
text.clone_into(&mut self.text);
}
self
}
#[must_use]
pub const fn with_secret(mut self, secret: bool) -> Self {
self.secret = secret;
self
}
#[must_use]
pub const fn with_synced(mut self, synced: bool) -> Self {
self.synced = synced;
self
}
#[must_use]
pub fn follows_server(&self) -> bool {
self.stage == Stage::Sync || self.synced
}
pub fn check(&self, value: &toml::Value) -> Result<(), ConfigError> {
let value = &tidy(value.clone());
if let Some(kind) = self.kind
&& !kind.accepts(Kind::of(value))
{
return Err(self.refused(format!("takes {}", kind_name(kind))));
}
if let toml::Value::Array(items) = value {
let element = self.element_kind();
for item in items {
if element.is_some_and(|kind| !kind.accepts(Kind::of(item))) {
return Err(self.refused(format!(
"takes a list of {}",
element.map_or("values", kind_name)
)));
}
self.check_bounds(item)?;
}
}
let number = self.check_bounds(value)?;
let chosen = |choice: &String| {
*choice == value.to_string()
|| number.is_some_and(|n| {
choice
.parse::<f64>()
.is_ok_and(|c| c.to_bits() == n.to_bits())
})
};
if !self.choices.is_empty() && !self.choices.iter().any(chosen) {
return Err(self.refused(format!("is {}", self.accepts())));
}
Ok(())
}
fn clamp_one(&self, value: &mut toml::Value, key: &str) -> Option<String> {
#[expect(
clippy::cast_precision_loss,
reason = "bounds are compared as f64; a setting near 2^53 is no setting"
)]
let n = match value {
toml::Value::Integer(n) => *n as f64,
toml::Value::Float(n) => *n,
_ => return None,
};
let bound = match (self.min, self.max) {
(Some(min), _) if n < min => min,
(_, Some(max)) if n > max => max,
_ => return None,
};
*value = match value {
#[expect(
clippy::cast_possible_truncation,
reason = "a whole-number range's ends are whole numbers"
)]
toml::Value::Integer(_) => toml::Value::Integer(bound.round() as i64),
_ => toml::Value::Float(bound),
};
Some(format!(
"{key} is outside {}; {value} applies",
self.accepts()
))
}
#[must_use]
pub fn element_kind(&self) -> Option<Kind> {
let table: toml::Table = toml::from_str(&format!("v = {}", self.default)).ok()?;
match table.get("v")? {
toml::Value::Array(items) => items.first().map(Kind::of),
_ => None,
}
}
fn check_bounds(&self, value: &toml::Value) -> Result<Option<f64>, ConfigError> {
#[expect(
clippy::cast_precision_loss,
reason = "bounds are compared as f64; a setting near 2^53 is no setting"
)]
let number = match value {
toml::Value::Integer(n) => Some(*n as f64),
toml::Value::Float(n) => Some(*n),
_ => None,
};
if let Some(n) = number
&& !(self.min.is_none_or(|min| n >= min) && self.max.is_none_or(|max| n <= max))
{
return Err(self.refused(format!("is {}", self.accepts())));
}
Ok(number)
}
fn refused(&self, need: String) -> ConfigError {
ConfigError::Value {
key: self.key.clone(),
need,
}
}
#[must_use]
pub fn accepts(&self) -> String {
if !self.choices.is_empty() {
return format!("one of {}", self.choices.join(", "));
}
match (self.min, self.max) {
(Some(min), Some(max)) => format!("{min} to {max}"),
(Some(min), None) => format!("at least {min}"),
(None, Some(max)) => format!("at most {max}"),
(None, None) => self.kind.map_or("any value", kind_name).to_owned(),
}
}
}
const fn kind_name(kind: Kind) -> &'static str {
match kind {
Kind::Bool => "true or false",
Kind::Integer => "a whole number",
Kind::Float => "a number",
Kind::String => "text in quotes",
Kind::Array => "a list in [ ]",
Kind::Table => "a table in { }",
Kind::Datetime => "a date or time",
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Schema {
pub doc: String,
pub text: String,
#[serde(default, rename = "field")]
pub fields: Vec<Field>,
#[serde(default, rename = "group", skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<Group>,
}
#[non_exhaustive]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Group {
pub key: String,
pub doc: String,
pub text: String,
}
impl Group {
#[must_use]
pub fn new(key: &str, doc: &str) -> Self {
Self {
key: key.to_owned(),
doc: doc.trim().to_owned(),
text: format!("config.{key}"),
}
}
#[doc(hidden)]
#[must_use]
pub fn __in_group(mut self, outer: &str) -> Self {
let inner = format!("config.{}", self.key);
self.key = format!("{outer}.{}", self.key);
if self.text == inner {
self.text = format!("config.{}", self.key);
}
self
}
}
fn value_at_mut<'a>(settings: &'a mut toml::Table, key: &str) -> Option<&'a mut toml::Value> {
let mut parts = key.split('.');
let mut value = settings.get_mut(parts.next()?)?;
for part in parts {
value = value.as_table_mut()?.get_mut(part)?;
}
Some(value)
}
#[must_use]
pub fn value_at<'a>(settings: &'a toml::Table, key: &str) -> Option<&'a toml::Value> {
let mut parts = key.split('.');
let mut value = settings.get(parts.next()?)?;
for part in parts {
value = value.as_table()?.get(part)?;
}
Some(value)
}
impl Schema {
#[must_use]
pub fn new(doc: &str, text: &str, fields: Vec<Field>) -> Self {
Self {
doc: doc.trim().to_owned(),
text: text.to_owned(),
fields,
groups: Vec::new(),
}
}
#[must_use]
pub fn with_groups(mut self, groups: Vec<Group>) -> Self {
self.groups = groups;
self
}
#[must_use]
pub(crate) fn clamp(&self, settings: &mut toml::Table) -> Vec<String> {
let mut held = Vec::new();
for field in &self.fields {
if field.min.is_none() && field.max.is_none() {
continue;
}
let Some(value) = value_at_mut(settings, &field.key) else {
continue;
};
if let toml::Value::Array(items) = value {
for (i, item) in items.iter_mut().enumerate() {
if let Some(line) = field.clamp_one(item, &format!("{}[{i}]", field.key)) {
held.push(line);
}
}
} else if let Some(line) = field.clamp_one(value, &field.key) {
held.push(line);
}
}
held
}
#[must_use]
pub fn group_of(&self, key: &str) -> Option<&Group> {
let (group, _) = key.rsplit_once('.')?;
self.groups.iter().find(|g| g.key == group)
}
#[must_use]
pub fn field(&self, key: &str) -> Option<&Field> {
self.fields.iter().find(|f| f.key == key)
}
#[must_use]
pub fn to_text(&self) -> String {
toml::to_string(self).unwrap_or_default()
}
pub fn parse(text: &str) -> Result<Self, ConfigError> {
let schema: Self = toml::from_str(text)
.map_err(|e| ConfigError::Parse(format!("settings schema: {}", e.message())))?;
let keys = schema.fields.iter().map(|f| &f.key);
if let Some(key) = keys
.chain(schema.groups.iter().map(|g| &g.key))
.find(|key| key.split('.').any(str::is_empty))
{
return Err(ConfigError::Parse(format!(
"settings schema: the key `{key}` has an empty part"
)));
}
Ok(schema)
}
pub fn check(&self, settings: &toml::Table) -> Result<(), ConfigError> {
self.fields.iter().try_for_each(|field| {
value_at(settings, &field.key).map_or(Ok(()), |value| field.check(value))
})
}
}
pub(super) fn tidy(value: toml::Value) -> toml::Value {
match value {
#[expect(
clippy::cast_possible_truncation,
reason = "only a float an f32 holds exactly is replaced"
)]
toml::Value::Float(n) if f64::from(n as f32).to_bits() == n.to_bits() => {
toml::Value::Float((n as f32).to_string().parse().unwrap_or(n))
}
toml::Value::Array(items) => toml::Value::Array(items.into_iter().map(tidy).collect()),
toml::Value::Table(table) => {
toml::Value::Table(table.into_iter().map(|(k, v)| (k, tidy(v))).collect())
}
other => other,
}
}
pub(super) fn toml_key(key: &str) -> String {
let bare = !key.is_empty()
&& key
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
if bare {
key.to_owned()
} else {
toml::Value::String(key.to_owned()).to_string()
}
}