1use serde::de::DeserializeOwned;
9
10#[derive(Debug, thiserror::Error)]
12pub enum LlmJsonParseError {
13 #[error("JSON repair failed: {0}")]
15 RepairFailed(String),
16
17 #[error("Deserialization failed: {details}")]
19 DeserializationFailed { details: String },
20
21 #[error("All {attempts} retry attempts failed")]
23 RetryExhausted { attempts: usize },
24}
25
26pub fn parse_llm_json<T: DeserializeOwned>(raw: &str) -> Result<T, LlmJsonParseError> {
37 let repaired = repair_json(raw)?;
38 serde_json::from_str::<T>(&repaired).map_err(|e| LlmJsonParseError::DeserializationFailed {
39 details: format!("{} (repaired JSON: {})", e, truncate(&repaired, 200)),
40 })
41}
42
43pub async fn parse_llm_json_with_retry<T, F, Fut>(
50 raw: &str,
51 max_retries: usize,
52 retry_callback: F,
53) -> Result<T, LlmJsonParseError>
54where
55 T: DeserializeOwned,
56 F: Fn(&str, &str) -> Fut,
57 Fut: std::future::Future<Output = Result<String, String>>,
58{
59 let mut current_raw = raw.to_string();
60
61 for attempt in 0..=max_retries {
62 match parse_llm_json::<T>(¤t_raw) {
63 Ok(value) => return Ok(value),
64 Err(e) if attempt < max_retries => {
65 let error_msg = e.to_string();
66 let corrected = retry_callback(¤t_raw, &error_msg)
67 .await
68 .map_err(|_| LlmJsonParseError::RetryExhausted {
69 attempts: attempt + 1,
70 })?;
71 current_raw = corrected;
72 }
73 Err(_) => {
74 return Err(LlmJsonParseError::RetryExhausted {
75 attempts: attempt + 1,
76 });
77 }
78 }
79 }
80
81 Err(LlmJsonParseError::RetryExhausted {
82 attempts: max_retries + 1,
83 })
84}
85
86fn repair_json(raw: &str) -> Result<String, LlmJsonParseError> {
88 let trimmed = raw.trim();
89
90 let stripped = strip_code_fences(trimmed);
92
93 let extracted = extract_bracket_pair(&stripped);
95
96 let no_trailing = remove_trailing_commas(&extracted);
98
99 let truncated = truncate_to_matching_bracket(&no_trailing);
101
102 if truncated.is_empty() {
104 return Err(LlmJsonParseError::RepairFailed(
105 "no JSON content found".to_string(),
106 ));
107 }
108
109 Ok(truncated)
110}
111
112fn strip_code_fences(text: &str) -> String {
114 let trimmed = text.trim();
115 if let Some(rest) = trimmed.strip_prefix("```json") {
116 if let Some(end) = rest.find("```") {
117 return rest[..end].trim().to_string();
118 }
119 return rest.trim().to_string();
121 }
122 if let Some(rest) = trimmed.strip_prefix("```") {
123 if let Some(end) = rest.find("```") {
124 return rest[..end].trim().to_string();
125 }
126 return rest.trim().to_string();
127 }
128 trimmed.to_string()
129}
130
131fn extract_bracket_pair(text: &str) -> String {
133 let bytes = text.as_bytes();
134 let start_idx = text.find(['[', '{']);
135
136 if let Some(start) = start_idx {
137 let open = bytes[start];
138 let close = if open == b'[' { b']' } else { b'}' };
139
140 let mut depth = 0i32;
141 let mut in_string = false;
142 let mut escape_next = false;
143
144 for i in start..bytes.len() {
145 let ch = bytes[i];
146 if escape_next {
147 escape_next = false;
148 continue;
149 }
150 if ch == b'\\' && in_string {
151 escape_next = true;
152 continue;
153 }
154 if ch == b'"' {
155 in_string = !in_string;
156 continue;
157 }
158 if in_string {
159 continue;
160 }
161 if ch == open {
162 depth += 1;
163 } else if ch == close {
164 depth -= 1;
165 if depth == 0 {
166 return text[start..=i].to_string();
167 }
168 }
169 }
170 }
171
172 text.to_string()
173}
174
175fn remove_trailing_commas(json: &str) -> String {
179 let mut result = String::with_capacity(json.len());
180 let chars: Vec<char> = json.chars().collect();
181 let len = chars.len();
182
183 for i in 0..len {
184 let ch = chars[i];
185 if ch == ',' {
187 let mut j = i + 1;
188 while j < len && chars[j].is_whitespace() {
189 j += 1;
190 }
191 if j < len && (chars[j] == ']' || chars[j] == '}') {
192 continue;
194 }
195 }
196 result.push(ch);
197 }
198
199 result
200}
201
202fn truncate_to_matching_bracket(json: &str) -> String {
204 let trimmed = json.trim_end();
206 if trimmed.ends_with(']') || trimmed.ends_with('}') {
207 return trimmed.to_string();
208 }
209
210 if let Some(last_close) = trimmed.rfind([']', '}']) {
212 return trimmed[..=last_close].to_string();
213 }
214
215 json.to_string()
216}
217
218fn truncate(s: &str, max_len: usize) -> String {
220 if s.len() <= max_len {
221 s.to_string()
222 } else {
223 let end = s
224 .char_indices()
225 .take(max_len)
226 .last()
227 .map(|(i, _)| i)
228 .unwrap_or(0);
229 format!("{}...", &s[..end])
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use serde::Deserialize;
237
238 #[derive(Debug, Deserialize, PartialEq)]
239 struct TestStruct {
240 name: String,
241 value: i32,
242 }
243
244 #[test]
245 fn test_parse_valid_json() {
246 let raw = r#"{"name": "test", "value": 42}"#;
247 let result: TestStruct = parse_llm_json(raw).unwrap();
248 assert_eq!(result.name, "test");
249 assert_eq!(result.value, 42);
250 }
251
252 #[test]
253 fn test_parse_json_with_code_fence() {
254 let raw = "```json\n{\"name\": \"test\", \"value\": 42}\n```";
255 let result: TestStruct = parse_llm_json(raw).unwrap();
256 assert_eq!(result.name, "test");
257 }
258
259 #[test]
260 fn test_parse_json_with_trailing_comma() {
261 let raw = r#"{"name": "test", "value": 42,}"#;
262 let result: TestStruct = parse_llm_json(raw).unwrap();
263 assert_eq!(result.name, "test");
264 assert_eq!(result.value, 42);
265 }
266
267 #[test]
268 fn test_parse_json_with_surrounding_text() {
269 let raw = "Here is the result: {\"name\": \"test\", \"value\": 42} done.";
270 let result: TestStruct = parse_llm_json(raw).unwrap();
271 assert_eq!(result.name, "test");
272 }
273
274 #[test]
275 fn test_parse_json_with_trailing_garbage() {
276 let raw = r#"{"name": "test", "value": 42} and some extra text"#;
277 let result: TestStruct = parse_llm_json(raw).unwrap();
278 assert_eq!(result.name, "test");
279 }
280
281 #[test]
282 fn test_parse_json_array_with_trailing_comma() {
283 let raw = r#"[{"name": "a", "value": 1}, {"name": "b", "value": 2},]"#;
284 let result: Vec<TestStruct> = parse_llm_json(raw).unwrap();
285 assert_eq!(result.len(), 2);
286 }
287
288 #[test]
289 fn test_parse_empty_text_fails() {
290 let result: Result<TestStruct, _> = parse_llm_json("");
291 assert!(result.is_err());
292 }
293
294 #[test]
295 fn test_parse_no_json_content_fails() {
296 let result: Result<TestStruct, _> = parse_llm_json("just some plain text");
297 assert!(result.is_err());
298 }
299
300 #[test]
301 fn test_strip_code_fences_json() {
302 let input = "```json\n{\"key\": \"val\"}\n```";
303 assert_eq!(strip_code_fences(input), "{\"key\": \"val\"}");
304 }
305
306 #[test]
307 fn test_strip_code_fences_plain() {
308 let input = "```\n{\"key\": \"val\"}\n```";
309 assert_eq!(strip_code_fences(input), "{\"key\": \"val\"}");
310 }
311
312 #[test]
313 fn test_strip_code_fences_no_fence() {
314 let input = "{\"key\": \"val\"}";
315 assert_eq!(strip_code_fences(input), "{\"key\": \"val\"}");
316 }
317
318 #[test]
319 fn test_remove_trailing_commas_object() {
320 let input = r#"{"a": 1, "b": 2,}"#;
321 assert_eq!(remove_trailing_commas(input), r#"{"a": 1, "b": 2}"#);
322 }
323
324 #[test]
325 fn test_remove_trailing_commas_array() {
326 let input = r#"[1, 2, 3,]"#;
327 assert_eq!(remove_trailing_commas(input), r#"[1, 2, 3]"#);
328 }
329
330 #[test]
331 fn test_remove_trailing_commas_nested() {
332 let input = r#"{"arr": [1, 2,], "val": 3,}"#;
333 assert_eq!(
334 remove_trailing_commas(input),
335 r#"{"arr": [1, 2], "val": 3}"#
336 );
337 }
338
339 #[test]
340 fn test_extract_bracket_pair_array() {
341 let input = "prefix [1, 2, 3] suffix";
342 assert_eq!(extract_bracket_pair(input), "[1, 2, 3]");
343 }
344
345 #[test]
346 fn test_extract_bracket_pair_object() {
347 let input = r#"text {"a": 1} more"#;
348 assert_eq!(extract_bracket_pair(input), r#"{"a": 1}"#);
349 }
350
351 #[test]
352 fn test_parse_json_no_closing_fence() {
353 let raw = "```json\n{\"name\": \"test\", \"value\": 42}";
354 let result: TestStruct = parse_llm_json(raw).unwrap();
355 assert_eq!(result.name, "test");
356 }
357
358 #[test]
359 fn test_error_display() {
360 let err = LlmJsonParseError::RepairFailed("no json".to_string());
361 assert!(err.to_string().contains("no json"));
362
363 let err = LlmJsonParseError::RetryExhausted { attempts: 3 };
364 assert!(err.to_string().contains("3"));
365 }
366
367 #[tokio::test]
368 async fn test_parse_with_retry_succeeds_on_first_try() {
369 let raw = r#"{"name": "test", "value": 42}"#;
370 let result: TestStruct = parse_llm_json_with_retry(raw, 2, |_raw, _err| async {
371 Ok("should not be called".to_string())
372 })
373 .await
374 .unwrap();
375 assert_eq!(result.name, "test");
376 }
377
378 #[tokio::test]
379 async fn test_parse_with_retry_succeeds_on_second_try() {
380 let bad_raw = "not json at all";
381 let good_raw = r#"{"name": "retry", "value": 7}"#;
382 let result: TestStruct =
383 parse_llm_json_with_retry(bad_raw, 2, |_raw, _err| async { Ok(good_raw.to_string()) })
384 .await
385 .unwrap();
386 assert_eq!(result.name, "retry");
387 assert_eq!(result.value, 7);
388 }
389
390 #[tokio::test]
391 async fn test_parse_with_retry_fails_all_attempts() {
392 let bad_raw = "not json";
393 let result: Result<TestStruct, _> =
394 parse_llm_json_with_retry(bad_raw, 1, |_raw, _err| async {
395 Ok("still not json".to_string())
396 })
397 .await;
398 assert!(result.is_err());
399 }
400}