Skip to main content

vantage_cli_util/output/
json.rs

1//! Lossy JSON output — best-effort, for piping to `jq` and human eyes.
2//!
3//! CBOR carries types JSON can't represent losslessly (int variants beyond
4//! ±2^53, byte strings, tagged values). This writer makes a clean
5//! best-effort:
6//! - Integers print as JSON numbers when they fit in i64; otherwise as
7//!   decimal strings.
8//! - Floats print as JSON numbers (NaN/Inf → `null`, which is what
9//!   `serde_json` does too).
10//! - Byte strings render as `"0x<hex>"` — strings, not numbers, so they
11//!   survive JSON-aware tools.
12//! - Tagged values render their inner value; the tag is dropped. Use
13//!   `cbor-diag` when the tag matters.
14
15use ciborium::Value as CborValue;
16use indexmap::IndexMap;
17use vantage_types::Record;
18
19pub fn write_list(records: &IndexMap<String, Record<CborValue>>) -> String {
20    let mut out = String::from("{");
21    let mut first = true;
22    for (id, record) in records {
23        if !first {
24            out.push(',');
25        }
26        first = false;
27        out.push_str(&write_string(id));
28        out.push(':');
29        out.push_str(&write_record_map(record));
30    }
31    out.push('}');
32    out.push('\n');
33    out
34}
35
36pub fn write_record(id: &str, record: &Record<CborValue>) -> String {
37    let mut out = String::from("{");
38    out.push_str(&write_string(id));
39    out.push(':');
40    out.push_str(&write_record_map(record));
41    out.push('}');
42    out.push('\n');
43    out
44}
45
46pub fn write_scalar(label: &str, value: &CborValue) -> String {
47    let mut out = String::from("{");
48    out.push_str(&write_string(label));
49    out.push(':');
50    out.push_str(&write_value(value));
51    out.push('}');
52    out.push('\n');
53    out
54}
55
56pub fn write_record_map(record: &Record<CborValue>) -> String {
57    let mut out = String::from("{");
58    let mut first = true;
59    for (k, v) in record.iter() {
60        if !first {
61            out.push(',');
62        }
63        first = false;
64        out.push_str(&write_string(k));
65        out.push(':');
66        out.push_str(&write_value(v));
67    }
68    out.push('}');
69    out
70}
71
72pub fn write_value(v: &CborValue) -> String {
73    match v {
74        CborValue::Integer(i) => write_integer(*i),
75        CborValue::Float(f) => write_float(*f),
76        CborValue::Text(s) => write_string(s),
77        CborValue::Bytes(b) => write_bytes(b),
78        CborValue::Bool(b) => b.to_string(),
79        CborValue::Null => "null".to_string(),
80        CborValue::Array(items) => write_array(items),
81        CborValue::Map(pairs) => write_map(pairs),
82        CborValue::Tag(_, inner) => write_value(inner),
83        other => write_string(&format!("{other:?}")),
84    }
85}
86
87/// Integers fitting in `i64` emit as bare numbers; anything wider is
88/// quoted so JSON parsers (which typically clamp at f64's 53-bit mantissa)
89/// don't silently round.
90fn write_integer(i: ciborium::value::Integer) -> String {
91    let n = i128::from(i);
92    if (i64::MIN as i128..=i64::MAX as i128).contains(&n) {
93        n.to_string()
94    } else {
95        format!("\"{n}\"")
96    }
97}
98
99/// JSON has no NaN/Infinity literals; collapse them to `null`.
100fn write_float(f: f64) -> String {
101    if f.is_nan() || f.is_infinite() {
102        "null".to_string()
103    } else {
104        f.to_string()
105    }
106}
107
108/// Byte strings have no native JSON form; emit as a `"0x…"` hex literal.
109fn write_bytes(b: &[u8]) -> String {
110    let mut hex = String::with_capacity(b.len() * 2 + 4);
111    hex.push_str("\"0x");
112    for byte in b {
113        hex.push_str(&format!("{byte:02x}"));
114    }
115    hex.push('"');
116    hex
117}
118
119fn write_array(items: &[CborValue]) -> String {
120    let mut out = String::from("[");
121    for (i, item) in items.iter().enumerate() {
122        if i > 0 {
123            out.push(',');
124        }
125        out.push_str(&write_value(item));
126    }
127    out.push(']');
128    out
129}
130
131fn write_map(pairs: &[(CborValue, CborValue)]) -> String {
132    let mut out = String::from("{");
133    for (i, (k, v)) in pairs.iter().enumerate() {
134        if i > 0 {
135            out.push(',');
136        }
137        out.push_str(&write_map_key(k));
138        out.push(':');
139        out.push_str(&write_value(v));
140    }
141    out.push('}');
142    out
143}
144
145/// JSON object keys must be strings; non-string keys get stringified
146/// through `write_value` first.
147fn write_map_key(k: &CborValue) -> String {
148    match k {
149        CborValue::Text(s) => write_string(s),
150        other => write_string(&write_value(other)),
151    }
152}
153
154fn write_string(s: &str) -> String {
155    // `serde_json` handles every escape rule we'd otherwise hand-roll;
156    // just route through it.
157    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn scalar_kinds() {
166        assert_eq!(write_value(&CborValue::Integer(42.into())), "42");
167        assert_eq!(write_value(&CborValue::Integer((-7).into())), "-7");
168        assert_eq!(write_value(&CborValue::Bool(true)), "true");
169        assert_eq!(write_value(&CborValue::Null), "null");
170        assert_eq!(write_value(&CborValue::Text("hi".into())), "\"hi\"");
171    }
172
173    #[test]
174    fn float_specials_become_null() {
175        assert_eq!(write_value(&CborValue::Float(f64::NAN)), "null");
176        assert_eq!(write_value(&CborValue::Float(f64::INFINITY)), "null");
177    }
178
179    #[test]
180    fn bytes_are_hex_strings() {
181        assert_eq!(
182            write_value(&CborValue::Bytes(vec![0xab, 0xcd])),
183            "\"0xabcd\""
184        );
185    }
186
187    #[test]
188    fn record_framing() {
189        let mut r = Record::new();
190        r.insert("name".to_string(), CborValue::Text("alice".into()));
191        r.insert("age".to_string(), CborValue::Integer(30.into()));
192        let s = write_record("u1", &r);
193        assert_eq!(s, "{\"u1\":{\"name\":\"alice\",\"age\":30}}\n");
194    }
195
196    #[test]
197    fn list_framing() {
198        let mut records: IndexMap<String, Record<CborValue>> = IndexMap::new();
199        let mut r1 = Record::new();
200        r1.insert("x".to_string(), CborValue::Integer(1.into()));
201        records.insert("a".to_string(), r1);
202        assert_eq!(write_list(&records), "{\"a\":{\"x\":1}}\n");
203    }
204}