use std::fmt;
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct JsonObject {
text: String,
}
impl JsonObject {
pub fn new(text: impl Into<String>) -> Result<Self, JsonObjectError> {
let text = text.into();
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(serde_json::Value::Object(_)) => Ok(Self { text }),
Ok(_) => Err(JsonObjectError::NotAnObject),
Err(error) => Err(JsonObjectError::InvalidJson {
offset: error.column(),
}),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.text
}
#[expect(
clippy::needless_pass_by_value,
reason = "the caller hands over a Value it built and does not want back. \
Almost every call site is `JsonObject::from_value(json!({..}))`, \
and by reference would force each one to bind a temporary \
first — the lint is right that the body only reads it, and \
taking it is still the signature a caller wants."
)]
pub fn from_value(value: serde_json::Value) -> Result<Self, JsonObjectError> {
if !value.is_object() {
return Err(JsonObjectError::NotAnObject);
}
serde_json::to_string(&value)
.map(|text| Self { text })
.map_err(|error| JsonObjectError::InvalidJson {
offset: error.column(),
})
}
#[must_use]
pub fn into_string(self) -> String {
self.text
}
}
impl Default for JsonObject {
fn default() -> Self {
Self {
text: String::from("{}"),
}
}
}
impl fmt::Debug for JsonObject {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_tuple("JsonObject")
.field(&self.text)
.finish()
}
}
impl fmt::Display for JsonObject {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.text)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum JsonObjectError {
#[error("not well-formed JSON, first problem at column {offset}")]
InvalidJson {
offset: usize,
},
#[error("parsed as JSON, but not as an object")]
NotAnObject,
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests assert on fallible calls and unwrap with the failing precondition in the message"
)]
use super::{JsonObject, JsonObjectError};
#[test]
fn a_well_formed_object_is_accepted() {
let json = JsonObject::new(r#"{"a":1}"#).unwrap();
assert_eq!(json.as_str(), r#"{"a":1}"#);
assert!(JsonObject::new("{}").is_ok());
assert!(JsonObject::new(r#"{"a":{"b":[{"c":null}]}}"#).is_ok());
}
#[test]
fn the_text_survives_unchanged() {
for text in [
r#"{ "b" : 2 , "a" : 1 }"#,
r#"{"n":1.0}"#,
r#"{"n":1e10}"#,
r#"{"s":"üñí çödé 😀"}"#,
r#"{"escaped":"line\nbreak"}"#,
] {
let json = JsonObject::new(text).unwrap();
assert_eq!(json.as_str(), text, "{text}");
assert_eq!(json.clone().into_string(), text, "{text}");
}
}
#[test]
fn a_non_object_is_rejected() {
for text in ["[]", r#""a string""#, "1", "true", "null"] {
assert_eq!(
JsonObject::new(text).unwrap_err(),
JsonObjectError::NotAnObject,
"{text}",
);
}
}
#[test]
fn malformed_json_is_rejected() {
for text in ["{", r#"{"a":}"#, "", "{'a':1}", r#"{"a":1,}"#] {
assert!(
matches!(
JsonObject::new(text),
Err(JsonObjectError::InvalidJson { .. })
),
"{text} should be invalid",
);
}
}
#[test]
fn equality_and_hashing_use_the_text() {
use std::collections::HashSet;
let a = JsonObject::new(r#"{"x":1}"#).unwrap();
let b = JsonObject::new(r#"{"x":1}"#).unwrap();
let c = JsonObject::new(r#"{ "x" : 1 }"#).unwrap();
assert_eq!(a, b);
assert_ne!(a, c);
let mut set = HashSet::new();
assert!(set.insert(a));
assert!(!set.insert(b));
assert!(set.insert(c));
}
#[test]
fn debug_and_display_show_the_text() {
let json = JsonObject::new(r#"{"k":"v"}"#).unwrap();
assert_eq!(json.to_string(), r#"{"k":"v"}"#);
let debug = format!("{json:?}");
assert!(debug.starts_with("JsonObject("), "{debug}");
assert!(debug.contains('k'), "{debug}");
}
}