use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub enum ExtensionValue {
Null,
Bool(bool),
Number(String),
String(String),
Object(Extensions),
Array(Vec<Self>),
Raw(String),
}
impl From<serde_json::Value> for ExtensionValue {
fn from(value: serde_json::Value) -> Self {
match value {
serde_json::Value::Null => Self::Null,
serde_json::Value::Bool(boolean) => Self::Bool(boolean),
serde_json::Value::Number(number) => Self::Number(number.to_string()),
serde_json::Value::String(string) => Self::String(string),
serde_json::Value::Object(object) => Self::Object(Extensions::from_json_object(object)),
serde_json::Value::Array(items) => {
Self::Array(items.into_iter().map(Self::from).collect())
}
}
}
}
impl From<&ExtensionValue> for serde_json::Value {
fn from(value: &ExtensionValue) -> Self {
match value {
ExtensionValue::Null => Self::Null,
ExtensionValue::Bool(boolean) => Self::Bool(*boolean),
ExtensionValue::Number(text) => {
text.parse()
.map_or_else(|_| Self::String(text.clone()), Self::Number)
}
ExtensionValue::String(string) => Self::String(string.clone()),
ExtensionValue::Object(object) => Self::Object(object.to_json_object()),
ExtensionValue::Array(items) => Self::Array(items.iter().map(Self::from).collect()),
ExtensionValue::Raw(text) => {
text.parse().unwrap_or_else(|_| Self::String(text.clone()))
}
}
}
}
impl Extensions {
#[must_use]
pub fn from_json_object(object: serde_json::Map<String, serde_json::Value>) -> Self {
Self {
entries: object
.into_iter()
.map(|(key, value)| (key, ExtensionValue::from(value)))
.collect(),
}
}
pub fn absorb_json(&mut self, object: &serde_json::Map<String, serde_json::Value>) {
for (key, value) in object {
self.insert(key.clone(), ExtensionValue::from(value.clone()));
}
}
pub fn take(&mut self, key: &str) -> Option<ExtensionValue> {
let index = self
.entries
.iter()
.position(|(existing, _)| existing == key)?;
Some(self.entries.remove(index).1)
}
#[must_use]
pub fn to_json_object(&self) -> serde_json::Map<String, serde_json::Value> {
self.entries
.iter()
.map(|(key, value)| (key.clone(), serde_json::Value::from(value)))
.collect()
}
}
impl ExtensionValue {
#[must_use]
pub const fn shape(&self) -> &'static str {
match self {
Self::Null => "null",
Self::Bool(_) => "bool",
Self::Number(_) => "number",
Self::String(_) => "string",
Self::Object(_) => "object",
Self::Array(_) => "array",
Self::Raw(_) => "raw",
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Extensions {
entries: Vec<(String, ExtensionValue)>,
}
impl Extensions {
#[must_use]
pub const fn empty() -> Self {
Self {
entries: Vec::new(),
}
}
#[must_use]
pub fn with(mut self, key: impl Into<String>, value: ExtensionValue) -> Self {
self.entries.push((key.into(), value));
self
}
pub fn insert(&mut self, key: impl Into<String>, value: ExtensionValue) {
self.entries.push((key.into(), value));
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&ExtensionValue> {
self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
}
pub fn get_all<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'a ExtensionValue> {
self.entries
.iter()
.filter(move |(k, _)| k == key)
.map(|(_, v)| v)
}
#[must_use]
pub fn contains(&self, key: &str) -> bool {
self.entries.iter().any(|(k, _)| k == key)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &ExtensionValue)> {
self.entries.iter().map(|(k, v)| (k.as_str(), v))
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub const fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn into_entries(self) -> Vec<(String, ExtensionValue)> {
self.entries
}
}
impl fmt::Display for Extensions {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut first = true;
for (key, value) in &self.entries {
if !first {
formatter.write_str(", ")?;
}
first = false;
write!(formatter, "{key}={}", value.shape())?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::panic,
clippy::unwrap_used,
reason = "tests assert on fallible calls and unwrap with the failing precondition in the message"
)]
use super::{ExtensionValue, Extensions};
#[test]
fn an_empty_bag_has_nothing() {
let bag = Extensions::empty();
assert!(bag.is_empty());
assert_eq!(bag.len(), 0);
assert!(!bag.contains("x"));
assert!(bag.get("x").is_none());
assert_eq!(bag.iter().count(), 0);
}
#[test]
fn insertion_order_is_preserved() {
let bag = Extensions::empty()
.with("z", ExtensionValue::Number("1".into()))
.with("a", ExtensionValue::Null)
.with("m", ExtensionValue::Bool(true));
let keys: Vec<&str> = bag.iter().map(|(k, _)| k).collect();
assert_eq!(keys, vec!["z", "a", "m"]);
}
#[test]
fn duplicate_keys_are_kept() {
let bag = Extensions::empty()
.with("k", ExtensionValue::String("first".into()))
.with("k", ExtensionValue::String("second".into()));
assert_eq!(bag.len(), 2);
assert_eq!(bag.get_all("k").count(), 2);
assert_eq!(bag.get("k"), Some(&ExtensionValue::String("first".into())));
assert_eq!(
bag.get_all("k").collect::<Vec<_>>(),
vec![
&ExtensionValue::String("first".into()),
&ExtensionValue::String("second".into()),
],
);
}
#[test]
fn nested_shapes_round_trip_by_construction() {
let inner = Extensions::empty().with("deep", ExtensionValue::Bool(false));
let value = ExtensionValue::Object(inner);
let bag = Extensions::empty().with("outer", value);
assert_eq!(bag.get("outer").unwrap().shape(), "object");
assert_eq!(
bag.get("outer"),
Some(&ExtensionValue::Object(
Extensions::empty().with("deep", ExtensionValue::Bool(false)),
)),
);
}
#[test]
fn arrays_keep_their_order() {
let value = ExtensionValue::Array(vec![
ExtensionValue::Number("1".into()),
ExtensionValue::String("two".into()),
ExtensionValue::Null,
]);
let bag = Extensions::empty().with("a", value.clone());
assert_eq!(bag.get("a"), Some(&value));
}
#[test]
fn raw_carries_text_that_did_not_parse() {
let bag = Extensions::empty().with("junk", ExtensionValue::Raw("{oops".into()));
assert_eq!(bag.get("junk").unwrap().shape(), "raw");
assert_eq!(bag.get("junk"), Some(&ExtensionValue::Raw("{oops".into())),);
}
#[test]
fn numbers_are_kept_as_text() {
for text in ["1.0", "1e10", "10000000000000000000", "0.1", "-0"] {
let bag = Extensions::empty().with("n", ExtensionValue::Number(text.into()));
assert_eq!(
bag.get("n"),
Some(&ExtensionValue::Number(text.into())),
"{text}"
);
}
}
#[test]
fn into_entries_preserves_order_and_duplicates() {
let bag = Extensions::empty()
.with("a", ExtensionValue::Null)
.with("b", ExtensionValue::Null)
.with("a", ExtensionValue::Bool(true));
let entries = bag.into_entries();
assert_eq!(entries.len(), 3);
let (Some(first), Some(last)) = (entries.first(), entries.last()) else {
panic!("expected three entries");
};
assert_eq!(first.0, "a");
assert_eq!(last.0, "a");
}
#[test]
fn display_summarises_shapes_for_logs() {
let bag = Extensions::empty()
.with("a", ExtensionValue::String("x".into()))
.with("b", ExtensionValue::Object(Extensions::empty()));
assert_eq!(bag.to_string(), "a=string, b=object");
assert_eq!(Extensions::empty().to_string(), "");
}
}
#[cfg(test)]
mod json_tests {
#![expect(
clippy::expect_used,
clippy::unwrap_used,
reason = "tests assert on fallible conversions; the source is a literal object, so a failure means the literal changed"
)]
use super::{ExtensionValue, Extensions};
use serde_json::Value;
#[test]
fn a_bag_survives_the_json_round_trip() {
for source in [
serde_json::json!({}),
serde_json::json!({"a": 1}),
serde_json::json!({"a": null, "b": true, "c": "text"}),
serde_json::json!({"big": 9_007_199_254_740_993_u64}),
serde_json::json!({"float": 1.0}),
serde_json::json!({"nested": {"deep": {"deeper": [1, "two", null]}}}),
serde_json::json!({"array": [1, 2, {"three": 3}]}),
] {
let bag = Extensions::from_json_object(source.as_object().unwrap().clone());
let back = bag.to_json_object();
assert_eq!(Value::Object(back), source);
}
}
#[test]
fn a_large_integer_is_not_rounded() {
let bag = Extensions::from_json_object(
serde_json::json!({ "n": 9_007_199_254_740_993_u64 })
.as_object()
.expect("object")
.clone(),
);
let back = bag.to_json_object();
assert_eq!(
back.get("n").and_then(Value::as_u64),
Some(9_007_199_254_740_993),
);
}
#[test]
fn keys_keep_their_set_regardless_of_order() {
let source = serde_json::json!({"zebra": 1, "apple": 2, "mango": 3});
let bag = Extensions::from_json_object(source.as_object().expect("object").clone());
let mut keys: Vec<&str> = bag.iter().map(|(key, _)| key).collect();
let mut want = vec!["zebra", "apple", "mango"];
keys.sort_unstable();
want.sort_unstable();
assert_eq!(keys, want);
let back = bag.to_json_object();
assert_eq!(back.get("zebra").and_then(Value::as_i64), Some(1));
assert_eq!(back.get("apple").and_then(Value::as_i64), Some(2));
assert_eq!(back.get("mango").and_then(Value::as_i64), Some(3));
}
#[test]
fn absorb_and_take_move_members_between_representations() {
let mut bag = Extensions::empty();
bag.absorb_json(
&serde_json::json!({"kept": 1, "took": "2"})
.as_object()
.expect("object")
.clone(),
);
assert_eq!(bag.len(), 2);
assert!(bag.contains("kept"));
assert!(bag.contains("took"));
let taken = bag.take("took");
assert_eq!(taken, Some(ExtensionValue::String("2".to_owned())));
assert_eq!(bag.len(), 1);
assert!(!bag.contains("took"));
assert!(bag.contains("kept"));
assert_eq!(bag.take("absent"), None);
assert_eq!(bag.to_json_object().len(), 1);
}
#[test]
fn raw_text_is_forwarded_rather_than_dropped() {
let bag = Extensions::empty().with("odd", ExtensionValue::Raw("not json".to_owned()));
let back = bag.to_json_object();
assert_eq!(back.get("odd"), Some(&Value::String("not json".to_owned())));
}
}