forge-guard 0.3.5

Pre-deployment smart contract auditing framework for Foundry
Documentation
//! `forge-guard report` — generate audit reports.

use crate::core::{AuditResult, Finding, ProjectConfig};
use crate::reports;

use super::ReportArgs;
use anyhow::{Context, Result};
use colored::*;

/// Generate audit reports from existing results or re-run.
pub fn run(args: &ReportArgs) -> Result<()> {
    let config = ProjectConfig::from_default_location();

    // Historical views short-circuit before loading a current result
    if args.history {
        return history_report(&config);
    }
    if args.regression {
        let result = load_current_result(args, &config)?;
        return regression_report(&config, &result);
    }

    let result = load_current_result(args, &config)?;

    let output_dir = std::path::PathBuf::from(&config.report.output_dir);
    std::fs::create_dir_all(&output_dir)?;

    match args.format.to_lowercase().as_str() {
        "json" => {
            let output_path = args
                .output
                .clone()
                .unwrap_or_else(|| output_dir.join("audit.json"));
            reports::json::write_report(&result, &output_path)?;
            println!(
                "{} JSON report written to {}",
                "".green(),
                output_path.display()
            );
        }
        "markdown" | "md" => {
            let output_path = args
                .output
                .clone()
                .unwrap_or_else(|| output_dir.join("audit.md"));
            let md = reports::markdown::generate_report(&result)?;
            std::fs::write(&output_path, md)?;
            println!(
                "{} Markdown report written to {}",
                "".green(),
                output_path.display()
            );
        }
        "html" => {
            let output_path = args
                .output
                .clone()
                .unwrap_or_else(|| output_dir.join("audit.html"));
            let html = reports::html::generate_report(&result)?;
            std::fs::write(&output_path, html)?;
            println!(
                "{} HTML report written to {}",
                "".green(),
                output_path.display()
            );
        }
        other => {
            anyhow::bail!(
                "Unsupported format: {}. Use 'json', 'markdown', or 'html'.",
                other
            );
        }
    }

    if args.shared.json {
        println!("{}", serde_json::to_string_pretty(&result)?);
    }

    Ok(())
}

/// Load the current audit result from `--input` or the last-audit cache.
fn load_current_result(args: &ReportArgs, config: &ProjectConfig) -> Result<AuditResult> {
    if let Some(input_path) = &args.input {
        let content = std::fs::read_to_string(input_path).context("Failed to read input file")?;
        serde_json::from_str::<AuditResult>(&content).context("Failed to parse audit result JSON")
    } else {
        // Try loading from cache
        let cache_dir = config.project_root.join(".forge-guard-cache");
        let cache_file = cache_dir.join("last_audit.json");
        if cache_file.exists() {
            let content =
                std::fs::read_to_string(&cache_file).context("Failed to read cached audit")?;
            serde_json::from_str::<AuditResult>(&content)
                .context("Failed to parse cached audit result")
        } else {
            anyhow::bail!(
                "No audit result found. Run `forge-guard audit` first or specify --input"
            );
        }
    }
}

/// `forge-guard report --history` — show score trends over time from the
/// local SQLite history database.
fn history_report(config: &ProjectConfig) -> Result<()> {
    let store = crate::history::HistoryStore::open(config)?;
    let project = config.project_root.to_string_lossy().to_string();
    let entries = store.trends(&project, 25)?;

    if entries.is_empty() {
        eprintln!(
            "📭 No recorded history for project '{project}'. Run `forge-guard audit --enable-history` to start tracking trends."
        );
        return Ok(());
    }

    println!(
        "\n{}",
        "════════ FORGE GUARD — SCORE TREND HISTORY ════════"
            .bright_blue()
            .bold()
    );
    println!("📋 Project: {project}");
    println!(
        "🗄️  Database: {}\n",
        crate::history::db_path(config).display()
    );

    println!(
        "  {:<19} {:<12} {:<9} {:<9} Findings",
        "Date", "Chain", "Score", "Risk"
    );
    println!("  {}", "".repeat(74));
    for entry in &entries {
        println!("{}", crate::history::format_trend_row(entry));
    }

    // Chronological score trend (oldest → newest)
    let chronological: Vec<u8> = entries.iter().rev().map(|e| e.overall_score).collect();
    if chronological.len() >= 2 {
        let first = chronological[0];
        let last = *chronological.last().unwrap();
        println!(
            "\n📈 Score trend: {}",
            chronological
                .iter()
                .map(|s| s.to_string())
                .collect::<Vec<_>>()
                .join("")
        );
        let delta = i16::from(last) - i16::from(first);
        if delta > 0 {
            println!(
                "🟢 Security posture improved by +{delta} points since the first recorded audit"
            );
        } else if delta < 0 {
            println!(
                "🔴 Security posture declined by {delta} points since the first recorded audit"
            );
        } else {
            println!("⚪ Score unchanged since the first recorded audit");
        }
    }
    println!(
        "\n{}",
        "═══════════════════════════════════════════════════════".bright_blue()
    );
    Ok(())
}

/// `forge-guard report --regression` — highlight findings that are new since
/// the last recorded audit (and note findings that were resolved).
fn regression_report(config: &ProjectConfig, result: &AuditResult) -> Result<()> {
    use crate::history::{db_path, finding_signature, regression, HistoryStore};

    let store = HistoryStore::open(config)?;
    let project = result.project_name.clone();
    let chain = result.chain.clone();

    let Some(previous) = store.latest(&project, &chain)? else {
        eprintln!(
            "📭 No previous audit recorded for '{project}' (chain: {chain}). Run `forge-guard audit --enable-history` first."
        );
        return Ok(());
    };

    let diff = regression(&result.findings, &previous.finding_signatures);
    let new_findings: Vec<&Finding> = result
        .findings
        .iter()
        .filter(|f| diff.new_signatures.contains(&finding_signature(f)))
        .collect();

    println!(
        "\n{}",
        "════════ FORGE GUARD — REGRESSION REPORT ════════"
            .bright_yellow()
            .bold()
    );
    println!("📋 Project:   {project}");
    println!("⛓️  Chain:     {chain}");
    println!("🗄️  Database:  {}", db_path(config).display());
    println!(
        "🕐 Previous:  {} (score {}/100, {} finding{})",
        crate::history::format_timestamp(&previous.timestamp),
        previous.overall_score,
        previous.total_findings,
        if previous.total_findings == 1 {
            ""
        } else {
            "s"
        }
    );
    println!(
        "📊 Current:   {} (score {}/100, {} finding{})",
        crate::history::format_timestamp(&result.timestamp),
        result.overall_score,
        result.summary.total_findings,
        if result.summary.total_findings == 1 {
            ""
        } else {
            "s"
        }
    );

    println!("\n{}", "── New Findings Since Last Audit ──".bold());
    if new_findings.is_empty() {
        println!("  ✅ No new findings — posture stable or improved");
    } else {
        for f in &new_findings {
            let location = match (&f.file, f.line) {
                (Some(file), Some(line)) => format!("{file}:{line}"),
                (Some(file), None) => file.clone(),
                (None, _) => "?".into(),
            };
            println!(
                "  [{}] {}{}\n       📄 {}",
                f.severity.to_string().red().bold(),
                f.title.bold(),
                f.recommendation.dimmed(),
                location
            );
        }
    }

    if !diff.resolved_signatures.is_empty() {
        println!("\n{}", "── Resolved Since Last Audit ──".bold().green());
        for sig in &diff.resolved_signatures {
            let parts: Vec<&str> = sig.splitn(3, '|').collect();
            let title = parts.get(2).copied().unwrap_or(sig);
            let file = parts.get(1).copied().unwrap_or("?");
            println!("  🟢 {title} ({file})");
        }
    }
    println!(
        "\n{}",
        "═══════════════════════════════════════════════════════".bright_yellow()
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_report_arg_defaults() {
        let args = ReportArgs {
            shared: super::super::SharedFlags {
                chain: "ethereum".into(),
                project: std::path::PathBuf::from("."),
                json: false,
                markdown: false,
                html: false,
                strict: false,
                offline: false,
                production: false,
                report: false,
                parallelism: 4,
            },
            input: None,
            format: "markdown".into(),
            output: None,
            exploit_paths: false,
            summary: false,
            history: false,
            regression: false,
        };
        assert!(args.input.is_none());
        assert_eq!(args.format, "markdown");
        assert!(args.output.is_none());
        assert!(!args.exploit_paths);
        assert!(!args.summary);
        assert!(!args.history);
        assert!(!args.regression);
    }

    #[test]
    fn test_report_with_json_format() {
        let args = ReportArgs {
            shared: super::super::SharedFlags::default(),
            input: Some(std::path::PathBuf::from("audit.json")),
            format: "json".into(),
            output: Some(std::path::PathBuf::from("report.json")),
            exploit_paths: true,
            summary: false,
            history: false,
            regression: false,
        };
        assert_eq!(args.format, "json");
        assert!(args.input.is_some());
        assert!(args.exploit_paths);
    }

    #[test]
    fn test_report_format_lowercasing() {
        // Test the format matching logic used in run()
        let format_upper = "MARKDOWN";
        let format_lower = format_upper.to_lowercase();
        assert_eq!(format_lower, "markdown");
        assert!(matches!(format_lower.as_str(), "json" | "markdown" | "md"));

        let format_json = "JSON";
        assert_eq!(format_json.to_lowercase(), "json");

        let format_md = "MD";
        assert_eq!(format_md.to_lowercase(), "md");
    }

    #[test]
    fn test_report_html_format() {
        let args = ReportArgs {
            shared: super::super::SharedFlags::default(),
            input: None,
            format: "html".into(),
            output: Some(std::path::PathBuf::from("report.html")),
            exploit_paths: false,
            summary: false,
            history: false,
            regression: false,
        };
        assert_eq!(args.format, "html");
        assert!(args.output.is_some());
    }

    #[test]
    fn test_report_unsupported_format() {
        let format = "pdf";
        assert!(!matches!(format, "json" | "markdown" | "md" | "html"));
    }

    #[test]
    fn test_report_output_dir_from_config() {
        let config = ProjectConfig::default();
        assert_eq!(config.report.output_dir, "reports");
    }
}