use std::collections::BTreeMap;
use serde::{Deserialize, Deserializer, Serialize};
pub(crate) const DEFAULT_VALUE_UNIT: &str = "magic_beans";
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
pub(crate) struct ValueConfig {
#[serde(default)]
pub unit: Option<String>,
}
pub(crate) fn resolve_unit(cfg: &ValueConfig) -> String {
match &cfg.unit {
Some(unit) if !unit.is_empty() => unit.clone(),
_ => DEFAULT_VALUE_UNIT.to_string(),
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct ValueFacet {
pub value: f64,
}
#[derive(Debug, Clone, Deserialize)]
struct ValueRaw {
value: Option<toml::Value>,
#[serde(flatten)]
_extra: BTreeMap<String, toml::Value>,
}
impl<'de> Deserialize<'de> for ValueFacet {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = ValueRaw::deserialize(d)?;
normalise(raw).map_err(serde::de::Error::custom)
}
}
pub(crate) fn parse_optional(
table: Option<&toml::value::Table>,
) -> anyhow::Result<Option<ValueFacet>> {
let Some(table) = table else {
return Ok(None);
};
let raw: ValueRaw = toml::from_str(&toml::to_string(table)?)?;
let facet = normalise(raw)?;
Ok(Some(facet))
}
fn normalise(raw: ValueRaw) -> anyhow::Result<ValueFacet> {
let value = raw
.value
.ok_or_else(|| anyhow::anyhow!("value: value is required"))?;
let facet = ValueFacet {
value: toml_to_f64(&value, "value")?,
};
validate(&facet)?;
Ok(facet)
}
pub(crate) fn validate(facet: &ValueFacet) -> anyhow::Result<()> {
if !facet.value.is_finite() {
anyhow::bail!("value: value must be finite");
}
Ok(())
}
fn toml_to_f64(value: &toml::Value, name: &str) -> anyhow::Result<f64> {
let f = match value {
#[expect(
clippy::cast_precision_loss,
clippy::as_conversions,
reason = "integer <= 2^53 fits exactly in f64"
)]
toml::Value::Integer(i) => *i as f64,
toml::Value::Float(f) => *f,
_ => anyhow::bail!("value: {name} must be a number"),
};
if !f.is_finite() {
anyhow::bail!("value: {name} must be finite");
}
Ok(f)
}
#[cfg(test)]
mod tests {
use super::*;
fn table_from(s: &str) -> toml::value::Table {
s.parse::<toml::Table>().unwrap()
}
#[test]
fn v1_absent() {
let result = parse_optional(None).unwrap();
assert!(result.is_none());
}
#[test]
fn v2_integer_value() {
let t = table_from("value=5");
let facet = parse_optional(Some(&t)).unwrap().unwrap();
assert_eq!(facet.value, 5.0);
}
#[test]
fn v3_float_value() {
let t = table_from("value=3.5");
let facet = parse_optional(Some(&t)).unwrap().unwrap();
assert_eq!(facet.value, 3.5);
}
#[test]
fn v4_missing_value() {
let t = table_from("unit=\"beans\"");
let err = parse_optional(Some(&t)).unwrap_err().to_string();
assert!(err.contains("value: value is required"), "got: {err}");
}
#[test]
fn v5_nan_value() {
let t = table_from("value=nan");
let err = parse_optional(Some(&t)).unwrap_err().to_string();
assert!(err.contains("value: value must be finite"), "got: {err}");
}
#[test]
fn v6_resolve_unit_default() {
assert_eq!(resolve_unit(&ValueConfig::default()), "magic_beans");
}
#[test]
fn v7_unknown_keys_tolerated() {
let t = table_from("value=5\ncurrency=\"USD\"\nsource=\"guess\"");
let facet = parse_optional(Some(&t)).unwrap().unwrap();
assert_eq!(facet.value, 5.0);
let serialised = toml::to_string(&facet).unwrap();
assert!(
!serialised.contains("currency"),
"extra key leaked: {serialised}"
);
assert!(
!serialised.contains("source"),
"extra key leaked: {serialised}"
);
}
#[test]
fn custom_deserialize_valid() {
let t = table_from("value=3");
let s = toml::to_string(&t).unwrap();
let facet: ValueFacet = toml::from_str(&s).unwrap();
assert_eq!(facet.value, 3.0);
}
#[test]
fn custom_deserialize_missing_field() {
let t = table_from("unit=\"beans\"");
let s = toml::to_string(&t).unwrap();
let err = toml::from_str::<ValueFacet>(&s).unwrap_err();
assert!(err.to_string().contains("value is required"));
}
#[test]
fn custom_deserialize_unknown_keys() {
let t = table_from("value=3\ncurrency=\"USD\"\nextra=42");
let s = toml::to_string(&t).unwrap();
let facet: ValueFacet = toml::from_str(&s).unwrap();
assert_eq!(facet.value, 3.0);
let s2 = toml::to_string(&facet).unwrap();
assert!(!s2.contains("currency"));
assert!(!s2.contains("extra"));
}
#[test]
fn value_raw_absorbs_unknown_keys() {
let t = table_from("value=1\nfoo=\"bar\"\nbaz=99");
let raw: ValueRaw = toml::from_str(&toml::to_string(&t).unwrap()).unwrap();
assert!(raw.value.is_some());
assert!(raw._extra.contains_key("foo"));
assert!(raw._extra.contains_key("baz"));
}
#[test]
fn validate_accepts_finite() {
assert!(validate(&ValueFacet { value: 3.5 }).is_ok());
}
#[test]
fn validate_rejects_infinity() {
let err = validate(&ValueFacet {
value: f64::INFINITY,
})
.unwrap_err()
.to_string();
assert!(err.contains("finite"), "got: {}", err);
}
#[test]
fn validate_rejects_nan() {
let err = validate(&ValueFacet { value: f64::NAN })
.unwrap_err()
.to_string();
assert!(err.contains("finite"), "got: {}", err);
}
}