use serde::{Deserialize, Serialize};
pub trait Config: serde::de::DeserializeOwned + Default {
fn template() -> String;
#[doc(hidden)]
#[must_use]
fn __template_body(path: &str) -> String {
let _ = path;
Self::template()
}
#[must_use]
fn schema() -> Schema {
Schema::default()
}
fn values(&self, _stage: Stage) -> Option<String> {
None
}
fn merge_values(&mut self, _stage: Stage, _values: &str) -> Result<(), ConfigError> {
Ok(())
}
fn validate(&self) -> Result<(), ConfigError> {
Ok(())
}
fn synced(&self) -> Option<String> {
self.values(Stage::Sync)
}
fn merge_synced(&mut self, synced: &str) -> Result<(), ConfigError> {
self.merge_values(Stage::Sync, synced)
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("{0}")]
Parse(String),
#[error("{key} {need}")]
Value {
key: String,
need: String,
},
#[error("{0}")]
Invalid(String),
}
impl ConfigError {
#[must_use]
pub fn invalid(why: impl core::fmt::Display) -> Self {
Self::Invalid(why.to_string())
}
}
impl From<toml::de::Error> for ConfigError {
fn from(error: toml::de::Error) -> Self {
Self::Parse(error.message().to_owned())
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Stage {
Startup,
#[default]
Runtime,
Client,
Sync,
Restart,
}
impl Stage {
#[must_use]
pub const fn needs_restart(self) -> bool {
matches!(self, Self::Restart)
}
#[must_use]
pub const fn scope(self) -> Scope {
match self {
Self::Client | Self::Restart => Scope::User,
_ => Scope::World,
}
}
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Startup => "startup",
Self::Runtime => "runtime",
Self::Client => "client",
Self::Sync => "sync",
Self::Restart => "restart",
}
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
User,
#[default]
World,
}
#[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,
};
let was = value.to_string();
*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} = {was} 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> {
toml::from_str(text)
.map_err(|e| ConfigError::Parse(format!("settings schema: {}", e.message())))
}
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))
})
}
}
#[doc(hidden)]
pub fn __check_field<T: Serialize>(
schema: &Schema,
key: &str,
value: &T,
) -> Result<(), ConfigError> {
let Some(field) = schema.field(key) else {
return Ok(());
};
let mut one = std::collections::BTreeMap::new();
one.insert(key, value);
match toml::Value::try_from(one) {
Ok(toml::Value::Table(table)) => table.get(key).map_or(Ok(()), |v| field.check(v)),
_ if field.min.is_none() && field.max.is_none() && field.choices.is_empty() => Ok(()),
_ => Err(ConfigError::Value {
key: key.to_owned(),
need: "cannot be checked: TOML cannot hold its value".to_owned(),
}),
}
}
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,
}
}
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()
}
}
#[doc(hidden)]
#[derive(Debug, Default)]
pub struct __Values(toml::Table);
impl __Values {
pub fn put<T: Serialize>(&mut self, key: &str, value: &T) {
if let Ok(value) = toml::Value::try_from(value) {
self.0.insert(key.to_owned(), value);
}
}
pub fn put_group(&mut self, key: &str, text: &str) -> Result<(), ConfigError> {
let group = Self::parse(text)?;
self.0.insert(key.to_owned(), toml::Value::Table(group.0));
Ok(())
}
#[must_use]
pub fn group(&self, key: &str) -> Option<String> {
self.0
.get(key)
.and_then(toml::Value::as_table)
.map(|table| toml::Value::Table(table.clone()).to_string())
}
#[must_use]
pub fn finish(self) -> String {
toml::Value::Table(self.0).to_string()
}
pub fn parse(text: &str) -> Result<Self, ConfigError> {
let mut document: toml::Table = toml::from_str(&format!("synced = {text}"))
.map_err(|e| ConfigError::Parse(format!("values: {}", e.message())))?;
match document.remove("synced") {
Some(toml::Value::Table(table)) if document.is_empty() => Ok(Self(table)),
_ => Err(ConfigError::Parse("values: not an inline table".to_owned())),
}
}
pub fn take<T: serde::de::DeserializeOwned>(
&self,
key: &str,
) -> Result<Option<T>, ConfigError> {
self.0
.get(key)
.map(|value| {
value.clone().try_into().map_err(|e: toml::de::Error| {
ConfigError::Parse(format!("value {key}: {}", e.message()))
})
})
.transpose()
}
}
pub(crate) fn changed_keys(before: &str, after: &str) -> String {
let table = |text| {
__Values::parse(text)
.map(|values| values.0)
.unwrap_or_default()
};
let (before, after) = (table(before), table(after));
let mut keys: Vec<&str> = before
.keys()
.chain(after.keys())
.filter(|key| before.get(*key) != after.get(*key))
.map(String::as_str)
.collect();
keys.sort_unstable();
keys.dedup();
keys.join(", ")
}
#[doc(hidden)]
#[must_use]
pub fn __entry<T: Serialize>(key: &str, value: &T) -> String {
match toml::Value::try_from(value) {
Ok(value) => format!("{} = {}\n\n", toml_key(key), tidy(value)),
Err(_) => format!("# {key}: no default could be written\n\n"),
}
}
#[cfg(test)]
mod tests {
use super::Config;
#[derive(Debug, Default, PartialEq, serde::Deserialize, serde::Serialize, crate::Config)]
#[serde(default)]
struct Settings {
#[config(sync)]
table: Vec<u16>,
#[config(sync)]
name: String,
local: u32,
}
fn settings() -> Settings {
Settings {
table: vec![2, 4, 8],
name: "Base \"one\"".into(),
local: 7,
}
}
#[test]
fn only_join_values_are_written_sorted_and_inline() {
assert_eq!(
settings().synced().as_deref(),
Some("{ name = 'Base \"one\"', table = [2, 4, 8] }")
);
}
#[test]
fn join_values_merge_over_the_rest_and_round_trip() {
let synced = settings().synced().unwrap();
let mut local = Settings {
local: 3,
..Settings::default()
};
local.merge_synced(&synced).unwrap();
assert_eq!(
local,
Settings {
local: 3,
..settings()
}
);
local.merge_synced("{ table = [1] }").unwrap();
assert_eq!(local.table, [1]);
assert_eq!(local.name, "Base \"one\"");
}
#[test]
fn a_bad_value_leaves_the_settings_as_they_were() {
let mut local = settings();
assert!(local.merge_synced("{ name = 'x', table = 'no' }").is_err());
assert!(local.merge_synced("table = [1]").is_err());
assert!(local.merge_synced("{ table = [1] }\nname = 'x'").is_err());
assert_eq!(local, settings());
}
#[derive(Debug, PartialEq, serde::Deserialize, serde::Serialize, crate::Config)]
#[config(text = "lockers.config")]
struct Staged {
#[config(startup, range = 1..=16)]
limit: u32,
#[config(restart)]
workers: u8,
#[config(choices = ["auto", "on"], text = "lockers.mode")]
mode: String,
#[config(client, range = 0.0..)]
volume: f32,
}
impl Default for Staged {
fn default() -> Self {
Self {
limit: 4,
workers: 2,
mode: "auto".into(),
volume: 1.0,
}
}
}
#[derive(Debug, Default, PartialEq, serde::Deserialize, serde::Serialize, crate::Config)]
struct Rules {
#[config(startup, sync)]
rule: bool,
#[config(startup)]
limit: u32,
#[config(sync)]
rate: u32,
}
#[test]
fn a_startup_sync_field_waits_for_a_world_and_joins_the_server_s_values() {
use super::Stage;
let rules = Rules {
rule: true,
limit: 4,
rate: 2,
};
assert_eq!(
rules.values(Stage::Startup).as_deref(),
Some("{ limit = 4, rule = true }")
);
assert_eq!(rules.synced().as_deref(), Some("{ rate = 2, rule = true }"));
let mut joined = Rules::default();
joined.merge_synced("{ rule = true, rate = 2 }").unwrap();
assert_eq!((joined.rule, joined.limit, joined.rate), (true, 0, 2));
let schema = Rules::schema();
let rule = schema.field("rule").unwrap();
assert_eq!((rule.stage, rule.synced), (Stage::Startup, true));
assert!(rule.follows_server());
assert!(!schema.field("limit").unwrap().follows_server());
assert!(schema.field("rate").unwrap().follows_server());
assert_eq!(super::Schema::parse(&schema.to_text()).unwrap(), schema);
assert!(Rules::template().contains(
"# Takes effect when the world loads; joined players run with the server's value.\nrule = false"
));
}
#[test]
fn the_schema_describes_stages_ranges_choices_and_texts() {
use super::{Kind, Stage};
let schema = Staged::schema();
assert_eq!(schema.doc, "Staged settings.");
assert_eq!(schema.text, "lockers.config");
let limit = schema.field("limit").unwrap();
assert_eq!(
(limit.stage, limit.kind, limit.min, limit.max),
(Stage::Startup, Some(Kind::Integer), Some(1.0), Some(16.0))
);
assert_eq!(
(limit.default.as_str(), limit.doc.as_str()),
("4", "How many, read when the world loads.")
);
assert_eq!(limit.text, "config.limit");
let mode = schema.field("mode").unwrap();
assert_eq!(mode.stage, Stage::Runtime);
assert_eq!(mode.choices, ["\"auto\"", "\"on\""]);
assert_eq!(mode.text, "lockers.mode");
let volume = schema.field("volume").unwrap();
assert_eq!(
(volume.stage, volume.min, volume.max),
(Stage::Client, Some(0.0), None)
);
assert_eq!(super::Schema::parse(&schema.to_text()).unwrap(), schema);
let workers = schema.field("workers").unwrap();
assert_eq!(
(workers.stage, workers.scope),
(Stage::Restart, super::Scope::User)
);
assert!(Staged::template().contains(
"# Takes effect when the world loads.
limit = 4"
));
assert!(Staged::template().contains(
"# Takes effect when the game starts again.
workers = 2"
));
}
#[test]
fn values_follow_their_stage_and_validation_uses_the_schema() {
use super::Stage;
let mut staged = Staged::default();
assert_eq!(
staged.values(Stage::Startup).as_deref(),
Some("{ limit = 4 }")
);
assert_eq!(
staged.values(Stage::Client).as_deref(),
Some("{ volume = 1.0 }")
);
assert_eq!(
staged.values(Stage::Restart).as_deref(),
Some("{ workers = 2 }")
);
assert_eq!(staged.values(Stage::Sync), None);
assert_eq!(staged.synced(), None);
staged
.merge_values(Stage::Startup, "{ limit = 9 }")
.unwrap();
assert_eq!(staged.limit, 9);
assert!(staged.validate().is_ok());
staged.limit = 17;
assert!(
staged
.validate()
.unwrap_err()
.to_string()
.contains("1 to 16")
);
staged.limit = 3;
staged.mode = "off".into();
assert!(
staged
.validate()
.unwrap_err()
.to_string()
.contains("one of")
);
}
#[test]
fn an_f32_at_its_decimal_bound_passes_and_reads_as_written() {
#[derive(Debug, serde::Deserialize, serde::Serialize, crate::Config)]
struct Ratio {
#[config(range = 0.0..=0.1, choices = [0.05, 0.1, 1.0])]
share: f32,
}
impl Default for Ratio {
fn default() -> Self {
Self { share: 0.1 }
}
}
assert!(Ratio::default().validate().is_ok());
assert!(Ratio::template().contains("share = 0.1\n"));
assert_eq!(Ratio::schema().field("share").unwrap().default, "0.1");
assert!(Ratio { share: 0.11 }.validate().is_err());
let field = &Ratio::schema().fields[0];
assert!(field.check(&toml::Value::Integer(1)).is_err());
let whole =
super::Field::new("whole", super::Stage::Runtime, &1.0_f64).with_choices(&[0.5, 1.0]);
assert!(whole.check(&toml::Value::Integer(1)).is_ok());
assert!(whole.check(&toml::Value::Integer(2)).is_err());
}
#[test]
fn a_field_toml_cannot_hold_still_leaves_the_others_checked() {
#[derive(Default, serde::Deserialize, serde::Serialize, crate::Config)]
struct Mixed {
names: std::collections::HashMap<u32, String>,
#[config(range = 1..=16)]
limit: u32,
#[config(range = 1..=4)]
spare: Option<u8>,
#[config(range = 1..)]
seed: u64,
}
let mut mixed = Mixed {
names: [(1, "one".to_owned())].into(),
limit: 99,
seed: 1,
..Mixed::default()
};
assert!(mixed.validate().unwrap_err().to_string().contains("limit"));
mixed.limit = 3;
assert!(mixed.validate().is_ok());
mixed.seed = u64::MAX;
assert!(mixed.validate().unwrap_err().to_string().contains("seed"));
}
#[test]
fn the_template_parses_back_whatever_the_names_and_docs() {
#[derive(
Debug, Default, PartialEq, serde::Deserialize, serde::Serialize, crate::Config,
)]
struct Odd {
r#type: u32,
#[doc = "First line\nsecond line"]
size: u8,
}
let template = Odd::template();
let parsed: Odd = toml::from_str(&template).unwrap();
assert_eq!(parsed, Odd::default());
assert!(template.contains("# over two lines."));
assert!(template.contains("#second line\nsize = 0"));
assert!(Odd::schema().field("type").is_some());
assert_eq!(super::__entry("größe", &0), "\"größe\" = 0\n\n");
}
#[test]
fn types_without_join_values_write_none() {
#[derive(Default, serde::Deserialize, serde::Serialize, crate::Config)]
struct Plain {
value: u8,
}
let mut plain = Plain { value: 1 };
assert_eq!(plain.synced(), None);
assert!(plain.merge_synced("{ value = 2 }").is_ok());
assert_eq!(plain.value, 1);
}
#[test]
fn a_held_change_names_its_keys_and_never_a_secret_value() {
#[derive(Default, serde::Deserialize, serde::Serialize, crate::Config)]
struct Login {
#[config(startup, secret)]
password: String,
#[config(startup)]
slots: u8,
}
let before = Login {
password: "old".to_owned(),
slots: 4,
};
let after = Login {
password: "hunter2".to_owned(),
slots: 4,
};
let before = before.values(super::Stage::Startup).unwrap_or_default();
let after = after.values(super::Stage::Startup).unwrap_or_default();
let line = super::changed_keys(&before, &after);
assert_eq!(line, "password");
assert!(!line.contains("hunter2"));
}
#[test]
fn generated_names_leave_the_users_constants_alone() {
#[expect(non_upper_case_globals, reason = "the names the derive binds")]
const table: u8 = 0;
#[expect(non_upper_case_globals, reason = "the names the derive binds")]
const defaults: u8 = 0;
#[derive(Default, serde::Deserialize, serde::Serialize, crate::Config)]
struct Names {
#[config(startup)]
table: u32,
#[config(startup)]
slots: u32,
}
let mut names = Names::default();
assert!(
names
.merge_values(super::Stage::Startup, "{ table = 7, slots = 3 }")
.is_ok()
);
assert_eq!((names.table, names.slots, table, defaults), (7, 3, 0, 0));
}
}