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 #[serde(default, deserialize_with = "lenient_strengths")]
28 pub strengths: BTreeMap<String, Vec<String>>,
29}
30
31fn lenient_strengths<'de, D>(d: D) -> Result<BTreeMap<String, Vec<String>>, D::Error>
36where
37 D: serde::Deserializer<'de>,
38{
39 let value = serde_json::Value::deserialize(d)?;
40 let mut out = BTreeMap::new();
41 if let serde_json::Value::Object(map) = value {
42 for (label, v) in map {
43 match v {
44 serde_json::Value::Array(items) => {
45 let list: Vec<String> = items
46 .into_iter()
47 .filter_map(|i| i.as_str().map(str::to_owned))
48 .collect();
49 if !list.is_empty() {
50 out.insert(label, list);
51 }
52 }
53 serde_json::Value::String(s) if !s.trim().is_empty() => {
54 out.insert(label, vec![s]);
55 }
56 _ => {}
57 }
58 }
59 }
60 Ok(out)
61}
62
63impl Ranking {
64 pub fn top(&self) -> Option<&str> {
66 self.ranking.first().map(String::as_str)
67 }
68
69 pub fn validate(&self, labels: &[char]) -> Result<()> {
72 let mut got: Vec<char> = self
73 .ranking
74 .iter()
75 .filter_map(|s| s.trim().chars().next())
76 .map(|c| c.to_ascii_uppercase())
77 .collect();
78 got.sort_unstable();
79 got.dedup();
80 let mut want: Vec<char> = labels.to_vec();
81 want.sort_unstable();
82 if got != want {
83 bail!(
84 "ranking {:?} is not a permutation of the candidate labels {:?}",
85 self.ranking,
86 labels
87 );
88 }
89 Ok(())
90 }
91
92 pub fn normalized(&self) -> Vec<char> {
94 self.ranking
95 .iter()
96 .filter_map(|s| s.trim().chars().next())
97 .map(|c| c.to_ascii_uppercase())
98 .collect()
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct FinalVote {
105 pub vote: String,
107 #[serde(default)]
109 pub reason: String,
110}
111
112impl FinalVote {
113 pub fn label(&self) -> Option<char> {
115 self.vote
116 .trim()
117 .chars()
118 .next()
119 .map(|c| c.to_ascii_uppercase())
120 }
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
125#[serde(rename_all = "lowercase")]
126pub enum Severity {
127 Nit,
129 Minor,
131 Major,
133 Blocker,
135}
136
137impl Severity {
138 pub fn blocks(self) -> bool {
140 matches!(self, Self::Major | Self::Blocker)
141 }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct Finding {
147 #[serde(default)]
150 pub id: String,
151 pub severity: Severity,
153 #[serde(default)]
155 pub file: Option<String>,
156 #[serde(default)]
158 pub line: Option<u32>,
159 pub title: String,
161 #[serde(default)]
163 pub detail: String,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum ReviewVote {
177 Approve,
179 ApproveWithFindings,
181 Reject,
183}
184
185impl ReviewVote {
186 pub fn label(self) -> &'static str {
189 match self {
190 Self::Approve => "approve",
191 Self::ApproveWithFindings => "approve with findings",
192 Self::Reject => "reject",
193 }
194 }
195
196 pub fn worst(votes: impl IntoIterator<Item = Self>) -> Option<Self> {
202 votes.into_iter().max()
203 }
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct Review {
209 #[serde(default)]
211 pub findings: Vec<Finding>,
212 #[serde(default)]
214 pub summary: String,
215 pub vote: ReviewVote,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ReviewRevote {
226 pub vote: ReviewVote,
228 #[serde(default)]
230 pub reason: String,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct FixReport {
236 #[serde(default)]
238 pub addressed: Vec<String>,
239 #[serde(default)]
241 pub rejected: Vec<Rejection>,
242 #[serde(default)]
244 pub notes: String,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct Rejection {
250 pub id: String,
252 #[serde(default)]
254 pub why: String,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct Position {
260 #[serde(default)]
262 pub tentative: Option<String>,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct Proposal {
274 pub approach: String,
276 pub key_tradeoff: String,
278 #[serde(default)]
280 pub risks: Vec<String>,
281 #[serde(default)]
283 pub touches: Vec<String>,
284 pub why_not_naive: String,
287}
288
289impl Proposal {
290 pub fn validate(&self) -> Result<()> {
294 for (field, value) in [
295 ("approach", &self.approach),
296 ("key_tradeoff", &self.key_tradeoff),
297 ("why_not_naive", &self.why_not_naive),
298 ] {
299 if value.trim().is_empty() {
300 bail!("`{field}` is empty");
301 }
302 }
303 Ok(())
304 }
305}
306
307pub fn extract_json<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
313 let bytes = text.as_bytes();
314 let mut spans: Vec<(usize, usize)> = Vec::new();
315 let mut i = 0usize;
316 while i < bytes.len() {
317 if bytes[i] != b'{' {
318 i += 1;
319 continue;
320 }
321 let mut depth = 0usize;
322 let mut in_str = false;
323 let mut escaped = false;
324 let mut j = i;
325 while j < bytes.len() {
326 let c = bytes[j];
327 if in_str {
328 if escaped {
329 escaped = false;
330 } else if c == b'\\' {
331 escaped = true;
332 } else if c == b'"' {
333 in_str = false;
334 }
335 } else {
336 match c {
337 b'"' => in_str = true,
338 b'{' => depth += 1,
339 b'}' => {
340 depth -= 1;
341 if depth == 0 {
342 spans.push((i, j + 1));
343 break;
344 }
345 }
346 _ => {}
347 }
348 }
349 j += 1;
350 }
351 i = if depth == 0 && j < bytes.len() {
354 j + 1
355 } else {
356 i + 1
357 };
358 }
359
360 let mut last_err = None;
361 for (start, end) in spans.iter().rev() {
362 match serde_json::from_str::<T>(&text[*start..*end]) {
363 Ok(v) => return Ok(v),
364 Err(e) => last_err = Some(e),
365 }
366 }
367 match last_err {
368 Some(e) => bail!("no JSON object in the reply matched the expected shape: {e}"),
369 None => bail!("the reply contained no JSON object"),
370 }
371}
372
373pub fn section(text: &str, heading: &str) -> Option<String> {
377 let want = heading.to_ascii_lowercase();
378 let mut out: Option<String> = None;
379 for line in text.lines() {
380 let trimmed = line.trim();
381 if let Some(rest) = trimmed.strip_prefix("##") {
382 let name = rest.trim_start_matches('#').trim().to_ascii_lowercase();
383 if name == want {
384 out = Some(String::new());
385 continue;
386 }
387 if out.is_some() {
388 break;
389 }
390 continue;
391 }
392 if let Some(buf) = out.as_mut() {
393 buf.push_str(line);
394 buf.push('\n');
395 }
396 }
397 out.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
398}
399
400pub const NO_CHANGE_NEEDED_MARKER: &str = "NO CHANGE NEEDED:";
405
406pub fn verified_noop(summary: &str) -> Option<String> {
417 let evidence = summary.trim_start().strip_prefix(NO_CHANGE_NEEDED_MARKER)?;
418 let evidence = evidence.trim();
419 (!evidence.is_empty()).then(|| evidence.to_owned())
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn malformed_strengths_never_cost_the_ranking() {
428 for bad in [
429 "null",
430 r#"{"B":"one point"}"#,
431 r#""x""#,
432 r#"[1]"#,
433 r#"{"B":[1,"ok"]}"#,
434 ] {
435 let text = format!(r#"{{"ranking":["A","B"],"strengths":{bad}}}"#);
436 let r: Ranking = extract_json(&text).unwrap();
437 assert_eq!(r.ranking, ["A", "B"]);
438 }
439 }
440
441 #[test]
442 fn fenced_block_is_found() {
443 let text = "Here is my verdict.\n\n```json\n{\"ranking\":[\"B\",\"A\"]}\n```\n";
444 let r: Ranking = extract_json(text).unwrap();
445 assert_eq!(r.top(), Some("B"));
446 }
447
448 #[test]
449 fn last_matching_object_wins_over_an_earlier_example() {
450 let text = concat!(
451 "The format is {\"ranking\":[\"X\"]} for illustration.\n",
452 "```json\n{\"ranking\":[\"C\",\"A\",\"B\"],\"confidence\":4}\n```\n",
453 "Happy to elaborate.\n"
454 );
455 let r: Ranking = extract_json(text).unwrap();
456 assert_eq!(r.normalized(), ['C', 'A', 'B']);
457 assert_eq!(r.confidence, Some(4));
458 }
459
460 #[test]
461 fn braces_inside_strings_do_not_close_the_object() {
462 let text = r#"{"ranking":["A"],"reasons":{"A":"uses format!(\"{}\", x) safely}"}}"#;
463 let r: Ranking = extract_json(text).unwrap();
464 assert_eq!(r.top(), Some("A"));
465 assert!(r.reasons["A"].contains("format!"));
466 }
467
468 #[test]
469 fn objects_of_the_wrong_shape_are_skipped() {
470 let text = concat!(
471 "```json\n{\"ranking\":[\"A\",\"B\"]}\n```\n",
472 "and some telemetry: {\"tokens\":123}\n"
473 );
474 let r: Ranking = extract_json(text).unwrap();
475 assert_eq!(r.normalized(), ['A', 'B']);
476 }
477
478 #[test]
479 fn no_json_is_an_error_not_a_default() {
480 let err = extract_json::<Ranking>("I decline to produce JSON.").unwrap_err();
481 assert!(err.to_string().contains("no JSON object"));
482 }
483
484 #[test]
485 fn truncated_object_does_not_hang() {
486 let err = extract_json::<Ranking>("{\"ranking\": [\"A\"").unwrap_err();
487 assert!(err.to_string().contains("no JSON object"));
488 }
489
490 #[test]
491 fn ranking_validation_rejects_a_non_permutation() {
492 let r = Ranking {
493 ranking: vec!["A".to_owned(), "A".to_owned()],
494 reasons: BTreeMap::new(),
495 confidence: None,
496 strengths: BTreeMap::new(),
497 };
498 assert!(r.validate(&['A', 'B', 'C']).is_err());
499
500 let r = Ranking {
501 ranking: vec!["c".to_owned(), "B".to_owned(), "A".to_owned()],
502 reasons: BTreeMap::new(),
503 confidence: None,
504 strengths: BTreeMap::new(),
505 };
506 r.validate(&['A', 'B', 'C']).expect("case is normalised");
507 assert_eq!(r.normalized(), ['C', 'B', 'A']);
508 }
509
510 #[test]
511 fn final_vote_label_is_normalised() {
512 let v: FinalVote = extract_json(r#"{"vote":" b ","reason":"tests"}"#).unwrap();
513 assert_eq!(v.label(), Some('B'));
514 }
515
516 #[test]
517 fn severity_blocking_is_major_and_up() {
518 assert!(Severity::Blocker.blocks());
519 assert!(Severity::Major.blocks());
520 assert!(!Severity::Minor.blocks());
521 assert!(!Severity::Nit.blocks());
522 assert!(Severity::Blocker > Severity::Nit);
523 }
524
525 #[test]
526 fn review_parses_with_optional_fields_missing() {
527 let r: Review = extract_json(
528 r#"{"vote":"reject","findings":[{"severity":"blocker","title":"panics on empty input"}]}"#,
529 )
530 .unwrap();
531 assert_eq!(r.findings.len(), 1);
532 assert!(r.findings[0].file.is_none());
533 assert_eq!(r.findings[0].id, "");
534 assert_eq!(r.vote, ReviewVote::Reject);
535 }
536
537 #[test]
538 fn review_without_a_vote_is_rejected_rather_than_defaulted() {
539 let err = extract_json::<Review>(r#"{"findings":[]}"#).unwrap_err();
540 assert!(err.to_string().contains("no JSON object"), "{err}");
541 }
542
543 #[test]
544 fn review_vote_worst_is_the_most_cautious() {
545 assert_eq!(
546 ReviewVote::worst([ReviewVote::Approve, ReviewVote::Reject, ReviewVote::Approve]),
547 Some(ReviewVote::Reject)
548 );
549 assert_eq!(
550 ReviewVote::worst([ReviewVote::Approve, ReviewVote::ApproveWithFindings]),
551 Some(ReviewVote::ApproveWithFindings)
552 );
553 assert_eq!(ReviewVote::worst(Vec::<ReviewVote>::new()), None);
554 }
555
556 #[test]
557 fn review_revote_parses_the_reconsideration_shape() {
558 let r: ReviewRevote =
559 extract_json(r#"{"vote":"approve","reason":"the other findings do not hold"}"#)
560 .unwrap();
561 assert_eq!(r.vote, ReviewVote::Approve);
562 assert_eq!(r.reason, "the other findings do not hold");
563 }
564
565 #[test]
566 fn fix_report_parses_rejections() {
567 let f: FixReport = extract_json(
568 r#"{"addressed":["R1-1-1"],"rejected":[{"id":"R1-2-1","why":"not reachable"}]}"#,
569 )
570 .unwrap();
571 assert_eq!(f.addressed, ["R1-1-1"]);
572 assert_eq!(f.rejected[0].id, "R1-2-1");
573 }
574
575 #[test]
576 fn proposal_parses_with_optional_fields_missing() {
577 let p: Proposal = extract_json(
578 r#"{"approach":"do X","key_tradeoff":"simpler now, slower later","why_not_naive":"the naive version corrupts state under a retry"}"#,
579 )
580 .unwrap();
581 assert!(p.risks.is_empty());
582 assert!(p.touches.is_empty());
583 p.validate()
584 .expect("a proposal with only required fields is valid");
585 }
586
587 #[test]
588 fn proposal_validation_rejects_an_empty_required_field() {
589 let p = Proposal {
590 approach: String::new(),
591 key_tradeoff: "t".to_owned(),
592 risks: Vec::new(),
593 touches: Vec::new(),
594 why_not_naive: "w".to_owned(),
595 };
596 let err = p.validate().unwrap_err();
597 assert!(err.to_string().contains("approach"));
598 }
599
600 #[test]
601 fn sections_are_sliced_by_heading() {
602 let text = "## SUMMARY\nchanged the retry loop.\nadded a test.\n\n## NOTES\nignore me\n";
603 assert_eq!(
604 section(text, "summary").unwrap(),
605 "changed the retry loop.\nadded a test."
606 );
607 assert_eq!(section(text, "notes").unwrap(), "ignore me");
608 assert!(section(text, "missing").is_none());
609 }
610
611 #[test]
612 fn no_change_needed_marker_yields_its_evidence() {
613 let summary = "NO CHANGE NEEDED: already fixed by b32cfc4, which is on main \
614 (git merge-base --is-ancestor confirms it).";
615 assert_eq!(
616 verified_noop(summary).unwrap(),
617 "already fixed by b32cfc4, which is on main (git merge-base --is-ancestor \
618 confirms it)."
619 );
620 }
621
622 #[test]
623 fn no_change_needed_marker_with_no_evidence_is_not_verified() {
624 assert!(verified_noop("NO CHANGE NEEDED:").is_none());
625 assert!(verified_noop("NO CHANGE NEEDED: \n ").is_none());
626 }
627
628 #[test]
629 fn an_ordinary_summary_is_not_verified() {
630 assert!(verified_noop("- changed the retry loop.\n- added a test.").is_none());
631 assert!(verified_noop("I considered NO CHANGE NEEDED: but wrote a fix instead.").is_none());
633 }
634}