protocol_droid/domain/ir/json.rs
1//! `JsonObject` — a validated JSON object, and the only way arbitrary JSON
2//! enters the IR.
3//!
4//! # Why a newtype rather than `serde_json::Value`
5//!
6//! The domain layer takes no `serde_json::Value` in its signatures. That rule
7//! is what keeps the domain free of the serializer, and it is worth restating
8//! *why* it exists: a `Value` in a signature means every layer above can hand
9//! the domain an arbitrary tree, so every domain function has to re-validate it
10//! and the type system stops saying where the shape was checked. `JsonObject`
11//! inverts that — by the time a caller has one, the value is known to be a JSON
12//! object, checked once, at the boundary.
13//!
14//! # What it stores, and why
15//!
16//! The original text, not a re-serialised tree. Three reasons:
17//!
18//! - **Losslessness.** Round-tripping through a typed tree can reorder keys and
19//! change number formatting, and the bridge's contract is that a value it
20//! did not understand comes out the other side byte-identical. Storing the
21//! text makes that the default rather than something to test for.
22//! - **Forward compatibility.** A JSON schema in `2026-07-28` may nest shapes
23//! this build has never heard of. Storing text means an unknown shape is not
24//! a problem, which is the same reason the rest of the IR carries an
25//! extension bag.
26//! - **The hot path.** Construction parses once to validate and then holds a
27//! `&str`. Nothing downstream re-parses, which is what the no-double-parse
28//! rule is protecting.
29//!
30//! The cost is that you cannot read a field out of a `JsonObject` without
31//! parsing. That is the right trade: callers who need structure go through the
32//! codecs, which parse at the transport boundary, and everything else treats
33//! the object as an opaque value to forward.
34
35use std::fmt;
36
37/// A JSON object, validated on construction.
38#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
39pub struct JsonObject {
40 text: String,
41}
42
43impl JsonObject {
44 /// Validates `text` as a JSON *object*.
45 ///
46 /// # Errors
47 ///
48 /// [`JsonObjectError::InvalidJson`] when the text is not well-formed JSON,
49 /// and [`JsonObjectError::NotAnObject`] when it parses but is not an
50 /// object — an array or a bare scalar is a different thing, and `2025-06-18`
51 /// `structuredContent` is specifically an object.
52 ///
53 /// # Examples
54 ///
55 /// ```
56 /// use protocol_droid::domain::ir::JsonObject;
57 ///
58 /// let json = JsonObject::new(r#"{"temperature": 0.7}"#)?;
59 /// assert_eq!(json.as_str(), r#"{"temperature": 0.7}"#);
60 /// # Ok::<(), protocol_droid::domain::ir::JsonObjectError>(())
61 /// ```
62 pub fn new(text: impl Into<String>) -> Result<Self, JsonObjectError> {
63 let text = text.into();
64 // `serde_json` is used here and nowhere else in the IR: this is the
65 // boundary check, and its output never escapes.
66 match serde_json::from_str::<serde_json::Value>(&text) {
67 Ok(serde_json::Value::Object(_)) => Ok(Self { text }),
68 Ok(_) => Err(JsonObjectError::NotAnObject),
69 Err(error) => Err(JsonObjectError::InvalidJson {
70 offset: error.column(),
71 }),
72 }
73 }
74
75 /// The text, exactly as it was validated.
76 #[must_use]
77 pub fn as_str(&self) -> &str {
78 &self.text
79 }
80
81 /// Validates a `serde_json::Value` and stores it.
82 ///
83 /// A tool handler's result is built from data — the argument echoed back,
84 /// a computed value, an error message composed from a failure. Doing that
85 /// with `new` means `format!`ing a JSON document by hand, and a handler
86 /// that interpolates a peer's string into a JSON literal without escaping
87 /// it is an injection bug. This is the constructor that makes that mistake
88 /// unavailable.
89 ///
90 /// The value is serialised rather than kept, so a `JsonObject` is still one
91 /// validated `String` and still owns no borrowed peer's data.
92 ///
93 /// # Errors
94 ///
95 /// [`JsonObjectError::NotAnObject`] when the value is not an object.
96 /// [`JsonObjectError::InvalidJson`] when the value cannot be serialised,
97 /// which for a `Value` cannot happen — but the error type is the honest
98 /// one, and inventing a second error for an unreachable case would be a
99 /// promise this function cannot keep.
100 ///
101 /// # Examples
102 ///
103 /// ```
104 /// use protocol_droid::domain::ir::JsonObject;
105 ///
106 /// let value = serde_json::json!({ "echoed": "quotes \" and \\ survive" });
107 /// let json = JsonObject::from_value(value)?;
108 /// assert_eq!(json.as_str(), r#"{"echoed":"quotes \" and \\ survive"}"#);
109 /// # Ok::<(), protocol_droid::domain::ir::JsonObjectError>(())
110 /// ```
111 #[expect(
112 clippy::needless_pass_by_value,
113 reason = "the caller hands over a Value it built and does not want back. \
114 Almost every call site is `JsonObject::from_value(json!({..}))`, \
115 and by reference would force each one to bind a temporary \
116 first — the lint is right that the body only reads it, and \
117 taking it is still the signature a caller wants."
118 )]
119 pub fn from_value(value: serde_json::Value) -> Result<Self, JsonObjectError> {
120 if !value.is_object() {
121 return Err(JsonObjectError::NotAnObject);
122 }
123 serde_json::to_string(&value)
124 .map(|text| Self { text })
125 .map_err(|error| JsonObjectError::InvalidJson {
126 offset: error.column(),
127 })
128 }
129
130 /// Consumes the object, yielding its text.
131 #[must_use]
132 pub fn into_string(self) -> String {
133 self.text
134 }
135}
136
137impl Default for JsonObject {
138 /// The empty object, `{}`.
139 ///
140 /// A tool handler returns a `JsonObject` and cannot return a `Result`,
141 /// because MCP reports a tool failure as a successful call carrying
142 /// `isError: true`. So a handler that cannot build the result it meant to
143 /// build needs somewhere to fall back to, and it needs to be infallible.
144 /// `{}` is that place: it is a valid document under every revision of the
145 /// protocol, so no fallback can itself be a parse error.
146 ///
147 /// Not `""`, which is the default a `String`-backed type reaches for and
148 /// which is not a JSON object at all.
149 fn default() -> Self {
150 Self {
151 text: String::from("{}"),
152 }
153 }
154}
155
156impl fmt::Debug for JsonObject {
157 /// Prints the text, since a `JsonObject` has no other meaningful state and
158 /// the text is what a caller debugging a lossless round trip needs to see.
159 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
160 formatter
161 .debug_tuple("JsonObject")
162 .field(&self.text)
163 .finish()
164 }
165}
166
167impl fmt::Display for JsonObject {
168 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
169 formatter.write_str(&self.text)
170 }
171}
172
173/// Why a string is not a [`JsonObject`].
174#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
175pub enum JsonObjectError {
176 /// The text is not well-formed JSON.
177 #[error("not well-formed JSON, first problem at column {offset}")]
178 InvalidJson {
179 /// The column the parser stopped at.
180 offset: usize,
181 },
182
183 /// The text parses, but as something other than an object.
184 #[error("parsed as JSON, but not as an object")]
185 NotAnObject,
186}
187
188#[cfg(test)]
189mod tests {
190 #![expect(
191 clippy::unwrap_used,
192 reason = "tests assert on fallible calls and unwrap with the failing precondition in the message"
193 )]
194
195 use super::{JsonObject, JsonObjectError};
196
197 #[test]
198 fn a_well_formed_object_is_accepted() {
199 let json = JsonObject::new(r#"{"a":1}"#).unwrap();
200 assert_eq!(json.as_str(), r#"{"a":1}"#);
201 // The empty object is an object.
202 assert!(JsonObject::new("{}").is_ok());
203 // Nesting is fine, and is exactly what a structured result is.
204 assert!(JsonObject::new(r#"{"a":{"b":[{"c":null}]}}"#).is_ok());
205 }
206
207 /// Losslessness is the whole reason this stores text, so it is asserted
208 /// rather than assumed.
209 #[test]
210 fn the_text_survives_unchanged() {
211 // Whitespace, key order and number formatting all preserved exactly.
212 for text in [
213 r#"{ "b" : 2 , "a" : 1 }"#,
214 r#"{"n":1.0}"#,
215 r#"{"n":1e10}"#,
216 r#"{"s":"üñí çödé 😀"}"#,
217 r#"{"escaped":"line\nbreak"}"#,
218 ] {
219 let json = JsonObject::new(text).unwrap();
220 assert_eq!(json.as_str(), text, "{text}");
221 assert_eq!(json.clone().into_string(), text, "{text}");
222 }
223 }
224
225 #[test]
226 fn a_non_object_is_rejected() {
227 for text in ["[]", r#""a string""#, "1", "true", "null"] {
228 assert_eq!(
229 JsonObject::new(text).unwrap_err(),
230 JsonObjectError::NotAnObject,
231 "{text}",
232 );
233 }
234 }
235
236 #[test]
237 fn malformed_json_is_rejected() {
238 for text in ["{", r#"{"a":}"#, "", "{'a':1}", r#"{"a":1,}"#] {
239 assert!(
240 matches!(
241 JsonObject::new(text),
242 Err(JsonObjectError::InvalidJson { .. })
243 ),
244 "{text} should be invalid",
245 );
246 }
247 }
248
249 #[test]
250 fn equality_and_hashing_use_the_text() {
251 use std::collections::HashSet;
252 let a = JsonObject::new(r#"{"x":1}"#).unwrap();
253 let b = JsonObject::new(r#"{"x":1}"#).unwrap();
254 let c = JsonObject::new(r#"{ "x" : 1 }"#).unwrap();
255 // Semantically equal but textually different, so *not* equal. This is
256 // the deliberate consequence of storing text: equality is textual.
257 assert_eq!(a, b);
258 assert_ne!(a, c);
259 let mut set = HashSet::new();
260 assert!(set.insert(a));
261 assert!(!set.insert(b));
262 assert!(set.insert(c));
263 }
264
265 #[test]
266 fn debug_and_display_show_the_text() {
267 let json = JsonObject::new(r#"{"k":"v"}"#).unwrap();
268 assert_eq!(json.to_string(), r#"{"k":"v"}"#);
269 // Debug quotes the text, so it is escaped — but it is still there, and
270 // naming the type is what makes a log line readable.
271 let debug = format!("{json:?}");
272 assert!(debug.starts_with("JsonObject("), "{debug}");
273 assert!(debug.contains('k'), "{debug}");
274 }
275}