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, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
138#[serde(rename_all = "snake_case")]
139pub enum ReviewVote {
140 Approve,
142 ApproveWithFindings,
144 Reject,
146}
147
148impl ReviewVote {
149 pub fn label(self) -> &'static str {
152 match self {
153 Self::Approve => "approve",
154 Self::ApproveWithFindings => "approve with findings",
155 Self::Reject => "reject",
156 }
157 }
158
159 pub fn worst(votes: impl IntoIterator<Item = Self>) -> Option<Self> {
165 votes.into_iter().max()
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct Review {
172 #[serde(default)]
174 pub findings: Vec<Finding>,
175 #[serde(default)]
177 pub summary: String,
178 pub vote: ReviewVote,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct ReviewRevote {
189 pub vote: ReviewVote,
191 #[serde(default)]
193 pub reason: String,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct FixReport {
199 #[serde(default)]
201 pub addressed: Vec<String>,
202 #[serde(default)]
204 pub rejected: Vec<Rejection>,
205 #[serde(default)]
207 pub notes: String,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Rejection {
213 pub id: String,
215 #[serde(default)]
217 pub why: String,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct Position {
223 #[serde(default)]
225 pub tentative: Option<String>,
226}
227
228pub fn extract_json<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
234 let bytes = text.as_bytes();
235 let mut spans: Vec<(usize, usize)> = Vec::new();
236 let mut i = 0usize;
237 while i < bytes.len() {
238 if bytes[i] != b'{' {
239 i += 1;
240 continue;
241 }
242 let mut depth = 0usize;
243 let mut in_str = false;
244 let mut escaped = false;
245 let mut j = i;
246 while j < bytes.len() {
247 let c = bytes[j];
248 if in_str {
249 if escaped {
250 escaped = false;
251 } else if c == b'\\' {
252 escaped = true;
253 } else if c == b'"' {
254 in_str = false;
255 }
256 } else {
257 match c {
258 b'"' => in_str = true,
259 b'{' => depth += 1,
260 b'}' => {
261 depth -= 1;
262 if depth == 0 {
263 spans.push((i, j + 1));
264 break;
265 }
266 }
267 _ => {}
268 }
269 }
270 j += 1;
271 }
272 i = if depth == 0 && j < bytes.len() {
275 j + 1
276 } else {
277 i + 1
278 };
279 }
280
281 let mut last_err = None;
282 for (start, end) in spans.iter().rev() {
283 match serde_json::from_str::<T>(&text[*start..*end]) {
284 Ok(v) => return Ok(v),
285 Err(e) => last_err = Some(e),
286 }
287 }
288 match last_err {
289 Some(e) => bail!("no JSON object in the reply matched the expected shape: {e}"),
290 None => bail!("the reply contained no JSON object"),
291 }
292}
293
294pub fn section(text: &str, heading: &str) -> Option<String> {
298 let want = heading.to_ascii_lowercase();
299 let mut out: Option<String> = None;
300 for line in text.lines() {
301 let trimmed = line.trim();
302 if let Some(rest) = trimmed.strip_prefix("##") {
303 let name = rest.trim_start_matches('#').trim().to_ascii_lowercase();
304 if name == want {
305 out = Some(String::new());
306 continue;
307 }
308 if out.is_some() {
309 break;
310 }
311 continue;
312 }
313 if let Some(buf) = out.as_mut() {
314 buf.push_str(line);
315 buf.push('\n');
316 }
317 }
318 out.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn fenced_block_is_found() {
327 let text = "Here is my verdict.\n\n```json\n{\"ranking\":[\"B\",\"A\"]}\n```\n";
328 let r: Ranking = extract_json(text).unwrap();
329 assert_eq!(r.top(), Some("B"));
330 }
331
332 #[test]
333 fn last_matching_object_wins_over_an_earlier_example() {
334 let text = concat!(
335 "The format is {\"ranking\":[\"X\"]} for illustration.\n",
336 "```json\n{\"ranking\":[\"C\",\"A\",\"B\"],\"confidence\":4}\n```\n",
337 "Happy to elaborate.\n"
338 );
339 let r: Ranking = extract_json(text).unwrap();
340 assert_eq!(r.normalized(), ['C', 'A', 'B']);
341 assert_eq!(r.confidence, Some(4));
342 }
343
344 #[test]
345 fn braces_inside_strings_do_not_close_the_object() {
346 let text = r#"{"ranking":["A"],"reasons":{"A":"uses format!(\"{}\", x) safely}"}}"#;
347 let r: Ranking = extract_json(text).unwrap();
348 assert_eq!(r.top(), Some("A"));
349 assert!(r.reasons["A"].contains("format!"));
350 }
351
352 #[test]
353 fn objects_of_the_wrong_shape_are_skipped() {
354 let text = concat!(
355 "```json\n{\"ranking\":[\"A\",\"B\"]}\n```\n",
356 "and some telemetry: {\"tokens\":123}\n"
357 );
358 let r: Ranking = extract_json(text).unwrap();
359 assert_eq!(r.normalized(), ['A', 'B']);
360 }
361
362 #[test]
363 fn no_json_is_an_error_not_a_default() {
364 let err = extract_json::<Ranking>("I decline to produce JSON.").unwrap_err();
365 assert!(err.to_string().contains("no JSON object"));
366 }
367
368 #[test]
369 fn truncated_object_does_not_hang() {
370 let err = extract_json::<Ranking>("{\"ranking\": [\"A\"").unwrap_err();
371 assert!(err.to_string().contains("no JSON object"));
372 }
373
374 #[test]
375 fn ranking_validation_rejects_a_non_permutation() {
376 let r = Ranking {
377 ranking: vec!["A".to_owned(), "A".to_owned()],
378 reasons: BTreeMap::new(),
379 confidence: None,
380 };
381 assert!(r.validate(&['A', 'B', 'C']).is_err());
382
383 let r = Ranking {
384 ranking: vec!["c".to_owned(), "B".to_owned(), "A".to_owned()],
385 reasons: BTreeMap::new(),
386 confidence: None,
387 };
388 r.validate(&['A', 'B', 'C']).expect("case is normalised");
389 assert_eq!(r.normalized(), ['C', 'B', 'A']);
390 }
391
392 #[test]
393 fn final_vote_label_is_normalised() {
394 let v: FinalVote = extract_json(r#"{"vote":" b ","reason":"tests"}"#).unwrap();
395 assert_eq!(v.label(), Some('B'));
396 }
397
398 #[test]
399 fn severity_blocking_is_major_and_up() {
400 assert!(Severity::Blocker.blocks());
401 assert!(Severity::Major.blocks());
402 assert!(!Severity::Minor.blocks());
403 assert!(!Severity::Nit.blocks());
404 assert!(Severity::Blocker > Severity::Nit);
405 }
406
407 #[test]
408 fn review_parses_with_optional_fields_missing() {
409 let r: Review = extract_json(
410 r#"{"vote":"reject","findings":[{"severity":"blocker","title":"panics on empty input"}]}"#,
411 )
412 .unwrap();
413 assert_eq!(r.findings.len(), 1);
414 assert!(r.findings[0].file.is_none());
415 assert_eq!(r.findings[0].id, "");
416 assert_eq!(r.vote, ReviewVote::Reject);
417 }
418
419 #[test]
420 fn review_without_a_vote_is_rejected_rather_than_defaulted() {
421 let err = extract_json::<Review>(r#"{"findings":[]}"#).unwrap_err();
422 assert!(err.to_string().contains("no JSON object"), "{err}");
423 }
424
425 #[test]
426 fn review_vote_worst_is_the_most_cautious() {
427 assert_eq!(
428 ReviewVote::worst([ReviewVote::Approve, ReviewVote::Reject, ReviewVote::Approve]),
429 Some(ReviewVote::Reject)
430 );
431 assert_eq!(
432 ReviewVote::worst([ReviewVote::Approve, ReviewVote::ApproveWithFindings]),
433 Some(ReviewVote::ApproveWithFindings)
434 );
435 assert_eq!(ReviewVote::worst(Vec::<ReviewVote>::new()), None);
436 }
437
438 #[test]
439 fn review_revote_parses_the_reconsideration_shape() {
440 let r: ReviewRevote =
441 extract_json(r#"{"vote":"approve","reason":"the other findings do not hold"}"#)
442 .unwrap();
443 assert_eq!(r.vote, ReviewVote::Approve);
444 assert_eq!(r.reason, "the other findings do not hold");
445 }
446
447 #[test]
448 fn fix_report_parses_rejections() {
449 let f: FixReport = extract_json(
450 r#"{"addressed":["R1-1-1"],"rejected":[{"id":"R1-2-1","why":"not reachable"}]}"#,
451 )
452 .unwrap();
453 assert_eq!(f.addressed, ["R1-1-1"]);
454 assert_eq!(f.rejected[0].id, "R1-2-1");
455 }
456
457 #[test]
458 fn sections_are_sliced_by_heading() {
459 let text = "## SUMMARY\nchanged the retry loop.\nadded a test.\n\n## NOTES\nignore me\n";
460 assert_eq!(
461 section(text, "summary").unwrap(),
462 "changed the retry loop.\nadded a test."
463 );
464 assert_eq!(section(text, "notes").unwrap(), "ignore me");
465 assert!(section(text, "missing").is_none());
466 }
467}