1use crate::core::{AuditResult, Finding, ForgeGuardError, ProjectConfig};
15use rusqlite::{params, Connection};
16use std::path::PathBuf;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct HistoryEntry {
21 pub id: i64,
23 pub timestamp: String,
25 pub project: String,
27 pub chain: String,
29 pub overall_score: u8,
31 pub risk_level: String,
33 pub total_findings: usize,
35 pub critical_count: usize,
36 pub high_count: usize,
37 pub medium_count: usize,
38 pub low_count: usize,
39 pub info_count: usize,
40 pub finding_signatures: Vec<String>,
42}
43
44pub fn finding_signature(f: &Finding) -> String {
48 format!(
49 "{}|{}|{}",
50 f.severity.label(),
51 f.file.as_deref().unwrap_or(""),
52 f.title.to_lowercase()
53 )
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Regression {
59 pub new_signatures: Vec<String>,
61 pub resolved_signatures: Vec<String>,
63}
64
65pub fn regression(current: &[Finding], previous: &[String]) -> Regression {
72 let current_set: std::collections::HashSet<String> =
73 current.iter().map(finding_signature).collect();
74 let previous_set: std::collections::HashSet<String> = previous.iter().cloned().collect();
75
76 let mut new_signatures: Vec<String> = current_set.difference(&previous_set).cloned().collect();
77 new_signatures.sort();
78
79 let mut resolved_signatures: Vec<String> =
80 previous_set.difference(¤t_set).cloned().collect();
81 resolved_signatures.sort();
82
83 Regression {
84 new_signatures,
85 resolved_signatures,
86 }
87}
88
89pub struct HistoryStore {
91 conn: Connection,
92}
93
94impl HistoryStore {
95 pub fn open(config: &ProjectConfig) -> Result<Self, ForgeGuardError> {
98 let path = db_path(config);
99 if let Some(parent) = path.parent() {
100 std::fs::create_dir_all(parent)?;
101 }
102 let conn = Connection::open(&path)?;
103 conn.execute_batch(
104 "CREATE TABLE IF NOT EXISTS audit_history (
105 id INTEGER PRIMARY KEY AUTOINCREMENT,
106 timestamp TEXT NOT NULL,
107 project TEXT NOT NULL,
108 chain TEXT NOT NULL,
109 overall_score INTEGER NOT NULL,
110 risk_level TEXT NOT NULL,
111 total_findings INTEGER NOT NULL,
112 critical_count INTEGER NOT NULL,
113 high_count INTEGER NOT NULL,
114 medium_count INTEGER NOT NULL,
115 low_count INTEGER NOT NULL,
116 info_count INTEGER NOT NULL,
117 finding_signatures TEXT NOT NULL
118 );
119 CREATE INDEX IF NOT EXISTS idx_history_project
120 ON audit_history(project, timestamp);",
121 )?;
122 Ok(Self { conn })
123 }
124
125 pub fn record(&self, result: &AuditResult) -> Result<(), ForgeGuardError> {
127 let signatures: Vec<String> = result.findings.iter().map(finding_signature).collect();
128 self.conn.execute(
129 "INSERT INTO audit_history (
130 timestamp, project, chain, overall_score, risk_level,
131 total_findings, critical_count, high_count, medium_count,
132 low_count, info_count, finding_signatures
133 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
134 params![
135 result.timestamp,
136 result.project_name,
137 result.chain,
138 i64::from(result.overall_score),
139 result.risk_level.to_string(),
140 result.summary.total_findings as i64,
141 result.summary.critical_count as i64,
142 result.summary.high_count as i64,
143 result.summary.medium_count as i64,
144 result.summary.low_count as i64,
145 result.summary.info_count as i64,
146 serde_json::to_string(&signatures).unwrap_or_else(|_| "[]".into()),
147 ],
148 )?;
149 Ok(())
150 }
151
152 pub fn trends(
154 &self,
155 project: &str,
156 limit: usize,
157 ) -> Result<Vec<HistoryEntry>, ForgeGuardError> {
158 let limit = limit.max(1) as i64;
159 let mut stmt = self.conn.prepare(
160 "SELECT id, timestamp, project, chain, overall_score, risk_level,
161 total_findings, critical_count, high_count, medium_count,
162 low_count, info_count, finding_signatures
163 FROM audit_history
164 WHERE project = ?1
165 ORDER BY timestamp DESC, id DESC
166 LIMIT ?2",
167 )?;
168 let rows = stmt.query_map(params![project, limit], row_to_entry)?;
169 let mut entries = Vec::new();
170 for row in rows {
171 entries.push(row?);
172 }
173 Ok(entries)
174 }
175
176 pub fn latest(
178 &self,
179 project: &str,
180 chain: &str,
181 ) -> Result<Option<HistoryEntry>, ForgeGuardError> {
182 let mut stmt = self.conn.prepare(
183 "SELECT id, timestamp, project, chain, overall_score, risk_level,
184 total_findings, critical_count, high_count, medium_count,
185 low_count, info_count, finding_signatures
186 FROM audit_history
187 WHERE project = ?1 AND chain = ?2
188 ORDER BY timestamp DESC, id DESC
189 LIMIT 1",
190 )?;
191 let mut rows = stmt.query_map(params![project, chain], row_to_entry)?;
192 Ok(rows.next().transpose()?)
193 }
194}
195
196fn row_to_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result<HistoryEntry> {
197 let signatures_json: String = row.get(12)?;
198 let finding_signatures: Vec<String> =
199 serde_json::from_str(&signatures_json).unwrap_or_default();
200 Ok(HistoryEntry {
201 id: row.get(0)?,
202 timestamp: row.get(1)?,
203 project: row.get(2)?,
204 chain: row.get(3)?,
205 overall_score: row.get::<_, i64>(4)? as u8,
206 risk_level: row.get(5)?,
207 total_findings: row.get::<_, i64>(6)? as usize,
208 critical_count: row.get::<_, i64>(7)? as usize,
209 high_count: row.get::<_, i64>(8)? as usize,
210 medium_count: row.get::<_, i64>(9)? as usize,
211 low_count: row.get::<_, i64>(10)? as usize,
212 info_count: row.get::<_, i64>(11)? as usize,
213 finding_signatures,
214 })
215}
216
217pub fn db_path(config: &ProjectConfig) -> PathBuf {
224 if let Some(p) = &config.history.db_path {
225 let expanded = expand_tilde(p);
226 let path = PathBuf::from(expanded);
227 if path.is_absolute() {
228 path
229 } else {
230 config.project_root.join(path)
231 }
232 } else {
233 default_db_path()
234 }
235}
236
237pub fn default_db_path() -> PathBuf {
240 if let Some(home) = home_dir() {
241 PathBuf::from(home).join(".forge-guard").join("history.db")
242 } else {
243 PathBuf::from(".forge-guard").join("history.db")
244 }
245}
246
247fn expand_tilde(path: &str) -> String {
248 if let Some(rest) = path.strip_prefix("~/") {
249 if let Some(home) = home_dir() {
250 return format!("{}/{}", home, rest);
251 }
252 }
253 path.to_string()
254}
255
256fn home_dir() -> Option<String> {
257 std::env::var("HOME")
258 .ok()
259 .or_else(|| std::env::var("USERPROFILE").ok())
260}
261
262pub fn format_timestamp(ts: &str) -> &str {
264 &ts[..ts.len().min(19)]
265}
266
267pub fn format_trend_row(entry: &HistoryEntry) -> String {
270 format!(
271 " {:<19} {:<12} {:>9} {:<9} {} (🛑{} 🔴{} 🟡{} 🔵{} ⚪{})",
272 format_timestamp(&entry.timestamp),
273 entry.chain,
274 format!("{}/100", entry.overall_score),
275 entry.risk_level,
276 entry.total_findings,
277 entry.critical_count,
278 entry.high_count,
279 entry.medium_count,
280 entry.low_count,
281 entry.info_count,
282 )
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::core::{AuditSummary, RiskLevel, SecurityScores, Severity};
289
290 fn finding(sev: Severity, title: &str, file: &str) -> Finding {
291 Finding::builder()
292 .id(&format!("FA-{}-1", sev.label()))
293 .title(title)
294 .description("desc")
295 .severity(sev)
296 .file(file)
297 .location(1, 0)
298 .recommendation("fix")
299 .category("Security")
300 .build()
301 }
302
303 fn sample_result(score: u8, findings: Vec<Finding>) -> AuditResult {
304 let mut summary = AuditSummary {
305 total_findings: findings.len(),
306 critical_count: 0,
307 high_count: 0,
308 medium_count: 0,
309 low_count: 0,
310 info_count: 0,
311 files_analyzed: 1,
312 lines_analyzed: 10,
313 contracts_analyzed: 1,
314 };
315 for f in &findings {
316 match f.severity {
317 Severity::Critical => summary.critical_count += 1,
318 Severity::High => summary.high_count += 1,
319 Severity::Medium => summary.medium_count += 1,
320 Severity::Low => summary.low_count += 1,
321 Severity::Informational => summary.info_count += 1,
322 }
323 }
324 AuditResult {
325 project_name: "demo".into(),
326 chain: "ethereum".into(),
327 chains: vec!["ethereum".into()],
328 timestamp: "2026-08-17T10:00:00Z".into(),
329 duration_seconds: 1.0,
330 findings,
331 scores: SecurityScores::perfect(),
332 overall_score: score,
333 risk_level: RiskLevel::Low,
334 production_ready: true,
335 deployment_approved: true,
336 summary,
337 }
338 }
339
340 #[test]
341 fn test_finding_signature_is_stable_across_lines() {
342 let a = finding(Severity::High, "Reentrancy", "Vault.sol");
343 let mut b = finding(Severity::High, "Reentrancy", "Vault.sol");
344 b.line = Some(999);
345 assert_eq!(finding_signature(&a), finding_signature(&b));
346
347 let c = finding(Severity::High, "Access Control", "Vault.sol");
348 assert_ne!(finding_signature(&a), finding_signature(&c));
349 assert_eq!(
350 finding_signature(&a),
351 "HIGH|Vault.sol|reentrancy".to_string()
352 );
353 }
354
355 #[test]
356 fn test_regression_detects_new_and_resolved() {
357 let current = vec![
358 finding(Severity::High, "Reentrancy", "Vault.sol"),
359 finding(Severity::Medium, "Gas", "Vault.sol"),
360 ];
361 let previous = vec![finding_signature(&finding(
362 Severity::High,
363 "Reentrancy",
364 "Vault.sol",
365 ))];
366
367 let diff = regression(¤t, &previous);
368 assert_eq!(diff.new_signatures, vec!["MEDIUM|Vault.sol|gas"]);
369 assert!(diff.resolved_signatures.is_empty());
370
371 let diff2 = regression(&[], &previous);
373 assert!(diff2.new_signatures.is_empty());
374 assert_eq!(diff2.resolved_signatures, previous);
375 }
376
377 #[test]
378 fn test_regression_ignores_line_shifts() {
379 let mut before = finding(Severity::High, "Reentrancy", "Vault.sol");
380 before.line = Some(10);
381 let after = finding(Severity::High, "Reentrancy", "Vault.sol");
382 let diff = regression(&[after], &[finding_signature(&before)]);
383 assert!(
384 diff.new_signatures.is_empty(),
385 "line shifts must not regress"
386 );
387 }
388
389 #[test]
390 fn test_record_and_trends_roundtrip() {
391 let dir = tempfile::tempdir().unwrap();
392 let config = ProjectConfig {
393 history: crate::core::config::HistoryConfig {
394 enabled: true,
395 db_path: Some(dir.path().join("history.db").to_string_lossy().into()),
396 },
397 ..ProjectConfig::default()
398 };
399
400 let store = HistoryStore::open(&config).unwrap();
401 let mut result = sample_result(80, vec![finding(Severity::High, "Reentrancy", "V.sol")]);
402 result.timestamp = "2026-08-17T10:00:00Z".into();
403 store.record(&result).unwrap();
404 let mut result2 = sample_result(90, vec![]);
405 result2.timestamp = "2026-08-18T10:00:00Z".into();
406 store.record(&result2).unwrap();
407
408 let trends = store.trends("demo", 10).unwrap();
409 assert_eq!(trends.len(), 2);
410 assert_eq!(trends[0].overall_score, 90);
412 assert_eq!(trends[0].finding_signatures.len(), 0);
413 assert_eq!(trends[1].overall_score, 80);
414 assert_eq!(
415 trends[1].finding_signatures,
416 vec!["HIGH|V.sol|reentrancy".to_string()]
417 );
418
419 let latest = store.latest("demo", "ethereum").unwrap().unwrap();
420 assert_eq!(latest.overall_score, 90);
421 }
422
423 #[test]
424 fn test_latest_filters_by_chain_and_project() {
425 let dir = tempfile::tempdir().unwrap();
426 let config = ProjectConfig {
427 history: crate::core::config::HistoryConfig {
428 enabled: true,
429 db_path: Some(dir.path().join("history.db").to_string_lossy().into()),
430 },
431 ..ProjectConfig::default()
432 };
433 let store = HistoryStore::open(&config).unwrap();
434 store.record(&sample_result(80, vec![])).unwrap();
435 assert!(store.latest("other-project", "ethereum").unwrap().is_none());
436 assert!(store.latest("demo", "base").unwrap().is_none());
437 assert!(store.latest("demo", "ethereum").unwrap().is_some());
438 }
439
440 #[test]
441 fn test_db_path_resolution() {
442 let config = ProjectConfig {
444 history: crate::core::config::HistoryConfig {
445 enabled: true,
446 db_path: Some("/tmp/custom/history.db".into()),
447 },
448 ..ProjectConfig::default()
449 };
450 assert_eq!(db_path(&config), PathBuf::from("/tmp/custom/history.db"));
451
452 let config = ProjectConfig {
454 project_root: "/home/user/proj".into(),
455 history: crate::core::config::HistoryConfig {
456 enabled: true,
457 db_path: Some("history.db".into()),
458 },
459 ..ProjectConfig::default()
460 };
461 assert_eq!(
462 db_path(&config),
463 PathBuf::from("/home/user/proj/history.db")
464 );
465
466 let config = ProjectConfig {
468 history: crate::core::config::HistoryConfig {
469 enabled: true,
470 db_path: Some("~/.forge-guard/history.db".into()),
471 },
472 ..ProjectConfig::default()
473 };
474 let path = db_path(&config);
475 assert!(path.is_absolute());
476
477 let config = ProjectConfig::default();
479 let path = default_db_path();
480 assert!(path.to_string_lossy().contains(".forge-guard"));
481 let _ = &config;
482 }
483}