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 brace matching backwards from the end.
20pub fn extract_json(text: &str) -> Result<Value> {
21    if text.trim().is_empty() {
22        return Err(SparError::new("empty response, expected JSON"));
23    }
24
25    let fenced: Vec<&str> = FENCE
26        .captures_iter(text)
27        .filter_map(|c| c.get(1).map(|m| m.as_str()))
28        .collect();
29    for blob in fenced.iter().rev() {
30        if let Ok(value) = serde_json::from_str::<Value>(blob) {
31            return Ok(value);
32        }
33    }
34
35    // Delimiters are ASCII and UTF-8 is self synchronising, so byte scanning
36    // can never land inside a multi byte character.
37    let bytes = text.as_bytes();
38    for (opener, closer) in [(b'{', b'}'), (b'[', b']')] {
39        let mut end = rfind_byte(bytes, closer, bytes.len());
40        while let Some(e) = end {
41            let mut depth = 0i32;
42            let mut start = None;
43            for i in (0..=e).rev() {
44                if bytes[i] == closer {
45                    depth += 1;
46                } else if bytes[i] == opener {
47                    depth -= 1;
48                    if depth == 0 {
49                        start = Some(i);
50                        break;
51                    }
52                }
53            }
54            if let Some(s) = start {
55                if let Ok(value) = serde_json::from_str::<Value>(&text[s..=e]) {
56                    return Ok(value);
57                }
58            }
59            end = rfind_byte(bytes, closer, e);
60        }
61    }
62
63    Err(SparError::new(format!(
64        "no JSON found in response:\n{}",
65        head(text, 800)
66    )))
67}
68
69/// Parse a model response straight into a typed value.
70pub fn extract_into<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
71    let value = extract_json(text)?;
72    serde_json::from_value(value.clone()).map_err(|e| {
73        SparError::new(format!(
74            "response did not match the expected shape ({e}).\nGot: {}",
75            head(&value.to_string(), 600)
76        ))
77    })
78}
79
80fn rfind_byte(haystack: &[u8], needle: u8, before: usize) -> Option<usize> {
81    haystack[..before.min(haystack.len())]
82        .iter()
83        .rposition(|b| *b == needle)
84}
85
86fn head(text: &str, max: usize) -> String {
87    text.chars().take(max).collect()
88}
89
90/// A stable identity for a review finding, so a refutation survives across
91/// rounds even when the reviewer rewords the point.
92///
93/// Wording noise, punctuation, and case are all discarded; the file is not,
94/// because the same complaint about two different files is two complaints.
95pub fn finding_key(title: &str, file: &str) -> String {
96    let basis: String = format!("{} {}", title.trim(), file.trim())
97        .to_lowercase()
98        .chars()
99        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '/' | '.' | '_' | '-'))
100        .collect();
101    let basis = basis.split_whitespace().collect::<Vec<_>>().join(" ");
102
103    let digest = Sha256::digest(basis.as_bytes());
104    let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
105    hex[..12].to_string()
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn bare_object() {
114        assert_eq!(
115            serde_json::json!({"a": 1}),
116            extract_json(r#"{"a": 1}"#).unwrap()
117        );
118    }
119
120    #[test]
121    fn fenced_block() {
122        let out = extract_json("here you go:\n```json\n{\"a\": 1}\n```\n").unwrap();
123        assert_eq!(serde_json::json!({"a": 1}), out);
124    }
125
126    #[test]
127    fn trailing_prose() {
128        let out = extract_json("Thoughts...\n{\"verdict\": \"approve\"}\nDone.").unwrap();
129        assert_eq!(serde_json::json!({"verdict": "approve"}), out);
130    }
131
132    #[test]
133    fn picks_the_last_fenced_block() {
134        let text = "```json\n{\"n\": 1}\n```\nrevised:\n```json\n{\"n\": 2}\n```";
135        assert_eq!(serde_json::json!({"n": 2}), extract_json(text).unwrap());
136    }
137
138    #[test]
139    fn nested_braces() {
140        let payload = r#"{"findings": [{"severity": "nit", "d": {"x": [1, 2]}}]}"#;
141        let out = extract_json(&format!("blah {payload} blah")).unwrap();
142        assert_eq!(1, out["findings"].as_array().unwrap().len());
143    }
144
145    #[test]
146    fn top_level_array() {
147        let out = extract_json("result: [1, 2, 3]").unwrap();
148        assert_eq!(3, out.as_array().unwrap().len());
149    }
150
151    #[test]
152    fn multibyte_prose_around_the_payload_does_not_panic() {
153        let out = extract_json("\u{1f600}\u{1f600} {\"a\": 1} \u{1f600}").unwrap();
154        assert_eq!(serde_json::json!({"a": 1}), out);
155    }
156
157    #[test]
158    fn raises_when_there_is_none() {
159        assert!(extract_json("no json here at all").is_err());
160    }
161
162    #[test]
163    fn raises_on_empty() {
164        assert!(extract_json("   ").is_err());
165    }
166
167    #[test]
168    fn malformed_trailing_object_falls_back_to_an_earlier_one() {
169        let text = "{\"good\": true}\nthen: {\"bad\": ,}";
170        assert_eq!(
171            serde_json::json!({"good": true}),
172            extract_json(text).unwrap()
173        );
174    }
175
176    // -- finding_key -----------------------------------------------------
177
178    #[test]
179    fn key_is_stable_across_wording_noise() {
180        assert_eq!(
181            finding_key("Unbounded loop!", "src/x.rs"),
182            finding_key("unbounded loop", "src/x.rs")
183        );
184    }
185
186    #[test]
187    fn key_differs_by_file() {
188        assert_ne!(finding_key("t", "a.rs"), finding_key("t", "b.rs"));
189    }
190
191    #[test]
192    fn key_is_case_insensitive_in_the_path_too() {
193        assert_eq!(
194            finding_key("t", "src/Main.rs"),
195            finding_key("t", "src/main.rs")
196        );
197    }
198
199    #[test]
200    fn key_is_stable_across_whitespace() {
201        assert_eq!(finding_key("a  b", "x.rs"), finding_key(" a b ", "x.rs"));
202    }
203
204    #[test]
205    fn key_is_twelve_hex_characters() {
206        let key = finding_key("anything", "file.rs");
207        assert_eq!(12, key.len());
208        assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
209    }
210}