Skip to main content

protocol_droid/domain/ir/
extensions.rs

1//! `Extensions` — the passthrough bag that makes translation lossless.
2//!
3//! # The problem this solves
4//!
5//! A bridge sees every message from both sides, and it does not understand all
6//! of them. A `2026-07-28` client can send a `_meta` key this build has never
7//! heard of; a `draft` peer can define a capability with no entry in the
8//! feature matrix. If the IR is a closed set of fields, that data is dropped at
9//! lift time and the peer on the other side of the bridge silently loses it.
10//!
11//! That is the failure mode this crate is built against, so it gets a first-class
12//! type rather than a comment. Anything the IR does not model is captured here,
13//! in order, and re-emitted on the other side. The bridge's contract becomes
14//! checkable: `lift(v, lower(v, ir)) == ir` is only a meaningful claim if
15//! unmodelled fields survive the round trip.
16//!
17//! # Why an ordered bag and not a map
18//!
19//! A `BTreeMap<String, ExtensionValue>` would be the obvious choice, and it is
20//! worse for this job for two reasons.
21//!
22//! **Order.** JSON object member order is not semantically significant, but a
23//! byte-identical round trip is the goal, and a map re-emits in its own order.
24//! Keeping insertion order means the common case — a peer whose fields the IR
25//! modelled fine, plus a couple it did not — comes back out looking the way it
26//! went in.
27//!
28//! **Duplicates.** JSON allows a repeated key, and a peer that sends one is
29//! telling you something (a merge artefact, or a buggy encoder). A map silently
30//! keeps the last. A bag keeps both, and the conflict is visible.
31//!
32//! # What is in it
33//!
34//! [`ExtensionValue`], which is deliberately closed: the six JSON shapes, plus
35//! [`ExtensionValue::Raw`](ExtensionValue::Raw) for text that failed to parse.
36//! `Raw` exists so lift can never lose data to a parse failure — an unparseable
37//! value is forwarded as the bytes that were received rather than dropped.
38
39use std::fmt;
40
41/// The value of an unmodelled field.
42#[derive(Debug, Clone, PartialEq)]
43pub enum ExtensionValue {
44    /// `null`.
45    Null,
46    /// `true` or `false`.
47    Bool(bool),
48    /// A number, kept as text so a large integer or a `1.0` survives exactly.
49    Number(String),
50    /// A string.
51    String(String),
52    /// A nested object, as a bag in its own right.
53    Object(Extensions),
54    /// An array, in order.
55    Array(Vec<Self>),
56    /// Text that did not parse, kept so a parse failure cannot drop data.
57    ///
58    /// Lowering emits it back verbatim. This is the escape hatch that makes
59    /// lift total: there is no input for which the bridge has to throw data
60    /// away, which is what "lossless" has to mean to be worth claiming.
61    Raw(String),
62}
63
64impl From<serde_json::Value> for ExtensionValue {
65    /// Converts a JSON value, total by construction.
66    ///
67    /// Every `serde_json::Value` has a matching variant, so this cannot fail —
68    /// which is what makes [`Extensions::from_json_object`] infallible and lift
69    /// total. A number keeps its source text rather than being reparsed, so a
70    /// large integer or a `1.0` survives the crossing byte for byte.
71    fn from(value: serde_json::Value) -> Self {
72        match value {
73            serde_json::Value::Null => Self::Null,
74            serde_json::Value::Bool(boolean) => Self::Bool(boolean),
75            serde_json::Value::Number(number) => Self::Number(number.to_string()),
76            serde_json::Value::String(string) => Self::String(string),
77            serde_json::Value::Object(object) => Self::Object(Extensions::from_json_object(object)),
78            serde_json::Value::Array(items) => {
79                Self::Array(items.into_iter().map(Self::from).collect())
80            }
81        }
82    }
83}
84
85impl From<&ExtensionValue> for serde_json::Value {
86    /// Converts back to JSON.
87    ///
88    /// `Number` round-trips through the stored text, and `Raw` is parsed with
89    /// the raw text as a fallback so an unparseable value is still emitted
90    /// rather than dropped. A value that reached `Raw` was already text that
91    /// serde could not read as JSON, so the fallback is a string, which is what
92    /// it came from.
93    fn from(value: &ExtensionValue) -> Self {
94        match value {
95            ExtensionValue::Null => Self::Null,
96            ExtensionValue::Bool(boolean) => Self::Bool(*boolean),
97            ExtensionValue::Number(text) => {
98                // `Number` holds the source text of a number that already was
99                // one, so this always parses. If it somehow does not, emitting
100                // the text as a string still forwards the bytes; substituting a
101                // different number would quietly change the value.
102                text.parse()
103                    .map_or_else(|_| Self::String(text.clone()), Self::Number)
104            }
105            ExtensionValue::String(string) => Self::String(string.clone()),
106            ExtensionValue::Object(object) => Self::Object(object.to_json_object()),
107            ExtensionValue::Array(items) => Self::Array(items.iter().map(Self::from).collect()),
108            ExtensionValue::Raw(text) => {
109                text.parse().unwrap_or_else(|_| Self::String(text.clone()))
110            }
111        }
112    }
113}
114
115impl Extensions {
116    /// Builds a bag from a JSON object.
117    #[must_use]
118    pub fn from_json_object(object: serde_json::Map<String, serde_json::Value>) -> Self {
119        Self {
120            entries: object
121                .into_iter()
122                .map(|(key, value)| (key, ExtensionValue::from(value)))
123                .collect(),
124        }
125    }
126
127    /// Merges a JSON object's members in, keeping existing keys.
128    pub fn absorb_json(&mut self, object: &serde_json::Map<String, serde_json::Value>) {
129        for (key, value) in object {
130            self.insert(key.clone(), ExtensionValue::from(value.clone()));
131        }
132    }
133
134    /// Removes a key and returns what was there.
135    pub fn take(&mut self, key: &str) -> Option<ExtensionValue> {
136        let index = self
137            .entries
138            .iter()
139            .position(|(existing, _)| existing == key)?;
140        Some(self.entries.remove(index).1)
141    }
142
143    /// Renders the bag back to a JSON object.
144    #[must_use]
145    pub fn to_json_object(&self) -> serde_json::Map<String, serde_json::Value> {
146        self.entries
147            .iter()
148            .map(|(key, value)| (key.clone(), serde_json::Value::from(value)))
149            .collect()
150    }
151}
152
153impl ExtensionValue {
154    /// A short name for the shape, for logs and error messages.
155    #[must_use]
156    pub const fn shape(&self) -> &'static str {
157        match self {
158            Self::Null => "null",
159            Self::Bool(_) => "bool",
160            Self::Number(_) => "number",
161            Self::String(_) => "string",
162            Self::Object(_) => "object",
163            Self::Array(_) => "array",
164            Self::Raw(_) => "raw",
165        }
166    }
167}
168
169/// Unmodelled fields, in `serde_json::Map` order.
170#[derive(Debug, Clone, PartialEq, Default)]
171pub struct Extensions {
172    entries: Vec<(String, ExtensionValue)>,
173}
174
175impl Extensions {
176    /// No extensions. The common case: most messages have none.
177    #[must_use]
178    pub const fn empty() -> Self {
179        Self {
180            entries: Vec::new(),
181        }
182    }
183
184    /// Adds an entry, keeping insertion order and allowing duplicate keys.
185    #[must_use]
186    pub fn with(mut self, key: impl Into<String>, value: ExtensionValue) -> Self {
187        self.entries.push((key.into(), value));
188        self
189    }
190
191    /// Adds an entry in place.
192    pub fn insert(&mut self, key: impl Into<String>, value: ExtensionValue) {
193        self.entries.push((key.into(), value));
194    }
195
196    /// The first value for `key`, or `None`.
197    ///
198    /// "First" because duplicates are preserved, and the first is the one
199    /// serde would have used.
200    #[must_use]
201    pub fn get(&self, key: &str) -> Option<&ExtensionValue> {
202        self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
203    }
204
205    /// Every value for `key`, in order.
206    pub fn get_all<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'a ExtensionValue> {
207        self.entries
208            .iter()
209            .filter(move |(k, _)| k == key)
210            .map(|(_, v)| v)
211    }
212
213    /// Whether `key` is present at all.
214    #[must_use]
215    pub fn contains(&self, key: &str) -> bool {
216        self.entries.iter().any(|(k, _)| k == key)
217    }
218
219    /// The entries, in order.
220    pub fn iter(&self) -> impl Iterator<Item = (&str, &ExtensionValue)> {
221        self.entries.iter().map(|(k, v)| (k.as_str(), v))
222    }
223
224    /// Whether there are none.
225    #[must_use]
226    pub const fn is_empty(&self) -> bool {
227        self.entries.is_empty()
228    }
229
230    /// How many entries there are, counting duplicates separately.
231    #[must_use]
232    pub const fn len(&self) -> usize {
233        self.entries.len()
234    }
235
236    /// Consumes the bag, yielding its entries in order.
237    #[must_use]
238    pub fn into_entries(self) -> Vec<(String, ExtensionValue)> {
239        self.entries
240    }
241}
242
243impl fmt::Display for Extensions {
244    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
245        let mut first = true;
246        for (key, value) in &self.entries {
247            if !first {
248                formatter.write_str(", ")?;
249            }
250            first = false;
251            write!(formatter, "{key}={}", value.shape())?;
252        }
253        Ok(())
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    #![expect(
260        clippy::panic,
261        clippy::unwrap_used,
262        reason = "tests assert on fallible calls and unwrap with the failing precondition in the message"
263    )]
264
265    use super::{ExtensionValue, Extensions};
266
267    #[test]
268    fn an_empty_bag_has_nothing() {
269        let bag = Extensions::empty();
270        assert!(bag.is_empty());
271        assert_eq!(bag.len(), 0);
272        assert!(!bag.contains("x"));
273        assert!(bag.get("x").is_none());
274        assert_eq!(bag.iter().count(), 0);
275    }
276
277    /// Order is preserved, because a byte-identical round trip depends on it.
278    #[test]
279    fn insertion_order_is_preserved() {
280        let bag = Extensions::empty()
281            .with("z", ExtensionValue::Number("1".into()))
282            .with("a", ExtensionValue::Null)
283            .with("m", ExtensionValue::Bool(true));
284        let keys: Vec<&str> = bag.iter().map(|(k, _)| k).collect();
285        assert_eq!(keys, vec!["z", "a", "m"]);
286    }
287
288    /// A repeated key is preserved, and `get` reports the first — which is the
289    /// one serde would have used.
290    #[test]
291    fn duplicate_keys_are_kept() {
292        let bag = Extensions::empty()
293            .with("k", ExtensionValue::String("first".into()))
294            .with("k", ExtensionValue::String("second".into()));
295        assert_eq!(bag.len(), 2);
296        assert_eq!(bag.get_all("k").count(), 2);
297        assert_eq!(bag.get("k"), Some(&ExtensionValue::String("first".into())));
298        assert_eq!(
299            bag.get_all("k").collect::<Vec<_>>(),
300            vec![
301                &ExtensionValue::String("first".into()),
302                &ExtensionValue::String("second".into()),
303            ],
304        );
305    }
306
307    #[test]
308    fn nested_shapes_round_trip_by_construction() {
309        let inner = Extensions::empty().with("deep", ExtensionValue::Bool(false));
310        let value = ExtensionValue::Object(inner);
311        let bag = Extensions::empty().with("outer", value);
312        assert_eq!(bag.get("outer").unwrap().shape(), "object");
313        assert_eq!(
314            bag.get("outer"),
315            Some(&ExtensionValue::Object(
316                Extensions::empty().with("deep", ExtensionValue::Bool(false)),
317            )),
318        );
319    }
320
321    #[test]
322    fn arrays_keep_their_order() {
323        let value = ExtensionValue::Array(vec![
324            ExtensionValue::Number("1".into()),
325            ExtensionValue::String("two".into()),
326            ExtensionValue::Null,
327        ]);
328        let bag = Extensions::empty().with("a", value.clone());
329        assert_eq!(bag.get("a"), Some(&value));
330    }
331
332    /// `Raw` is the reason lift can be total. An unparseable value is data the
333    /// bridge has to forward, not data it can drop.
334    #[test]
335    fn raw_carries_text_that_did_not_parse() {
336        let bag = Extensions::empty().with("junk", ExtensionValue::Raw("{oops".into()));
337        assert_eq!(bag.get("junk").unwrap().shape(), "raw");
338        assert_eq!(bag.get("junk"), Some(&ExtensionValue::Raw("{oops".into())),);
339    }
340
341    #[test]
342    fn numbers_are_kept_as_text() {
343        // Formatting that a typed f64 round trip would destroy.
344        for text in ["1.0", "1e10", "10000000000000000000", "0.1", "-0"] {
345            let bag = Extensions::empty().with("n", ExtensionValue::Number(text.into()));
346            assert_eq!(
347                bag.get("n"),
348                Some(&ExtensionValue::Number(text.into())),
349                "{text}"
350            );
351        }
352    }
353
354    #[test]
355    fn into_entries_preserves_order_and_duplicates() {
356        let bag = Extensions::empty()
357            .with("a", ExtensionValue::Null)
358            .with("b", ExtensionValue::Null)
359            .with("a", ExtensionValue::Bool(true));
360        let entries = bag.into_entries();
361        assert_eq!(entries.len(), 3);
362        let (Some(first), Some(last)) = (entries.first(), entries.last()) else {
363            panic!("expected three entries");
364        };
365        assert_eq!(first.0, "a");
366        assert_eq!(last.0, "a");
367    }
368
369    #[test]
370    fn display_summarises_shapes_for_logs() {
371        let bag = Extensions::empty()
372            .with("a", ExtensionValue::String("x".into()))
373            .with("b", ExtensionValue::Object(Extensions::empty()));
374        assert_eq!(bag.to_string(), "a=string, b=object");
375        assert_eq!(Extensions::empty().to_string(), "");
376    }
377}
378
379#[cfg(test)]
380mod json_tests {
381    #![expect(
382        clippy::expect_used,
383        clippy::unwrap_used,
384        reason = "tests assert on fallible conversions; the source is a literal object, so a failure means the literal changed"
385    )]
386
387    use super::{ExtensionValue, Extensions};
388    use serde_json::Value;
389
390    /// A bag converted to JSON and back is the bag it started as. The codec
391    /// layer leans on this for every unmodelled field on every message, so a
392    /// violation here is a field silently re-shaped on the way through a
393    /// bridge.
394    #[test]
395    fn a_bag_survives_the_json_round_trip() {
396        for source in [
397            serde_json::json!({}),
398            serde_json::json!({"a": 1}),
399            serde_json::json!({"a": null, "b": true, "c": "text"}),
400            serde_json::json!({"big": 9_007_199_254_740_993_u64}),
401            serde_json::json!({"float": 1.0}),
402            serde_json::json!({"nested": {"deep": {"deeper": [1, "two", null]}}}),
403            serde_json::json!({"array": [1, 2, {"three": 3}]}),
404        ] {
405            let bag = Extensions::from_json_object(source.as_object().unwrap().clone());
406            let back = bag.to_json_object();
407            assert_eq!(Value::Object(back), source);
408        }
409    }
410
411    /// A large integer is the case a `f64`-based conversion would mangle, which
412    /// is why `Number` holds text rather than a parsed value.
413    #[test]
414    fn a_large_integer_is_not_rounded() {
415        let bag = Extensions::from_json_object(
416            serde_json::json!({ "n": 9_007_199_254_740_993_u64 })
417                .as_object()
418                .expect("object")
419                .clone(),
420        );
421        let back = bag.to_json_object();
422        assert_eq!(
423            back.get("n").and_then(Value::as_u64),
424            Some(9_007_199_254_740_993),
425        );
426    }
427
428    /// Key order is `serde_json::Map`'s, which is sorted unless the
429    /// `preserve_order` feature is enabled. JSON makes member order
430    /// insignificant, so preserving it is not part of lossless — the same set of
431    /// keys with the same values is. Enabling `preserve_order` to keep the byte
432    /// order would be a dependency for something the format does not promise.
433    #[test]
434    fn keys_keep_their_set_regardless_of_order() {
435        let source = serde_json::json!({"zebra": 1, "apple": 2, "mango": 3});
436        let bag = Extensions::from_json_object(source.as_object().expect("object").clone());
437        let mut keys: Vec<&str> = bag.iter().map(|(key, _)| key).collect();
438        let mut want = vec!["zebra", "apple", "mango"];
439        keys.sort_unstable();
440        want.sort_unstable();
441        assert_eq!(keys, want);
442        // And the values follow their keys, which is the part that matters.
443        let back = bag.to_json_object();
444        assert_eq!(back.get("zebra").and_then(Value::as_i64), Some(1));
445        assert_eq!(back.get("apple").and_then(Value::as_i64), Some(2));
446        assert_eq!(back.get("mango").and_then(Value::as_i64), Some(3));
447    }
448
449    /// `absorb_json` merges members in, and `take` removes one, so a codec can
450    /// pull `_meta` and the untyped `annotations` out of the bag on the way
451    /// down and put the rest into the wire's `extra` field.
452    #[test]
453    fn absorb_and_take_move_members_between_representations() {
454        let mut bag = Extensions::empty();
455        bag.absorb_json(
456            &serde_json::json!({"kept": 1, "took": "2"})
457                .as_object()
458                .expect("object")
459                .clone(),
460        );
461        assert_eq!(bag.len(), 2);
462        assert!(bag.contains("kept"));
463        assert!(bag.contains("took"));
464
465        let taken = bag.take("took");
466        assert_eq!(taken, Some(ExtensionValue::String("2".to_owned())));
467        assert_eq!(bag.len(), 1);
468        assert!(!bag.contains("took"));
469        assert!(bag.contains("kept"));
470
471        // A key that was never there is `None`, not a panic.
472        assert_eq!(bag.take("absent"), None);
473        assert_eq!(bag.to_json_object().len(), 1);
474    }
475
476    /// `Raw` exists so an unparseable value can be forwarded rather than
477    /// dropped. It emits its text back.
478    #[test]
479    fn raw_text_is_forwarded_rather_than_dropped() {
480        let bag = Extensions::empty().with("odd", ExtensionValue::Raw("not json".to_owned()));
481        let back = bag.to_json_object();
482        assert_eq!(back.get("odd"), Some(&Value::String("not json".to_owned())));
483    }
484}