use serde::{Deserialize, Serialize};
mod reason;
mod schema;
mod table;
mod values;
pub(crate) use reason::reason;
pub use schema::{Field, Group, Kind, Schema, value_at};
pub use table::TableConfig;
pub(crate) use values::changed_keys;
#[doc(hidden)]
pub use values::{__Values, __check_field, __entry};
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),
#[error("{}", crate::i18n::reason::show(key, args))]
Text {
key: String,
args: Vec<(String, String)>,
},
}
impl ConfigError {
#[must_use]
pub fn invalid(why: impl core::fmt::Display) -> Self {
Self::Invalid(why.to_string())
}
#[must_use]
pub fn text(key: &str, args: &[(&str, &str)]) -> Self {
Self::Text {
key: key.to_owned(),
args: args
.iter()
.map(|&(name, value)| (name.to_owned(), value.to_owned()))
.collect(),
}
}
pub(crate) fn to_reason(&self) -> String {
match self {
Self::Text { key, args } => crate::i18n::reason::encode(key, args),
_ => self.to_string(),
}
}
}
impl From<String> for ConfigError {
fn from(why: String) -> Self {
Self::Invalid(why)
}
}
impl From<&str> for ConfigError {
fn from(why: &str) -> Self {
Self::Invalid(why.to_owned())
}
}
impl From<toml::de::Error> for ConfigError {
fn from(error: toml::de::Error) -> Self {
Self::Parse(reason(None, &error))
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[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,
}
#[cfg(test)]
mod tests {
use super::{Config, ConfigError};
#[test]
fn a_text_refusal_reaches_the_loader_as_its_key_and_values() {
let refused = ConfigError::text("config.min", &[("min", "2"), ("of\t=x", "a\tb")]);
assert_eq!(refused.to_reason(), "config.min\tmin=2\tof x=a b");
assert_eq!(
ConfigError::text("config.off", &[]).to_reason(),
"config.off"
);
assert_eq!(ConfigError::from("plain").to_reason(), "plain");
}
#[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));
}
#[test]
fn a_key_with_an_empty_part_is_refused_by_name() {
use super::{Field, Group, Schema, Stage};
for key in ["", ".token", "token.", "auth..token"] {
let schema = Schema::new("", "config", vec![Field::new(key, Stage::Runtime, "x")]);
let error = Schema::parse(&schema.to_text()).unwrap_err().to_string();
assert!(error.contains(&format!("`{key}`")), "{error}");
}
let grouped = Schema::new(
"",
"config",
vec![Field::new("auth.token", Stage::Runtime, "x")],
)
.with_groups(vec![Group::new("auth..inner", "")]);
let error = Schema::parse(&grouped.to_text()).unwrap_err().to_string();
assert!(error.contains("`auth..inner`"), "{error}");
let fine = Schema::new(
"",
"config",
vec![Field::new("auth.token", Stage::Runtime, "x")],
);
assert_eq!(Schema::parse(&fine.to_text()).unwrap(), fine);
}
#[test]
fn a_held_value_is_not_logged() {
use super::{Field, Schema, Stage};
let pin = Field::new("pin", Stage::Runtime, &4).with_range(Some(1.0), Some(16.0));
let schema = Schema::new("", "config", vec![pin]);
let mut settings: toml::Table = toml::from_str("pin = 4815162342").unwrap();
let held = schema.clamp(&mut settings);
assert_eq!(held, ["pin is outside 1 to 16; 16 applies"]);
}
}