1use std::collections::BTreeMap;
9
10use anyhow::{Result, bail};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Ranking {
16 pub ranking: Vec<String>,
18 #[serde(default)]
20 pub reasons: BTreeMap<String, String>,
21 #[serde(default)]
23 pub confidence: Option<u8>,
24}
25
26impl Ranking {
27 pub fn top(&self) -> Option<&str> {
29 self.ranking.first().map(String::as_str)
30 }
31
32 pub fn validate(&self, labels: &[char]) -> Result<()> {
35 let mut got: Vec<char> = self
36 .ranking
37 .iter()
38 .filter_map(|s| s.trim().chars().next())
39 .map(|c| c.to_ascii_uppercase())
40 .collect();
41 got.sort_unstable();
42 got.dedup();
43 let mut want: Vec<char> = labels.to_vec();
44 want.sort_unstable();
45 if got != want {
46 bail!(
47 "ranking {:?} is not a permutation of the candidate labels {:?}",
48 self.ranking,
49 labels
50 );
51 }
52 Ok(())
53 }
54
55 pub fn normalized(&self) -> Vec<char> {
57 self.ranking
58 .iter()
59 .filter_map(|s| s.trim().chars().next())
60 .map(|c| c.to_ascii_uppercase())
61 .collect()
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct FinalVote {
68 pub vote: String,
70 #[serde(default)]
72 pub reason: String,
73}
74
75impl FinalVote {
76 pub fn label(&self) -> Option<char> {
78 self.vote
79 .trim()
80 .chars()
81 .next()
82 .map(|c| c.to_ascii_uppercase())
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
88#[serde(rename_all = "lowercase")]
89pub enum Severity {
90 Nit,
92 Minor,
94 Major,
96 Blocker,
98}
99
100impl Severity {
101 pub fn blocks(self) -> bool {
103 matches!(self, Self::Major | Self::Blocker)
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct Finding {
110 #[serde(default)]
113 pub id: String,
114 pub severity: Severity,
116 #[serde(default)]
118 pub file: Option<String>,
119 #[serde(default)]
121 pub line: Option<u32>,
122 pub title: String,
124 #[serde(default)]
126 pub detail: String,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct Review {
132 #[serde(default)]
134 pub findings: Vec<Finding>,
135 #[serde(default)]
137 pub summary: String,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct FixReport {
143 #[serde(default)]
145 pub addressed: Vec<String>,
146 #[serde(default)]
148 pub rejected: Vec<Rejection>,
149 #[serde(default)]
151 pub notes: String,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct Rejection {
157 pub id: String,
159 #[serde(default)]
161 pub why: String,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct Position {
167 #[serde(default)]
169 pub tentative: Option<String>,
170}
171
172pub fn extract_json<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
178 let bytes = text.as_bytes();
179 let mut spans: Vec<(usize, usize)> = Vec::new();
180 let mut i = 0usize;
181 while i < bytes.len() {
182 if bytes[i] != b'{' {
183 i += 1;
184 continue;
185 }
186 let mut depth = 0usize;
187 let mut in_str = false;
188 let mut escaped = false;
189 let mut j = i;
190 while j < bytes.len() {
191 let c = bytes[j];
192 if in_str {
193 if escaped {
194 escaped = false;
195 } else if c == b'\\' {
196 escaped = true;
197 } else if c == b'"' {
198 in_str = false;
199 }
200 } else {
201 match c {
202 b'"' => in_str = true,
203 b'{' => depth += 1,
204 b'}' => {
205 depth -= 1;
206 if depth == 0 {
207 spans.push((i, j + 1));
208 break;
209 }
210 }
211 _ => {}
212 }
213 }
214 j += 1;
215 }
216 i = if depth == 0 && j < bytes.len() {
219 j + 1
220 } else {
221 i + 1
222 };
223 }
224
225 let mut last_err = None;
226 for (start, end) in spans.iter().rev() {
227 match serde_json::from_str::<T>(&text[*start..*end]) {
228 Ok(v) => return Ok(v),
229 Err(e) => last_err = Some(e),
230 }
231 }
232 match last_err {
233 Some(e) => bail!("no JSON object in the reply matched the expected shape: {e}"),
234 None => bail!("the reply contained no JSON object"),
235 }
236}
237
238pub fn section(text: &str, heading: &str) -> Option<String> {
242 let want = heading.to_ascii_lowercase();
243 let mut out: Option<String> = None;
244 for line in text.lines() {
245 let trimmed = line.trim();
246 if let Some(rest) = trimmed.strip_prefix("##") {
247 let name = rest.trim_start_matches('#').trim().to_ascii_lowercase();
248 if name == want {
249 out = Some(String::new());
250 continue;
251 }
252 if out.is_some() {
253 break;
254 }
255 continue;
256 }
257 if let Some(buf) = out.as_mut() {
258 buf.push_str(line);
259 buf.push('\n');
260 }
261 }
262 out.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn fenced_block_is_found() {
271 let text = "Here is my verdict.\n\n```json\n{\"ranking\":[\"B\",\"A\"]}\n```\n";
272 let r: Ranking = extract_json(text).unwrap();
273 assert_eq!(r.top(), Some("B"));
274 }
275
276 #[test]
277 fn last_matching_object_wins_over_an_earlier_example() {
278 let text = concat!(
279 "The format is {\"ranking\":[\"X\"]} for illustration.\n",
280 "```json\n{\"ranking\":[\"C\",\"A\",\"B\"],\"confidence\":4}\n```\n",
281 "Happy to elaborate.\n"
282 );
283 let r: Ranking = extract_json(text).unwrap();
284 assert_eq!(r.normalized(), ['C', 'A', 'B']);
285 assert_eq!(r.confidence, Some(4));
286 }
287
288 #[test]
289 fn braces_inside_strings_do_not_close_the_object() {
290 let text = r#"{"ranking":["A"],"reasons":{"A":"uses format!(\"{}\", x) safely}"}}"#;
291 let r: Ranking = extract_json(text).unwrap();
292 assert_eq!(r.top(), Some("A"));
293 assert!(r.reasons["A"].contains("format!"));
294 }
295
296 #[test]
297 fn objects_of_the_wrong_shape_are_skipped() {
298 let text = concat!(
299 "```json\n{\"ranking\":[\"A\",\"B\"]}\n```\n",
300 "and some telemetry: {\"tokens\":123}\n"
301 );
302 let r: Ranking = extract_json(text).unwrap();
303 assert_eq!(r.normalized(), ['A', 'B']);
304 }
305
306 #[test]
307 fn no_json_is_an_error_not_a_default() {
308 let err = extract_json::<Ranking>("I decline to produce JSON.").unwrap_err();
309 assert!(err.to_string().contains("no JSON object"));
310 }
311
312 #[test]
313 fn truncated_object_does_not_hang() {
314 let err = extract_json::<Ranking>("{\"ranking\": [\"A\"").unwrap_err();
315 assert!(err.to_string().contains("no JSON object"));
316 }
317
318 #[test]
319 fn ranking_validation_rejects_a_non_permutation() {
320 let r = Ranking {
321 ranking: vec!["A".to_owned(), "A".to_owned()],
322 reasons: BTreeMap::new(),
323 confidence: None,
324 };
325 assert!(r.validate(&['A', 'B', 'C']).is_err());
326
327 let r = Ranking {
328 ranking: vec!["c".to_owned(), "B".to_owned(), "A".to_owned()],
329 reasons: BTreeMap::new(),
330 confidence: None,
331 };
332 r.validate(&['A', 'B', 'C']).expect("case is normalised");
333 assert_eq!(r.normalized(), ['C', 'B', 'A']);
334 }
335
336 #[test]
337 fn final_vote_label_is_normalised() {
338 let v: FinalVote = extract_json(r#"{"vote":" b ","reason":"tests"}"#).unwrap();
339 assert_eq!(v.label(), Some('B'));
340 }
341
342 #[test]
343 fn severity_blocking_is_major_and_up() {
344 assert!(Severity::Blocker.blocks());
345 assert!(Severity::Major.blocks());
346 assert!(!Severity::Minor.blocks());
347 assert!(!Severity::Nit.blocks());
348 assert!(Severity::Blocker > Severity::Nit);
349 }
350
351 #[test]
352 fn review_parses_with_optional_fields_missing() {
353 let r: Review = extract_json(
354 r#"{"findings":[{"severity":"blocker","title":"panics on empty input"}]}"#,
355 )
356 .unwrap();
357 assert_eq!(r.findings.len(), 1);
358 assert!(r.findings[0].file.is_none());
359 assert_eq!(r.findings[0].id, "");
360 }
361
362 #[test]
363 fn fix_report_parses_rejections() {
364 let f: FixReport = extract_json(
365 r#"{"addressed":["R1-1-1"],"rejected":[{"id":"R1-2-1","why":"not reachable"}]}"#,
366 )
367 .unwrap();
368 assert_eq!(f.addressed, ["R1-1-1"]);
369 assert_eq!(f.rejected[0].id, "R1-2-1");
370 }
371
372 #[test]
373 fn sections_are_sliced_by_heading() {
374 let text = "## SUMMARY\nchanged the retry loop.\nadded a test.\n\n## NOTES\nignore me\n";
375 assert_eq!(
376 section(text, "summary").unwrap(),
377 "changed the retry loop.\nadded a test."
378 );
379 assert_eq!(section(text, "notes").unwrap(), "ignore me");
380 assert!(section(text, "missing").is_none());
381 }
382}