Skip to main content

jigs_log/
json.rs

1use jigs_trace::Entry;
2
3/// Render entries as newline-delimited JSON, one object per line.
4///
5/// Each object has fields: `name`, `depth`, `duration_ns`, `ok`, and `error`
6/// (omitted when absent).
7pub fn render_ndjson(entries: &[Entry]) -> String {
8    let mut out = String::new();
9    for e in entries {
10        out.push('{');
11        push_field_str(&mut out, "name", e.name);
12        out.push(',');
13        push_field_num(&mut out, "depth", e.depth as u128);
14        out.push(',');
15        push_field_num(&mut out, "duration_ns", e.duration.as_nanos());
16        out.push(',');
17        push_field_bool(&mut out, "ok", e.ok);
18        if let Some(err) = &e.error {
19            out.push(',');
20            push_field_str(&mut out, "error", err);
21        }
22        out.push_str("}\n");
23    }
24    out
25}
26
27fn push_field_str(out: &mut String, key: &str, value: &str) {
28    out.push('"');
29    out.push_str(key);
30    out.push_str("\":");
31    push_json_str(out, value);
32}
33
34fn push_field_num(out: &mut String, key: &str, value: u128) {
35    out.push('"');
36    out.push_str(key);
37    out.push_str("\":");
38    out.push_str(&value.to_string());
39}
40
41fn push_field_bool(out: &mut String, key: &str, value: bool) {
42    out.push('"');
43    out.push_str(key);
44    out.push_str("\":");
45    out.push_str(if value { "true" } else { "false" });
46}
47
48fn push_json_str(out: &mut String, s: &str) {
49    out.push('"');
50    for c in s.chars() {
51        match c {
52            '"' => out.push_str("\\\""),
53            '\\' => out.push_str("\\\\"),
54            '\n' => out.push_str("\\n"),
55            '\r' => out.push_str("\\r"),
56            '\t' => out.push_str("\\t"),
57            c if (c as u32) < 0x20 => {
58                out.push_str(&format!("\\u{:04x}", c as u32));
59            }
60            c => out.push(c),
61        }
62    }
63    out.push('"');
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use std::time::Duration;
70
71    fn entry(name: &'static str, depth: usize, ok: bool, err: Option<&str>) -> Entry {
72        Entry {
73            name,
74            depth,
75            duration: Duration::from_nanos(1_500),
76            ok,
77            error: err.map(|s| s.to_string()),
78        }
79    }
80
81    #[test]
82    fn empty_input_renders_empty_string() {
83        assert_eq!(render_ndjson(&[]), "");
84    }
85
86    #[test]
87    fn one_entry_per_line_with_required_fields() {
88        let entries = [entry("step", 2, true, None)];
89        let out = render_ndjson(&entries);
90        assert_eq!(
91            out,
92            "{\"name\":\"step\",\"depth\":2,\"duration_ns\":1500,\"ok\":true}\n"
93        );
94    }
95
96    #[test]
97    fn errors_are_included_and_escaped() {
98        let entries = [entry("bad", 0, false, Some("quote: \" and \\ slash"))];
99        let out = render_ndjson(&entries);
100        assert!(out.contains("\"ok\":false"));
101        assert!(out.contains("\"error\":\"quote: \\\" and \\\\ slash\""));
102        assert!(out.ends_with("\n"));
103    }
104
105    #[test]
106    fn newlines_in_strings_are_escaped() {
107        let entries = [entry("x", 0, false, Some("line1\nline2"))];
108        let out = render_ndjson(&entries);
109        assert!(out.contains("line1\\nline2"));
110        // Exactly one physical line.
111        assert_eq!(out.matches('\n').count(), 1);
112    }
113}