use serde::{Deserialize, Serialize};
pub mod status;
pub use status::ReviewStatus;
use crate::config::constants::REVIEW_VERSION;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Verdict {
Approve,
#[serde(rename = "APPROVE*")]
ApproveWithReservations,
RequestChanges,
Block,
Unknown,
}
impl Verdict {
pub fn ordinal(&self) -> u8 {
match self {
Verdict::Approve => 0,
Verdict::ApproveWithReservations => 1,
Verdict::RequestChanges => 2,
Verdict::Block => 3,
Verdict::Unknown => 4,
}
}
}
impl std::fmt::Display for Verdict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Verdict::Approve => write!(f, "APPROVE"),
Verdict::ApproveWithReservations => write!(f, "APPROVE*"),
Verdict::RequestChanges => write!(f, "REQUEST_CHANGES"),
Verdict::Block => write!(f, "BLOCK"),
Verdict::Unknown => write!(f, "UNKNOWN"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Effort {
Low,
Medium,
High,
}
impl Effort {
pub fn is_issue_eligible(&self) -> bool {
matches!(self, Effort::Low | Effort::Medium)
}
}
impl std::fmt::Display for Effort {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Effort::Low => write!(f, "low"),
Effort::Medium => write!(f, "medium"),
Effort::High => write!(f, "high"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VerifyOutcome {
Confirmed,
Refuted,
ErrorRefuted {
error_class: String,
},
TruncationRefuted,
Skipped,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum FindingCategory {
#[default]
Correctness,
MethodConformance,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub file: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
pub kind: String,
pub description: String,
#[serde(default)]
pub consequence: String,
pub suggestion: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_replacement: Option<String>,
pub confidence: f32,
pub effort: Effort,
#[serde(default)]
pub category: FindingCategory,
#[serde(skip_serializing_if = "Option::is_none")]
pub verified: Option<VerifyOutcome>,
#[serde(default)]
pub issue_eligible: bool,
}
impl Finding {
pub fn new(
file: impl Into<String>,
kind: impl Into<String>,
description: impl Into<String>,
suggestion: impl Into<String>,
confidence: f32,
effort: Effort,
) -> Self {
Self {
file: file.into(),
line: None,
kind: kind.into(),
description: description.into(),
consequence: String::new(),
suggestion: suggestion.into(),
suggested_replacement: None,
confidence: confidence.clamp(0.0, 1.0),
effort,
category: FindingCategory::Correctness,
verified: None,
issue_eligible: false,
}
}
#[must_use]
pub fn with_category(mut self, category: FindingCategory) -> Self {
self.category = category;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InlineCommentOut {
pub path: String,
pub line: u32,
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewResult {
pub owner: String,
pub repo: String,
pub pr_number: u64,
pub pr_title: String,
pub pr_url: String,
pub review_body: String,
pub verdict: Verdict,
#[serde(skip_serializing_if = "Option::is_none")]
pub grade: Option<String>,
pub findings: Vec<Finding>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inline_comments: Vec<InlineCommentOut>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inline_finding_indices: Vec<usize>,
#[serde(default, skip_serializing_if = "is_zero_usize")]
pub suppressed_nits: usize,
pub model: String,
pub input_tokens: u32,
pub output_tokens: u32,
pub cost_estimate_usd: f64,
pub latency_ms: u64,
#[serde(default)]
pub status: ReviewStatus,
pub dry_run: bool,
pub posted: bool,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub head_sha: String,
pub review_version: String,
}
impl ReviewResult {
pub fn new(
owner: impl Into<String>,
repo: impl Into<String>,
pr_number: u64,
pr_title: impl Into<String>,
pr_url: impl Into<String>,
) -> Self {
Self {
owner: owner.into(),
repo: repo.into(),
pr_number,
pr_title: pr_title.into(),
pr_url: pr_url.into(),
review_body: String::new(),
verdict: Verdict::Unknown,
grade: None,
findings: Vec::new(),
inline_comments: Vec::new(),
inline_finding_indices: Vec::new(),
suppressed_nits: 0,
model: String::new(),
input_tokens: 0,
output_tokens: 0,
cost_estimate_usd: 0.0,
latency_ms: 0,
status: ReviewStatus::Completed,
dry_run: true,
posted: false,
timestamp: chrono_now(),
error: None,
head_sha: String::new(),
review_version: REVIEW_VERSION.to_string(),
}
}
pub fn apply_llm_response(&mut self, resp: &crate::llm::LlmResponse) {
self.model = resp.model.clone();
self.input_tokens = resp.input_tokens;
self.output_tokens = resp.output_tokens;
self.cost_estimate_usd = resp.cost_usd;
self.latency_ms = resp.latency_ms;
self.review_body = resp.text.clone();
}
}
fn is_zero_usize(n: &usize) -> bool {
*n == 0
}
fn chrono_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let s = secs % 60;
let m = (secs / 60) % 60;
let h = (secs / 3600) % 24;
let days = secs / 86400;
let (year, month, day) = epoch_days_to_ymd(days);
format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
}
fn epoch_days_to_ymd(days: u64) -> (u64, u64, u64) {
let z = days + 719468;
let era = z / 146097;
let doe = z % 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verdict_serde_roundtrip() {
let cases = [
(Verdict::Approve, "\"APPROVE\""),
(Verdict::ApproveWithReservations, "\"APPROVE*\""),
(Verdict::RequestChanges, "\"REQUEST_CHANGES\""),
(Verdict::Block, "\"BLOCK\""),
(Verdict::Unknown, "\"UNKNOWN\""),
];
for (v, expected_json) in cases {
let json = serde_json::to_string(&v).unwrap();
assert_eq!(json, expected_json, "serialise mismatch for {v:?}");
let back: Verdict = serde_json::from_str(&json).unwrap();
assert_eq!(back, v, "deserialise mismatch for {expected_json}");
}
}
#[test]
fn verdict_display() {
assert_eq!(Verdict::Approve.to_string(), "APPROVE");
assert_eq!(Verdict::ApproveWithReservations.to_string(), "APPROVE*");
assert_eq!(Verdict::RequestChanges.to_string(), "REQUEST_CHANGES");
assert_eq!(Verdict::Block.to_string(), "BLOCK");
assert_eq!(Verdict::Unknown.to_string(), "UNKNOWN");
}
#[test]
fn verdict_unknown_round_trip() {
let json = serde_json::to_string(&Verdict::Unknown).unwrap();
assert_eq!(json, "\"UNKNOWN\"");
let back: Verdict = serde_json::from_str(&json).unwrap();
assert_eq!(back, Verdict::Unknown);
}
#[test]
fn verdict_ordinal_is_monotonic() {
assert!(Verdict::Approve.ordinal() < Verdict::ApproveWithReservations.ordinal());
assert!(Verdict::ApproveWithReservations.ordinal() < Verdict::RequestChanges.ordinal());
assert!(Verdict::RequestChanges.ordinal() < Verdict::Block.ordinal());
assert!(Verdict::Block.ordinal() < Verdict::Unknown.ordinal());
assert_eq!(Verdict::Approve.ordinal(), 0);
assert_eq!(Verdict::Block.ordinal(), 3);
}
#[test]
fn effort_serde_roundtrip() {
let json = serde_json::to_string(&Effort::Low).unwrap();
assert_eq!(json, "\"low\"");
let back: Effort = serde_json::from_str(&json).unwrap();
assert_eq!(back, Effort::Low);
}
#[test]
fn effort_issue_eligibility() {
assert!(Effort::Low.is_issue_eligible());
assert!(Effort::Medium.is_issue_eligible());
assert!(!Effort::High.is_issue_eligible());
}
#[test]
fn finding_confidence_clamping() {
let f_over = Finding::new("src/lib.rs", "bug", "desc", "fix", 1.5, Effort::Low);
assert!(
(f_over.confidence - 1.0_f32).abs() < f32::EPSILON,
"over 1.0 should clamp to 1.0"
);
let f_under = Finding::new("src/lib.rs", "bug", "desc", "fix", -0.1, Effort::Low);
assert!(
(f_under.confidence - 0.0_f32).abs() < f32::EPSILON,
"under 0.0 should clamp to 0.0"
);
let f_mid = Finding::new("src/lib.rs", "bug", "desc", "fix", 0.85, Effort::Medium);
assert!((f_mid.confidence - 0.85_f32).abs() < f32::EPSILON);
}
#[test]
fn finding_category_serde_roundtrip() {
assert_eq!(
serde_json::to_string(&FindingCategory::Correctness).unwrap(),
"\"correctness\""
);
assert_eq!(
serde_json::to_string(&FindingCategory::MethodConformance).unwrap(),
"\"method-conformance\""
);
let back: FindingCategory = serde_json::from_str("\"method-conformance\"").unwrap();
assert_eq!(back, FindingCategory::MethodConformance);
assert_eq!(FindingCategory::default(), FindingCategory::Correctness);
}
#[test]
fn finding_defaults_category_correctness() {
let f = Finding::new("src/lib.rs", "bug", "desc", "fix", 0.9, Effort::Low);
assert_eq!(f.category, FindingCategory::Correctness);
}
#[test]
fn finding_with_category_sets_method_conformance() {
let f = Finding::new("src/lib.rs", "bug", "desc", "fix", 0.9, Effort::Medium)
.with_category(FindingCategory::MethodConformance);
assert_eq!(f.category, FindingCategory::MethodConformance);
}
#[test]
fn finding_without_category_field_defaults_correctness() {
let json = r#"{
"file": "src/main.rs",
"kind": "logic-error",
"description": "off-by-one",
"suggestion": "use <=",
"confidence": 0.7,
"effort": "medium"
}"#;
let f: Finding = serde_json::from_str(json).expect("legacy finding must deserialise");
assert_eq!(f.category, FindingCategory::Correctness);
assert_eq!(f.kind, "logic-error");
}
#[test]
fn inline_comment_out_serde_roundtrip() {
let c = InlineCommentOut {
path: "src/db.rs".to_string(),
line: 42,
body: "**security** — SQL injection".to_string(),
};
let json = serde_json::to_string(&c).expect("serialise");
let back: InlineCommentOut = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back, c);
}
#[test]
fn review_result_without_inline_comments_defaults_empty() {
let result = ReviewResult::new("o", "r", 1, "t", "u");
let json = serde_json::to_string(&result).expect("serialise");
assert!(
!json.contains("inline_comments"),
"empty inline_comments is skipped in serialisation"
);
let back: ReviewResult = serde_json::from_str(&json).expect("deserialise");
assert!(back.inline_comments.is_empty());
}
#[test]
fn review_result_serde_roundtrip() {
let mut result = ReviewResult::new(
"acme",
"backend",
42,
"Add feature X",
"https://github.com/acme/backend/pull/42",
);
result.verdict = Verdict::RequestChanges;
result.review_version = "tr-0.1".to_string();
result.findings.push(Finding::new(
"src/main.rs",
"security",
"SQL injection risk",
"Use parameterised query",
0.92,
Effort::Medium,
));
let json = serde_json::to_string(&result).expect("serialise");
let back: ReviewResult = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back.owner, "acme");
assert_eq!(back.repo, "backend");
assert_eq!(back.pr_number, 42);
assert_eq!(back.verdict, Verdict::RequestChanges);
assert_eq!(back.review_version, "tr-0.1");
assert_eq!(back.findings.len(), 1);
assert_eq!(back.findings[0].kind, "security");
assert!((back.findings[0].confidence - 0.92_f32).abs() < f32::EPSILON);
assert!(back.dry_run, "dry_run defaults to true");
assert!(!back.posted, "posted defaults to false");
}
#[test]
fn timestamp_format_is_iso8601() {
let ts = chrono_now();
assert_eq!(ts.len(), 20, "timestamp should be 20 chars: {ts}");
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[7..8], "-");
assert_eq!(&ts[10..11], "T");
assert_eq!(&ts[13..14], ":");
assert_eq!(&ts[16..17], ":");
assert_eq!(&ts[19..20], "Z");
}
#[test]
fn verify_outcome_serde() {
let confirmed = VerifyOutcome::Confirmed;
let json = serde_json::to_string(&confirmed).unwrap();
assert_eq!(json, "\"confirmed\"");
let error_refuted = VerifyOutcome::ErrorRefuted {
error_class: "ModelNotFound".to_string(),
};
let json = serde_json::to_string(&error_refuted).unwrap();
let back: VerifyOutcome = serde_json::from_str(&json).unwrap();
assert!(
matches!(back, VerifyOutcome::ErrorRefuted { error_class } if error_class == "ModelNotFound")
);
}
}