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
144impl std::fmt::Display for Severity {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 f.write_str(match self {
147 Self::Nit => "nit",
148 Self::Minor => "minor",
149 Self::Major => "major",
150 Self::Blocker => "blocker",
151 })
152 }
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct Finding {
158 #[serde(default)]
161 pub id: String,
162 pub severity: Severity,
164 #[serde(default)]
166 pub file: Option<String>,
167 #[serde(default)]
169 pub line: Option<u32>,
170 pub title: String,
172 #[serde(default)]
174 pub detail: String,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
186#[serde(rename_all = "snake_case")]
187pub enum ReviewVote {
188 Approve,
190 ApproveWithFindings,
192 Reject,
194}
195
196impl ReviewVote {
197 pub fn label(self) -> &'static str {
200 match self {
201 Self::Approve => "approve",
202 Self::ApproveWithFindings => "approve with findings",
203 Self::Reject => "reject",
204 }
205 }
206
207 pub fn worst(votes: impl IntoIterator<Item = Self>) -> Option<Self> {
213 votes.into_iter().max()
214 }
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct Review {
220 #[serde(default)]
222 pub findings: Vec<Finding>,
223 #[serde(default)]
225 pub summary: String,
226 pub vote: ReviewVote,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct ReviewRevote {
237 pub vote: ReviewVote,
239 #[serde(default)]
241 pub reason: String,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct FixReport {
247 #[serde(default)]
249 pub addressed: Vec<String>,
250 #[serde(default)]
252 pub rejected: Vec<Rejection>,
253 #[serde(default)]
255 pub notes: String,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct Rejection {
261 pub id: String,
263 #[serde(default)]
265 pub why: String,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct Position {
271 #[serde(default)]
273 pub tentative: Option<String>,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct Proposal {
285 pub approach: String,
287 pub key_tradeoff: String,
289 #[serde(default)]
291 pub risks: Vec<String>,
292 #[serde(default)]
294 pub touches: Vec<String>,
295 pub why_not_naive: String,
298}
299
300impl Proposal {
301 pub fn validate(&self) -> Result<()> {
305 for (field, value) in [
306 ("approach", &self.approach),
307 ("key_tradeoff", &self.key_tradeoff),
308 ("why_not_naive", &self.why_not_naive),
309 ] {
310 if value.trim().is_empty() {
311 bail!("`{field}` is empty");
312 }
313 }
314 Ok(())
315 }
316}
317
318pub fn extract_json<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
324 let bytes = text.as_bytes();
325 let mut spans: Vec<(usize, usize)> = Vec::new();
326 let mut i = 0usize;
327 while i < bytes.len() {
328 if bytes[i] != b'{' {
329 i += 1;
330 continue;
331 }
332 let mut depth = 0usize;
333 let mut in_str = false;
334 let mut escaped = false;
335 let mut j = i;
336 while j < bytes.len() {
337 let c = bytes[j];
338 if in_str {
339 if escaped {
340 escaped = false;
341 } else if c == b'\\' {
342 escaped = true;
343 } else if c == b'"' {
344 in_str = false;
345 }
346 } else {
347 match c {
348 b'"' => in_str = true,
349 b'{' => depth += 1,
350 b'}' => {
351 depth -= 1;
352 if depth == 0 {
353 spans.push((i, j + 1));
354 break;
355 }
356 }
357 _ => {}
358 }
359 }
360 j += 1;
361 }
362 i = if depth == 0 && j < bytes.len() {
365 j + 1
366 } else {
367 i + 1
368 };
369 }
370
371 let mut last_err = None;
372 for (start, end) in spans.iter().rev() {
373 match serde_json::from_str::<T>(&text[*start..*end]) {
374 Ok(v) => return Ok(v),
375 Err(e) => last_err = Some(e),
376 }
377 }
378 match last_err {
379 Some(e) => bail!("no JSON object in the reply matched the expected shape: {e}"),
380 None => bail!("the reply contained no JSON object"),
381 }
382}
383
384pub fn section(text: &str, heading: &str) -> Option<String> {
388 let want = heading.to_ascii_lowercase();
389 let mut out: Option<String> = None;
390 for line in text.lines() {
391 let trimmed = line.trim();
392 if let Some(rest) = trimmed.strip_prefix("##") {
393 let name = rest.trim_start_matches('#').trim().to_ascii_lowercase();
394 if name == want {
395 out = Some(String::new());
396 continue;
397 }
398 if out.is_some() {
399 break;
400 }
401 continue;
402 }
403 if let Some(buf) = out.as_mut() {
404 buf.push_str(line);
405 buf.push('\n');
406 }
407 }
408 out.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
409}
410
411pub const NO_CHANGE_NEEDED_MARKER: &str = "NO CHANGE NEEDED:";
416
417pub fn verified_noop(summary: &str) -> Option<String> {
428 let evidence = summary.trim_start().strip_prefix(NO_CHANGE_NEEDED_MARKER)?;
429 let evidence = evidence.trim();
430 (!evidence.is_empty()).then(|| evidence.to_owned())
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn severity_has_explicit_operator_labels() {
439 for (severity, label) in [
440 (Severity::Nit, "nit"),
441 (Severity::Minor, "minor"),
442 (Severity::Major, "major"),
443 (Severity::Blocker, "blocker"),
444 ] {
445 assert_eq!(severity.to_string(), label);
446 }
447 }
448
449 #[test]
450 fn malformed_strengths_never_cost_the_ranking() {
451 for bad in [
452 "null",
453 r#"{"B":"one point"}"#,
454 r#""x""#,
455 r#"[1]"#,
456 r#"{"B":[1,"ok"]}"#,
457 ] {
458 let text = format!(r#"{{"ranking":["A","B"],"strengths":{bad}}}"#);
459 let r: Ranking = extract_json(&text).unwrap();
460 assert_eq!(r.ranking, ["A", "B"]);
461 }
462 }
463
464 #[test]
465 fn fenced_block_is_found() {
466 let text = "Here is my verdict.\n\n```json\n{\"ranking\":[\"B\",\"A\"]}\n```\n";
467 let r: Ranking = extract_json(text).unwrap();
468 assert_eq!(r.top(), Some("B"));
469 }
470
471 #[test]
472 fn last_matching_object_wins_over_an_earlier_example() {
473 let text = concat!(
474 "The format is {\"ranking\":[\"X\"]} for illustration.\n",
475 "```json\n{\"ranking\":[\"C\",\"A\",\"B\"],\"confidence\":4}\n```\n",
476 "Happy to elaborate.\n"
477 );
478 let r: Ranking = extract_json(text).unwrap();
479 assert_eq!(r.normalized(), ['C', 'A', 'B']);
480 assert_eq!(r.confidence, Some(4));
481 }
482
483 #[test]
484 fn braces_inside_strings_do_not_close_the_object() {
485 let text = r#"{"ranking":["A"],"reasons":{"A":"uses format!(\"{}\", x) safely}"}}"#;
486 let r: Ranking = extract_json(text).unwrap();
487 assert_eq!(r.top(), Some("A"));
488 assert!(r.reasons["A"].contains("format!"));
489 }
490
491 #[test]
492 fn objects_of_the_wrong_shape_are_skipped() {
493 let text = concat!(
494 "```json\n{\"ranking\":[\"A\",\"B\"]}\n```\n",
495 "and some telemetry: {\"tokens\":123}\n"
496 );
497 let r: Ranking = extract_json(text).unwrap();
498 assert_eq!(r.normalized(), ['A', 'B']);
499 }
500
501 #[test]
502 fn no_json_is_an_error_not_a_default() {
503 let err = extract_json::<Ranking>("I decline to produce JSON.").unwrap_err();
504 assert!(err.to_string().contains("no JSON object"));
505 }
506
507 #[test]
508 fn truncated_object_does_not_hang() {
509 let err = extract_json::<Ranking>("{\"ranking\": [\"A\"").unwrap_err();
510 assert!(err.to_string().contains("no JSON object"));
511 }
512
513 #[test]
514 fn ranking_validation_rejects_a_non_permutation() {
515 let r = Ranking {
516 ranking: vec!["A".to_owned(), "A".to_owned()],
517 reasons: BTreeMap::new(),
518 confidence: None,
519 strengths: BTreeMap::new(),
520 };
521 assert!(r.validate(&['A', 'B', 'C']).is_err());
522
523 let r = Ranking {
524 ranking: vec!["c".to_owned(), "B".to_owned(), "A".to_owned()],
525 reasons: BTreeMap::new(),
526 confidence: None,
527 strengths: BTreeMap::new(),
528 };
529 r.validate(&['A', 'B', 'C']).expect("case is normalised");
530 assert_eq!(r.normalized(), ['C', 'B', 'A']);
531 }
532
533 #[test]
534 fn final_vote_label_is_normalised() {
535 let v: FinalVote = extract_json(r#"{"vote":" b ","reason":"tests"}"#).unwrap();
536 assert_eq!(v.label(), Some('B'));
537 }
538
539 #[test]
540 fn severity_blocking_is_major_and_up() {
541 assert!(Severity::Blocker.blocks());
542 assert!(Severity::Major.blocks());
543 assert!(!Severity::Minor.blocks());
544 assert!(!Severity::Nit.blocks());
545 assert!(Severity::Blocker > Severity::Nit);
546 }
547
548 #[test]
549 fn review_parses_with_optional_fields_missing() {
550 let r: Review = extract_json(
551 r#"{"vote":"reject","findings":[{"severity":"blocker","title":"panics on empty input"}]}"#,
552 )
553 .unwrap();
554 assert_eq!(r.findings.len(), 1);
555 assert!(r.findings[0].file.is_none());
556 assert_eq!(r.findings[0].id, "");
557 assert_eq!(r.vote, ReviewVote::Reject);
558 }
559
560 #[test]
561 fn review_without_a_vote_is_rejected_rather_than_defaulted() {
562 let err = extract_json::<Review>(r#"{"findings":[]}"#).unwrap_err();
563 assert!(err.to_string().contains("no JSON object"), "{err}");
564 }
565
566 #[test]
567 fn review_vote_worst_is_the_most_cautious() {
568 assert_eq!(
569 ReviewVote::worst([ReviewVote::Approve, ReviewVote::Reject, ReviewVote::Approve]),
570 Some(ReviewVote::Reject)
571 );
572 assert_eq!(
573 ReviewVote::worst([ReviewVote::Approve, ReviewVote::ApproveWithFindings]),
574 Some(ReviewVote::ApproveWithFindings)
575 );
576 assert_eq!(ReviewVote::worst(Vec::<ReviewVote>::new()), None);
577 }
578
579 #[test]
580 fn review_revote_parses_the_reconsideration_shape() {
581 let r: ReviewRevote =
582 extract_json(r#"{"vote":"approve","reason":"the other findings do not hold"}"#)
583 .unwrap();
584 assert_eq!(r.vote, ReviewVote::Approve);
585 assert_eq!(r.reason, "the other findings do not hold");
586 }
587
588 #[test]
589 fn fix_report_parses_rejections() {
590 let f: FixReport = extract_json(
591 r#"{"addressed":["R1-1-1"],"rejected":[{"id":"R1-2-1","why":"not reachable"}]}"#,
592 )
593 .unwrap();
594 assert_eq!(f.addressed, ["R1-1-1"]);
595 assert_eq!(f.rejected[0].id, "R1-2-1");
596 }
597
598 #[test]
599 fn proposal_parses_with_optional_fields_missing() {
600 let p: Proposal = extract_json(
601 r#"{"approach":"do X","key_tradeoff":"simpler now, slower later","why_not_naive":"the naive version corrupts state under a retry"}"#,
602 )
603 .unwrap();
604 assert!(p.risks.is_empty());
605 assert!(p.touches.is_empty());
606 p.validate()
607 .expect("a proposal with only required fields is valid");
608 }
609
610 #[test]
611 fn proposal_validation_rejects_an_empty_required_field() {
612 let p = Proposal {
613 approach: String::new(),
614 key_tradeoff: "t".to_owned(),
615 risks: Vec::new(),
616 touches: Vec::new(),
617 why_not_naive: "w".to_owned(),
618 };
619 let err = p.validate().unwrap_err();
620 assert!(err.to_string().contains("approach"));
621 }
622
623 #[test]
624 fn sections_are_sliced_by_heading() {
625 let text = "## SUMMARY\nchanged the retry loop.\nadded a test.\n\n## NOTES\nignore me\n";
626 assert_eq!(
627 section(text, "summary").unwrap(),
628 "changed the retry loop.\nadded a test."
629 );
630 assert_eq!(section(text, "notes").unwrap(), "ignore me");
631 assert!(section(text, "missing").is_none());
632 }
633
634 #[test]
635 fn no_change_needed_marker_yields_its_evidence() {
636 let summary = "NO CHANGE NEEDED: already fixed by b32cfc4, which is on main \
637 (git merge-base --is-ancestor confirms it).";
638 assert_eq!(
639 verified_noop(summary).unwrap(),
640 "already fixed by b32cfc4, which is on main (git merge-base --is-ancestor \
641 confirms it)."
642 );
643 }
644
645 #[test]
646 fn no_change_needed_marker_with_no_evidence_is_not_verified() {
647 assert!(verified_noop("NO CHANGE NEEDED:").is_none());
648 assert!(verified_noop("NO CHANGE NEEDED: \n ").is_none());
649 }
650
651 #[test]
652 fn an_ordinary_summary_is_not_verified() {
653 assert!(verified_noop("- changed the retry loop.\n- added a test.").is_none());
654 assert!(verified_noop("I considered NO CHANGE NEEDED: but wrote a fix instead.").is_none());
656 }
657}