use rusqlite::Connection;
use crate::engine::Severity;
use crate::engine::types::{ReviewIssue, TokenUsage};
use crate::index::schema;
pub struct ReviewRecord<'a> {
pub command: &'a str,
pub project_root: &'a str,
pub commit_hash: Option<&'a str>,
pub branch: Option<&'a str>,
pub summary: &'a str,
pub gate_status: &'a str,
pub files_scanned: usize,
pub lines_scanned: usize,
pub should_block: bool,
pub tokens: Option<&'a TokenUsage>,
pub issues: &'a [ReviewIssue],
}
pub fn save_review_to_db(record: &ReviewRecord<'_>) -> Option<i64> {
let conn = open_db().ok()?;
let project_id = schema::get_or_create_project(&conn, record.project_root).ok()?;
let (input_tokens, output_tokens, cost_usd) = record
.tokens
.map(|t| {
(
t.input_tokens as i64,
t.output_tokens as i64,
t.estimated_cost_usd,
)
})
.unwrap_or((0, 0, 0.0));
conn.execute(
"INSERT INTO reviews
(project_id, command, commit_hash, branch, summary, score, gate_status,
files_scanned, lines_scanned, should_block, input_tokens, output_tokens, cost_usd)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
rusqlite::params![
project_id,
record.command,
record.commit_hash,
record.branch,
record.summary,
calculate_score(record.issues) as i64,
record.gate_status,
record.files_scanned as i64,
record.lines_scanned as i64,
record.should_block as i64,
input_tokens,
output_tokens,
cost_usd,
],
)
.ok()?;
let review_id = conn.last_insert_rowid();
let mut stmt_findings = conn
.prepare(
"INSERT INTO findings
(review_id, file_path, line_number, severity, issue_type, title, body,
suggested_fix, status, fingerprint)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'open', ?9)",
)
.ok()?;
let mut stmt_events = conn
.prepare(
"INSERT INTO finding_events (finding_id, event_type, note)
VALUES (?1, 'opened', NULL)",
)
.ok()?;
for issue in record.issues {
let fingerprint = compute_fingerprint(issue);
stmt_findings
.execute(rusqlite::params![
review_id,
issue.file,
issue.line.map(|l| l as i64),
issue.severity.to_string(),
issue.issue_type.as_deref(),
issue.title,
issue.body,
issue.suggested_fix.as_deref(),
fingerprint,
])
.ok()?;
let finding_id = conn.last_insert_rowid();
stmt_events.execute(rusqlite::params![finding_id]).ok()?;
}
Some(review_id)
}
pub fn resolve_stale_findings(project_root: &str, current_fingerprints: &[String]) -> usize {
let Ok(conn) = open_db() else { return 0 };
let Ok(project_id) = schema::get_or_create_project(&conn, project_root) else {
return 0;
};
let mut stmt = match conn.prepare(
"SELECT f.id, f.fingerprint FROM findings f
JOIN reviews r ON f.review_id = r.id
WHERE r.project_id = ?1
AND f.status = 'open'
AND f.fingerprint IS NOT NULL",
) {
Ok(s) => s,
Err(_) => return 0,
};
let mut rows = match stmt.query(rusqlite::params![project_id]) {
Ok(r) => r,
Err(_) => return 0,
};
let mut stale_ids: Vec<i64> = Vec::new();
while let Some(row) = rows.next().unwrap_or(None) {
let id: i64 = row.get(0).unwrap_or(0);
let fp: String = row.get(1).unwrap_or_default();
if !current_fingerprints.contains(&fp) {
stale_ids.push(id);
}
}
let mut resolved = 0;
let mut stmt_update = conn
.prepare("UPDATE findings SET status = 'resolved' WHERE id = ?1")
.ok();
let mut stmt_event = conn
.prepare(
"INSERT INTO finding_events (finding_id, event_type, note)
VALUES (?1, 'auto_resolved', 'No longer found in latest review')",
)
.ok();
for id in &stale_ids {
if let (Some(u), Some(e)) = (stmt_update.as_mut(), stmt_event.as_mut()) {
if u.execute(rusqlite::params![id]).is_ok() && e.execute(rusqlite::params![id]).is_ok()
{
resolved += 1;
}
}
}
resolved
}
fn open_db() -> anyhow::Result<Connection> {
crate::data_dir::ensure_data_dir()?;
let db_path = crate::data_dir::graph_db_path();
let conn = Connection::open(&db_path)?;
conn.execute_batch("PRAGMA foreign_keys=ON;")?;
conn.execute_batch("PRAGMA journal_mode=WAL;")?;
schema::run_migrations(&conn)?;
Ok(conn)
}
pub fn open_db_for_read() -> Option<Connection> {
let db_path = crate::data_dir::graph_db_path();
if !std::path::Path::new(&db_path).exists() {
return None;
}
Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).ok()
}
pub fn open_db_for_write() -> Option<Connection> {
open_db().ok()
}
pub fn compute_fingerprint_pub(issue: &ReviewIssue) -> String {
compute_fingerprint(issue)
}
fn compute_fingerprint(issue: &ReviewIssue) -> String {
let line = issue.line.unwrap_or(0);
let title_slug = issue.title.to_lowercase().replace(' ', "_");
format!("{}:{}:{}", issue.file, line, title_slug)
}
fn calculate_score(issues: &[ReviewIssue]) -> f64 {
let mut score: f64 = 100.0;
for issue in issues {
let penalty: f64 = match issue.severity {
Severity::Critical => 20.0,
Severity::Major => 10.0,
Severity::Minor => 3.0,
Severity::Info => 1.0,
};
score -= penalty;
}
score.max(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_issue(file: &str, line: u32, severity: Severity, title: &str) -> ReviewIssue {
ReviewIssue {
file: file.to_string(),
line: Some(line),
severity,
issue_type: Some("security".to_string()),
title: title.to_string(),
body: "test body".to_string(),
suggested_fix: Some("fix it".to_string()),
}
}
#[test]
fn test_fingerprint_format() {
let issue = make_issue("src/main.rs", 42, Severity::Critical, "SQL Injection");
let fp = compute_fingerprint(&issue);
assert_eq!(fp, "src/main.rs:42:sql_injection");
}
#[test]
fn test_fingerprint_no_line() {
let mut issue = make_issue("src/lib.rs", 0, Severity::Minor, "Unused Import");
issue.line = None;
let fp = compute_fingerprint(&issue);
assert_eq!(fp, "src/lib.rs:0:unused_import");
}
#[test]
fn test_score_no_issues() {
let issues: Vec<ReviewIssue> = vec![];
assert_eq!(calculate_score(&issues), 100.0);
}
#[test]
fn test_score_with_critical() {
let issues = vec![make_issue("a.rs", 1, Severity::Critical, "x")];
assert_eq!(calculate_score(&issues), 80.0);
}
#[test]
fn test_score_floor_zero() {
let issues = vec![
make_issue("a.rs", 1, Severity::Critical, "x"),
make_issue("a.rs", 2, Severity::Critical, "y"),
make_issue("a.rs", 3, Severity::Critical, "z"),
make_issue("a.rs", 4, Severity::Critical, "w"),
make_issue("a.rs", 5, Severity::Critical, "v"),
make_issue("a.rs", 6, Severity::Critical, "u"),
];
assert_eq!(calculate_score(&issues), 0.0);
}
#[test]
fn test_score_mixed() {
let issues = vec![
make_issue("a.rs", 1, Severity::Critical, "c"),
make_issue("b.rs", 2, Severity::Major, "m"),
make_issue("c.rs", 3, Severity::Minor, "n"),
make_issue("d.rs", 4, Severity::Info, "i"),
];
assert_eq!(calculate_score(&issues), 66.0);
}
}