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
228#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct Proposal {
238 pub approach: String,
240 pub key_tradeoff: String,
242 #[serde(default)]
244 pub risks: Vec<String>,
245 #[serde(default)]
247 pub touches: Vec<String>,
248 pub why_not_naive: String,
251}
252
253impl Proposal {
254 pub fn validate(&self) -> Result<()> {
258 for (field, value) in [
259 ("approach", &self.approach),
260 ("key_tradeoff", &self.key_tradeoff),
261 ("why_not_naive", &self.why_not_naive),
262 ] {
263 if value.trim().is_empty() {
264 bail!("`{field}` is empty");
265 }
266 }
267 Ok(())
268 }
269}
270
271pub fn extract_json<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
277 let bytes = text.as_bytes();
278 let mut spans: Vec<(usize, usize)> = Vec::new();
279 let mut i = 0usize;
280 while i < bytes.len() {
281 if bytes[i] != b'{' {
282 i += 1;
283 continue;
284 }
285 let mut depth = 0usize;
286 let mut in_str = false;
287 let mut escaped = false;
288 let mut j = i;
289 while j < bytes.len() {
290 let c = bytes[j];
291 if in_str {
292 if escaped {
293 escaped = false;
294 } else if c == b'\\' {
295 escaped = true;
296 } else if c == b'"' {
297 in_str = false;
298 }
299 } else {
300 match c {
301 b'"' => in_str = true,
302 b'{' => depth += 1,
303 b'}' => {
304 depth -= 1;
305 if depth == 0 {
306 spans.push((i, j + 1));
307 break;
308 }
309 }
310 _ => {}
311 }
312 }
313 j += 1;
314 }
315 i = if depth == 0 && j < bytes.len() {
318 j + 1
319 } else {
320 i + 1
321 };
322 }
323
324 let mut last_err = None;
325 for (start, end) in spans.iter().rev() {
326 match serde_json::from_str::<T>(&text[*start..*end]) {
327 Ok(v) => return Ok(v),
328 Err(e) => last_err = Some(e),
329 }
330 }
331 match last_err {
332 Some(e) => bail!("no JSON object in the reply matched the expected shape: {e}"),
333 None => bail!("the reply contained no JSON object"),
334 }
335}
336
337pub fn section(text: &str, heading: &str) -> Option<String> {
341 let want = heading.to_ascii_lowercase();
342 let mut out: Option<String> = None;
343 for line in text.lines() {
344 let trimmed = line.trim();
345 if let Some(rest) = trimmed.strip_prefix("##") {
346 let name = rest.trim_start_matches('#').trim().to_ascii_lowercase();
347 if name == want {
348 out = Some(String::new());
349 continue;
350 }
351 if out.is_some() {
352 break;
353 }
354 continue;
355 }
356 if let Some(buf) = out.as_mut() {
357 buf.push_str(line);
358 buf.push('\n');
359 }
360 }
361 out.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn fenced_block_is_found() {
370 let text = "Here is my verdict.\n\n```json\n{\"ranking\":[\"B\",\"A\"]}\n```\n";
371 let r: Ranking = extract_json(text).unwrap();
372 assert_eq!(r.top(), Some("B"));
373 }
374
375 #[test]
376 fn last_matching_object_wins_over_an_earlier_example() {
377 let text = concat!(
378 "The format is {\"ranking\":[\"X\"]} for illustration.\n",
379 "```json\n{\"ranking\":[\"C\",\"A\",\"B\"],\"confidence\":4}\n```\n",
380 "Happy to elaborate.\n"
381 );
382 let r: Ranking = extract_json(text).unwrap();
383 assert_eq!(r.normalized(), ['C', 'A', 'B']);
384 assert_eq!(r.confidence, Some(4));
385 }
386
387 #[test]
388 fn braces_inside_strings_do_not_close_the_object() {
389 let text = r#"{"ranking":["A"],"reasons":{"A":"uses format!(\"{}\", x) safely}"}}"#;
390 let r: Ranking = extract_json(text).unwrap();
391 assert_eq!(r.top(), Some("A"));
392 assert!(r.reasons["A"].contains("format!"));
393 }
394
395 #[test]
396 fn objects_of_the_wrong_shape_are_skipped() {
397 let text = concat!(
398 "```json\n{\"ranking\":[\"A\",\"B\"]}\n```\n",
399 "and some telemetry: {\"tokens\":123}\n"
400 );
401 let r: Ranking = extract_json(text).unwrap();
402 assert_eq!(r.normalized(), ['A', 'B']);
403 }
404
405 #[test]
406 fn no_json_is_an_error_not_a_default() {
407 let err = extract_json::<Ranking>("I decline to produce JSON.").unwrap_err();
408 assert!(err.to_string().contains("no JSON object"));
409 }
410
411 #[test]
412 fn truncated_object_does_not_hang() {
413 let err = extract_json::<Ranking>("{\"ranking\": [\"A\"").unwrap_err();
414 assert!(err.to_string().contains("no JSON object"));
415 }
416
417 #[test]
418 fn ranking_validation_rejects_a_non_permutation() {
419 let r = Ranking {
420 ranking: vec!["A".to_owned(), "A".to_owned()],
421 reasons: BTreeMap::new(),
422 confidence: None,
423 };
424 assert!(r.validate(&['A', 'B', 'C']).is_err());
425
426 let r = Ranking {
427 ranking: vec!["c".to_owned(), "B".to_owned(), "A".to_owned()],
428 reasons: BTreeMap::new(),
429 confidence: None,
430 };
431 r.validate(&['A', 'B', 'C']).expect("case is normalised");
432 assert_eq!(r.normalized(), ['C', 'B', 'A']);
433 }
434
435 #[test]
436 fn final_vote_label_is_normalised() {
437 let v: FinalVote = extract_json(r#"{"vote":" b ","reason":"tests"}"#).unwrap();
438 assert_eq!(v.label(), Some('B'));
439 }
440
441 #[test]
442 fn severity_blocking_is_major_and_up() {
443 assert!(Severity::Blocker.blocks());
444 assert!(Severity::Major.blocks());
445 assert!(!Severity::Minor.blocks());
446 assert!(!Severity::Nit.blocks());
447 assert!(Severity::Blocker > Severity::Nit);
448 }
449
450 #[test]
451 fn review_parses_with_optional_fields_missing() {
452 let r: Review = extract_json(
453 r#"{"vote":"reject","findings":[{"severity":"blocker","title":"panics on empty input"}]}"#,
454 )
455 .unwrap();
456 assert_eq!(r.findings.len(), 1);
457 assert!(r.findings[0].file.is_none());
458 assert_eq!(r.findings[0].id, "");
459 assert_eq!(r.vote, ReviewVote::Reject);
460 }
461
462 #[test]
463 fn review_without_a_vote_is_rejected_rather_than_defaulted() {
464 let err = extract_json::<Review>(r#"{"findings":[]}"#).unwrap_err();
465 assert!(err.to_string().contains("no JSON object"), "{err}");
466 }
467
468 #[test]
469 fn review_vote_worst_is_the_most_cautious() {
470 assert_eq!(
471 ReviewVote::worst([ReviewVote::Approve, ReviewVote::Reject, ReviewVote::Approve]),
472 Some(ReviewVote::Reject)
473 );
474 assert_eq!(
475 ReviewVote::worst([ReviewVote::Approve, ReviewVote::ApproveWithFindings]),
476 Some(ReviewVote::ApproveWithFindings)
477 );
478 assert_eq!(ReviewVote::worst(Vec::<ReviewVote>::new()), None);
479 }
480
481 #[test]
482 fn review_revote_parses_the_reconsideration_shape() {
483 let r: ReviewRevote =
484 extract_json(r#"{"vote":"approve","reason":"the other findings do not hold"}"#)
485 .unwrap();
486 assert_eq!(r.vote, ReviewVote::Approve);
487 assert_eq!(r.reason, "the other findings do not hold");
488 }
489
490 #[test]
491 fn fix_report_parses_rejections() {
492 let f: FixReport = extract_json(
493 r#"{"addressed":["R1-1-1"],"rejected":[{"id":"R1-2-1","why":"not reachable"}]}"#,
494 )
495 .unwrap();
496 assert_eq!(f.addressed, ["R1-1-1"]);
497 assert_eq!(f.rejected[0].id, "R1-2-1");
498 }
499
500 #[test]
501 fn sections_are_sliced_by_heading() {
502 let text = "## SUMMARY\nchanged the retry loop.\nadded a test.\n\n## NOTES\nignore me\n";
503 assert_eq!(
504 section(text, "summary").unwrap(),
505 "changed the retry loop.\nadded a test."
506 );
507 assert_eq!(section(text, "notes").unwrap(), "ignore me");
508 assert!(section(text, "missing").is_none());
509 }
510
511 fn proposal() -> Proposal {
512 Proposal {
513 approach: "extract a helper".to_owned(),
514 key_tradeoff: "one more indirection for less duplication".to_owned(),
515 risks: vec!["callers must agree on the new signature".to_owned()],
516 touches: vec!["src/config.rs".to_owned()],
517 why_not_naive: "the naive copy-paste drifts the next time a field is added".to_owned(),
518 }
519 }
520
521 #[test]
522 fn a_complete_proposal_validates() {
523 assert!(proposal().validate().is_ok());
524 }
525
526 #[test]
527 fn a_proposal_missing_why_not_naive_is_rejected() {
528 let mut p = proposal();
529 p.why_not_naive = " ".to_owned();
530 let err = p.validate().expect_err("must be rejected").to_string();
531 assert!(err.contains("why_not_naive"), "{err}");
532 }
533
534 #[test]
535 fn a_proposal_parses_from_a_fenced_json_block_with_no_risks_or_touches_given() {
536 let text = "```json\n{\"approach\":\"a\",\"key_tradeoff\":\"b\",\
537 \"why_not_naive\":\"c\"}\n```\n";
538 let p: Proposal = extract_json(text).unwrap();
539 assert!(p.validate().is_ok());
540 assert!(p.risks.is_empty());
541 assert!(p.touches.is_empty());
542 }
543}