use anyhow::Result;
use colored::{Color, Colorize};
use crate::engine::review_store::{self, FindingFilter, FindingStats, ReviewStore, Transition};
const EXIT_OK: i32 = 0;
const EXIT_NOT_FOUND: i32 = 1;
#[derive(Debug, clap::Subcommand)]
pub enum FindingsAction {
List {
#[clap(long)]
all: bool,
#[clap(
long,
value_parser = ["info", "minor", "major", "critical"],
ignore_case = true
)]
severity: Option<String>,
#[clap(long)]
file: Option<String>,
#[clap(long)]
json: bool,
#[clap(long, default_value = "50")]
limit: usize,
},
Stats {
#[clap(long)]
json: bool,
},
Dismiss {
id: i64,
#[clap(long)]
reason: Option<String>,
},
Reopen {
id: i64,
},
}
pub fn execute_findings(action: &FindingsAction) -> Result<i32> {
match action {
FindingsAction::List { .. } | FindingsAction::Stats { .. } => {
let Ok(conn) = review_store::open_read() else {
eprintln!("{}", "Error: could not open cora.db".red());
return Ok(EXIT_NOT_FOUND);
};
let store = ReviewStore::new(&conn);
match action {
FindingsAction::List {
all,
severity,
file,
json,
limit,
} => {
let filter = FindingFilter {
all: *all,
severity: severity.clone(),
file: file.clone(),
limit: *limit,
};
list_findings(&store, &filter, *json)
}
FindingsAction::Stats { json } => stats(&store, *json),
_ => unreachable!(),
}
}
FindingsAction::Dismiss { id, reason } => {
let Ok(conn) = review_store::open_write() else {
eprintln!("{}", "Error: could not open cora.db for writing".red());
return Ok(EXIT_NOT_FOUND);
};
dismiss(&ReviewStore::new(&conn), *id, reason.as_deref())
}
FindingsAction::Reopen { id } => {
let Ok(conn) = review_store::open_write() else {
eprintln!("{}", "Error: could not open cora.db for writing".red());
return Ok(EXIT_NOT_FOUND);
};
reopen(&ReviewStore::new(&conn), *id)
}
}
}
fn severity_color(severity: &str) -> Option<Color> {
match severity.to_ascii_lowercase().as_str() {
"critical" => Some(Color::Red),
"major" => Some(Color::Yellow),
"minor" => Some(Color::Green),
_ => None,
}
}
fn list_findings(store: &ReviewStore<'_>, filter: &FindingFilter, json: bool) -> Result<i32> {
let rows = store.list_findings(filter)?;
if json {
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(EXIT_OK);
}
if rows.is_empty() {
println!("{}", "No findings found.".dimmed());
return Ok(EXIT_OK);
}
println!(
"{} {} finding(s)\n",
"▸".cyan(),
rows.len().to_string().bold()
);
for r in &rows {
let sev = match severity_color(&r.severity) {
Some(Color::Red) => r.severity.clone().red().to_string(),
Some(Color::Yellow) => r.severity.clone().yellow().to_string(),
Some(Color::Green) => r.severity.clone().green().to_string(),
_ => r.severity.clone().dimmed().to_string(),
};
let status_tag = match r.status.as_str() {
"open" => "OPEN".green().to_string(),
"resolved" => "RESOLVED".dimmed().to_string(),
"dismissed" => "DISMISSED".dimmed().to_string(),
_ => r.status.to_uppercase().dimmed().to_string(),
};
let line_info = match r.line_number {
Some(l) => format!(":{}", l),
None => String::new(),
};
println!(
" #{} {} {} {}{} [{}]",
r.id.to_string().dimmed(),
sev,
r.file_path.to_string().blue(),
line_info.dimmed(),
format_args!(" | {}", r.title),
status_tag,
);
}
Ok(EXIT_OK)
}
fn stats(store: &ReviewStore<'_>, json: bool) -> Result<i32> {
let FindingStats {
total,
open,
resolved,
dismissed,
reviews,
} = store.stats()?;
if json {
let stats = serde_json::json!({
"total_findings": total,
"open": open,
"resolved": resolved,
"dismissed": dismissed,
"total_reviews": reviews,
"resolution_rate": if total > 0 { (resolved as f64 / total as f64 * 100.0).round() } else { 0.0 },
});
println!("{}", serde_json::to_string_pretty(&stats)?);
return Ok(EXIT_OK);
}
println!("{}", "Findings Summary".bold());
println!();
println!(" Reviews: {}", reviews.to_string().bold());
println!(" Total: {}", total);
println!(" {}", format!("Open: {}", open).green());
println!(" {}", format!("Resolved: {}", resolved).dimmed());
println!(" {}", format!("Dismissed: {}", dismissed).dimmed());
if total > 0 {
let rate = resolved as f64 / total as f64 * 100.0;
println!(" Resolution: {:.1}%", rate);
}
Ok(EXIT_OK)
}
fn dismiss(store: &ReviewStore<'_>, id: i64, reason: Option<&str>) -> Result<i32> {
match store.dismiss(id, reason)? {
Transition::NotFound => {
eprintln!("{}", format!("Finding #{} not found.", id).red());
Ok(EXIT_NOT_FOUND)
}
_ => {
println!("{} Finding #{} dismissed.", "✓".green(), id);
Ok(EXIT_OK)
}
}
}
fn reopen(store: &ReviewStore<'_>, id: i64) -> Result<i32> {
match store.reopen(id)? {
Transition::Unchanged => {
println!("{}", format!("Finding #{} is already open.", id).yellow());
Ok(EXIT_OK)
}
Transition::NotFound => {
eprintln!("{}", format!("Finding #{} not found.", id).red());
Ok(EXIT_NOT_FOUND)
}
Transition::Applied => {
println!("{} Finding #{} reopened.", "✓".green(), id);
Ok(EXIT_OK)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser, Debug)]
struct Harness {
#[clap(subcommand)]
action: FindingsAction,
}
fn parse_severity(arg: &str) -> Result<Option<String>, clap::Error> {
let h = Harness::try_parse_from(["t", "list", "--severity", arg])?;
match h.action {
FindingsAction::List { severity, .. } => Ok(severity),
_ => unreachable!(),
}
}
#[test]
fn severity_arg_accepts_any_case_and_rejects_unknown() {
for (arg, want) in [("major", "major"), ("MAJOR", "major"), ("Info", "info")] {
let got = parse_severity(arg).unwrap().unwrap();
assert!(got.eq_ignore_ascii_case(want));
}
assert!(parse_severity("bogus").is_err());
}
#[test]
fn severity_color_is_case_insensitive() {
assert_eq!(severity_color("critical"), Some(Color::Red));
assert_eq!(severity_color("CRITICAL"), Some(Color::Red));
assert_eq!(severity_color("major"), Some(Color::Yellow));
assert_eq!(severity_color("Minor"), Some(Color::Green));
assert_eq!(severity_color("info"), None);
assert_eq!(severity_color("weird"), None);
}
}