Skip to main content

forge_guard/history/
mod.rs

1//! Historical trend tracking — M16.
2//!
3//! Persists audit results in a local SQLite database (default
4//! `~/.forge-guard/history.db`) so teams can track their security posture
5//! over time:
6//!
7//! * `forge-guard report --history` — score trends over time
8//! * `forge-guard report --regression` — new findings since the last audit
9//!
10//! Recording is opt-in (`--enable-history` or `[history] enabled = true`) and
11//! best-effort — a failed write is logged as a warning and never fails the
12//! audit itself.
13
14use crate::core::{AuditResult, Finding, ForgeGuardError, ProjectConfig};
15use rusqlite::{params, Connection};
16use std::path::PathBuf;
17
18/// One stored audit record.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct HistoryEntry {
21    /// Row id in the database.
22    pub id: i64,
23    /// RFC3339 timestamp of the audit.
24    pub timestamp: String,
25    /// Project name (project root path as reported by the audit).
26    pub project: String,
27    /// Target chain (or "all" for `--all-chains` audits).
28    pub chain: String,
29    /// Overall security score (0-100).
30    pub overall_score: u8,
31    /// Risk level label.
32    pub risk_level: String,
33    /// Total number of findings.
34    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    /// Finding signatures present in this audit (for regression detection).
41    pub finding_signatures: Vec<String>,
42}
43
44/// A stable signature identifying a finding across audits. Line numbers are
45/// intentionally excluded so that code edits which shift lines don't turn a
46/// pre-existing issue into a false "new finding".
47pub 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/// Result of comparing the current audit against the previous snapshot.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Regression {
59    /// Signatures of findings present now but not in the previous audit.
60    pub new_signatures: Vec<String>,
61    /// Signatures present in the previous audit but gone now.
62    pub resolved_signatures: Vec<String>,
63}
64
65/// Compute new/resolved findings between two audits.
66///
67/// `previous` is the set of signatures from the earlier audit (e.g.
68/// [`HistoryEntry::finding_signatures`]); `current` is the live findings of
69/// the latest audit. Signatures are matched set-wise, so findings are stable
70/// across line-number shifts.
71pub 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(&current_set).cloned().collect();
81    resolved_signatures.sort();
82
83    Regression {
84        new_signatures,
85        resolved_signatures,
86    }
87}
88
89/// Handle to the local history SQLite database.
90pub struct HistoryStore {
91    conn: Connection,
92}
93
94impl HistoryStore {
95    /// Open (creating if needed) the history database configured for the
96    /// given project. Creates the parent directory and the schema.
97    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    /// Persist an audit result as a history entry.
126    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    /// Most recent history entries for a project, newest first.
153    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    /// Latest history entry for a project + chain (used for regression).
177    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
217/// Resolve the history database path from configuration.
218///
219/// * `[history] db_path` set and absolute → used directly
220/// * `[history] db_path` set and relative → resolved against the project root
221/// * `~` prefix → expanded to the user's home directory
222/// * unset → `~/.forge-guard/history.db`
223pub 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
237/// `~/.forge-guard/history.db`, falling back to `$USERPROFILE` on Windows and
238/// finally to `.forge-guard/history.db` relative to the current directory.
239pub 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
262/// Truncate an RFC3339 timestamp to second precision for display.
263pub fn format_timestamp(ts: &str) -> &str {
264    &ts[..ts.len().min(19)]
265}
266
267/// Format a history entry for the `report --history` table:
268/// `date, chain, score, risk, findings summary`.
269pub 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(&current, &previous);
368        assert_eq!(diff.new_signatures, vec!["MEDIUM|Vault.sol|gas"]);
369        assert!(diff.resolved_signatures.is_empty());
370
371        // A finding fixed in the current audit shows up as resolved
372        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        // Newest first
411        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        // Explicit absolute path is used directly
443        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        // Relative path resolves against the project root
453        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        // Tilde expands to the home directory
467        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        // Default resolves to ~/.forge-guard/history.db
478        let config = ProjectConfig::default();
479        let path = default_db_path();
480        assert!(path.to_string_lossy().contains(".forge-guard"));
481        let _ = &config;
482    }
483}