Skip to main content

ai_agent/
cli_ndjson_safe_stringify.rs

1// Source: ~/claudecode/openclaudecode/src/cli/ndjsonSafeStringify.ts
2//! NDJSON-safe JSON serialization.
3//!
4//! JSON.stringify emits U+2028/U+2029 raw (valid per ECMA-404). When the
5//! output is a single NDJSON line, any receiver that uses JavaScript
6//! line-terminator semantics (ECMA-262 §11.3 — \n \r U+2028 U+2029) to
7//! split the stream will cut the JSON mid-string.
8//!
9//! The \uXXXX form is equivalent JSON but can never be mistaken for a
10//! line terminator by ANY receiver.
11
12#![allow(dead_code)]
13
14/// Escape U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR) in JSON output.
15///
16/// These characters are valid in JSON strings per ECMA-404 but treated as
17/// line terminators in JavaScript (ECMA-262). When NDJSON output is consumed
18/// by JS code that splits on line terminators, unescaped U+2028/U+2029 will
19/// silently break the stream.
20fn escape_js_line_terminators(json: &str) -> String {
21    json.replace('\u{2028}', "\\u2028")
22        .replace('\u{2029}', "\\u2029")
23}
24
25/// Serialize any serde value to NDJSON-safe JSON.
26pub fn serialize_to_ndjson<T: serde::Serialize>(value: &T) -> Result<String, serde_json::Error> {
27    let json = serde_json::to_string(value)?;
28    Ok(escape_js_line_terminators(&json))
29}
30
31/// Serialize a JSON value to NDJSON-safe string, falling back to empty string on error.
32pub fn serialize_to_ndjson_safe(value: &serde_json::Value) -> String {
33    let json = serde_json::to_string(value).unwrap_or_default();
34    escape_js_line_terminators(&json)
35}
36
37/// Escape U+2028/U+2029 in an arbitrary string (not just JSON).
38/// Useful for session transcript content that may contain these characters.
39pub fn escape_unicode_line_separators(text: &str) -> String {
40    escape_js_line_terminators(text)
41}
42
43#[cfg(test)]
44mod tests {
45    use super::*;
46
47    #[test]
48    fn test_escape_u2028() {
49        let json = r#""hello
world""#;
50        // The actual U+2028 character in a string
51        let input = "hello\u{2028}world";
52        let quoted = format!("\"{}\"", input);
53        let escaped = escape_js_line_terminators(&quoted);
54        assert!(escaped.contains("\\u2028"));
55        assert!(!escaped.contains('\u{2028}'));
56    }
57
58    #[test]
59    fn test_escape_u2029() {
60        let input = "hello\u{2029}world";
61        let quoted = format!("\"{}\"", input);
62        let escaped = escape_js_line_terminators(&quoted);
63        assert!(escaped.contains("\\u2029"));
64        assert!(!escaped.contains('\u{2029}'));
65    }
66
67    #[test]
68    fn test_escape_both() {
69        let input = "\u{2028}start\u{2029}middle\u{2028}end";
70        let escaped = escape_js_line_terminators(input);
71        assert_eq!(escaped, "\\u2028start\\u2029middle\\u2028end");
72    }
73
74    #[test]
75    fn test_no_escape_needed() {
76        let input = r#""hello world""#;
77        let escaped = escape_js_line_terminators(input);
78        assert_eq!(escaped, r#""hello world""#);
79    }
80
81    #[test]
82    fn test_serialize_to_ndjson_safe() {
83        let value = serde_json::json!("test\u{2028}value\u{2029}end");
84        let result = serialize_to_ndjson_safe(&value);
85        assert!(result.contains("\\u2028"));
86        assert!(result.contains("\\u2029"));
87        // Result must be valid JSON
88        assert!(serde_json::from_str::<serde_json::Value>(&result).is_ok());
89    }
90
91    #[test]
92    fn test_serialize_to_ndjson_with_struct() {
93        #[derive(serde::Serialize)]
94        struct Test {
95            text: String,
96        }
97        let t = Test {
98            text: "line\u{2028}separator".to_string(),
99        };
100        let result = serialize_to_ndjson(&t).unwrap();
101        assert!(result.contains("\\u2028"));
102    }
103
104    #[test]
105    fn test_escape_unicode_line_separators() {
106        let input = "normal\nline\u{2028}separator\u{2029}paragraph";
107        let escaped = escape_unicode_line_separators(&input);
108        // Regular newlines should be preserved
109        assert!(escaped.contains('\n'));
110        // U+2028/U+2029 should be escaped
111        assert!(escaped.contains("\\u2028"));
112        assert!(escaped.contains("\\u2029"));
113    }
114
115    #[test]
116    fn test_roundtrip_parsing() {
117        // Verify escaped JSON parses to the same value
118        let original = "hello\u{2028}world\u{2029}!";
119        let value = serde_json::json!(original);
120        let escaped = serialize_to_ndjson_safe(&value);
121        let parsed = serde_json::from_str::<serde_json::Value>(&escaped).unwrap();
122        assert_eq!(parsed.as_str().unwrap(), original);
123    }
124}