Skip to main content

mockforge_bench/conformance/
report.rs

1//! Conformance test report parsing and display
2
3use super::spec::ConformanceFeature;
4use crate::error::{BenchError, Result};
5use crate::owasp_api::categories::OwaspCategory;
6use colored::*;
7use std::collections::{HashMap, HashSet};
8use std::path::Path;
9
10/// Per-category conformance result
11#[derive(Debug, Clone, Default)]
12pub struct CategoryResult {
13    pub passed: usize,
14    pub failed: usize,
15}
16
17impl CategoryResult {
18    pub fn total(&self) -> usize {
19        self.passed + self.failed
20    }
21
22    pub fn rate(&self) -> f64 {
23        if self.total() == 0 {
24            0.0
25        } else {
26            (self.passed as f64 / self.total() as f64) * 100.0
27        }
28    }
29}
30
31/// Conformance test report
32pub struct ConformanceReport {
33    /// Per-check results: check_name -> (passes, fails)
34    check_results: HashMap<String, (u64, u64)>,
35}
36
37impl ConformanceReport {
38    /// Parse a conformance report from k6's handleSummary JSON output
39    pub fn from_file(path: &Path) -> Result<Self> {
40        let content = std::fs::read_to_string(path)
41            .map_err(|e| BenchError::Other(format!("Failed to read conformance report: {}", e)))?;
42        Self::from_json(&content)
43    }
44
45    /// Parse from JSON string
46    pub fn from_json(json_str: &str) -> Result<Self> {
47        let json: serde_json::Value = serde_json::from_str(json_str)
48            .map_err(|e| BenchError::Other(format!("Failed to parse conformance JSON: {}", e)))?;
49
50        let mut check_results = HashMap::new();
51
52        if let Some(checks) = json.get("checks").and_then(|c| c.as_object()) {
53            for (name, result) in checks {
54                let passes = result.get("passes").and_then(|v| v.as_u64()).unwrap_or(0);
55                let fails = result.get("fails").and_then(|v| v.as_u64()).unwrap_or(0);
56                check_results.insert(name.clone(), (passes, fails));
57            }
58        }
59
60        Ok(Self { check_results })
61    }
62
63    /// Get results grouped by category
64    pub fn by_category(&self) -> HashMap<&'static str, CategoryResult> {
65        let mut categories: HashMap<&'static str, CategoryResult> = HashMap::new();
66
67        // Initialize all categories
68        for cat in ConformanceFeature::categories() {
69            categories.insert(cat, CategoryResult::default());
70        }
71
72        // Map check results to features.
73        // Check names are path-qualified (e.g. "constraint:required:/users")
74        // so we match by prefix as well as exact name.
75        for feature in ConformanceFeature::all() {
76            let check_name = feature.check_name();
77            let category = feature.category();
78
79            let entry = categories.entry(category).or_default();
80
81            // First try exact match (reference mode)
82            if let Some((passes, fails)) = self.check_results.get(check_name) {
83                if *fails == 0 && *passes > 0 {
84                    entry.passed += 1;
85                } else {
86                    entry.failed += 1;
87                }
88            } else {
89                // Try prefix match (path-qualified: "constraint:required:/path")
90                let prefix = format!("{}:", check_name);
91                for (name, (passes, fails)) in &self.check_results {
92                    if name.starts_with(&prefix) {
93                        if *fails == 0 && *passes > 0 {
94                            entry.passed += 1;
95                        } else {
96                            entry.failed += 1;
97                        }
98                    }
99                }
100                // Features not in results are not counted
101            }
102        }
103
104        categories
105    }
106
107    /// Print the conformance report to stdout
108    pub fn print_report(&self) {
109        self.print_report_with_options(false);
110    }
111
112    /// Print the conformance report with options controlling detail level
113    pub fn print_report_with_options(&self, all_operations: bool) {
114        let categories = self.by_category();
115
116        println!("\n{}", "OpenAPI 3.0.0 Conformance Report".bold());
117        println!("{}", "=".repeat(64).bright_green());
118
119        println!(
120            "{:<20} {:>8} {:>8} {:>8} {:>8}",
121            "Category".bold(),
122            "Passed".green().bold(),
123            "Failed".red().bold(),
124            "Total".bold(),
125            "Rate".bold()
126        );
127        println!("{}", "-".repeat(64));
128
129        let mut total_passed = 0usize;
130        let mut total_failed = 0usize;
131
132        for cat_name in ConformanceFeature::categories() {
133            if let Some(result) = categories.get(cat_name) {
134                let total = result.total();
135                if total == 0 {
136                    continue;
137                }
138                total_passed += result.passed;
139                total_failed += result.failed;
140
141                let rate_str = format!("{:.0}%", result.rate());
142                let rate_colored = if result.rate() >= 100.0 {
143                    rate_str.green()
144                } else if result.rate() >= 80.0 {
145                    rate_str.yellow()
146                } else {
147                    rate_str.red()
148                };
149
150                println!(
151                    "{:<20} {:>8} {:>8} {:>8} {:>8}",
152                    cat_name,
153                    result.passed.to_string().green(),
154                    result.failed.to_string().red(),
155                    total,
156                    rate_colored
157                );
158            }
159        }
160
161        println!("{}", "=".repeat(64).bright_green());
162
163        let grand_total = total_passed + total_failed;
164        let overall_rate = if grand_total > 0 {
165            (total_passed as f64 / grand_total as f64) * 100.0
166        } else {
167            0.0
168        };
169        let rate_str = format!("{:.0}%", overall_rate);
170        let rate_colored = if overall_rate >= 100.0 {
171            rate_str.green()
172        } else if overall_rate >= 80.0 {
173            rate_str.yellow()
174        } else {
175            rate_str.red()
176        };
177
178        println!(
179            "{:<20} {:>8} {:>8} {:>8} {:>8}",
180            "Total:".bold(),
181            total_passed.to_string().green(),
182            total_failed.to_string().red(),
183            grand_total,
184            rate_colored
185        );
186
187        // Print failed checks detail section
188        let failed_checks: Vec<_> =
189            self.check_results.iter().filter(|(_, (_, fails))| *fails > 0).collect();
190
191        if !failed_checks.is_empty() {
192            println!();
193            println!("{}", "Failed Checks:".red().bold());
194            let mut sorted_failures: Vec<_> = failed_checks.into_iter().collect();
195            sorted_failures.sort_by_key(|(name, _)| (*name).clone());
196            for (name, (passes, fails)) in sorted_failures {
197                println!(
198                    "  {} ({} passed, {} failed)",
199                    name.red(),
200                    passes.to_string().green(),
201                    fails.to_string().red()
202                );
203            }
204
205            if !all_operations {
206                println!();
207                println!(
208                    "{}",
209                    "Tip: Use --conformance-all-operations (without --conformance-categories) to see which specific endpoints failed across all categories."
210                        .yellow()
211                );
212            }
213        }
214
215        // OWASP API Top 10 coverage section
216        self.print_owasp_coverage();
217
218        println!();
219    }
220
221    /// Print OWASP API Security Top 10 coverage based on tested features
222    fn print_owasp_coverage(&self) {
223        println!();
224        println!("{}", "OWASP API Security Top 10 Coverage".bold());
225        println!("{}", "=".repeat(64).bright_green());
226
227        // Build a map of feature check_name → passed/failed status
228        let mut feature_status: HashMap<&str, bool> = HashMap::new(); // true = all passed
229        for feature in ConformanceFeature::all() {
230            let check_name = feature.check_name();
231
232            // Exact match (reference mode)
233            if let Some((passes, fails)) = self.check_results.get(check_name) {
234                let passed = *fails == 0 && *passes > 0;
235                feature_status
236                    .entry(check_name)
237                    .and_modify(|prev| *prev = *prev && passed)
238                    .or_insert(passed);
239            } else {
240                // Prefix match (path-qualified check names)
241                let prefix = format!("{}:", check_name);
242                for (name, (passes, fails)) in &self.check_results {
243                    if name.starts_with(&prefix) {
244                        let passed = *fails == 0 && *passes > 0;
245                        feature_status
246                            .entry(check_name)
247                            .and_modify(|prev| *prev = *prev && passed)
248                            .or_insert(passed);
249                    }
250                }
251            }
252        }
253
254        for category in OwaspCategory::all() {
255            let id = category.identifier();
256            let name = category.short_name();
257
258            // Find features that map to this OWASP category and were tested
259            let mut tested = false;
260            let mut all_passed = true;
261            let mut via_categories: HashSet<&str> = HashSet::new();
262
263            for feature in ConformanceFeature::all() {
264                if !feature.related_owasp().contains(&id) {
265                    continue;
266                }
267                if let Some(&passed) = feature_status.get(feature.check_name()) {
268                    tested = true;
269                    if !passed {
270                        all_passed = false;
271                    }
272                    via_categories.insert(feature.category());
273                }
274            }
275
276            let (status, via) = if !tested {
277                ("-".bright_black(), String::new())
278            } else {
279                let mut cats: Vec<&str> = via_categories.into_iter().collect();
280                cats.sort();
281                let via_str = format!(" (via {})", cats.join(", "));
282                if all_passed {
283                    ("✓".green(), via_str)
284                } else {
285                    ("⚠".yellow(), format!("{} — has failures", via_str))
286                }
287            };
288
289            println!("  {:<12} {:<40} {}{}", id, name, status, via);
290        }
291    }
292
293    /// Get raw per-check results (for SARIF conversion)
294    pub fn raw_check_results(&self) -> &HashMap<String, (u64, u64)> {
295        &self.check_results
296    }
297
298    /// Overall pass rate (0.0 - 100.0)
299    pub fn overall_rate(&self) -> f64 {
300        let categories = self.by_category();
301        let total_passed: usize = categories.values().map(|r| r.passed).sum();
302        let total: usize = categories.values().map(|r| r.total()).sum();
303        if total == 0 {
304            0.0
305        } else {
306            (total_passed as f64 / total as f64) * 100.0
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn test_parse_conformance_report() {
317        let json = r#"{
318            "checks": {
319                "param:path:string": { "passes": 1, "fails": 0 },
320                "param:path:integer": { "passes": 1, "fails": 0 },
321                "body:json": { "passes": 0, "fails": 1 },
322                "method:GET": { "passes": 1, "fails": 0 }
323            },
324            "overall": { "overall_pass_rate": 0.75 }
325        }"#;
326
327        let report = ConformanceReport::from_json(json).unwrap();
328        let categories = report.by_category();
329
330        let params = categories.get("Parameters").unwrap();
331        assert_eq!(params.passed, 2);
332
333        let bodies = categories.get("Request Bodies").unwrap();
334        assert_eq!(bodies.failed, 1);
335    }
336
337    #[test]
338    fn test_empty_report() {
339        let json = r#"{ "checks": {} }"#;
340        let report = ConformanceReport::from_json(json).unwrap();
341        assert_eq!(report.overall_rate(), 0.0);
342    }
343
344    #[test]
345    fn test_owasp_coverage_with_failures() {
346        // response:404 maps to API8 + API9, body:json maps to API4 + API8
347        // response:404 fails, so API8 and API9 should show as having failures
348        // body:json passes, so API4 should show as passing
349        let json = r#"{
350            "checks": {
351                "response:404": { "passes": 0, "fails": 1 },
352                "body:json": { "passes": 1, "fails": 0 },
353                "method:GET": { "passes": 1, "fails": 0 }
354            },
355            "overall": {}
356        }"#;
357
358        let report = ConformanceReport::from_json(json).unwrap();
359        // Print the report to verify visually (--nocapture)
360        report.print_report_with_options(false);
361    }
362}