use anyhow::{bail, Context, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewPhase {
Context,
DeepReview,
CrossExamine,
Produce,
Done,
}
impl Default for ReviewPhase {
fn default() -> Self {
ReviewPhase::Context
}
}
impl fmt::Display for ReviewPhase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ReviewPhase::Context => write!(f, "Context"),
ReviewPhase::DeepReview => write!(f, "Deep Review"),
ReviewPhase::CrossExamine => write!(f, "Cross-Examine"),
ReviewPhase::Produce => write!(f, "Produce"),
ReviewPhase::Done => write!(f, "Done"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewAxis {
Correctness,
Readability,
Performance,
Security,
ErrorHandling,
Testing,
Conventions,
Architecture,
}
impl fmt::Display for ReviewAxis {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ReviewAxis::Correctness => write!(f, "Correctness"),
ReviewAxis::Readability => write!(f, "Readability"),
ReviewAxis::Performance => write!(f, "Performance"),
ReviewAxis::Security => write!(f, "Security"),
ReviewAxis::ErrorHandling => write!(f, "Error Handling"),
ReviewAxis::Testing => write!(f, "Testing"),
ReviewAxis::Conventions => write!(f, "Conventions"),
ReviewAxis::Architecture => write!(f, "Architecture"),
}
}
}
impl ReviewAxis {
pub fn all() -> &'static [ReviewAxis] {
&[
ReviewAxis::Correctness,
ReviewAxis::ErrorHandling,
ReviewAxis::Security,
ReviewAxis::Testing,
ReviewAxis::Performance,
ReviewAxis::Readability,
ReviewAxis::Conventions,
ReviewAxis::Architecture,
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingSeverity {
Nit,
Minor,
Important,
Critical,
}
impl fmt::Display for FindingSeverity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FindingSeverity::Nit => write!(f, "nit"),
FindingSeverity::Minor => write!(f, "minor"),
FindingSeverity::Important => write!(f, "important"),
FindingSeverity::Critical => write!(f, "critical"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingVerdict {
Confirmed,
FalsePositive,
Deferred,
}
impl fmt::Display for FindingVerdict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FindingVerdict::Confirmed => write!(f, "confirmed"),
FindingVerdict::FalsePositive => write!(f, "false positive"),
FindingVerdict::Deferred => write!(f, "deferred"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeEvidence {
pub file: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
pub observation: String,
}
impl fmt::Display for CodeEvidence {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.line {
Some(line) => write!(f, "`{}:{}` — {}", self.file, line, self.observation),
None => write!(f, "`{}` — {}", self.file, self.observation),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewFinding {
pub description: String,
pub axis: ReviewAxis,
pub severity: FindingSeverity,
pub evidence: Vec<CodeEvidence>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestion: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verdict: Option<FindingVerdict>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verdict_reason: Option<String>,
}
impl ReviewFinding {
pub fn new(
description: impl Into<String>,
axis: ReviewAxis,
severity: FindingSeverity,
) -> Self {
Self {
description: description.into(),
axis,
severity,
evidence: Vec::new(),
suggestion: None,
verdict: None,
verdict_reason: None,
}
}
pub fn with_evidence(mut self, evidence: CodeEvidence) -> Self {
self.evidence.push(evidence);
self
}
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.suggestion = Some(suggestion.into());
self
}
pub fn set_verdict(&mut self, verdict: FindingVerdict, reason: impl Into<String>) {
self.verdict = Some(verdict);
self.verdict_reason = Some(reason.into());
}
pub fn is_confirmed(&self) -> bool {
matches!(self.verdict, Some(FindingVerdict::Confirmed))
}
pub fn is_examined(&self) -> bool {
self.verdict.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewScope {
pub description: String,
pub files: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit_range: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewReport {
pub meta: ReviewMeta,
pub scope: ReviewScope,
pub axis_summaries: Vec<AxisSummary>,
pub findings: Vec<ReviewFinding>,
pub overall: OverallAssessment,
pub recommendations: Vec<Recommendation>,
pub discarded: Vec<DiscardedFinding>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewMeta {
pub date: String,
pub reviewer: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AxisSummary {
pub axis: ReviewAxis,
pub impression: AxisImpression,
pub finding_count: usize,
pub summary: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AxisImpression {
Clean,
Acceptable,
NeedsWork,
Concerning,
}
impl fmt::Display for AxisImpression {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AxisImpression::Clean => write!(f, "✅ Clean"),
AxisImpression::Acceptable => write!(f, "🟢 Acceptable"),
AxisImpression::NeedsWork => write!(f, "🟡 Needs Work"),
AxisImpression::Concerning => write!(f, "🔴 Concerning"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverallAssessment {
pub summary: String,
pub ready_to_ship: bool,
pub critical_count: usize,
pub important_count: usize,
pub minor_count: usize,
pub nit_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recommendation {
pub action: String,
pub rationale: String,
pub severity: FindingSeverity,
pub files: Vec<String>,
pub priority: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscardedFinding {
pub description: String,
pub reason: String,
pub discard_type: FindingVerdict,
}
impl ReviewReport {
pub fn render_markdown(&self) -> String {
let mut md = String::with_capacity(4096);
md.push_str(&format!("# Code Review: {}\n\n", self.scope.description));
md.push_str(&format!(
"> Date: {} | Reviewer: {}\n",
self.meta.date, self.meta.reviewer
));
if let Some(ref range) = self.scope.commit_range {
md.push_str(&format!("> Commits: {}\n", range));
}
if let Some(ref branch) = self.scope.branch {
md.push_str(&format!("> Branch: {}\n", branch));
}
md.push('\n');
md.push_str("## Overall Assessment\n\n");
let ship_status = if self.overall.ready_to_ship {
"✅ Ready to ship"
} else {
"❌ Not ready to ship"
};
md.push_str(&format!(
"**{}** — {}\n\n",
ship_status, self.overall.summary
));
md.push_str(&format!(
"- Critical: {} | Important: {} | Minor: {} | Nit: {}\n\n",
self.overall.critical_count,
self.overall.important_count,
self.overall.minor_count,
self.overall.nit_count,
));
if !self.axis_summaries.is_empty() {
md.push_str("## Axis Summary\n\n");
md.push_str("| Axis | Impression | Findings | Summary |\n");
md.push_str("|------|-----------|----------|----------|\n");
for summary in &self.axis_summaries {
md.push_str(&format!(
"| {} | {} | {} | {} |\n",
summary.axis, summary.impression, summary.finding_count, summary.summary
));
}
md.push('\n');
}
if !self.scope.files.is_empty() {
md.push_str("## Files Reviewed\n\n");
for file in &self.scope.files {
md.push_str(&format!("- `{}`\n", file));
}
md.push('\n');
}
let confirmed: Vec<&ReviewFinding> =
self.findings.iter().filter(|f| f.is_confirmed()).collect();
if !confirmed.is_empty() {
md.push_str("## Findings\n\n");
for (i, finding) in confirmed.iter().enumerate() {
md.push_str(&format!(
"### {}. [{}] {} ({})\n\n",
i + 1,
finding.severity,
finding.description,
finding.axis,
));
if !finding.evidence.is_empty() {
md.push_str("**Evidence:**\n");
for ev in &finding.evidence {
md.push_str(&format!("- {}\n", ev));
}
md.push('\n');
}
if let Some(ref suggestion) = finding.suggestion {
md.push_str(&format!("**Suggestion:** {}\n\n", suggestion));
}
}
}
if !self.recommendations.is_empty() {
md.push_str("## Recommendations\n\n");
for (i, rec) in self.recommendations.iter().enumerate() {
md.push_str(&format!(
"{}. **[{}] {}**\n {}\n",
i + 1,
rec.severity,
rec.action,
rec.rationale,
));
if !rec.files.is_empty() {
md.push_str(&format!(" Files: {}\n", rec.files.join(", ")));
}
}
md.push('\n');
}
if !self.discarded.is_empty() {
md.push_str("## Discarded Findings\n\n");
for discarded in &self.discarded {
let icon = match discarded.discard_type {
FindingVerdict::FalsePositive => "❌",
FindingVerdict::Deferred => "⏸️",
FindingVerdict::Confirmed => "✅",
};
md.push_str(&format!(
"- {} {}: {}\n",
icon, discarded.description, discarded.reason
));
}
md.push('\n');
}
md
}
pub fn write_to_file(&self, dir: &Path) -> Result<PathBuf> {
fs::create_dir_all(dir).with_context(|| format!("Failed to create {}", dir.display()))?;
let date = &self.meta.date;
let slug = slugify(&self.scope.description);
let filename = format!("{}-{}.md", date, slug);
let path = dir.join(&filename);
let content = self.render_markdown();
fs::write(&path, &content)
.with_context(|| format!("Failed to write review to {}", path.display()))?;
Ok(path)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewSession {
pub phase: ReviewPhase,
pub scope: Option<ReviewScope>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_root: Option<PathBuf>,
pub findings: Vec<ReviewFinding>,
pub axis_summaries: Vec<AxisSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub report: Option<ReviewReport>,
}
impl ReviewSession {
pub fn new() -> Self {
Self {
phase: ReviewPhase::Context,
scope: None,
project_root: None,
findings: Vec::new(),
axis_summaries: Vec::new(),
report: None,
}
}
pub fn with_scope(mut self, scope: ReviewScope) -> Self {
self.scope = Some(scope);
self
}
pub fn with_project_root(mut self, root: impl Into<PathBuf>) -> Self {
self.project_root = Some(root.into());
self
}
pub fn advance(&mut self) -> Result<()> {
let next = match self.phase {
ReviewPhase::Context => ReviewPhase::DeepReview,
ReviewPhase::DeepReview => ReviewPhase::CrossExamine,
ReviewPhase::CrossExamine => ReviewPhase::Produce,
ReviewPhase::Produce => ReviewPhase::Done,
ReviewPhase::Done => bail!("Cannot advance past Done"),
};
self.phase = next;
Ok(())
}
pub fn set_phase(&mut self, phase: ReviewPhase) {
self.phase = phase;
}
pub fn add_finding(&mut self, finding: ReviewFinding) {
self.findings.push(finding);
}
pub fn finding_count(&self) -> usize {
self.findings.len()
}
pub fn confirmed_findings(&self) -> Vec<&ReviewFinding> {
self.findings.iter().filter(|f| f.is_confirmed()).collect()
}
pub fn findings_by_axis(&self, axis: ReviewAxis) -> Vec<&ReviewFinding> {
self.findings.iter().filter(|f| f.axis == axis).collect()
}
pub fn findings_by_severity(&self, severity: FindingSeverity) -> Vec<&ReviewFinding> {
self.findings
.iter()
.filter(|f| f.severity == severity)
.collect()
}
pub fn cross_examine(
&mut self,
index: usize,
verdict: FindingVerdict,
reason: impl Into<String>,
) -> Result<()> {
let finding = self
.findings
.get_mut(index)
.with_context(|| format!("No finding at index {}", index))?;
finding.set_verdict(verdict, reason);
Ok(())
}
pub fn cross_examine_all(
&mut self,
verdict: FindingVerdict,
reason: impl Into<String>,
) -> usize {
let reason_str = reason.into();
let mut already_examined = 0;
for finding in &mut self.findings {
if finding.is_examined() {
already_examined += 1;
} else {
finding.set_verdict(verdict, reason_str.clone());
}
}
already_examined
}
pub fn produce_report(&mut self) -> Result<()> {
let scope = self
.scope
.clone()
.context("Review scope not set — call with_scope() first")?;
let findings_snapshot: Vec<ReviewFinding> = self.findings.clone();
let confirmed: Vec<&ReviewFinding> = findings_snapshot
.iter()
.filter(|f| f.is_confirmed())
.collect();
let critical_count = confirmed
.iter()
.filter(|f| f.severity == FindingSeverity::Critical)
.count();
let important_count = confirmed
.iter()
.filter(|f| f.severity == FindingSeverity::Important)
.count();
let minor_count = confirmed
.iter()
.filter(|f| f.severity == FindingSeverity::Minor)
.count();
let nit_count = confirmed
.iter()
.filter(|f| f.severity == FindingSeverity::Nit)
.count();
let ready_to_ship = critical_count == 0 && important_count == 0;
let mut axis_summaries = Vec::new();
for axis in ReviewAxis::all() {
let axis_findings: Vec<&&ReviewFinding> =
confirmed.iter().filter(|f| f.axis == *axis).collect();
let count = axis_findings.len();
let has_critical = axis_findings
.iter()
.any(|f| f.severity == FindingSeverity::Critical);
let has_important = axis_findings
.iter()
.any(|f| f.severity == FindingSeverity::Important);
let impression = if count == 0 {
AxisImpression::Clean
} else if has_critical {
AxisImpression::Concerning
} else if has_important {
AxisImpression::NeedsWork
} else {
AxisImpression::Acceptable
};
let summary = if count == 0 {
"No issues found.".to_string()
} else {
format!("{} finding(s).", count)
};
axis_summaries.push(AxisSummary {
axis: *axis,
impression,
finding_count: count,
summary,
});
}
self.axis_summaries = axis_summaries.clone();
let mut recommendations: Vec<Recommendation> = confirmed
.iter()
.enumerate()
.map(|(i, f)| Recommendation {
action: f
.suggestion
.clone()
.unwrap_or_else(|| f.description.clone()),
rationale: f.description.clone(),
severity: f.severity,
files: f.evidence.iter().map(|e| e.file.clone()).collect(),
priority: i as u32,
})
.collect();
recommendations.sort_by(|a, b| b.severity.cmp(&a.severity));
for (i, rec) in recommendations.iter_mut().enumerate() {
rec.priority = i as u32;
}
let discarded: Vec<DiscardedFinding> = findings_snapshot
.iter()
.filter(|f| {
matches!(
f.verdict,
Some(FindingVerdict::FalsePositive) | Some(FindingVerdict::Deferred)
)
})
.map(|f| DiscardedFinding {
description: f.description.clone(),
reason: f.verdict_reason.clone().unwrap_or_default(),
discard_type: f.verdict.unwrap_or(FindingVerdict::FalsePositive),
})
.collect();
let summary = if ready_to_ship {
format!(
"Code is ready to ship with {} minor finding(s).",
minor_count + nit_count
)
} else {
format!(
"{} critical and {} important finding(s) must be addressed before shipping.",
critical_count, important_count
)
};
let report = ReviewReport {
meta: ReviewMeta {
date: Utc::now().format("%Y-%m-%d").to_string(),
reviewer: "oxi reviewer".to_string(),
},
scope,
axis_summaries,
findings: findings_snapshot,
overall: OverallAssessment {
summary,
ready_to_ship,
critical_count,
important_count,
minor_count,
nit_count,
},
recommendations,
discarded,
};
self.report = Some(report);
Ok(())
}
pub fn write_report(&self, explicit_path: Option<&Path>) -> Result<PathBuf> {
let report = self
.report
.as_ref()
.context("Report not produced — call produce_report() first")?;
if let Some(path) = explicit_path {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let content = report.render_markdown();
fs::write(path, &content)
.with_context(|| format!("Failed to write review to {}", path.display()))?;
Ok(path.to_path_buf())
} else {
let root = self
.project_root
.as_deref()
.context("No project root and no explicit path")?;
let review_dir = root.join("docs").join("review");
report.write_to_file(&review_dir)
}
}
}
impl Default for ReviewSession {
fn default() -> Self {
Self::new()
}
}
pub struct ReviewerSkill;
impl ReviewerSkill {
pub fn new() -> Self {
Self
}
pub fn skill_prompt() -> String {
r#"# Reviewer Skill
You are running the **reviewer** skill. Your job is to perform a thorough,
structured code review that catches real problems without generating false
positives.
## Workflow
### Phase 1: Context
1. Identify the scope of changes (files changed, diff summary).
2. Read project conventions (AGENTS.md, CONTRIBUTING.md, linting config).
3. Understand what the changes are trying to accomplish.
4. Note the project's actual conventions — don't impose external standards.
### Phase 2: Deep Review
Review each changed file against these axes, in priority order:
1. **Correctness** — Does the code do what it claims? Logic errors?
2. **Error Handling** — Are edge cases covered? Errors propagated?
3. **Security** — Input validation, injection risks, secrets?
4. **Testing** — Are new behaviors tested? Edge cases covered?
5. **Performance** — N+1 queries? Unnecessary allocations? Hot paths?
6. **Readability** — Clear naming? Obvious control flow?
7. **Conventions** — Follows project style? Consistent patterns?
8. **Architecture** — Clean boundaries? Good abstractions?
For each finding, record:
- **Description** — What's wrong
- **Severity** — critical / important / minor / nit
- **Evidence** — Exact file, line, and code
- **Suggestion** — How to fix it
### Phase 3: Cross-Examine
For EVERY finding, ask:
- Does this violate a project convention, or just a generic best practice?
- Is this actually in scope for this change?
- Would a staff engineer on this project flag this?
- Is the "correct" version actually better in this context?
- Does this affect actual behavior, or is it theoretical?
Discard false positives. Defer out-of-scope items. Only keep genuine findings.
### Phase 4: Produce
1. Generate the review report with confirmed findings.
2. Determine if code is ready to ship.
3. Write to `docs/review/YYYY-MM-DD-<slug>.md`.
## Rules
- Every finding MUST cite specific code (file + line + observation).
- Never flag something without explaining WHY it's a problem.
- Severity must match reality — don't inflate nits to critical.
- If the project intentionally uses a pattern, don't flag it as wrong.
- It's better to miss a minor issue than to waste time on false positives.
- The goal is to help the author, not to prove you found the most issues.
"#
.to_string()
}
}
impl Default for ReviewerSkill {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for ReviewerSkill {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReviewerSkill").finish()
}
}
fn slugify(s: &str) -> String {
s.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c
} else if c == ' ' || c == '_' || c == '-' {
'-'
} else {
'\0'
}
})
.filter(|c| *c != '\0')
.collect::<String>()
.trim_matches('-')
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_finding(axis: ReviewAxis, severity: FindingSeverity) -> ReviewFinding {
ReviewFinding::new(format!("{:?} issue", axis), axis, severity)
.with_evidence(CodeEvidence {
file: "src/main.rs".to_string(),
line: Some(42),
snippet: Some("unwrap()".to_string()),
observation: "Direct unwrap on Option".to_string(),
})
.with_suggestion("Use ok_or_else()? instead")
}
#[test]
fn test_session_new() {
let session = ReviewSession::new();
assert_eq!(session.phase, ReviewPhase::Context);
assert!(session.scope.is_none());
assert!(session.findings.is_empty());
}
#[test]
fn test_phase_advance() {
let mut session = ReviewSession::new();
assert_eq!(session.phase, ReviewPhase::Context);
session.advance().unwrap();
assert_eq!(session.phase, ReviewPhase::DeepReview);
session.advance().unwrap();
assert_eq!(session.phase, ReviewPhase::CrossExamine);
session.advance().unwrap();
assert_eq!(session.phase, ReviewPhase::Produce);
session.advance().unwrap();
assert_eq!(session.phase, ReviewPhase::Done);
assert!(session.advance().is_err());
}
#[test]
fn test_set_phase() {
let mut session = ReviewSession::new();
session.set_phase(ReviewPhase::CrossExamine);
assert_eq!(session.phase, ReviewPhase::CrossExamine);
}
#[test]
fn test_phase_display() {
assert_eq!(format!("{}", ReviewPhase::Context), "Context");
assert_eq!(format!("{}", ReviewPhase::DeepReview), "Deep Review");
assert_eq!(format!("{}", ReviewPhase::CrossExamine), "Cross-Examine");
assert_eq!(format!("{}", ReviewPhase::Produce), "Produce");
assert_eq!(format!("{}", ReviewPhase::Done), "Done");
}
#[test]
fn test_review_axis_display() {
assert_eq!(format!("{}", ReviewAxis::Correctness), "Correctness");
assert_eq!(format!("{}", ReviewAxis::Security), "Security");
assert_eq!(format!("{}", ReviewAxis::Testing), "Testing");
}
#[test]
fn test_review_axis_all() {
let all = ReviewAxis::all();
assert_eq!(all.len(), 8);
assert_eq!(all[0], ReviewAxis::Correctness);
}
#[test]
fn test_severity_display_and_ordering() {
assert_eq!(format!("{}", FindingSeverity::Critical), "critical");
assert_eq!(format!("{}", FindingSeverity::Important), "important");
assert_eq!(format!("{}", FindingSeverity::Minor), "minor");
assert_eq!(format!("{}", FindingSeverity::Nit), "nit");
assert!(FindingSeverity::Critical > FindingSeverity::Important);
assert!(FindingSeverity::Important > FindingSeverity::Minor);
assert!(FindingSeverity::Minor > FindingSeverity::Nit);
}
#[test]
fn test_verdict_display() {
assert_eq!(format!("{}", FindingVerdict::Confirmed), "confirmed");
assert_eq!(
format!("{}", FindingVerdict::FalsePositive),
"false positive"
);
assert_eq!(format!("{}", FindingVerdict::Deferred), "deferred");
}
#[test]
fn test_axis_impression_display() {
assert!(format!("{}", AxisImpression::Clean).contains("Clean"));
assert!(format!("{}", AxisImpression::Acceptable).contains("Acceptable"));
assert!(format!("{}", AxisImpression::NeedsWork).contains("Needs Work"));
assert!(format!("{}", AxisImpression::Concerning).contains("Concerning"));
}
#[test]
fn test_code_evidence_display() {
let ev = CodeEvidence {
file: "src/main.rs".to_string(),
line: Some(42),
snippet: None,
observation: "Direct unwrap".to_string(),
};
assert_eq!(format!("{}", ev), "`src/main.rs:42` — Direct unwrap");
let ev_no_line = CodeEvidence {
file: "src/lib.rs".to_string(),
line: None,
snippet: None,
observation: "Module issue".to_string(),
};
assert_eq!(format!("{}", ev_no_line), "`src/lib.rs` — Module issue");
}
#[test]
fn test_finding_creation() {
let f = ReviewFinding::new(
"Missing error check",
ReviewAxis::ErrorHandling,
FindingSeverity::Important,
);
assert_eq!(f.description, "Missing error check");
assert_eq!(f.axis, ReviewAxis::ErrorHandling);
assert_eq!(f.severity, FindingSeverity::Important);
assert!(f.evidence.is_empty());
assert!(f.suggestion.is_none());
assert!(f.verdict.is_none());
assert!(!f.is_examined());
assert!(!f.is_confirmed());
}
#[test]
fn test_finding_with_evidence_and_suggestion() {
let f = sample_finding(ReviewAxis::Security, FindingSeverity::Critical);
assert_eq!(f.evidence.len(), 1);
assert_eq!(f.evidence[0].file, "src/main.rs");
assert!(f.suggestion.is_some());
}
#[test]
fn test_finding_set_verdict() {
let mut f = sample_finding(ReviewAxis::Correctness, FindingSeverity::Important);
f.set_verdict(FindingVerdict::Confirmed, "Reproduces consistently");
assert!(f.is_examined());
assert!(f.is_confirmed());
assert_eq!(
f.verdict_reason,
Some("Reproduces consistently".to_string())
);
}
#[test]
fn test_finding_false_positive() {
let mut f = sample_finding(ReviewAxis::Conventions, FindingSeverity::Nit);
f.set_verdict(FindingVerdict::FalsePositive, "Project convention differs");
assert!(f.is_examined());
assert!(!f.is_confirmed());
}
#[test]
fn test_add_findings() {
let mut session = ReviewSession::new();
session.add_finding(sample_finding(
ReviewAxis::Correctness,
FindingSeverity::Critical,
));
session.add_finding(sample_finding(
ReviewAxis::Security,
FindingSeverity::Important,
));
session.add_finding(sample_finding(
ReviewAxis::Readability,
FindingSeverity::Nit,
));
assert_eq!(session.finding_count(), 3);
}
#[test]
fn test_findings_by_axis() {
let mut session = ReviewSession::new();
session.add_finding(sample_finding(
ReviewAxis::Correctness,
FindingSeverity::Important,
));
session.add_finding(sample_finding(
ReviewAxis::Correctness,
FindingSeverity::Minor,
));
session.add_finding(sample_finding(
ReviewAxis::Security,
FindingSeverity::Critical,
));
assert_eq!(session.findings_by_axis(ReviewAxis::Correctness).len(), 2);
assert_eq!(session.findings_by_axis(ReviewAxis::Security).len(), 1);
assert_eq!(session.findings_by_axis(ReviewAxis::Testing).len(), 0);
}
#[test]
fn test_findings_by_severity() {
let mut session = ReviewSession::new();
session.add_finding(sample_finding(
ReviewAxis::Correctness,
FindingSeverity::Critical,
));
session.add_finding(sample_finding(
ReviewAxis::Security,
FindingSeverity::Critical,
));
session.add_finding(sample_finding(
ReviewAxis::Readability,
FindingSeverity::Minor,
));
assert_eq!(
session
.findings_by_severity(FindingSeverity::Critical)
.len(),
2
);
assert_eq!(
session.findings_by_severity(FindingSeverity::Minor).len(),
1
);
}
#[test]
fn test_cross_examine_single() {
let mut session = ReviewSession::new();
session.add_finding(sample_finding(
ReviewAxis::Correctness,
FindingSeverity::Important,
));
session
.cross_examine(0, FindingVerdict::Confirmed, "Real bug")
.unwrap();
assert!(session.findings[0].is_confirmed());
assert!(session
.cross_examine(5, FindingVerdict::FalsePositive, "test")
.is_err());
}
#[test]
fn test_cross_examine_all() {
let mut session = ReviewSession::new();
session.add_finding(sample_finding(
ReviewAxis::Correctness,
FindingSeverity::Important,
));
session.add_finding(sample_finding(
ReviewAxis::Security,
FindingSeverity::Critical,
));
session.findings[0].set_verdict(FindingVerdict::Confirmed, "Already done");
let already = session.cross_examine_all(FindingVerdict::FalsePositive, "Generic reason");
assert_eq!(already, 1);
assert!(session.findings[0].is_confirmed());
assert!(!session.findings[1].is_confirmed()); }
#[test]
fn test_produce_report_no_scope() {
let mut session = ReviewSession::new();
assert!(session.produce_report().is_err());
}
#[test]
fn test_produce_report_ready_to_ship() {
let mut session = ReviewSession::new().with_scope(ReviewScope {
description: "Test review".to_string(),
files: vec!["src/main.rs".to_string()],
commit_range: None,
branch: None,
});
session.produce_report().unwrap();
let report = session.report.as_ref().unwrap();
assert!(report.overall.ready_to_ship);
assert_eq!(report.overall.critical_count, 0);
assert_eq!(report.overall.important_count, 0);
}
#[test]
fn test_produce_report_not_ready() {
let mut session = ReviewSession::new().with_scope(ReviewScope {
description: "Test".to_string(),
files: vec!["src/main.rs".to_string()],
commit_range: None,
branch: None,
});
let mut f = sample_finding(ReviewAxis::Correctness, FindingSeverity::Critical);
f.set_verdict(FindingVerdict::Confirmed, "Real bug");
session.add_finding(f);
let mut f2 = sample_finding(ReviewAxis::Security, FindingSeverity::Important);
f2.set_verdict(FindingVerdict::Confirmed, "SQL injection");
session.add_finding(f2);
let mut f3 = sample_finding(ReviewAxis::Conventions, FindingSeverity::Nit);
f3.set_verdict(FindingVerdict::FalsePositive, "Project convention");
session.add_finding(f3);
session.produce_report().unwrap();
let report = session.report.as_ref().unwrap();
assert!(!report.overall.ready_to_ship);
assert_eq!(report.overall.critical_count, 1);
assert_eq!(report.overall.important_count, 1);
assert_eq!(report.recommendations.len(), 2);
assert_eq!(report.discarded.len(), 1);
assert_eq!(
report.discarded[0].discard_type,
FindingVerdict::FalsePositive
);
}
#[test]
fn test_render_markdown() {
let mut session = ReviewSession::new().with_scope(ReviewScope {
description: "Auth feature".to_string(),
files: vec!["src/auth.rs".to_string(), "src/middleware.rs".to_string()],
commit_range: Some("abc..def".to_string()),
branch: Some("feat/auth".to_string()),
});
let mut f = ReviewFinding::new(
"SQL injection in login query",
ReviewAxis::Security,
FindingSeverity::Critical,
)
.with_evidence(CodeEvidence {
file: "src/auth.rs".to_string(),
line: Some(45),
snippet: Some("format!(\"SELECT * FROM users WHERE name = '{}'\", name)".to_string()),
observation: "String interpolation in SQL query".to_string(),
})
.with_suggestion("Use parameterized query");
f.set_verdict(FindingVerdict::Confirmed, "Direct injection risk");
session.add_finding(f);
session.produce_report().unwrap();
let md = session.report.as_ref().unwrap().render_markdown();
assert!(md.contains("# Code Review: Auth feature"));
assert!(md.contains("Not ready to ship"));
assert!(md.contains("## Axis Summary"));
assert!(md.contains("## Files Reviewed"));
assert!(md.contains("`src/auth.rs`"));
assert!(md.contains("## Findings"));
assert!(md.contains("SQL injection"));
assert!(md.contains("parameterized query"));
assert!(md.contains("Commits: abc..def"));
assert!(md.contains("Branch: feat/auth"));
}
#[test]
fn test_write_report() {
let tmp = tempfile::tempdir().unwrap();
let mut session = ReviewSession::new()
.with_scope(ReviewScope {
description: "Test Review".to_string(),
files: vec![],
commit_range: None,
branch: None,
})
.with_project_root(tmp.path());
session.produce_report().unwrap();
let path = session.write_report(None).unwrap();
assert!(path.exists());
assert!(path.to_string_lossy().contains("docs/review"));
}
#[test]
fn test_write_report_explicit_path() {
let tmp = tempfile::tempdir().unwrap();
let mut session = ReviewSession::new().with_scope(ReviewScope {
description: "Test".to_string(),
files: vec![],
commit_range: None,
branch: None,
});
session.produce_report().unwrap();
let explicit = tmp.path().join("review.md");
let path = session.write_report(Some(&explicit)).unwrap();
assert_eq!(path, explicit);
assert!(path.exists());
}
#[test]
fn test_write_report_not_produced() {
let session = ReviewSession::new();
assert!(session.write_report(None).is_err());
}
#[test]
fn test_session_serialization_roundtrip() {
let mut session = ReviewSession::new().with_scope(ReviewScope {
description: "Test".to_string(),
files: vec!["src/main.rs".to_string()],
commit_range: None,
branch: None,
});
session.add_finding(sample_finding(
ReviewAxis::Correctness,
FindingSeverity::Important,
));
session.set_phase(ReviewPhase::DeepReview);
let json = serde_json::to_string(&session).unwrap();
let parsed: ReviewSession = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.finding_count(), 1);
assert_eq!(parsed.phase, ReviewPhase::DeepReview);
}
#[test]
fn test_axis_summaries_populated() {
let mut session = ReviewSession::new().with_scope(ReviewScope {
description: "Test".to_string(),
files: vec![],
commit_range: None,
branch: None,
});
let mut f = sample_finding(ReviewAxis::Security, FindingSeverity::Critical);
f.set_verdict(FindingVerdict::Confirmed, "Real");
session.add_finding(f);
session.produce_report().unwrap();
let security_summary = session
.axis_summaries
.iter()
.find(|s| s.axis == ReviewAxis::Security)
.unwrap();
assert_eq!(security_summary.finding_count, 1);
assert_eq!(security_summary.impression, AxisImpression::Concerning);
let correctness_summary = session
.axis_summaries
.iter()
.find(|s| s.axis == ReviewAxis::Correctness)
.unwrap();
assert_eq!(correctness_summary.finding_count, 0);
assert_eq!(correctness_summary.impression, AxisImpression::Clean);
}
#[test]
fn test_recommendations_sorted_by_severity() {
let mut session = ReviewSession::new().with_scope(ReviewScope {
description: "Test".to_string(),
files: vec![],
commit_range: None,
branch: None,
});
let mut f1 = sample_finding(ReviewAxis::Readability, FindingSeverity::Nit);
f1.set_verdict(FindingVerdict::Confirmed, "Real");
session.add_finding(f1);
let mut f2 = sample_finding(ReviewAxis::Correctness, FindingSeverity::Critical);
f2.set_verdict(FindingVerdict::Confirmed, "Real");
session.add_finding(f2);
let mut f3 = sample_finding(ReviewAxis::Performance, FindingSeverity::Important);
f3.set_verdict(FindingVerdict::Confirmed, "Real");
session.add_finding(f3);
session.produce_report().unwrap();
let report = session.report.as_ref().unwrap();
assert_eq!(
report.recommendations[0].severity,
FindingSeverity::Critical
);
assert_eq!(
report.recommendations[1].severity,
FindingSeverity::Important
);
assert_eq!(report.recommendations[2].severity, FindingSeverity::Nit);
}
#[test]
fn test_skill_prompt_not_empty() {
let prompt = ReviewerSkill::skill_prompt();
assert!(prompt.contains("Reviewer Skill"));
assert!(prompt.contains("Phase 1: Context"));
assert!(prompt.contains("Phase 3: Cross-Examine"));
}
#[test]
fn test_slugify() {
assert_eq!(slugify("Auth Feature Review"), "auth-feature-review");
assert_eq!(slugify("hello_world"), "hello-world");
}
}