Skip to main content

spar/
jsonx.rs

1//! Getting structured data back out of a model, and hashing it stably.
2
3use 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
15/// Pull the last JSON value out of a model response.
16///
17/// Models wrap JSON in prose or fences despite explicit instructions not to,
18/// and some emit a draft before the real answer, so the *last* well formed
19/// value wins: fenced blocks first, then the whole text, then balanced spans
20/// with the last closing one first.
21pub 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
31/// Every JSON value plausibly present in a model response, most likely first.
32///
33/// Fenced blocks come first because a model that fences its answer means it,
34/// then the whole trimmed text, then the balanced spans inside it.
35pub 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    // A CLI that returns exactly the object is the common case and needs no
54    // scanning at all.
55    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
69/// Byte ranges of balanced `{}` or `[]` pairs, last closing first.
70///
71/// String aware, which the backwards scan this replaces was not. A review whose
72/// detail quotes code is the ordinary case, and code has braces: an unbalanced
73/// `{` inside a string made the depth reach zero at the wrong byte, the
74/// substring failed to parse, and the outer object was never found at all. The
75/// answer was then reported as "a JSON array", because the `[` pass had matched
76/// the findings list, which is not what happened and sends the retry after the
77/// wrong problem.
78///
79/// Last closing first because `extract_into` tries candidates in order and a
80/// model that emits a draft before its real answer means the later one.
81fn 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
110/// Whether a response looks cut off rather than merely malformed.
111///
112/// A model with an output limit stops mid-object on a long answer. The braces
113/// it opened outnumber the ones it closed, and every complete object left is
114/// something nested inside the one it was building.
115pub 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
135/// Parse a model response straight into a typed value.
136///
137/// Tries every candidate rather than only the last one found. A review cut off
138/// before its outer object closed used to yield the last *nested* finding,
139/// which parsed as JSON perfectly well and then failed to be a review, and the
140/// error blamed the shape rather than the truncation.
141pub 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    // Which failure to report is not the same question as which candidate to
155    // parse. Any candidate that parses wins, and a stray object never will,
156    // because every schema here requires fields it does not have. But when
157    // nothing parses, this error is handed straight back to the model on the
158    // retry, so it has to be about the answer the model meant.
159    //
160    // Neither end of the list is that. The order here is really last-closing
161    // first, so the last object a response happens to contain leads, and a
162    // model that wrote its answer and then a sentence with an object in it gets
163    // told about the sentence. The biggest candidate is the better guess: an
164    // answer is longer than the fragments around it.
165    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
190/// An extra sentence when serde's own message would send the model to the wrong
191/// place.
192///
193/// serde maps a JSON array onto a struct's fields by position, so a bare array
194/// of findings tried as a review fails on field zero: "invalid type: map,
195/// expected a string", where the string is `verdict`. A model told that goes
196/// looking at a field, and the field is not what is wrong. Every schema here
197/// asks for one object, so an array is always the envelope rather than the
198/// contents.
199fn 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
212/// The original public finding key, retained for persisted-state and library
213/// compatibility.
214pub 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
224/// An exact identity for a review finding within one answer.
225///
226/// Wording noise, punctuation, and title case are discarded. The full location
227/// is retained because the same complaint at two sites in one file is two
228/// complaints. Cross-round matching uses `stable_finding_key` as a guarded
229/// fallback instead of making this identity lossy.
230pub(crate) fn exact_finding_key(title: &str, file: &str) -> String {
231    identity_key(title, file.trim())
232}
233
234/// A location-tolerant identity used only for unambiguous cross-round matches.
235pub(crate) fn stable_finding_key(title: &str, file: &str) -> String {
236    identity_key(title, &finding_file(file))
237}
238
239/// A title with a leading severity tag removed, such as `[blocking] `.
240///
241/// Reviewers and authors are told to copy a finding's title across exactly so
242/// the two can be matched up, and they mostly do, but one side decorating it
243/// with the severity it already reports in its own field is common enough to
244/// cost a round: the disposition matches nothing, the finding it answered stays
245/// open, and both are reported as unresolved. Only a bracketed tag whose word
246/// is a severity is dropped, so a title that genuinely opens with a bracketed
247/// subject keeps it and two findings that differ only there stay distinct.
248pub(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
284/// A finding's repository path without an optional trailing line or column.
285///
286/// Review locations move as fixes land. The path identifies the point across
287/// rounds, while the full location is still kept on the finding for display.
288pub(crate) fn finding_file(file: &str) -> String {
289    split_location(file).0.to_string()
290}
291
292/// A finding's path and the lines it names, first and last, from `path:N`,
293/// `path:N-M` or `path:N:C`, whose column is ignored. None when it names no
294/// line, or a line no file has.
295///
296/// What puts a finding on its line in the pull request rather than only in
297/// the summary, so a location it cannot read is no location at all.
298pub(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
311/// The path, and the first `:N` or `:N-M` after it when there is one.
312fn 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    // -- finding_key -----------------------------------------------------
401
402    #[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    /// A finding that quotes code is the ordinary case, and code has braces.
519    ///
520    /// The backwards scan counted raw bytes, so a lone `{` inside a string made
521    /// the depth reach zero at the wrong byte and the outer object was never
522    /// found. The `[` pass then offered the findings array as the only
523    /// candidate, and the model was told "the answer was a JSON array", which
524    /// is not what happened, so the retry was spent on the wrong problem.
525    #[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    /// The same, in the other direction: an unbalanced closer.
538    #[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    /// A model that answers with exactly the object needs no scanning, and
551    /// that is the common case for a CLI with native structured output.
552    #[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    /// An array answer still reaches the parser, braces in its strings and all.
559    #[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    /// What actually happened on a real pull request. A long review hit the
587    /// model's output limit and stopped before its outer object closed, so the
588    /// last complete JSON in the response was a nested finding. It parsed, it
589    /// was not a review, and the error blamed the shape.
590    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        // A brace inside a string is not an open brace.
608        assert!(!looks_truncated(r#"{"a":"a { in a string"}"#));
609        assert!(!looks_truncated(r#"{"a":"an escaped \" quote { here"}"#));
610    }
611
612    /// The fix that matters: the right object is found even when it is not the
613    /// last one in the response.
614    #[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    /// What a real round two review produced. The model wrote a review object
642    /// and a findings array, the object failed to parse, and the complaint that
643    /// went back to it described the array: "invalid type: map, expected a
644    /// string", which is field zero of a struct serde had mapped an array onto
645    /// by position. The model was sent to look at a field, and the field was
646    /// not what was wrong.
647    #[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        // The review object is what failed, and its own field is named.
654        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    /// serde maps a JSON array onto a struct by position, so a bare array of
666    /// findings fails on the first field and says "expected a string". Left at
667    /// that, the model reads it as a field problem.
668    #[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    /// And an object that is merely the wrong shape says nothing about arrays.
678    #[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}