Skip to main content

xchecker_runner/
ndjson.rs

1/// Result of NDJSON parsing from stdout
2#[derive(Debug, Clone)]
3pub enum NdjsonResult {
4    /// Successfully found at least one valid JSON object (returns the last one)
5    ValidJson(String),
6    /// No valid JSON found, includes a tail excerpt for error reporting
7    NoValidJson { tail_excerpt: String },
8}
9
10pub(crate) fn parse_ndjson(stdout: &str) -> NdjsonResult {
11    let mut last_valid_json: Option<String> = None;
12
13    // Parse line by line
14    for line in stdout.lines() {
15        let trimmed = line.trim();
16
17        // Skip empty lines
18        if trimmed.is_empty() {
19            continue;
20        }
21
22        // Try to parse as JSON
23        if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
24            // Valid JSON - store it as the last valid object
25            // We serialize it back to ensure it's a valid JSON string
26            if let Ok(json_str) = serde_json::to_string(&value) {
27                last_valid_json = Some(json_str);
28            }
29        }
30        // If parsing fails, ignore the line (it's noise)
31    }
32
33    // Return the last valid JSON if we found any
34    if let Some(json) = last_valid_json {
35        NdjsonResult::ValidJson(json)
36    } else {
37        // No valid JSON found - create a tail excerpt
38        // Take up to 256 characters from the end of stdout
39        let tail_excerpt = if stdout.len() <= 256 {
40            stdout.to_string()
41        } else {
42            // Take the last 256 characters
43            let start = stdout.len() - 256;
44            stdout[start..].to_string()
45        };
46
47        NdjsonResult::NoValidJson { tail_excerpt }
48    }
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    // NDJSON parsing tests
56
57    #[test]
58    fn test_parse_ndjson_single_valid_json() {
59        let stdout = r#"{"status": "success", "result": "done"}"#;
60        let result = parse_ndjson(stdout);
61
62        match result {
63            NdjsonResult::ValidJson(json) => {
64                let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
65                assert_eq!(parsed["status"], "success");
66                assert_eq!(parsed["result"], "done");
67            }
68            NdjsonResult::NoValidJson { .. } => panic!("Expected ValidJson"),
69        }
70    }
71
72    #[test]
73    fn test_parse_ndjson_multiple_valid_json_returns_last() {
74        let stdout = r#"{"frame": 1}
75{"frame": 2}
76{"frame": 3}"#;
77        let result = parse_ndjson(stdout);
78
79        match result {
80            NdjsonResult::ValidJson(json) => {
81                let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
82                assert_eq!(parsed["frame"], 3);
83            }
84            NdjsonResult::NoValidJson { .. } => panic!("Expected ValidJson"),
85        }
86    }
87
88    #[test]
89    fn test_parse_ndjson_interleaved_noise_and_json() {
90        // AT-RUN-004: Interleaved noise + multiple JSON frames → last valid frame wins
91        let stdout = r#"Starting process...
92{"frame": 1, "status": "initializing"}
93Some debug output
94Warning: something happened
95{"frame": 2, "status": "processing"}
96More noise here
97{"frame": 3, "status": "complete"}
98Done!"#;
99        let result = parse_ndjson(stdout);
100
101        match result {
102            NdjsonResult::ValidJson(json) => {
103                let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
104                assert_eq!(parsed["frame"], 3);
105                assert_eq!(parsed["status"], "complete");
106            }
107            NdjsonResult::NoValidJson { .. } => panic!("Expected ValidJson"),
108        }
109    }
110
111    #[test]
112    fn test_parse_ndjson_no_valid_json() {
113        let stdout = "This is just plain text\nNo JSON here\nJust noise";
114        let result = parse_ndjson(stdout);
115
116        match result {
117            NdjsonResult::ValidJson(_) => panic!("Expected NoValidJson"),
118            NdjsonResult::NoValidJson { tail_excerpt } => {
119                assert_eq!(tail_excerpt, stdout);
120            }
121        }
122    }
123
124    #[test]
125    fn test_parse_ndjson_partial_json() {
126        // AT-RUN-005: Partial JSON followed by timeout → claude_failure with excerpt
127        let stdout = r#"{"frame": 1, "status": "ok"}
128{"frame": 2, "incomplete": tru"#;
129        let result = parse_ndjson(stdout);
130
131        match result {
132            NdjsonResult::ValidJson(json) => {
133                // Should return the last valid JSON (frame 1)
134                let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
135                assert_eq!(parsed["frame"], 1);
136                assert_eq!(parsed["status"], "ok");
137            }
138            NdjsonResult::NoValidJson { .. } => panic!("Expected ValidJson from first frame"),
139        }
140    }
141
142    #[test]
143    fn test_parse_ndjson_only_partial_json() {
144        let stdout = r#"{"incomplete": tru"#;
145        let result = parse_ndjson(stdout);
146
147        match result {
148            NdjsonResult::ValidJson(_) => panic!("Expected NoValidJson"),
149            NdjsonResult::NoValidJson { tail_excerpt } => {
150                assert_eq!(tail_excerpt, stdout);
151            }
152        }
153    }
154
155    #[test]
156    fn test_parse_ndjson_empty_string() {
157        let stdout = "";
158        let result = parse_ndjson(stdout);
159
160        match result {
161            NdjsonResult::ValidJson(_) => panic!("Expected NoValidJson"),
162            NdjsonResult::NoValidJson { tail_excerpt } => {
163                assert_eq!(tail_excerpt, "");
164            }
165        }
166    }
167
168    #[test]
169    fn test_parse_ndjson_only_whitespace() {
170        let stdout = "   \n\n  \t  \n";
171        let result = parse_ndjson(stdout);
172
173        match result {
174            NdjsonResult::ValidJson(_) => panic!("Expected NoValidJson"),
175            NdjsonResult::NoValidJson { tail_excerpt } => {
176                assert_eq!(tail_excerpt, stdout);
177            }
178        }
179    }
180
181    #[test]
182    fn test_parse_ndjson_tail_excerpt_truncation() {
183        // Create a string longer than 256 characters
184        let long_text = "x".repeat(300);
185        let result = parse_ndjson(&long_text);
186
187        match result {
188            NdjsonResult::ValidJson(_) => panic!("Expected NoValidJson"),
189            NdjsonResult::NoValidJson { tail_excerpt } => {
190                assert_eq!(tail_excerpt.len(), 256);
191                // Should be the last 256 characters
192                assert_eq!(tail_excerpt, "x".repeat(256));
193            }
194        }
195    }
196
197    #[test]
198    fn test_parse_ndjson_tail_excerpt_no_truncation() {
199        let short_text = "Short text";
200        let result = parse_ndjson(short_text);
201
202        match result {
203            NdjsonResult::ValidJson(_) => panic!("Expected NoValidJson"),
204            NdjsonResult::NoValidJson { tail_excerpt } => {
205                assert_eq!(tail_excerpt, short_text);
206            }
207        }
208    }
209
210    #[test]
211    fn test_parse_ndjson_malformed_json_lines() {
212        let stdout = r#"{"valid": "json"}
213{malformed json}
214{"another": "valid"}
215[not an object]
216{"final": "valid"}"#;
217        let result = parse_ndjson(stdout);
218
219        match result {
220            NdjsonResult::ValidJson(json) => {
221                let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
222                assert_eq!(parsed["final"], "valid");
223            }
224            NdjsonResult::NoValidJson { .. } => panic!("Expected ValidJson"),
225        }
226    }
227
228    #[test]
229    fn test_parse_ndjson_json_array_is_valid() {
230        // Arrays are valid JSON, should be accepted
231        let stdout = r#"[1, 2, 3]
232{"object": "value"}
233[4, 5, 6]"#;
234        let result = parse_ndjson(stdout);
235
236        match result {
237            NdjsonResult::ValidJson(json) => {
238                let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
239                assert!(parsed.is_array());
240                assert_eq!(parsed[0], 4);
241            }
242            NdjsonResult::NoValidJson { .. } => panic!("Expected ValidJson"),
243        }
244    }
245
246    #[test]
247    fn test_parse_ndjson_json_primitives() {
248        // JSON primitives (strings, numbers, booleans, null) are valid JSON
249        let stdout = r#""string value"
25042
251true
252null
253{"final": "object"}"#;
254        let result = parse_ndjson(stdout);
255
256        match result {
257            NdjsonResult::ValidJson(json) => {
258                let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
259                assert_eq!(parsed["final"], "object");
260            }
261            NdjsonResult::NoValidJson { .. } => panic!("Expected ValidJson"),
262        }
263    }
264
265    #[test]
266    fn test_parse_ndjson_unicode_content() {
267        let stdout = r#"{"message": "Hello 世界"}
268{"emoji": "🎉🎊"}
269{"final": "完成"}"#;
270        let result = parse_ndjson(stdout);
271
272        match result {
273            NdjsonResult::ValidJson(json) => {
274                let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
275                assert_eq!(parsed["final"], "完成");
276            }
277            NdjsonResult::NoValidJson { .. } => panic!("Expected ValidJson"),
278        }
279    }
280
281    #[test]
282    fn test_parse_ndjson_escaped_characters() {
283        let stdout = r#"{"path": "C:\\Users\\test\\file.txt"}
284{"quote": "He said \\"hello\\""}
285{"final": "done"}"#;
286        let result = parse_ndjson(stdout);
287
288        match result {
289            NdjsonResult::ValidJson(json) => {
290                let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
291                assert_eq!(parsed["final"], "done");
292            }
293            NdjsonResult::NoValidJson { .. } => panic!("Expected ValidJson"),
294        }
295    }
296}