use serde_json::Value;
#[derive(Debug, Clone)]
pub enum PropValue {
Null,
Bool(bool),
F64(f64),
I64(i64),
U64(u64),
Str(String),
Array(Vec<PropValue>),
Object(PropMap),
}
const I64_MIN_AS_F64: f64 = -9_223_372_036_854_775_808.0;
const I64_MAX_PLUS_ONE_AS_F64: f64 = 9_223_372_036_854_775_808.0;
const U64_MAX_PLUS_ONE_AS_F64: f64 = 18_446_744_073_709_551_616.0;
fn exact_i64_to_f64(value: i64) -> Option<f64> {
let float = value as f64;
((I64_MIN_AS_F64..I64_MAX_PLUS_ONE_AS_F64).contains(&float) && float as i64 == value)
.then_some(float)
}
fn exact_u64_to_f64(value: u64) -> Option<f64> {
let float = value as f64;
((0.0..U64_MAX_PLUS_ONE_AS_F64).contains(&float) && float as u64 == value).then_some(float)
}
fn exact_f64_to_i64(value: f64) -> Option<i64> {
(value.is_finite()
&& value.fract() == 0.0
&& (I64_MIN_AS_F64..I64_MAX_PLUS_ONE_AS_F64).contains(&value))
.then_some(value as i64)
}
fn exact_f64_to_u64(value: f64) -> Option<u64> {
(value.is_finite() && value.fract() == 0.0 && (0.0..U64_MAX_PLUS_ONE_AS_F64).contains(&value))
.then_some(value as u64)
}
fn finite_f64_to_f32(value: f64) -> Option<f32> {
let narrowed = value as f32;
(value.is_finite() && narrowed.is_finite()).then_some(narrowed)
}
impl PartialEq for PropValue {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Null, Self::Null) => true,
(Self::Bool(a), Self::Bool(b)) => a == b,
(Self::Str(a), Self::Str(b)) => a == b,
(Self::Array(a), Self::Array(b)) => a == b,
(Self::Object(a), Self::Object(b)) => a == b,
(Self::I64(a), Self::I64(b)) => a == b,
(Self::U64(a), Self::U64(b)) => a == b,
(Self::F64(a), Self::F64(b)) => a == b,
(Self::I64(i), Self::U64(u)) | (Self::U64(u), Self::I64(i)) => {
u64::try_from(*i).is_ok_and(|iu| iu == *u)
}
(Self::I64(i), Self::F64(f)) | (Self::F64(f), Self::I64(i)) => {
exact_f64_to_i64(*f).is_some_and(|fi| fi == *i)
}
(Self::U64(u), Self::F64(f)) | (Self::F64(f), Self::U64(u)) => {
exact_f64_to_u64(*f).is_some_and(|fu| fu == *u)
}
_ => false,
}
}
}
impl PropValue {
pub fn as_str(&self) -> Option<&str> {
match self {
Self::Str(s) => Some(s),
_ => None,
}
}
pub fn as_f64(&self) -> Option<f64> {
match self {
Self::F64(v) => v.is_finite().then_some(*v),
Self::I64(v) => exact_i64_to_f64(*v),
Self::U64(v) => exact_u64_to_f64(*v),
_ => None,
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Bool(v) => Some(*v),
_ => None,
}
}
pub fn as_i64(&self) -> Option<i64> {
match self {
Self::I64(v) => Some(*v),
Self::U64(v) => i64::try_from(*v).ok(),
Self::F64(v) => exact_f64_to_i64(*v),
_ => None,
}
}
pub fn as_u64(&self) -> Option<u64> {
match self {
Self::U64(v) => Some(*v),
Self::I64(v) => u64::try_from(*v).ok(),
Self::F64(v) => exact_f64_to_u64(*v),
_ => None,
}
}
pub fn as_array(&self) -> Option<&[PropValue]> {
match self {
Self::Array(a) => Some(a),
_ => None,
}
}
pub fn as_object(&self) -> Option<&PropMap> {
match self {
Self::Object(m) => Some(m),
_ => None,
}
}
pub fn is_null(&self) -> bool {
matches!(self, Self::Null)
}
}
impl From<bool> for PropValue {
fn from(v: bool) -> Self {
Self::Bool(v)
}
}
impl From<f32> for PropValue {
fn from(v: f32) -> Self {
Self::F64(v as f64)
}
}
impl From<f64> for PropValue {
fn from(v: f64) -> Self {
Self::F64(v)
}
}
impl From<i32> for PropValue {
fn from(v: i32) -> Self {
Self::I64(v as i64)
}
}
impl From<i64> for PropValue {
fn from(v: i64) -> Self {
Self::I64(v)
}
}
impl From<u32> for PropValue {
fn from(v: u32) -> Self {
Self::U64(v as u64)
}
}
impl From<u64> for PropValue {
fn from(v: u64) -> Self {
Self::U64(v)
}
}
impl From<&str> for PropValue {
fn from(v: &str) -> Self {
Self::Str(v.to_string())
}
}
impl From<String> for PropValue {
fn from(v: String) -> Self {
Self::Str(v)
}
}
impl From<Value> for PropValue {
fn from(v: Value) -> Self {
match v {
Value::Null => Self::Null,
Value::Bool(b) => Self::Bool(b),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Self::I64(i)
} else if let Some(u) = n.as_u64() {
Self::U64(u)
} else if let Some(f) = n.as_f64() {
Self::F64(f)
} else {
Self::Null
}
}
Value::String(s) => Self::Str(s),
Value::Array(arr) => Self::Array(arr.into_iter().map(PropValue::from).collect()),
Value::Object(map) => Self::Object(PropMap::from_json_map(map)),
}
}
}
impl From<PropValue> for Value {
fn from(v: PropValue) -> Self {
match v {
PropValue::Null => Value::Null,
PropValue::Bool(b) => Value::Bool(b),
PropValue::F64(f) => {
if !f.is_finite() {
log::warn!(
"non-finite f64 ({f}) in PropValue silently encoded as JSON null; \
caller passed an invalid value through `From<f32>`/`From<f64>`"
);
}
serde_json::json!(f)
}
PropValue::I64(i) => Value::Number(i.into()),
PropValue::U64(u) => Value::Number(u.into()),
PropValue::Str(s) => Value::String(s),
PropValue::Array(arr) => Value::Array(arr.into_iter().map(Value::from).collect()),
PropValue::Object(map) => Value::Object(map.into_json_map()),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct PropMap(Vec<(String, PropValue)>);
impl PartialEq for PropMap {
fn eq(&self, other: &Self) -> bool {
let non_null =
|pairs: &[(String, PropValue)]| pairs.iter().filter(|(_, v)| !v.is_null()).count();
if non_null(&self.0) != non_null(&other.0) {
return false;
}
self.0
.iter()
.filter(|(_, v)| !v.is_null())
.all(|(k, v)| match other.get(k) {
Some(ov) if !ov.is_null() => ov == v,
_ => false,
})
}
}
impl Eq for PropMap {}
impl PropMap {
pub fn new() -> Self {
Self(Vec::new())
}
pub fn with_capacity(cap: usize) -> Self {
Self(Vec::with_capacity(cap))
}
pub fn get(&self, key: &str) -> Option<&PropValue> {
self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v)
}
pub fn get_mut(&mut self, key: &str) -> Option<&mut PropValue> {
self.0.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v)
}
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<PropValue>) {
let key = key.into();
let value = value.into();
if let Some(entry) = self.0.iter_mut().find(|(k, _)| *k == key) {
entry.1 = value;
} else {
self.0.push((key, value));
}
}
pub fn remove(&mut self, key: &str) -> Option<PropValue> {
let idx = self.0.iter().position(|(k, _)| k == key)?;
Some(self.0.remove(idx).1)
}
pub fn contains_key(&self, key: &str) -> bool {
self.0.iter().any(|(k, _)| k == key)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &PropValue)> {
self.0.iter().map(|(k, v)| (k.as_str(), v))
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.0.iter().map(|(k, _)| k.as_str())
}
pub fn from_json_map(map: serde_json::Map<String, Value>) -> Self {
Self(
map.into_iter()
.map(|(k, v)| (k, PropValue::from(v)))
.collect(),
)
}
pub fn into_json_map(self) -> serde_json::Map<String, Value> {
self.0
.into_iter()
.map(|(k, v)| (k, Value::from(v)))
.collect()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Props(PropMap);
impl Props {
pub fn from_json(value: Value) -> Self {
match value {
Value::Object(map) => Self(PropMap::from_json_map(map)),
_ => Self(PropMap::new()),
}
}
pub fn get_str(&self, key: &str) -> Option<&str> {
self.0.get(key)?.as_str()
}
pub fn get_f64(&self, key: &str) -> Option<f64> {
self.0.get(key)?.as_f64()
}
pub fn get_f32(&self, key: &str) -> Option<f32> {
finite_f64_to_f32(self.get_f64(key)?)
}
pub fn get_bool(&self, key: &str) -> Option<bool> {
self.0.get(key)?.as_bool()
}
pub fn get_i64(&self, key: &str) -> Option<i64> {
self.0.get(key)?.as_i64()
}
pub fn get_u64(&self, key: &str) -> Option<u64> {
self.0.get(key)?.as_u64()
}
pub fn contains_key(&self, key: &str) -> bool {
self.0.contains_key(key)
}
pub fn as_value_cow(&self) -> std::borrow::Cow<'_, Value> {
std::borrow::Cow::Owned(Value::Object(self.0.clone().into_json_map()))
}
pub fn as_prop_map(&self) -> &PropMap {
&self.0
}
pub fn as_prop_map_mut(&mut self) -> &mut PropMap {
&mut self.0
}
pub fn get(&self, key: &str) -> Option<&PropValue> {
self.0.get(key)
}
pub fn get_value(&self, key: &str) -> Option<Value> {
self.0.get(key).map(|pv| Value::from(pv.clone()))
}
pub fn to_value(&self) -> Value {
Value::Object(self.0.clone().into_json_map())
}
pub fn is_object(&self) -> bool {
true
}
}
impl From<PropMap> for Props {
fn from(map: PropMap) -> Self {
Self(map)
}
}
impl serde::Serialize for Props {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.to_value().serialize(serializer)
}
}
impl<'de> serde::Deserialize<'de> for Props {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = Value::deserialize(deserializer)?;
Ok(Self::from_json(value))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn prop_map_insert_and_get() {
let mut map = PropMap::new();
map.insert("label", "Save");
map.insert("size", 18.0f64);
map.insert("disabled", false);
assert_eq!(map.get("label").unwrap().as_str(), Some("Save"));
assert_eq!(map.get("size").unwrap().as_f64(), Some(18.0));
assert_eq!(map.get("disabled").unwrap().as_bool(), Some(false));
assert!(map.get("missing").is_none());
}
#[test]
fn prop_map_insert_replaces() {
let mut map = PropMap::new();
map.insert("value", 1.0f64);
map.insert("value", 2.0f64);
assert_eq!(map.len(), 1);
assert_eq!(map.get("value").unwrap().as_f64(), Some(2.0));
}
#[test]
fn prop_map_remove() {
let mut map = PropMap::new();
map.insert("a", "hello");
map.insert("b", "world");
assert_eq!(map.remove("a").unwrap().as_str(), Some("hello"));
assert_eq!(map.len(), 1);
assert!(map.get("a").is_none());
}
#[test]
fn props_typed_accessors() {
let mut map = PropMap::new();
map.insert("title", "Hello");
map.insert("size", 24.0f64);
map.insert("visible", true);
let props = Props::from(map);
assert_eq!(props.get_str("title"), Some("Hello"));
assert_eq!(props.get_f64("size"), Some(24.0));
assert_eq!(props.get_f32("size"), Some(24.0));
assert_eq!(props.get_bool("visible"), Some(true));
assert!(props.get_str("missing").is_none());
}
#[test]
fn props_wire_accessors() {
let props = Props::from_json(json!({"title": "Hello", "size": 24.0, "visible": true}));
assert_eq!(props.get_str("title"), Some("Hello"));
assert_eq!(props.get_f64("size"), Some(24.0));
assert_eq!(props.get_bool("visible"), Some(true));
}
#[test]
fn props_deserialize_round_trip_accessors() {
let json_str = r#"{"a": 1, "b": "x", "c": true}"#;
let props: Props = serde_json::from_str(json_str).unwrap();
assert_eq!(props.get_i64("a"), Some(1));
assert_eq!(props.get_str("b"), Some("x"));
assert_eq!(props.get_bool("c"), Some(true));
}
#[test]
fn props_from_non_object_json_is_empty() {
let props = Props::from_json(json!("stray string"));
assert!(props.as_prop_map().is_empty());
assert!(props.is_object());
assert_eq!(props.get_str("anything"), None);
}
#[test]
fn props_null_entries_are_absent_for_eq() {
let mut with_null = PropMap::new();
with_null.insert("content", "hello");
with_null.insert("size", PropValue::Null);
let empty_size = PropMap::new();
let mut plain = empty_size.clone();
plain.insert("content", "hello");
assert_eq!(Props::from(with_null), Props::from(plain));
}
#[test]
fn props_typed_eq_wire() {
let mut map = PropMap::new();
map.insert("content", "hello");
map.insert("size", 18.0f64);
let typed = Props::from(map);
let wire = Props::from_json(json!({"content": "hello", "size": 18.0}));
assert_eq!(typed, wire);
}
#[test]
fn prop_value_round_trip_through_json() {
let mut map = PropMap::new();
map.insert("text", "hello");
map.insert("num", 42.0f64);
map.insert("flag", true);
map.insert(
"items",
PropValue::Array(vec![PropValue::from(1.0f64), PropValue::from(2.0f64)]),
);
let json_map = map.clone().into_json_map();
let round_tripped = PropMap::from_json_map(json_map);
assert_eq!(map, round_tripped);
}
#[test]
fn props_serializes_as_json_object() {
let mut map = PropMap::new();
map.insert("label", "Save");
let props = Props::from(map);
let json_str = serde_json::to_string(&props).unwrap();
assert!(json_str.contains("\"label\":\"Save\""));
}
#[test]
fn props_deserializes_to_prop_map() {
let json_str = r#"{"label":"Save","size":18}"#;
let props: Props = serde_json::from_str(json_str).unwrap();
assert_eq!(props.get_str("label"), Some("Save"));
assert_eq!(props.get_i64("size"), Some(18));
}
#[test]
fn props_default_is_empty() {
let props = Props::default();
assert!(props.as_prop_map().is_empty());
}
#[test]
fn prop_value_numeric_coercion() {
assert_eq!(PropValue::I64(42).as_f64(), Some(42.0));
assert_eq!(PropValue::U64(42).as_f64(), Some(42.0));
assert_eq!(
PropValue::I64(9_007_199_254_740_994).as_f64(),
Some(9_007_199_254_740_994.0)
);
assert_eq!(
PropValue::U64(9_007_199_254_740_994).as_f64(),
Some(9_007_199_254_740_994.0)
);
assert_eq!(PropValue::F64(42.0).as_i64(), Some(42));
assert_eq!(PropValue::F64(42.0).as_u64(), Some(42));
assert_eq!(PropValue::I64(42).as_u64(), Some(42));
}
#[test]
fn prop_value_rejects_fractional_float_integer_access() {
let value = PropValue::F64(42.9);
assert_eq!(value.as_i64(), None);
assert_eq!(value.as_u64(), None);
let mut map = PropMap::new();
map.insert("value", value);
let props = Props::from(map);
assert_eq!(props.get_i64("value"), None);
assert_eq!(props.get_u64("value"), None);
}
#[test]
fn prop_value_rejects_non_finite_float_access() {
for value in [
PropValue::F64(f64::NAN),
PropValue::F64(f64::INFINITY),
PropValue::F64(f64::NEG_INFINITY),
] {
assert_eq!(value.as_f64(), None);
assert_eq!(value.as_i64(), None);
assert_eq!(value.as_u64(), None);
}
let mut map = PropMap::new();
map.insert("value", PropValue::F64(f64::INFINITY));
let props = Props::from(map);
assert_eq!(props.get_f64("value"), None);
assert_eq!(props.get_f32("value"), None);
}
#[test]
fn prop_value_rejects_lossy_integer_float_access() {
assert_eq!(PropValue::I64(9_007_199_254_740_993).as_f64(), None);
assert_eq!(PropValue::I64(i64::MAX).as_f64(), None);
assert_eq!(PropValue::U64(9_007_199_254_740_993).as_f64(), None);
assert_eq!(PropValue::U64(u64::MAX).as_f64(), None);
}
#[test]
fn props_get_f32_accepts_finite_narrowing_and_rejects_overflow() {
let mut map = PropMap::new();
map.insert("exact_float", 1.5f64);
map.insert("from_f32", 1.1f32);
map.insert("lossy_float", 1.1f64);
map.insert("lossy_integer", 16_777_217_u64);
map.insert("too_large", f64::from(f32::MAX) * 2.0);
let props = Props::from(map);
assert_eq!(props.get_f32("exact_float"), Some(1.5));
assert_eq!(props.get_f32("from_f32"), Some(1.1f32));
assert_eq!(props.get_f32("lossy_float"), Some(1.1f32));
assert_eq!(props.get_f32("lossy_integer"), Some(16_777_216.0));
assert_eq!(props.get_f32("too_large"), None);
}
#[test]
fn props_serialise_keys_alphabetically() {
let mut map = PropMap::new();
map.insert("zebra", "z");
map.insert("mango", "m");
map.insert("apple", "a");
let props = Props::from(map);
let json_str = serde_json::to_string(&props).unwrap();
let expected = r#"{"apple":"a","mango":"m","zebra":"z"}"#;
assert_eq!(
json_str, expected,
"serde_json Props serialisation must be alphabetical; \
if this fails, serde_json's preserve_order feature may have \
leaked in via a transitive dependency"
);
}
#[test]
fn nested_props_serialise_keys_alphabetically() {
let mut inner = PropMap::new();
inner.insert("width", 100.0f64);
inner.insert("height", 50.0f64);
let mut outer = PropMap::new();
outer.insert("z_field", PropValue::Object(inner));
outer.insert("a_field", "a");
let props = Props::from(outer);
let json_str = serde_json::to_string(&props).unwrap();
assert_eq!(
json_str,
r#"{"a_field":"a","z_field":{"height":50.0,"width":100.0}}"#
);
}
}