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) => {
75            let n = i128::from(*i);
76            if (i64::MIN as i128..=i64::MAX as i128).contains(&n) {
77                n.to_string()
78            } else {
79                // out of double-precision-safe range: emit as quoted decimal
80                // so JSON parsers don't silently round.
81                format!("\"{n}\"")
82            }
83        }
84        CborValue::Float(f) => {
85            if f.is_nan() || f.is_infinite() {
86                "null".to_string()
87            } else {
88                f.to_string()
89            }
90        }
91        CborValue::Text(s) => write_string(s),
92        CborValue::Bytes(b) => {
93            let mut hex = String::with_capacity(b.len() * 2 + 4);
94            hex.push_str("\"0x");
95            for byte in b {
96                hex.push_str(&format!("{byte:02x}"));
97            }
98            hex.push('"');
99            hex
100        }
101        CborValue::Bool(b) => b.to_string(),
102        CborValue::Null => "null".to_string(),
103        CborValue::Array(items) => {
104            let mut out = String::from("[");
105            for (i, item) in items.iter().enumerate() {
106                if i > 0 {
107                    out.push(',');
108                }
109                out.push_str(&write_value(item));
110            }
111            out.push(']');
112            out
113        }
114        CborValue::Map(pairs) => {
115            let mut out = String::from("{");
116            for (i, (k, v)) in pairs.iter().enumerate() {
117                if i > 0 {
118                    out.push(',');
119                }
120                // JSON object keys must be strings; coerce non-string keys.
121                let key = match k {
122                    CborValue::Text(s) => write_string(s),
123                    other => write_string(&write_value(other)),
124                };
125                out.push_str(&key);
126                out.push(':');
127                out.push_str(&write_value(v));
128            }
129            out.push('}');
130            out
131        }
132        CborValue::Tag(_, inner) => write_value(inner),
133        other => write_string(&format!("{other:?}")),
134    }
135}
136
137fn write_string(s: &str) -> String {
138    // `serde_json` handles every escape rule we'd otherwise hand-roll;
139    // just route through it.
140    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn scalar_kinds() {
149        assert_eq!(write_value(&CborValue::Integer(42.into())), "42");
150        assert_eq!(write_value(&CborValue::Integer((-7).into())), "-7");
151        assert_eq!(write_value(&CborValue::Bool(true)), "true");
152        assert_eq!(write_value(&CborValue::Null), "null");
153        assert_eq!(write_value(&CborValue::Text("hi".into())), "\"hi\"");
154    }
155
156    #[test]
157    fn float_specials_become_null() {
158        assert_eq!(write_value(&CborValue::Float(f64::NAN)), "null");
159        assert_eq!(write_value(&CborValue::Float(f64::INFINITY)), "null");
160    }
161
162    #[test]
163    fn bytes_are_hex_strings() {
164        assert_eq!(
165            write_value(&CborValue::Bytes(vec![0xab, 0xcd])),
166            "\"0xabcd\""
167        );
168    }
169
170    #[test]
171    fn record_framing() {
172        let mut r = Record::new();
173        r.insert("name".to_string(), CborValue::Text("alice".into()));
174        r.insert("age".to_string(), CborValue::Integer(30.into()));
175        let s = write_record("u1", &r);
176        assert_eq!(s, "{\"u1\":{\"name\":\"alice\",\"age\":30}}\n");
177    }
178
179    #[test]
180    fn list_framing() {
181        let mut records: IndexMap<String, Record<CborValue>> = IndexMap::new();
182        let mut r1 = Record::new();
183        r1.insert("x".to_string(), CborValue::Integer(1.into()));
184        records.insert("a".to_string(), r1);
185        assert_eq!(write_list(&records), "{\"a\":{\"x\":1}}\n");
186    }
187}