Skip to main content

forge_guard/reports/
mod.rs

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