use std::collections::BTreeMap;
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ranking {
pub ranking: Vec<String>,
#[serde(default)]
pub reasons: BTreeMap<String, String>,
#[serde(default)]
pub confidence: Option<u8>,
}
impl Ranking {
pub fn top(&self) -> Option<&str> {
self.ranking.first().map(String::as_str)
}
pub fn validate(&self, labels: &[char]) -> Result<()> {
let mut got: Vec<char> = self
.ranking
.iter()
.filter_map(|s| s.trim().chars().next())
.map(|c| c.to_ascii_uppercase())
.collect();
got.sort_unstable();
got.dedup();
let mut want: Vec<char> = labels.to_vec();
want.sort_unstable();
if got != want {
bail!(
"ranking {:?} is not a permutation of the candidate labels {:?}",
self.ranking,
labels
);
}
Ok(())
}
pub fn normalized(&self) -> Vec<char> {
self.ranking
.iter()
.filter_map(|s| s.trim().chars().next())
.map(|c| c.to_ascii_uppercase())
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FinalVote {
pub vote: String,
#[serde(default)]
pub reason: String,
}
impl FinalVote {
pub fn label(&self) -> Option<char> {
self.vote
.trim()
.chars()
.next()
.map(|c| c.to_ascii_uppercase())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Nit,
Minor,
Major,
Blocker,
}
impl Severity {
pub fn blocks(self) -> bool {
matches!(self, Self::Major | Self::Blocker)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
#[serde(default)]
pub id: String,
pub severity: Severity,
#[serde(default)]
pub file: Option<String>,
#[serde(default)]
pub line: Option<u32>,
pub title: String,
#[serde(default)]
pub detail: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Review {
#[serde(default)]
pub findings: Vec<Finding>,
#[serde(default)]
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixReport {
#[serde(default)]
pub addressed: Vec<String>,
#[serde(default)]
pub rejected: Vec<Rejection>,
#[serde(default)]
pub notes: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rejection {
pub id: String,
#[serde(default)]
pub why: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Position {
#[serde(default)]
pub tentative: Option<String>,
}
pub fn extract_json<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
let bytes = text.as_bytes();
let mut spans: Vec<(usize, usize)> = Vec::new();
let mut i = 0usize;
while i < bytes.len() {
if bytes[i] != b'{' {
i += 1;
continue;
}
let mut depth = 0usize;
let mut in_str = false;
let mut escaped = false;
let mut j = i;
while j < bytes.len() {
let c = bytes[j];
if in_str {
if escaped {
escaped = false;
} else if c == b'\\' {
escaped = true;
} else if c == b'"' {
in_str = false;
}
} else {
match c {
b'"' => in_str = true,
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
spans.push((i, j + 1));
break;
}
}
_ => {}
}
}
j += 1;
}
i = if depth == 0 && j < bytes.len() {
j + 1
} else {
i + 1
};
}
let mut last_err = None;
for (start, end) in spans.iter().rev() {
match serde_json::from_str::<T>(&text[*start..*end]) {
Ok(v) => return Ok(v),
Err(e) => last_err = Some(e),
}
}
match last_err {
Some(e) => bail!("no JSON object in the reply matched the expected shape: {e}"),
None => bail!("the reply contained no JSON object"),
}
}
pub fn section(text: &str, heading: &str) -> Option<String> {
let want = heading.to_ascii_lowercase();
let mut out: Option<String> = None;
for line in text.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("##") {
let name = rest.trim_start_matches('#').trim().to_ascii_lowercase();
if name == want {
out = Some(String::new());
continue;
}
if out.is_some() {
break;
}
continue;
}
if let Some(buf) = out.as_mut() {
buf.push_str(line);
buf.push('\n');
}
}
out.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fenced_block_is_found() {
let text = "Here is my verdict.\n\n```json\n{\"ranking\":[\"B\",\"A\"]}\n```\n";
let r: Ranking = extract_json(text).unwrap();
assert_eq!(r.top(), Some("B"));
}
#[test]
fn last_matching_object_wins_over_an_earlier_example() {
let text = concat!(
"The format is {\"ranking\":[\"X\"]} for illustration.\n",
"```json\n{\"ranking\":[\"C\",\"A\",\"B\"],\"confidence\":4}\n```\n",
"Happy to elaborate.\n"
);
let r: Ranking = extract_json(text).unwrap();
assert_eq!(r.normalized(), ['C', 'A', 'B']);
assert_eq!(r.confidence, Some(4));
}
#[test]
fn braces_inside_strings_do_not_close_the_object() {
let text = r#"{"ranking":["A"],"reasons":{"A":"uses format!(\"{}\", x) safely}"}}"#;
let r: Ranking = extract_json(text).unwrap();
assert_eq!(r.top(), Some("A"));
assert!(r.reasons["A"].contains("format!"));
}
#[test]
fn objects_of_the_wrong_shape_are_skipped() {
let text = concat!(
"```json\n{\"ranking\":[\"A\",\"B\"]}\n```\n",
"and some telemetry: {\"tokens\":123}\n"
);
let r: Ranking = extract_json(text).unwrap();
assert_eq!(r.normalized(), ['A', 'B']);
}
#[test]
fn no_json_is_an_error_not_a_default() {
let err = extract_json::<Ranking>("I decline to produce JSON.").unwrap_err();
assert!(err.to_string().contains("no JSON object"));
}
#[test]
fn truncated_object_does_not_hang() {
let err = extract_json::<Ranking>("{\"ranking\": [\"A\"").unwrap_err();
assert!(err.to_string().contains("no JSON object"));
}
#[test]
fn ranking_validation_rejects_a_non_permutation() {
let r = Ranking {
ranking: vec!["A".to_owned(), "A".to_owned()],
reasons: BTreeMap::new(),
confidence: None,
};
assert!(r.validate(&['A', 'B', 'C']).is_err());
let r = Ranking {
ranking: vec!["c".to_owned(), "B".to_owned(), "A".to_owned()],
reasons: BTreeMap::new(),
confidence: None,
};
r.validate(&['A', 'B', 'C']).expect("case is normalised");
assert_eq!(r.normalized(), ['C', 'B', 'A']);
}
#[test]
fn final_vote_label_is_normalised() {
let v: FinalVote = extract_json(r#"{"vote":" b ","reason":"tests"}"#).unwrap();
assert_eq!(v.label(), Some('B'));
}
#[test]
fn severity_blocking_is_major_and_up() {
assert!(Severity::Blocker.blocks());
assert!(Severity::Major.blocks());
assert!(!Severity::Minor.blocks());
assert!(!Severity::Nit.blocks());
assert!(Severity::Blocker > Severity::Nit);
}
#[test]
fn review_parses_with_optional_fields_missing() {
let r: Review = extract_json(
r#"{"findings":[{"severity":"blocker","title":"panics on empty input"}]}"#,
)
.unwrap();
assert_eq!(r.findings.len(), 1);
assert!(r.findings[0].file.is_none());
assert_eq!(r.findings[0].id, "");
}
#[test]
fn fix_report_parses_rejections() {
let f: FixReport = extract_json(
r#"{"addressed":["R1-1-1"],"rejected":[{"id":"R1-2-1","why":"not reachable"}]}"#,
)
.unwrap();
assert_eq!(f.addressed, ["R1-1-1"]);
assert_eq!(f.rejected[0].id, "R1-2-1");
}
#[test]
fn sections_are_sliced_by_heading() {
let text = "## SUMMARY\nchanged the retry loop.\nadded a test.\n\n## NOTES\nignore me\n";
assert_eq!(
section(text, "summary").unwrap(),
"changed the retry loop.\nadded a test."
);
assert_eq!(section(text, "notes").unwrap(), "ignore me");
assert!(section(text, "missing").is_none());
}
}