Skip to main content

forge_guard/reports/
mod.rs

1//! Report engine — generates JSON, Markdown, and executive summary audit reports.
2
3pub mod json;
4pub mod markdown;
5
6use crate::core::{AuditResult, Finding, Severity};
7
8/// Trait for report generators.
9pub trait ReportGenerator {
10    /// Generate a report from audit results.
11    fn generate(&self, result: &AuditResult) -> Result<String, crate::core::ForgeGuardError>;
12
13    /// Get the file extension for this report format.
14    fn extension(&self) -> &'static str;
15}
16
17/// Generate an executive summary report with actionable findings.
18/// This is a concise, decision-focused report showing the key risks,
19/// action items, and deployment readiness at a glance.
20pub fn generate_executive_summary(result: &AuditResult) -> String {
21    let mut s = String::new();
22
23    // ── Header ──
24    s.push_str("═══════════════════════════════════════════════════\n");
25    s.push_str("  FORGE AUDIT — EXECUTIVE SUMMARY\n");
26    s.push_str("═══════════════════════════════════════════════════\n\n");
27
28    s.push_str(&format!("  Project:          {}\n", result.project_name));
29    s.push_str(&format!("  Chain:            {}\n", result.chain));
30    s.push_str(&format!(
31        "  Files Analyzed:   {}\n",
32        result.summary.files_analyzed
33    ));
34    s.push_str(&format!(
35        "  Duration:         {:.2}s\n",
36        result.duration_seconds
37    ));
38
39    // ── Verdict ──
40    s.push_str("\n── VERDICT ──\n");
41    let verdict = if result.production_ready && result.deployment_approved {
42        "✅ PASS — Ready for deployment"
43    } else if result.production_ready {
44        "⚠️  PASS WITH WARNINGS — Review findings before deploying"
45    } else {
46        "❌ FAIL — Must fix issues before deployment"
47    };
48    s.push_str(&format!("  {}\n\n", verdict));
49
50    // ── Key Metrics ──
51    s.push_str("── KEY METRICS ──\n");
52    s.push_str(&format!(
53        "  Overall Score:    {:>3}/100\n",
54        result.overall_score
55    ));
56    s.push_str(&format!("  Risk Level:       {}\n", result.risk_level));
57    s.push_str(&format!(
58        "  Deployment:      {}\n",
59        if result.deployment_approved {
60            "✅ APPROVED"
61        } else {
62            "❌ BLOCKED"
63        }
64    ));
65
66    // ── Finding Summary ──
67    s.push_str("\n── FINDING SUMMARY ──\n");
68    s.push_str(&format!(
69        "  🛑 Critical:  {}\n",
70        result.summary.critical_count
71    ));
72    s.push_str(&format!("  🔴 High:      {}\n", result.summary.high_count));
73    s.push_str(&format!(
74        "  🟡 Medium:    {}\n",
75        result.summary.medium_count
76    ));
77    s.push_str(&format!("  🔵 Low:       {}\n", result.summary.low_count));
78    s.push_str(&format!("  ⚪ Info:      {}\n", result.summary.info_count));
79
80    // ── Top Action Items ──
81    let critical_high: Vec<&Finding> = result
82        .findings
83        .iter()
84        .filter(|f| f.severity == Severity::Critical || f.severity == Severity::High)
85        .collect();
86
87    if !critical_high.is_empty() {
88        s.push_str("\n── TOP ACTION ITEMS ──\n");
89        for (i, f) in critical_high.iter().take(5).enumerate() {
90            let location = match (&f.file, f.line) {
91                (Some(file), Some(line)) => format!("{}:{}", file, line),
92                (Some(file), None) => file.clone(),
93                (None, _) => "unknown".into(),
94            };
95            s.push_str(&format!(
96                "  {}. [{}] {} ({})\n",
97                i + 1,
98                f.severity,
99                f.title,
100                location
101            ));
102            s.push_str(&format!("     💡 {}\n", f.recommendation));
103        }
104        if critical_high.len() > 5 {
105            s.push_str(&format!(
106                "     ... and {} more critical/high findings\n",
107                critical_high.len() - 5
108            ));
109        }
110    }
111
112    // ── Worst Categories ──
113    let categories = [
114        ("Access Control", result.scores.access_control),
115        ("Security", result.scores.security),
116        ("Architecture", result.scores.architecture),
117        ("Production Ready", result.scores.production_readiness),
118        ("Gas", result.scores.gas),
119    ];
120    let worst: Vec<&(&str, u8)> = categories.iter().filter(|(_, score)| *score < 70).collect();
121    if !worst.is_empty() {
122        s.push_str("\n── AREAS FOR IMPROVEMENT ──\n");
123        for (name, score) in worst {
124            s.push_str(&format!(
125                "  🔸 {}: {:>3}/100 — needs attention\n",
126                name, score
127            ));
128        }
129    }
130
131    // ── Quick Mode Indicator ──
132    if result.duration_seconds < 1.0 {
133        s.push_str("\n── QUICK MODE ──\n");
134        s.push_str("  ⚡ Quick mode enabled — parser-heavy checks were skipped.\n");
135        s.push_str("  🔍 Run without --quick for a comprehensive audit.\n");
136    }
137
138    s.push_str("\n═══════════════════════════════════════════════════\n");
139    s
140}
141
142/// Generate a human-readable recommendation count breakdown.
143pub fn count_recommendations(findings: &[Finding]) -> std::collections::HashMap<String, usize> {
144    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
145    for f in findings {
146        let cat = if f.category.is_empty() {
147            "General"
148        } else {
149            &f.category
150        };
151        *counts.entry(cat.to_string()).or_insert(0) += 1;
152    }
153    counts
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::core::*;
160
161    fn sample_result() -> AuditResult {
162        let findings = vec![
163            Finding::builder()
164                .id("FA-H-001-1")
165                .title("Reentrancy")
166                .description("CEI violation")
167                .severity(Severity::High)
168                .file("Vuln.sol")
169                .location(15, 0)
170                .code("externalCall(); balance -= amount;")
171                .recommendation("Use ReentrancyGuard")
172                .category("Logic")
173                .blocks_deployment(true)
174                .build(),
175            Finding::builder()
176                .id("FA-M-001-1")
177                .title("Gas Problem")
178                .description("Loop gas issue")
179                .severity(Severity::Medium)
180                .file("Vuln.sol")
181                .location(30, 0)
182                .code("for(uint i;i<arr.length;i++)")
183                .recommendation("Cache array length")
184                .category("Gas")
185                .blocks_deployment(false)
186                .build(),
187        ];
188
189        AuditResult {
190            project_name: "test".into(),
191            chain: "ethereum".into(),
192            timestamp: "2026-01-01T00:00:00Z".into(),
193            duration_seconds: 0.5,
194            findings,
195            scores: SecurityScores {
196                access_control: 100,
197                security: 70,
198                fuzzing: 100,
199                gas: 85,
200                architecture: 65,
201                upgradeability: 100,
202                dependencies: 100,
203                deployment: 100,
204                proxy_safety: 100,
205                chain_compatibility: 100,
206                production_readiness: 55,
207                exploit_resistance: 100,
208            },
209            overall_score: 76,
210            risk_level: RiskLevel::High,
211            production_ready: false,
212            deployment_approved: false,
213            summary: AuditSummary {
214                total_findings: 2,
215                critical_count: 0,
216                high_count: 1,
217                medium_count: 1,
218                low_count: 0,
219                info_count: 0,
220                files_analyzed: 5,
221                lines_analyzed: 500,
222                contracts_analyzed: 3,
223            },
224        }
225    }
226
227    #[test]
228    fn test_executive_summary_contains_verdict() {
229        let result = sample_result();
230        let summary = generate_executive_summary(&result);
231        assert!(summary.contains("EXECUTIVE SUMMARY"));
232        assert!(summary.contains("FAIL"));
233        assert!(summary.contains("BLOCKED"));
234        assert!(summary.contains("ACTION ITEMS"));
235        assert!(summary.contains("Reentrancy"));
236    }
237
238    #[test]
239    fn test_executive_summary_top_findings() {
240        let result = sample_result();
241        let summary = generate_executive_summary(&result);
242        assert!(summary.contains("1."));
243        assert!(summary.contains("ReentrancyGuard"));
244    }
245
246    #[test]
247    fn test_executive_summary_improvement_areas() {
248        let result = sample_result();
249        let summary = generate_executive_summary(&result);
250        assert!(summary.contains("IMPROVEMENT"));
251        // Production Ready is 55 — below 70
252        assert!(summary.contains("Production Ready"));
253    }
254
255    #[test]
256    fn test_executive_summary_quick_mode_indicator() {
257        let result = sample_result();
258        let summary = generate_executive_summary(&result);
259        assert!(summary.contains("QUICK MODE"));
260        assert!(summary.contains("--quick"));
261    }
262
263    #[test]
264    fn test_count_recommendations() {
265        let result = sample_result();
266        let counts = count_recommendations(&result.findings);
267        assert_eq!(counts.get("Logic").copied().unwrap_or(0), 1);
268        assert_eq!(counts.get("Gas").copied().unwrap_or(0), 1);
269    }
270
271    #[test]
272    fn test_executive_summary_passed() {
273        let mut result = sample_result();
274        result.production_ready = true;
275        result.deployment_approved = true;
276        result.overall_score = 92;
277        result.risk_level = RiskLevel::Low;
278        result.duration_seconds = 3.5;
279        let summary = generate_executive_summary(&result);
280        assert!(summary.contains("PASS"));
281        assert!(summary.contains("APPROVED"));
282        assert!(!summary.contains("QUICK MODE"));
283    }
284}