use crate::core::{AuditResult, Finding, ForgeGuardError, ProjectConfig};
use rusqlite::{params, Connection};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryEntry {
pub id: i64,
pub timestamp: String,
pub project: String,
pub chain: String,
pub overall_score: u8,
pub risk_level: String,
pub total_findings: usize,
pub critical_count: usize,
pub high_count: usize,
pub medium_count: usize,
pub low_count: usize,
pub info_count: usize,
pub finding_signatures: Vec<String>,
}
pub fn finding_signature(f: &Finding) -> String {
format!(
"{}|{}|{}",
f.severity.label(),
f.file.as_deref().unwrap_or(""),
f.title.to_lowercase()
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Regression {
pub new_signatures: Vec<String>,
pub resolved_signatures: Vec<String>,
}
pub fn regression(current: &[Finding], previous: &[String]) -> Regression {
let current_set: std::collections::HashSet<String> =
current.iter().map(finding_signature).collect();
let previous_set: std::collections::HashSet<String> = previous.iter().cloned().collect();
let mut new_signatures: Vec<String> = current_set.difference(&previous_set).cloned().collect();
new_signatures.sort();
let mut resolved_signatures: Vec<String> =
previous_set.difference(¤t_set).cloned().collect();
resolved_signatures.sort();
Regression {
new_signatures,
resolved_signatures,
}
}
pub struct HistoryStore {
conn: Connection,
}
impl HistoryStore {
pub fn open(config: &ProjectConfig) -> Result<Self, ForgeGuardError> {
let path = db_path(config);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(&path)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS audit_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
project TEXT NOT NULL,
chain TEXT NOT NULL,
overall_score INTEGER NOT NULL,
risk_level TEXT NOT NULL,
total_findings INTEGER NOT NULL,
critical_count INTEGER NOT NULL,
high_count INTEGER NOT NULL,
medium_count INTEGER NOT NULL,
low_count INTEGER NOT NULL,
info_count INTEGER NOT NULL,
finding_signatures TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_history_project
ON audit_history(project, timestamp);",
)?;
Ok(Self { conn })
}
pub fn record(&self, result: &AuditResult) -> Result<(), ForgeGuardError> {
let signatures: Vec<String> = result.findings.iter().map(finding_signature).collect();
self.conn.execute(
"INSERT INTO audit_history (
timestamp, project, chain, overall_score, risk_level,
total_findings, critical_count, high_count, medium_count,
low_count, info_count, finding_signatures
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
result.timestamp,
result.project_name,
result.chain,
i64::from(result.overall_score),
result.risk_level.to_string(),
result.summary.total_findings as i64,
result.summary.critical_count as i64,
result.summary.high_count as i64,
result.summary.medium_count as i64,
result.summary.low_count as i64,
result.summary.info_count as i64,
serde_json::to_string(&signatures).unwrap_or_else(|_| "[]".into()),
],
)?;
Ok(())
}
pub fn trends(
&self,
project: &str,
limit: usize,
) -> Result<Vec<HistoryEntry>, ForgeGuardError> {
let limit = limit.max(1) as i64;
let mut stmt = self.conn.prepare(
"SELECT id, timestamp, project, chain, overall_score, risk_level,
total_findings, critical_count, high_count, medium_count,
low_count, info_count, finding_signatures
FROM audit_history
WHERE project = ?1
ORDER BY timestamp DESC, id DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(params![project, limit], row_to_entry)?;
let mut entries = Vec::new();
for row in rows {
entries.push(row?);
}
Ok(entries)
}
pub fn latest(
&self,
project: &str,
chain: &str,
) -> Result<Option<HistoryEntry>, ForgeGuardError> {
let mut stmt = self.conn.prepare(
"SELECT id, timestamp, project, chain, overall_score, risk_level,
total_findings, critical_count, high_count, medium_count,
low_count, info_count, finding_signatures
FROM audit_history
WHERE project = ?1 AND chain = ?2
ORDER BY timestamp DESC, id DESC
LIMIT 1",
)?;
let mut rows = stmt.query_map(params![project, chain], row_to_entry)?;
Ok(rows.next().transpose()?)
}
}
fn row_to_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result<HistoryEntry> {
let signatures_json: String = row.get(12)?;
let finding_signatures: Vec<String> =
serde_json::from_str(&signatures_json).unwrap_or_default();
Ok(HistoryEntry {
id: row.get(0)?,
timestamp: row.get(1)?,
project: row.get(2)?,
chain: row.get(3)?,
overall_score: row.get::<_, i64>(4)? as u8,
risk_level: row.get(5)?,
total_findings: row.get::<_, i64>(6)? as usize,
critical_count: row.get::<_, i64>(7)? as usize,
high_count: row.get::<_, i64>(8)? as usize,
medium_count: row.get::<_, i64>(9)? as usize,
low_count: row.get::<_, i64>(10)? as usize,
info_count: row.get::<_, i64>(11)? as usize,
finding_signatures,
})
}
pub fn db_path(config: &ProjectConfig) -> PathBuf {
if let Some(p) = &config.history.db_path {
let expanded = expand_tilde(p);
let path = PathBuf::from(expanded);
if path.is_absolute() {
path
} else {
config.project_root.join(path)
}
} else {
default_db_path()
}
}
pub fn default_db_path() -> PathBuf {
if let Some(home) = home_dir() {
PathBuf::from(home).join(".forge-guard").join("history.db")
} else {
PathBuf::from(".forge-guard").join("history.db")
}
}
fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = home_dir() {
return format!("{}/{}", home, rest);
}
}
path.to_string()
}
fn home_dir() -> Option<String> {
std::env::var("HOME")
.ok()
.or_else(|| std::env::var("USERPROFILE").ok())
}
pub fn format_timestamp(ts: &str) -> &str {
&ts[..ts.len().min(19)]
}
pub fn format_trend_row(entry: &HistoryEntry) -> String {
format!(
" {:<19} {:<12} {:>9} {:<9} {} (🛑{} 🔴{} 🟡{} 🔵{} ⚪{})",
format_timestamp(&entry.timestamp),
entry.chain,
format!("{}/100", entry.overall_score),
entry.risk_level,
entry.total_findings,
entry.critical_count,
entry.high_count,
entry.medium_count,
entry.low_count,
entry.info_count,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{AuditSummary, RiskLevel, SecurityScores, Severity};
fn finding(sev: Severity, title: &str, file: &str) -> Finding {
Finding::builder()
.id(&format!("FA-{}-1", sev.label()))
.title(title)
.description("desc")
.severity(sev)
.file(file)
.location(1, 0)
.recommendation("fix")
.category("Security")
.build()
}
fn sample_result(score: u8, findings: Vec<Finding>) -> AuditResult {
let mut summary = AuditSummary {
total_findings: findings.len(),
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 0,
info_count: 0,
files_analyzed: 1,
lines_analyzed: 10,
contracts_analyzed: 1,
};
for f in &findings {
match f.severity {
Severity::Critical => summary.critical_count += 1,
Severity::High => summary.high_count += 1,
Severity::Medium => summary.medium_count += 1,
Severity::Low => summary.low_count += 1,
Severity::Informational => summary.info_count += 1,
}
}
AuditResult {
project_name: "demo".into(),
chain: "ethereum".into(),
chains: vec!["ethereum".into()],
timestamp: "2026-08-17T10:00:00Z".into(),
duration_seconds: 1.0,
findings,
scores: SecurityScores::perfect(),
overall_score: score,
risk_level: RiskLevel::Low,
production_ready: true,
deployment_approved: true,
summary,
}
}
#[test]
fn test_finding_signature_is_stable_across_lines() {
let a = finding(Severity::High, "Reentrancy", "Vault.sol");
let mut b = finding(Severity::High, "Reentrancy", "Vault.sol");
b.line = Some(999);
assert_eq!(finding_signature(&a), finding_signature(&b));
let c = finding(Severity::High, "Access Control", "Vault.sol");
assert_ne!(finding_signature(&a), finding_signature(&c));
assert_eq!(
finding_signature(&a),
"HIGH|Vault.sol|reentrancy".to_string()
);
}
#[test]
fn test_regression_detects_new_and_resolved() {
let current = vec![
finding(Severity::High, "Reentrancy", "Vault.sol"),
finding(Severity::Medium, "Gas", "Vault.sol"),
];
let previous = vec![finding_signature(&finding(
Severity::High,
"Reentrancy",
"Vault.sol",
))];
let diff = regression(¤t, &previous);
assert_eq!(diff.new_signatures, vec!["MEDIUM|Vault.sol|gas"]);
assert!(diff.resolved_signatures.is_empty());
let diff2 = regression(&[], &previous);
assert!(diff2.new_signatures.is_empty());
assert_eq!(diff2.resolved_signatures, previous);
}
#[test]
fn test_regression_ignores_line_shifts() {
let mut before = finding(Severity::High, "Reentrancy", "Vault.sol");
before.line = Some(10);
let after = finding(Severity::High, "Reentrancy", "Vault.sol");
let diff = regression(&[after], &[finding_signature(&before)]);
assert!(
diff.new_signatures.is_empty(),
"line shifts must not regress"
);
}
#[test]
fn test_record_and_trends_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = ProjectConfig {
history: crate::core::config::HistoryConfig {
enabled: true,
db_path: Some(dir.path().join("history.db").to_string_lossy().into()),
},
..ProjectConfig::default()
};
let store = HistoryStore::open(&config).unwrap();
let mut result = sample_result(80, vec![finding(Severity::High, "Reentrancy", "V.sol")]);
result.timestamp = "2026-08-17T10:00:00Z".into();
store.record(&result).unwrap();
let mut result2 = sample_result(90, vec![]);
result2.timestamp = "2026-08-18T10:00:00Z".into();
store.record(&result2).unwrap();
let trends = store.trends("demo", 10).unwrap();
assert_eq!(trends.len(), 2);
assert_eq!(trends[0].overall_score, 90);
assert_eq!(trends[0].finding_signatures.len(), 0);
assert_eq!(trends[1].overall_score, 80);
assert_eq!(
trends[1].finding_signatures,
vec!["HIGH|V.sol|reentrancy".to_string()]
);
let latest = store.latest("demo", "ethereum").unwrap().unwrap();
assert_eq!(latest.overall_score, 90);
}
#[test]
fn test_latest_filters_by_chain_and_project() {
let dir = tempfile::tempdir().unwrap();
let config = ProjectConfig {
history: crate::core::config::HistoryConfig {
enabled: true,
db_path: Some(dir.path().join("history.db").to_string_lossy().into()),
},
..ProjectConfig::default()
};
let store = HistoryStore::open(&config).unwrap();
store.record(&sample_result(80, vec![])).unwrap();
assert!(store.latest("other-project", "ethereum").unwrap().is_none());
assert!(store.latest("demo", "base").unwrap().is_none());
assert!(store.latest("demo", "ethereum").unwrap().is_some());
}
#[test]
fn test_db_path_resolution() {
let config = ProjectConfig {
history: crate::core::config::HistoryConfig {
enabled: true,
db_path: Some("/tmp/custom/history.db".into()),
},
..ProjectConfig::default()
};
assert_eq!(db_path(&config), PathBuf::from("/tmp/custom/history.db"));
let config = ProjectConfig {
project_root: "/home/user/proj".into(),
history: crate::core::config::HistoryConfig {
enabled: true,
db_path: Some("history.db".into()),
},
..ProjectConfig::default()
};
assert_eq!(
db_path(&config),
PathBuf::from("/home/user/proj/history.db")
);
let config = ProjectConfig {
history: crate::core::config::HistoryConfig {
enabled: true,
db_path: Some("~/.forge-guard/history.db".into()),
},
..ProjectConfig::default()
};
let path = db_path(&config);
assert!(path.is_absolute());
let config = ProjectConfig::default();
let path = default_db_path();
assert!(path.to_string_lossy().contains(".forge-guard"));
let _ = &config;
}
}