Skip to main content

rac_engine/
pyjson.rs

1//! Python `json.dumps`-shaped writers over `serde_json::Value`
2//! (PORT-CONTRACT.d/07 §1).
3//!
4//! Two dialects:
5//! - [`dumps_indent2`] — `json.dumps(x, indent=2)`: bare `,` before the
6//!   newline, `": "` key separator, `ensure_ascii=True` (`\uXXXX` escapes,
7//!   surrogate pairs for astral), empty `[]`/`{}` inline, insertion-order
8//!   keys, floats via `py_float_repr`, no trailing newline (the caller's
9//!   `print` adds it).
10//! - [`dumps_compact`] — the `export --documents` JSONL dialect:
11//!   `json.dumps(x, ensure_ascii=False)` = separators `", "` / `": "`,
12//!   raw UTF-8 for non-ASCII.
13//!
14//! Int vs float: a `serde_json::Number` holding an i64/u64 renders in
15//! Python int form (`2`); one holding an f64 renders in float form
16//! (`2.0`, `1e-05`, ...). serde_json preserves that distinction through
17//! parsing (`"2"` parses integral, `"2.0"` parses as f64), and
18//! `serde_json::Number::from_f64` always yields the float variant — use
19//! [`py_float`] to force float form for whole values when *building*
20//! payloads (e.g. a computed `2.0` must not collapse to `2`).
21
22use crate::pycompat::py_float_repr;
23use serde_json::Value;
24use std::fmt::Write;
25
26/// Build a `Value` that always serializes in Python float form (`2.0`),
27/// never as an int. Panics on NaN/infinity, which `json.dumps` cannot
28/// round-trip and which never occur in covered payloads.
29pub fn py_float(x: f64) -> Value {
30    Value::Number(serde_json::Number::from_f64(x).expect("finite float"))
31}
32
33/// `json.dumps(value, indent=2)` (ensure_ascii=True). No trailing newline.
34pub fn dumps_indent2(value: &Value) -> String {
35    let mut out = String::new();
36    write_value(&mut out, value, true, Some(0));
37    out
38}
39
40/// `json.dumps(value, indent=2, ensure_ascii=False)` — raw UTF-8 output
41/// (the `decided coverage` JSON contract). No trailing newline.
42pub fn dumps_indent2_no_ascii(value: &Value) -> String {
43    let mut out = String::new();
44    write_value(&mut out, value, false, Some(0));
45    out
46}
47
48/// `json.dumps(value, ensure_ascii=False)` — compact separators
49/// (`", "` item, `": "` key), raw UTF-8. No trailing newline.
50pub fn dumps_compact(value: &Value) -> String {
51    let mut out = String::new();
52    write_value(&mut out, value, false, None);
53    out
54}
55
56/// `json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)`
57/// — the canonical digest dialect (agent-rules provenance digest): keys
58/// sorted (code-point order, like Python `str` `<`), no separator spaces,
59/// raw UTF-8. No trailing newline.
60pub fn dumps_canonical_sorted(value: &Value) -> String {
61    let mut out = String::new();
62    write_canonical(&mut out, value);
63    out
64}
65
66fn write_canonical(out: &mut String, value: &Value) {
67    match value {
68        Value::Array(items) => {
69            out.push('[');
70            for (i, item) in items.iter().enumerate() {
71                if i > 0 {
72                    out.push(',');
73                }
74                write_canonical(out, item);
75            }
76            out.push(']');
77        }
78        Value::Object(map) => {
79            let mut keys: Vec<&String> = map.keys().collect();
80            keys.sort();
81            out.push('{');
82            for (i, key) in keys.iter().enumerate() {
83                if i > 0 {
84                    out.push(',');
85                }
86                write_string(out, key, false);
87                out.push(':');
88                write_canonical(out, &map[key.as_str()]);
89            }
90            out.push('}');
91        }
92        other => write_value(out, other, false, None),
93    }
94}
95
96fn write_value(out: &mut String, value: &Value, ensure_ascii: bool, indent: Option<usize>) {
97    match value {
98        Value::Null => out.push_str("null"),
99        Value::Bool(true) => out.push_str("true"),
100        Value::Bool(false) => out.push_str("false"),
101        Value::Number(n) => write_number(out, n),
102        Value::String(s) => write_string(out, s, ensure_ascii),
103        Value::Array(items) => {
104            if items.is_empty() {
105                out.push_str("[]");
106                return;
107            }
108            out.push('[');
109            for (i, item) in items.iter().enumerate() {
110                if i > 0 {
111                    out.push_str(item_sep(indent));
112                }
113                open_line(out, indent, 1);
114                write_value(out, item, ensure_ascii, indent.map(|d| d + 1));
115            }
116            open_line(out, indent, 0);
117            out.push(']');
118        }
119        Value::Object(map) => {
120            if map.is_empty() {
121                out.push_str("{}");
122                return;
123            }
124            out.push('{');
125            for (i, (key, item)) in map.iter().enumerate() {
126                if i > 0 {
127                    out.push_str(item_sep(indent));
128                }
129                open_line(out, indent, 1);
130                write_string(out, key, ensure_ascii);
131                out.push_str(": ");
132                write_value(out, item, ensure_ascii, indent.map(|d| d + 1));
133            }
134            open_line(out, indent, 0);
135            out.push('}');
136        }
137    }
138}
139
140/// Item separator: bare `,` with indent (newline follows), `", "` compact.
141fn item_sep(indent: Option<usize>) -> &'static str {
142    match indent {
143        Some(_) => ",",
144        None => ", ",
145    }
146}
147
148/// With indent: newline plus `2 * (depth + extra)` spaces. Compact: nothing.
149fn open_line(out: &mut String, indent: Option<usize>, extra: usize) {
150    if let Some(depth) = indent {
151        out.push('\n');
152        for _ in 0..(depth + extra) * 2 {
153            out.push(' ');
154        }
155    }
156}
157
158fn write_number(out: &mut String, n: &serde_json::Number) {
159    if let Some(i) = n.as_i64() {
160        out.push_str(&i.to_string());
161    } else if let Some(u) = n.as_u64() {
162        out.push_str(&u.to_string());
163    } else {
164        out.push_str(&py_float_repr(n.as_f64().expect("number is f64")));
165    }
166}
167
168fn write_string(out: &mut String, s: &str, ensure_ascii: bool) {
169    out.push('"');
170    for c in s.chars() {
171        match c {
172            '"' => out.push_str("\\\""),
173            '\\' => out.push_str("\\\\"),
174            '\u{8}' => out.push_str("\\b"),
175            '\t' => out.push_str("\\t"),
176            '\n' => out.push_str("\\n"),
177            '\u{c}' => out.push_str("\\f"),
178            '\r' => out.push_str("\\r"),
179            c if (c as u32) < 0x20 => write!(out, "\\u{:04x}", c as u32).unwrap(),
180            c if ensure_ascii && (c as u32) > 0x7e => {
181                // stdin surrogateescape sentinel: json.dumps writes the lone
182                // surrogate itself — `\udcXX` under ensure_ascii, the raw
183                // surrogate char otherwise (which stdout emission then
184                // re-encodes as the original byte; keep the sentinel here).
185                let cp = c as u32;
186                if let Some(sur) = crate::pycompat::sentinel_surrogate(c) {
187                    write!(out, "\\u{sur:04x}").unwrap();
188                } else if cp <= 0xffff {
189                    write!(out, "\\u{cp:04x}").unwrap();
190                } else {
191                    let v = cp - 0x10000;
192                    let hi = 0xd800 + (v >> 10);
193                    let lo = 0xdc00 + (v & 0x3ff);
194                    write!(out, "\\u{hi:04x}\\u{lo:04x}").unwrap();
195                }
196            }
197            c => out.push(c),
198        }
199    }
200    out.push('"');
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use serde_json::json;
207
208    #[test]
209    fn indent2_layout() {
210        let v = json!({"a": [], "b": {}, "c": [1], "d": {"x": 1}});
211        assert_eq!(
212            dumps_indent2(&v),
213            "{\n  \"a\": [],\n  \"b\": {},\n  \"c\": [\n    1\n  ],\n  \"d\": {\n    \"x\": 1\n  }\n}"
214        );
215    }
216
217    #[test]
218    fn ensure_ascii_split() {
219        let v = json!({"u": "café 🎉"});
220        assert_eq!(
221            dumps_indent2(&v),
222            "{\n  \"u\": \"caf\\u00e9 \\ud83c\\udf89\"\n}"
223        );
224        assert_eq!(dumps_compact(&v), "{\"u\": \"café 🎉\"}");
225    }
226
227    #[test]
228    fn int_vs_float_form() {
229        let v = json!({"i": 2, "f": py_float(2.0), "t": 1e-5});
230        assert_eq!(dumps_compact(&v), "{\"i\": 2, \"f\": 2.0, \"t\": 1e-05}");
231    }
232
233    #[test]
234    fn canonical_sorted_dialect() {
235        // json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
236        let v = json!([{"title": "café — x", "identifier": "A", "category": null}]);
237        assert_eq!(
238            dumps_canonical_sorted(&v),
239            "[{\"category\":null,\"identifier\":\"A\",\"title\":\"café — x\"}]"
240        );
241    }
242}