1use jigs_trace::Entry;
2
3pub 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 assert_eq!(out.matches('\n').count(), 1);
112 }
113}