1use serde_json::Value;
4use sha2::{Digest, Sha256};
5use std::sync::LazyLock;
6
7use regex::Regex;
8
9use crate::error::{Result, SparError};
10
11static FENCE: LazyLock<Regex> = LazyLock::new(|| {
12 Regex::new(r"(?s)```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```").expect("fence pattern")
13});
14
15pub fn extract_json(text: &str) -> Result<Value> {
22 if text.trim().is_empty() {
23 return Err(SparError::new("empty response, expected JSON"));
24 }
25 candidates(text)
26 .into_iter()
27 .next()
28 .ok_or_else(|| SparError::new(format!("no JSON found in response:\n{}", head(text, 800))))
29}
30
31pub fn candidates(text: &str) -> Vec<Value> {
36 let mut out = Vec::new();
37 let mut push = |value: Value| {
38 if !out.contains(&value) {
39 out.push(value);
40 }
41 };
42
43 let fenced: Vec<&str> = FENCE
44 .captures_iter(text)
45 .filter_map(|c| c.get(1).map(|m| m.as_str()))
46 .collect();
47 for blob in fenced.iter().rev() {
48 if let Ok(value) = serde_json::from_str::<Value>(blob) {
49 push(value);
50 }
51 }
52
53 if let Ok(value) = serde_json::from_str::<Value>(text.trim()) {
56 push(value);
57 }
58
59 for opener in *b"{[" {
60 for (start, end) in spans(text, opener) {
61 if let Ok(value) = serde_json::from_str::<Value>(&text[start..end]) {
62 push(value);
63 }
64 }
65 }
66 out
67}
68
69fn spans(text: &str, opener: u8) -> Vec<(usize, usize)> {
82 let closer = if opener == b'{' { b'}' } else { b']' };
83 let bytes = text.as_bytes();
84 let mut open_at: Vec<usize> = Vec::new();
85 let mut found: Vec<(usize, usize)> = Vec::new();
86 let mut in_string = false;
87 let mut escaped = false;
88 for (i, byte) in bytes.iter().enumerate() {
89 if escaped {
90 escaped = false;
91 continue;
92 }
93 match *byte {
94 b'\\' if in_string => escaped = true,
95 b'"' => in_string = !in_string,
96 _ if in_string => {}
97 b if b == opener => open_at.push(i),
98 b if b == closer => {
99 if let Some(start) = open_at.pop() {
100 found.push((start, i + 1));
101 }
102 }
103 _ => {}
104 }
105 }
106 found.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
107 found
108}
109
110pub fn looks_truncated(text: &str) -> bool {
116 let mut opened = 0i64;
117 let mut in_string = false;
118 let mut escaped = false;
119 for c in text.chars() {
120 if escaped {
121 escaped = false;
122 continue;
123 }
124 match c {
125 '\\' if in_string => escaped = true,
126 '"' => in_string = !in_string,
127 '{' if !in_string => opened += 1,
128 '}' if !in_string => opened -= 1,
129 _ => {}
130 }
131 }
132 opened > 0
133}
134
135pub fn extract_into<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
142 let found = candidates(text);
143 if found.is_empty() {
144 return Err(SparError::new(if looks_truncated(text) {
145 format!(
146 "the response was cut off before any complete JSON:\n{}",
147 head(text, 400)
148 )
149 } else {
150 format!("no JSON found in response:\n{}", head(text, 800))
151 }));
152 }
153
154 let mut failures: Vec<(serde_json::Error, &Value)> = Vec::new();
166 for value in &found {
167 match serde_json::from_value::<T>(value.clone()) {
168 Ok(parsed) => return Ok(parsed),
169 Err(e) => failures.push((e, value)),
170 }
171 }
172 let (error, value) = failures
173 .into_iter()
174 .max_by_key(|(_, value)| value.to_string().len())
175 .expect("non-empty");
176 if looks_truncated(text) {
177 return Err(SparError::new(format!(
178 "the response was cut off before the answer was complete, so only fragments of it \
179 parsed ({error}). Ask for less in one go, or give this agent a CLI flag for native \
180 structured output."
181 )));
182 }
183 Err(SparError::new(format!(
184 "response did not match the expected shape ({error}).{}\nGot: {}",
185 envelope_hint(value),
186 head(&value.to_string(), 600)
187 )))
188}
189
190fn envelope_hint(value: &Value) -> &'static str {
200 if value.is_array() {
201 " The answer was a JSON array, and the schema asks for a single object: \
202 the array belongs in a field of it."
203 } else {
204 ""
205 }
206}
207
208fn head(text: &str, max: usize) -> String {
209 text.chars().take(max).collect()
210}
211
212pub fn finding_key(title: &str, file: &str) -> String {
215 let basis: String = format!("{} {}", title.trim(), file.trim())
216 .to_lowercase()
217 .chars()
218 .filter(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '/' | '.' | '_' | '-'))
219 .collect();
220 let basis = basis.split_whitespace().collect::<Vec<_>>().join(" ");
221 short_hash(&basis)
222}
223
224pub(crate) fn exact_finding_key(title: &str, file: &str) -> String {
231 identity_key(title, file.trim())
232}
233
234pub(crate) fn stable_finding_key(title: &str, file: &str) -> String {
236 identity_key(title, &finding_file(file))
237}
238
239pub(crate) fn untagged_title(title: &str) -> &str {
249 let trimmed = title.trim();
250 let Some(rest) = trimmed.strip_prefix('[') else {
251 return trimmed;
252 };
253 let Some((tag, rest)) = rest.split_once(']') else {
254 return trimmed;
255 };
256 if crate::model::Severity::parse_lenient(tag.trim()).is_none() {
257 return trimmed;
258 }
259 let rest = rest.trim_start();
260 if rest.is_empty() {
261 return trimmed;
262 }
263 rest
264}
265
266fn identity_key(title: &str, file: &str) -> String {
267 let title: String = untagged_title(title)
268 .to_lowercase()
269 .chars()
270 .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
271 .collect();
272 let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
273 let basis = format!("{title}\0{file}");
274
275 short_hash(&basis)
276}
277
278fn short_hash(basis: &str) -> String {
279 let digest = Sha256::digest(basis.as_bytes());
280 let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
281 hex[..12].to_string()
282}
283
284pub(crate) fn finding_file(file: &str) -> String {
289 split_location(file).0.to_string()
290}
291
292pub(crate) fn finding_location(file: &str) -> Option<(String, u32, u32)> {
299 let (path, lines) = split_location(file);
300 let lines = lines?;
301 let (start, end) = match lines.split_once('-') {
302 Some((start, end)) => (start.parse::<u32>().ok()?, end.parse::<u32>().ok()?),
303 None => {
304 let line = lines.parse::<u32>().ok()?;
305 (line, line)
306 }
307 };
308 (!path.is_empty() && start > 0 && start <= end).then(|| (path.to_string(), start, end))
309}
310
311fn split_location(file: &str) -> (&str, Option<&str>) {
313 let mut path = file.trim();
314 let mut lines = None;
315 while let Some((head, suffix)) = path.rsplit_once(':') {
316 let is_number = !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit());
317 let is_range = suffix.split_once('-').is_some_and(|(start, end)| {
318 !start.is_empty()
319 && !end.is_empty()
320 && start.chars().all(|c| c.is_ascii_digit())
321 && end.chars().all(|c| c.is_ascii_digit())
322 });
323 if !is_number && !is_range {
324 break;
325 }
326 lines = Some(suffix);
327 path = head.trim_end();
328 }
329 (path, lines)
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn bare_object() {
338 assert_eq!(
339 serde_json::json!({"a": 1}),
340 extract_json(r#"{"a": 1}"#).unwrap()
341 );
342 }
343
344 #[test]
345 fn fenced_block() {
346 let out = extract_json("here you go:\n```json\n{\"a\": 1}\n```\n").unwrap();
347 assert_eq!(serde_json::json!({"a": 1}), out);
348 }
349
350 #[test]
351 fn trailing_prose() {
352 let out = extract_json("Thoughts...\n{\"verdict\": \"approve\"}\nDone.").unwrap();
353 assert_eq!(serde_json::json!({"verdict": "approve"}), out);
354 }
355
356 #[test]
357 fn picks_the_last_fenced_block() {
358 let text = "```json\n{\"n\": 1}\n```\nrevised:\n```json\n{\"n\": 2}\n```";
359 assert_eq!(serde_json::json!({"n": 2}), extract_json(text).unwrap());
360 }
361
362 #[test]
363 fn nested_braces() {
364 let payload = r#"{"findings": [{"severity": "nit", "d": {"x": [1, 2]}}]}"#;
365 let out = extract_json(&format!("blah {payload} blah")).unwrap();
366 assert_eq!(1, out["findings"].as_array().unwrap().len());
367 }
368
369 #[test]
370 fn top_level_array() {
371 let out = extract_json("result: [1, 2, 3]").unwrap();
372 assert_eq!(3, out.as_array().unwrap().len());
373 }
374
375 #[test]
376 fn multibyte_prose_around_the_payload_does_not_panic() {
377 let out = extract_json("\u{1f600}\u{1f600} {\"a\": 1} \u{1f600}").unwrap();
378 assert_eq!(serde_json::json!({"a": 1}), out);
379 }
380
381 #[test]
382 fn raises_when_there_is_none() {
383 assert!(extract_json("no json here at all").is_err());
384 }
385
386 #[test]
387 fn raises_on_empty() {
388 assert!(extract_json(" ").is_err());
389 }
390
391 #[test]
392 fn malformed_trailing_object_falls_back_to_an_earlier_one() {
393 let text = "{\"good\": true}\nthen: {\"bad\": ,}";
394 assert_eq!(
395 serde_json::json!({"good": true}),
396 extract_json(text).unwrap()
397 );
398 }
399
400 #[test]
403 fn key_is_stable_across_wording_noise() {
404 assert_eq!(
405 finding_key("Unbounded loop!", "src/x.rs"),
406 finding_key("unbounded loop", "src/x.rs")
407 );
408 }
409
410 #[test]
411 fn key_differs_by_file() {
412 assert_ne!(finding_key("t", "a.rs"), finding_key("t", "b.rs"));
413 }
414
415 #[test]
416 fn public_key_keeps_its_original_case_insensitive_paths() {
417 assert_eq!(
418 finding_key("t", "src/Main.rs"),
419 finding_key("t", "src/main.rs")
420 );
421 assert_ne!(
422 exact_finding_key("t", "src/Main.rs"),
423 exact_finding_key("t", "src/main.rs")
424 );
425 }
426
427 #[test]
428 fn key_is_stable_across_whitespace() {
429 assert_eq!(finding_key("a b", "x.rs"), finding_key(" a b ", "x.rs"));
430 }
431
432 #[test]
433 fn a_severity_tag_does_not_change_a_title() {
434 assert_eq!(
435 "Unbounded loop",
436 untagged_title("[blocking] Unbounded loop")
437 );
438 assert_eq!("Unbounded loop", untagged_title(" [ NIT ]Unbounded loop "));
439 assert_eq!(
440 exact_finding_key("Unbounded loop", "x.rs"),
441 exact_finding_key("[non-blocking] Unbounded loop", "x.rs")
442 );
443 }
444
445 #[test]
446 fn a_bracketed_subject_is_part_of_the_title() {
447 assert_eq!("[iOS] Startup crash", untagged_title("[iOS] Startup crash"));
448 assert_eq!("[blocking]", untagged_title("[blocking]"));
449 assert_eq!("[blocking Unbounded", untagged_title("[blocking Unbounded"));
450 assert_ne!(
451 exact_finding_key("[iOS] Startup crash", "x.rs"),
452 exact_finding_key("[Android] Startup crash", "x.rs")
453 );
454 }
455
456 #[test]
457 fn exact_key_keeps_distinct_locations() {
458 assert_ne!(
459 exact_finding_key("t", "src/net.rs:88"),
460 exact_finding_key("t", "src/net.rs:91")
461 );
462 assert_eq!(
463 stable_finding_key("t", "src/net.rs:88"),
464 stable_finding_key("t", "src/net.rs:91")
465 );
466 assert_eq!(
467 stable_finding_key("t", "src/net.rs:88-94"),
468 stable_finding_key("t", "src/net.rs")
469 );
470 assert_eq!(
471 stable_finding_key("t", "src/net.rs:88:12"),
472 stable_finding_key("t", "src/net.rs")
473 );
474 }
475
476 #[test]
477 fn a_numeric_filename_is_not_treated_as_a_line_number() {
478 assert_eq!("fixtures/2024", finding_file("fixtures/2024"));
479 }
480
481 #[test]
482 fn a_location_names_its_path_and_lines() {
483 let at = |path: &str, start, end| Some((path.to_string(), start, end));
484 assert_eq!(at("src/net.rs", 88, 88), finding_location("src/net.rs:88"));
485 assert_eq!(
486 at("src/net.rs", 88, 94),
487 finding_location(" src/net.rs:88-94 ")
488 );
489 assert_eq!(
490 at("src/net.rs", 88, 88),
491 finding_location("src/net.rs:88:12")
492 );
493 }
494
495 #[test]
496 fn a_location_without_a_usable_line_is_none() {
497 assert_eq!(None, finding_location("src/net.rs"));
498 assert_eq!(None, finding_location(""));
499 assert_eq!(None, finding_location("fixtures/2024"));
500 assert_eq!(None, finding_location("src/net.rs:0"));
501 assert_eq!(None, finding_location("src/net.rs:94-88"));
502 assert_eq!(None, finding_location(":88"));
503 assert_eq!(None, finding_location("src/net.rs:99999999999"));
504 }
505
506 #[test]
507 fn key_is_twelve_hex_characters() {
508 let key = finding_key("anything", "file.rs");
509 assert_eq!(12, key.len());
510 assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
511 }
512}
513
514#[cfg(test)]
515mod brace_tests {
516 use super::*;
517
518 #[test]
526 fn a_brace_inside_a_string_does_not_lose_the_answer() {
527 let text = r#"{"verdict":"approve","next_action":"merge","summary":"the loop `for (;;) {` never breaks","findings":[]}"#;
528 let found = candidates(text);
529 assert!(!found.is_empty(), "nothing was found at all");
530 assert_eq!(
531 "approve",
532 found[0]["verdict"].as_str().unwrap_or_default(),
533 "the outer object was lost: {found:?}"
534 );
535 }
536
537 #[test]
539 fn an_unbalanced_closer_inside_a_string_is_not_a_closer() {
540 let text = r#"prose first
541{"summary":"the guard ends with } and no else","findings":[{"title":"a {"}]}"#;
542 let found = candidates(text);
543 assert_eq!(
544 "the guard ends with } and no else",
545 found[0]["summary"].as_str().unwrap_or_default(),
546 "{found:?}"
547 );
548 }
549
550 #[test]
553 fn the_whole_answer_is_tried_before_anything_is_scanned() {
554 let found = candidates(" {\"a\":1}\n");
555 assert_eq!(1, found[0]["a"].as_i64().unwrap_or_default());
556 }
557
558 #[test]
560 fn an_array_with_braces_in_its_strings_still_parses() {
561 let text = r#"here it is: [{"title":"missing } in the macro"},{"title":"second"}]"#;
562 let found = candidates(text);
563 let array = found
564 .iter()
565 .find(|v| v.is_array())
566 .expect("the array was lost");
567 assert_eq!(2, array.as_array().unwrap().len());
568 }
569}
570
571#[cfg(test)]
572mod truncation_tests {
573 use super::*;
574 use serde::Deserialize;
575
576 #[derive(Debug, Deserialize)]
577 struct Review {
578 verdict: String,
579 findings: Vec<Finding>,
580 }
581 #[derive(Debug, Deserialize)]
582 struct Finding {
583 title: String,
584 }
585
586 const TRUNCATED: &str = r#"Here is my review.
591{"verdict":"changes_requested","next_action":"hand_back","summary":"Two problems.",
592 "findings":[
593 {"severity":"blocking","title":"First","detail":"one","file":"a.ts","in_scope":true},
594 {"severity":"non-blocking","title":"Second","detail":"numbers unnamed keys by position"#;
595
596 #[test]
597 fn a_truncated_review_is_reported_as_truncated_not_as_the_wrong_shape() {
598 let err = extract_into::<Review>(TRUNCATED).unwrap_err().to_string();
599 assert!(err.contains("cut off"), "{err}");
600 assert!(!err.contains("did not match the expected shape"), "{err}");
601 }
602
603 #[test]
604 fn truncation_is_detected_from_the_unclosed_braces() {
605 assert!(looks_truncated(TRUNCATED));
606 assert!(!looks_truncated(r#"{"a":1}"#));
607 assert!(!looks_truncated(r#"{"a":"a { in a string"}"#));
609 assert!(!looks_truncated(r#"{"a":"an escaped \" quote { here"}"#));
610 }
611
612 #[test]
615 fn the_review_is_found_even_with_nested_objects_after_it() {
616 let text = r#"Thinking out loud first.
617{"verdict":"approve","next_action":"merge","summary":"Fine.","findings":[{"severity":"nit","title":"Wording","detail":"d","file":"a.ts","in_scope":true}]}
618And here is a stray object afterwards: {"title":"not the review"}"#;
619 let review: Review = extract_into(text).unwrap();
620 assert_eq!("approve", review.verdict);
621 assert_eq!(1, review.findings.len());
622 assert_eq!("Wording", review.findings[0].title);
623 }
624
625 #[test]
626 fn candidates_are_offered_most_likely_first() {
627 let text = "```json\n{\"verdict\":\"approve\",\"findings\":[]}\n```\ntrailing {\"x\":1}";
628 let review: Review = extract_into(text).unwrap();
629 assert_eq!("approve", review.verdict);
630 }
631
632 #[test]
633 fn a_genuinely_wrong_shape_still_says_so() {
634 let err = extract_into::<Review>(r#"{"colour":"blue"}"#)
635 .unwrap_err()
636 .to_string();
637 assert!(err.contains("did not match the expected shape"), "{err}");
638 assert!(!err.contains("cut off"), "{err}");
639 }
640
641 #[test]
648 fn the_complaint_is_about_the_answer_the_model_meant() {
649 let text = r#"Here is my review.
650{"verdict":"changes_requested","next_action":"hand_back","summary":"Two problems.","findings":"should have been a list"}
651Supporting detail: [{"detail":"The working tree bumps 0.5.9 to 0.5.10."}]"#;
652 let err = extract_into::<Review>(text).unwrap_err().to_string();
653 assert!(err.contains("findings"), "{err}");
655 assert!(
656 err.contains("changes_requested"),
657 "the object is shown:\n{err}"
658 );
659 assert!(
660 !err.contains("The working tree"),
661 "the stray array leaked in:\n{err}"
662 );
663 }
664
665 #[test]
669 fn a_bare_array_is_named_as_the_envelope_problem() {
670 let err = extract_into::<Review>(r#"[{"title":"First"},{"title":"Second"}]"#)
671 .unwrap_err()
672 .to_string();
673 assert!(err.contains("JSON array"), "{err}");
674 assert!(err.contains("single object"), "{err}");
675 }
676
677 #[test]
679 fn a_wrong_object_is_not_told_it_was_an_array() {
680 let err = extract_into::<Review>(r#"{"colour":"blue"}"#)
681 .unwrap_err()
682 .to_string();
683 assert!(!err.contains("JSON array"), "{err}");
684 }
685
686 #[test]
687 fn nothing_parseable_is_still_reported_plainly() {
688 let err = extract_into::<Review>("no json at all")
689 .unwrap_err()
690 .to_string();
691 assert!(err.contains("no JSON found"), "{err}");
692 }
693}