Skip to main content

rustledger_core/
meta_json.rs

1//! Canonical JSON wire codec for [`MetaValue`].
2//!
3//! Metadata values cross several wire boundaries — the CLI `query --format json`
4//! output and the WASI FFI JSON-RPC surface. Each used to re-implement the same
5//! `MetaValue` ↔ JSON match, so adding a variant meant editing every copy and
6//! the copies could silently drift (the type tag in particular). This module is
7//! the single source of that mapping.
8//!
9//! The WASM binding deliberately keeps `serde_json` a host-only dev-dependency
10//! (it emits a typed enum instead), so it cannot call [`meta_value_to_json`] —
11//! but it shares [`meta_value_type_tag`], which is `serde_json`-free, so the
12//! drift-prone type tag stays single-sourced across *every* surface.
13//!
14//! The plugin (`MetaValueData`, `MessagePack`) and the Component-Model WIT
15//! `meta-value` are separate, narrower wire contracts and are intentionally not
16//! routed through this JSON codec.
17
18use crate::MetaValue;
19
20/// Canonical JSON form of a metadata value.
21///
22/// Numeric values are stringified (lossless for `Decimal`, and uniform with
23/// `Int`). Typed references (`Account`/`Currency`/`Tag`/`Link`) lower to their
24/// string form — matching bean-query, which has no first-class type for them on
25/// the SQL/JSON surface.
26#[must_use]
27pub fn meta_value_to_json(value: &MetaValue) -> serde_json::Value {
28    match value {
29        MetaValue::String(s) => serde_json::Value::String(s.clone()),
30        MetaValue::Account(a) => serde_json::Value::String(a.to_string()),
31        MetaValue::Currency(c) => serde_json::Value::String(c.to_string()),
32        MetaValue::Tag(t) => serde_json::Value::String(t.to_string()),
33        MetaValue::Link(l) => serde_json::Value::String(l.to_string()),
34        MetaValue::Date(d) => serde_json::Value::String(d.to_string()),
35        MetaValue::Number(n) => serde_json::Value::String(n.to_string()),
36        MetaValue::Int(i) => serde_json::Value::String(i.to_string()),
37        MetaValue::Bool(b) => serde_json::Value::Bool(*b),
38        MetaValue::Amount(a) => serde_json::json!({
39            "number": a.number.to_string(),
40            "currency": a.currency.to_string(),
41        }),
42        MetaValue::None => serde_json::Value::Null,
43    }
44}
45
46/// The wire type tag for a metadata value (`"string"`, `"int"`, `"amount"`, …).
47///
48/// Single source for the `type`/`value_type` discriminator emitted by the FFI
49/// and WASM "typed value" forms. `serde_json`-free, so the WASM binding (which
50/// avoids `serde_json`) shares it too.
51#[must_use]
52pub const fn meta_value_type_tag(value: &MetaValue) -> &'static str {
53    match value {
54        MetaValue::String(_) => "string",
55        MetaValue::Account(_) => "account",
56        MetaValue::Currency(_) => "currency",
57        MetaValue::Tag(_) => "tag",
58        MetaValue::Link(_) => "link",
59        MetaValue::Date(_) => "date",
60        MetaValue::Number(_) => "number",
61        MetaValue::Int(_) => "int",
62        MetaValue::Bool(_) => "bool",
63        MetaValue::Amount(_) => "amount",
64        MetaValue::None => "null",
65    }
66}
67
68/// Parse a metadata value from its canonical JSON form (the inverse of
69/// [`meta_value_to_json`] for the round-trippable cases).
70///
71/// A JSON integer becomes `Int`; an `{number, currency}` object becomes
72/// `Amount`. Unparsable numbers and unrecognized shapes (arrays, foreign
73/// objects) become `None` rather than coercing to zero or panicking — metadata
74/// is informational, so "I saw something but couldn't interpret it" is the
75/// honest result.
76#[must_use]
77pub fn json_to_meta_value(value: &serde_json::Value) -> MetaValue {
78    use rust_decimal::Decimal;
79    match value {
80        serde_json::Value::String(s) => MetaValue::String(s.clone()),
81        serde_json::Value::Bool(b) => MetaValue::Bool(*b),
82        serde_json::Value::Number(n) => {
83            if let Some(i) = n.as_i64() {
84                MetaValue::Int(i)
85            } else {
86                // Parse the number's exact textual form rather than round-tripping
87                // through f64 — that preserves `u64` values above `i64::MAX` and
88                // high-precision decimals (within `Decimal`'s range).
89                Decimal::from_str_exact(&n.to_string()).map_or(MetaValue::None, MetaValue::Number)
90            }
91        }
92        serde_json::Value::Object(obj) => {
93            if let (Some(number), Some(currency)) = (obj.get("number"), obj.get("currency"))
94                && let (Some(n), Some(c)) = (number.as_str(), currency.as_str())
95                && let Ok(number) = Decimal::from_str_exact(n)
96            {
97                return MetaValue::Amount(crate::Amount {
98                    number,
99                    currency: c.into(),
100                });
101            }
102            MetaValue::None
103        }
104        serde_json::Value::Null | serde_json::Value::Array(_) => MetaValue::None,
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use rust_decimal_macros::dec;
112
113    /// Every variant maps to a non-degenerate tag + JSON, and the
114    /// round-trippable ones survive `to_json -> json_to`. This is the fitness
115    /// function: a new `MetaValue` variant forces a new arm in all three
116    /// functions above (exhaustive match) and must be added here.
117    #[test]
118    fn codec_covers_every_variant() {
119        let cases = [
120            MetaValue::String("hi".into()),
121            MetaValue::Account("Assets:Cash".into()),
122            MetaValue::Currency("USD".into()),
123            MetaValue::Tag("trip".into()),
124            MetaValue::Link("inv-1".into()),
125            MetaValue::Date(crate::naive_date(2024, 6, 15).unwrap()),
126            MetaValue::Number(dec!(123.456)),
127            MetaValue::Int(42),
128            MetaValue::Bool(true),
129            MetaValue::Amount(crate::Amount::new(dec!(99.99), "EUR")),
130            MetaValue::None,
131        ];
132        for mv in &cases {
133            // tag is non-empty for every variant
134            assert!(!meta_value_type_tag(mv).is_empty());
135            // to_json never panics and is well-formed
136            let _ = meta_value_to_json(mv);
137        }
138        // Tags are exhaustive + distinct (one per variant).
139        let tags: Vec<&str> = cases.iter().map(meta_value_type_tag).collect();
140        let mut uniq = tags.clone();
141        uniq.sort_unstable();
142        uniq.dedup();
143        assert_eq!(
144            uniq.len(),
145            tags.len(),
146            "type tags must be distinct: {tags:?}"
147        );
148    }
149
150    #[test]
151    fn json_round_trip() {
152        // Cases whose JSON form is faithfully invertible.
153        assert_eq!(
154            json_to_meta_value(&meta_value_to_json(&MetaValue::String("x".into()))),
155            MetaValue::String("x".into())
156        );
157        assert_eq!(
158            json_to_meta_value(&meta_value_to_json(&MetaValue::Bool(true))),
159            MetaValue::Bool(true)
160        );
161        assert_eq!(
162            json_to_meta_value(&meta_value_to_json(&MetaValue::None)),
163            MetaValue::None
164        );
165        assert_eq!(
166            json_to_meta_value(&meta_value_to_json(&MetaValue::Amount(crate::Amount::new(
167                dec!(1.50),
168                "USD"
169            )))),
170            MetaValue::Amount(crate::Amount::new(dec!(1.50), "USD"))
171        );
172        // Numbers are stringified on the wire, so they round-trip back as a
173        // String (the wire form is intentionally lossy on numeric type — the
174        // typed `meta_value_type_tag` carries the original type).
175        assert_eq!(
176            json_to_meta_value(&meta_value_to_json(&MetaValue::Int(7))),
177            MetaValue::String("7".into())
178        );
179        // A real JSON integer (e.g. from a plugin) parses as Int.
180        assert_eq!(json_to_meta_value(&serde_json::json!(7)), MetaValue::Int(7));
181        // A u64 above i64::MAX is preserved exactly (not lost via an f64 hop).
182        assert_eq!(
183            json_to_meta_value(&serde_json::json!(18_446_744_073_709_551_615_u64)),
184            MetaValue::Number(dec!(18446744073709551615))
185        );
186    }
187}