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
172#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct Proposal {
182 pub approach: String,
184 pub key_tradeoff: String,
186 #[serde(default)]
188 pub risks: Vec<String>,
189 #[serde(default)]
191 pub touches: Vec<String>,
192 pub why_not_naive: String,
195}
196
197impl Proposal {
198 pub fn validate(&self) -> Result<()> {
202 for (field, value) in [
203 ("approach", &self.approach),
204 ("key_tradeoff", &self.key_tradeoff),
205 ("why_not_naive", &self.why_not_naive),
206 ] {
207 if value.trim().is_empty() {
208 bail!("`{field}` is empty");
209 }
210 }
211 Ok(())
212 }
213}
214
215pub fn extract_json<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
221 let bytes = text.as_bytes();
222 let mut spans: Vec<(usize, usize)> = Vec::new();
223 let mut i = 0usize;
224 while i < bytes.len() {
225 if bytes[i] != b'{' {
226 i += 1;
227 continue;
228 }
229 let mut depth = 0usize;
230 let mut in_str = false;
231 let mut escaped = false;
232 let mut j = i;
233 while j < bytes.len() {
234 let c = bytes[j];
235 if in_str {
236 if escaped {
237 escaped = false;
238 } else if c == b'\\' {
239 escaped = true;
240 } else if c == b'"' {
241 in_str = false;
242 }
243 } else {
244 match c {
245 b'"' => in_str = true,
246 b'{' => depth += 1,
247 b'}' => {
248 depth -= 1;
249 if depth == 0 {
250 spans.push((i, j + 1));
251 break;
252 }
253 }
254 _ => {}
255 }
256 }
257 j += 1;
258 }
259 i = if depth == 0 && j < bytes.len() {
262 j + 1
263 } else {
264 i + 1
265 };
266 }
267
268 let mut last_err = None;
269 for (start, end) in spans.iter().rev() {
270 match serde_json::from_str::<T>(&text[*start..*end]) {
271 Ok(v) => return Ok(v),
272 Err(e) => last_err = Some(e),
273 }
274 }
275 match last_err {
276 Some(e) => bail!("no JSON object in the reply matched the expected shape: {e}"),
277 None => bail!("the reply contained no JSON object"),
278 }
279}
280
281pub fn section(text: &str, heading: &str) -> Option<String> {
285 let want = heading.to_ascii_lowercase();
286 let mut out: Option<String> = None;
287 for line in text.lines() {
288 let trimmed = line.trim();
289 if let Some(rest) = trimmed.strip_prefix("##") {
290 let name = rest.trim_start_matches('#').trim().to_ascii_lowercase();
291 if name == want {
292 out = Some(String::new());
293 continue;
294 }
295 if out.is_some() {
296 break;
297 }
298 continue;
299 }
300 if let Some(buf) = out.as_mut() {
301 buf.push_str(line);
302 buf.push('\n');
303 }
304 }
305 out.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn fenced_block_is_found() {
314 let text = "Here is my verdict.\n\n```json\n{\"ranking\":[\"B\",\"A\"]}\n```\n";
315 let r: Ranking = extract_json(text).unwrap();
316 assert_eq!(r.top(), Some("B"));
317 }
318
319 #[test]
320 fn last_matching_object_wins_over_an_earlier_example() {
321 let text = concat!(
322 "The format is {\"ranking\":[\"X\"]} for illustration.\n",
323 "```json\n{\"ranking\":[\"C\",\"A\",\"B\"],\"confidence\":4}\n```\n",
324 "Happy to elaborate.\n"
325 );
326 let r: Ranking = extract_json(text).unwrap();
327 assert_eq!(r.normalized(), ['C', 'A', 'B']);
328 assert_eq!(r.confidence, Some(4));
329 }
330
331 #[test]
332 fn braces_inside_strings_do_not_close_the_object() {
333 let text = r#"{"ranking":["A"],"reasons":{"A":"uses format!(\"{}\", x) safely}"}}"#;
334 let r: Ranking = extract_json(text).unwrap();
335 assert_eq!(r.top(), Some("A"));
336 assert!(r.reasons["A"].contains("format!"));
337 }
338
339 #[test]
340 fn objects_of_the_wrong_shape_are_skipped() {
341 let text = concat!(
342 "```json\n{\"ranking\":[\"A\",\"B\"]}\n```\n",
343 "and some telemetry: {\"tokens\":123}\n"
344 );
345 let r: Ranking = extract_json(text).unwrap();
346 assert_eq!(r.normalized(), ['A', 'B']);
347 }
348
349 #[test]
350 fn no_json_is_an_error_not_a_default() {
351 let err = extract_json::<Ranking>("I decline to produce JSON.").unwrap_err();
352 assert!(err.to_string().contains("no JSON object"));
353 }
354
355 #[test]
356 fn truncated_object_does_not_hang() {
357 let err = extract_json::<Ranking>("{\"ranking\": [\"A\"").unwrap_err();
358 assert!(err.to_string().contains("no JSON object"));
359 }
360
361 #[test]
362 fn ranking_validation_rejects_a_non_permutation() {
363 let r = Ranking {
364 ranking: vec!["A".to_owned(), "A".to_owned()],
365 reasons: BTreeMap::new(),
366 confidence: None,
367 };
368 assert!(r.validate(&['A', 'B', 'C']).is_err());
369
370 let r = Ranking {
371 ranking: vec!["c".to_owned(), "B".to_owned(), "A".to_owned()],
372 reasons: BTreeMap::new(),
373 confidence: None,
374 };
375 r.validate(&['A', 'B', 'C']).expect("case is normalised");
376 assert_eq!(r.normalized(), ['C', 'B', 'A']);
377 }
378
379 #[test]
380 fn final_vote_label_is_normalised() {
381 let v: FinalVote = extract_json(r#"{"vote":" b ","reason":"tests"}"#).unwrap();
382 assert_eq!(v.label(), Some('B'));
383 }
384
385 #[test]
386 fn severity_blocking_is_major_and_up() {
387 assert!(Severity::Blocker.blocks());
388 assert!(Severity::Major.blocks());
389 assert!(!Severity::Minor.blocks());
390 assert!(!Severity::Nit.blocks());
391 assert!(Severity::Blocker > Severity::Nit);
392 }
393
394 #[test]
395 fn review_parses_with_optional_fields_missing() {
396 let r: Review = extract_json(
397 r#"{"findings":[{"severity":"blocker","title":"panics on empty input"}]}"#,
398 )
399 .unwrap();
400 assert_eq!(r.findings.len(), 1);
401 assert!(r.findings[0].file.is_none());
402 assert_eq!(r.findings[0].id, "");
403 }
404
405 #[test]
406 fn fix_report_parses_rejections() {
407 let f: FixReport = extract_json(
408 r#"{"addressed":["R1-1-1"],"rejected":[{"id":"R1-2-1","why":"not reachable"}]}"#,
409 )
410 .unwrap();
411 assert_eq!(f.addressed, ["R1-1-1"]);
412 assert_eq!(f.rejected[0].id, "R1-2-1");
413 }
414
415 #[test]
416 fn sections_are_sliced_by_heading() {
417 let text = "## SUMMARY\nchanged the retry loop.\nadded a test.\n\n## NOTES\nignore me\n";
418 assert_eq!(
419 section(text, "summary").unwrap(),
420 "changed the retry loop.\nadded a test."
421 );
422 assert_eq!(section(text, "notes").unwrap(), "ignore me");
423 assert!(section(text, "missing").is_none());
424 }
425
426 fn proposal() -> Proposal {
427 Proposal {
428 approach: "extract a helper".to_owned(),
429 key_tradeoff: "one more indirection for less duplication".to_owned(),
430 risks: vec!["callers must agree on the new signature".to_owned()],
431 touches: vec!["src/config.rs".to_owned()],
432 why_not_naive: "the naive copy-paste drifts the next time a field is added".to_owned(),
433 }
434 }
435
436 #[test]
437 fn a_complete_proposal_validates() {
438 assert!(proposal().validate().is_ok());
439 }
440
441 #[test]
442 fn a_proposal_missing_why_not_naive_is_rejected() {
443 let mut p = proposal();
444 p.why_not_naive = " ".to_owned();
445 let err = p.validate().expect_err("must be rejected").to_string();
446 assert!(err.contains("why_not_naive"), "{err}");
447 }
448
449 #[test]
450 fn a_proposal_parses_from_a_fenced_json_block_with_no_risks_or_touches_given() {
451 let text = "```json\n{\"approach\":\"a\",\"key_tradeoff\":\"b\",\
452 \"why_not_naive\":\"c\"}\n```\n";
453 let p: Proposal = extract_json(text).unwrap();
454 assert!(p.validate().is_ok());
455 assert!(p.risks.is_empty());
456 assert!(p.touches.is_empty());
457 }
458}