forge-guard 0.3.1

Pre-deployment smart contract auditing framework for Foundry
Documentation
//! `forge-guard import` — import findings from external analyzers.

use crate::core::Severity;
use crate::importer::{self, ExternalTool, ImportResult};

use super::ImportArgs;
use anyhow::Result;
use colored::Colorize;
use std::path::PathBuf;

/// Import external analyzer findings (Slither, Mythril, Semgrep) into
/// Forge Guard's unified format, deduplicating against existing findings.
pub fn run(args: &ImportArgs) -> Result<()> {
    let tool = ExternalTool::parse(&args.from)?;

    let input_path = args
        .input
        .clone()
        .unwrap_or_else(|| PathBuf::from("results.json"));
    if !input_path.exists() {
        anyhow::bail!("Results file not found: {}", input_path.display());
    }
    let content = std::fs::read_to_string(&input_path)?;
    let imported = importer::import_from_json(tool, &content)?;

    // Optional: deduplicate against forge-guard's own findings for a
    // combined "unified" report.
    let forge_guard_findings = match &args.findings {
        Some(path) if path.exists() => importer::load_forge_guard_findings(path)?,
        Some(path) => {
            anyhow::bail!("Forge-guard findings file not found: {}", path.display());
        }
        None => Vec::new(),
    };

    let result = if args.findings.is_some() {
        importer::build_unified(tool, forge_guard_findings, imported)
    } else {
        let (deduped, removed) = importer::deduplicate(&[], imported);
        let mut files: Vec<String> = deduped.iter().filter_map(|f| f.file.clone()).collect();
        files.sort();
        files.dedup();
        ImportResult {
            tool,
            findings: deduped,
            duplicates_removed: removed,
            source_files: files,
            merged_with_forge_guard: false,
        }
    };

    // Output
    if args.shared.json || args.shared.markdown {
        if let Some(out) = &args.output {
            let json = serde_json::to_string_pretty(&result)?;
            std::fs::write(out, json)?;
            eprintln!("📄 Unified report written to {}", out.display());
        } else if args.shared.json {
            println!("{}", serde_json::to_string_pretty(&result)?);
        } else {
            println!("{}", render_markdown(&result));
        }
    } else if let Some(out) = &args.output {
        let json = serde_json::to_string_pretty(&result)?;
        std::fs::write(out, json)?;
        eprintln!("📄 Unified report written to {}", out.display());
    } else {
        print_terminal(&result);
    }

    Ok(())
}

fn print_terminal(result: &ImportResult) {
    println!(
        "\n{}\n",
        "═══════════════════════════════════════════════════".bright_blue()
    );
    println!(
        "{} {}\n",
        "EXTERNAL ANALYZER IMPORT —".bright_blue().bold(),
        result.tool.as_str().to_uppercase().bright_blue().bold()
    );
    println!(
        "{}\n",
        "═══════════════════════════════════════════════════".bright_blue()
    );

    println!("  🔧 Source:        {}", result.tool.as_str().cyan());
    println!("  📄 Files:         {}", result.source_files.len());
    println!(
        "  🧹 Duplicates:    {} removed",
        result.duplicates_removed.to_string().yellow()
    );
    println!(
        "  🛡️  Findings:      {} total",
        result.total().to_string().bold()
    );
    if result.merged_with_forge_guard {
        println!("  🔗 Merged with forge-guard findings: yes");
    }

    let critical = result.count_at_or_above(Severity::Critical);
    let high = result
        .findings
        .iter()
        .filter(|f| f.severity == Severity::High)
        .count();
    let medium = result
        .findings
        .iter()
        .filter(|f| f.severity == Severity::Medium)
        .count();
    let low = result
        .findings
        .iter()
        .filter(|f| f.severity == Severity::Low || f.severity == Severity::Informational)
        .count();
    println!(
        "  🛑 Critical: {}   🔴 High: {}   🟡 Medium: {}   🔵 Low/Info: {}",
        critical, high, medium, low
    );

    if !result.findings.is_empty() {
        println!("\n{}", "── Findings ──".bold());
        for f in result.findings.iter().take(20) {
            let location = match (&f.file, f.line) {
                (Some(file), Some(line)) => format!("{}:{}", file, line),
                (Some(file), None) => file.clone(),
                (None, _) => "unknown".into(),
            };
            println!(
                "\n  [{}] {}",
                f.severity.to_string().red().bold(),
                f.title.bold()
            );
            println!("       📄 {} ({})", location, f.category);
            if !f.description.is_empty() {
                println!("       📝 {}", f.description);
            }
            if !f.recommendation.is_empty() {
                println!("       💡 {}", f.recommendation);
            }
        }
        if result.total() > 20 {
            println!("\n  ... and {} more findings", result.total() - 20);
        }
    }
    println!(
        "\n{}\n",
        "═══════════════════════════════════════════════════".bright_blue()
    );
}

fn render_markdown(result: &ImportResult) -> String {
    let mut md = String::new();
    md.push_str(&format!(
        "# Imported findings — {}\n\n",
        result.tool.as_str()
    ));
    md.push_str(&format!("- **Total findings:** {}\n", result.total()));
    md.push_str(&format!(
        "- **Duplicates removed:** {}\n",
        result.duplicates_removed
    ));
    md.push_str(&format!(
        "- **Files:** {}\n\n",
        result.source_files.join(", ")
    ));
    for f in &result.findings {
        md.push_str(&format!("### [{}] {}\n\n", f.severity, f.title));
        md.push_str(&format!(
            "- **Location:** {}:{} ({})\n",
            f.file.as_deref().unwrap_or("unknown"),
            f.line.map_or_else(|| "?".into(), |l| l.to_string()),
            f.category
        ));
        if !f.description.is_empty() {
            md.push_str(&format!("- **Description:** {}\n", f.description));
        }
        if !f.recommendation.is_empty() {
            md.push_str(&format!("- **Recommendation:** {}\n", f.recommendation));
        }
        md.push('\n');
    }
    md
}