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        // In --conformance-all-operations mode, check names are path-qualified
74        // (e.g. "constraint:required:/users") so we match by prefix as well as
75        // exact name.
76        for feature in ConformanceFeature::all() {
77            let check_name = feature.check_name();
78            let category = feature.category();
79
80            let entry = categories.entry(category).or_default();
81
82            // First try exact match (reference mode)
83            if let Some((passes, fails)) = self.check_results.get(check_name) {
84                if *fails == 0 && *passes > 0 {
85                    entry.passed += 1;
86                } else {
87                    entry.failed += 1;
88                }
89            } else {
90                // Try prefix match (all-operations mode: "constraint:required:/path")
91                let prefix = format!("{}:", check_name);
92                for (name, (passes, fails)) in &self.check_results {
93                    if name.starts_with(&prefix) {
94                        if *fails == 0 && *passes > 0 {
95                            entry.passed += 1;
96                        } else {
97                            entry.failed += 1;
98                        }
99                    }
100                }
101                // Features not in results are not counted
102            }
103        }
104
105        categories
106    }
107
108    /// Print the conformance report to stdout
109    pub fn print_report(&self) {
110        self.print_report_with_options(false);
111    }
112
113    /// Print the conformance report with options controlling detail level
114    pub fn print_report_with_options(&self, all_operations: bool) {
115        let categories = self.by_category();
116
117        println!("\n{}", "OpenAPI 3.0.0 Conformance Report".bold());
118        println!("{}", "=".repeat(64).bright_green());
119
120        println!(
121            "{:<20} {:>8} {:>8} {:>8} {:>8}",
122            "Category".bold(),
123            "Passed".green().bold(),
124            "Failed".red().bold(),
125            "Total".bold(),
126            "Rate".bold()
127        );
128        println!("{}", "-".repeat(64));
129
130        let mut total_passed = 0usize;
131        let mut total_failed = 0usize;
132
133        for cat_name in ConformanceFeature::categories() {
134            if let Some(result) = categories.get(cat_name) {
135                let total = result.total();
136                if total == 0 {
137                    continue;
138                }
139                total_passed += result.passed;
140                total_failed += result.failed;
141
142                let rate_str = format!("{:.0}%", result.rate());
143                let rate_colored = if result.rate() >= 100.0 {
144                    rate_str.green()
145                } else if result.rate() >= 80.0 {
146                    rate_str.yellow()
147                } else {
148                    rate_str.red()
149                };
150
151                println!(
152                    "{:<20} {:>8} {:>8} {:>8} {:>8}",
153                    cat_name,
154                    result.passed.to_string().green(),
155                    result.failed.to_string().red(),
156                    total,
157                    rate_colored
158                );
159            }
160        }
161
162        println!("{}", "=".repeat(64).bright_green());
163
164        let grand_total = total_passed + total_failed;
165        let overall_rate = if grand_total > 0 {
166            (total_passed as f64 / grand_total as f64) * 100.0
167        } else {
168            0.0
169        };
170        let rate_str = format!("{:.0}%", overall_rate);
171        let rate_colored = if overall_rate >= 100.0 {
172            rate_str.green()
173        } else if overall_rate >= 80.0 {
174            rate_str.yellow()
175        } else {
176            rate_str.red()
177        };
178
179        println!(
180            "{:<20} {:>8} {:>8} {:>8} {:>8}",
181            "Total:".bold(),
182            total_passed.to_string().green(),
183            total_failed.to_string().red(),
184            grand_total,
185            rate_colored
186        );
187
188        // Print failed checks detail section
189        let failed_checks: Vec<_> =
190            self.check_results.iter().filter(|(_, (_, fails))| *fails > 0).collect();
191
192        if !failed_checks.is_empty() {
193            println!();
194            println!("{}", "Failed Checks:".red().bold());
195            let mut sorted_failures: Vec<_> = failed_checks.into_iter().collect();
196            sorted_failures.sort_by_key(|(name, _)| (*name).clone());
197            for (name, (passes, fails)) in sorted_failures {
198                println!(
199                    "  {} ({} passed, {} failed)",
200                    name.red(),
201                    passes.to_string().green(),
202                    fails.to_string().red()
203                );
204            }
205
206            if !all_operations {
207                println!();
208                println!(
209                    "{}",
210                    "Tip: Use --conformance-all-operations to see which specific endpoints failed."
211                        .yellow()
212                );
213            }
214        }
215
216        // OWASP API Top 10 coverage section
217        self.print_owasp_coverage();
218
219        println!();
220    }
221
222    /// Print OWASP API Security Top 10 coverage based on tested features
223    fn print_owasp_coverage(&self) {
224        println!();
225        println!("{}", "OWASP API Security Top 10 Coverage".bold());
226        println!("{}", "=".repeat(64).bright_green());
227
228        // Collect which OWASP IDs are covered by tested features
229        let tested_check_names: HashSet<&str> = self
230            .check_results
231            .keys()
232            .filter_map(|name| {
233                // Map back to a feature check_name (handle path-qualified names)
234                ConformanceFeature::all()
235                    .iter()
236                    .find(|f| {
237                        name == f.check_name() || name.starts_with(&format!("{}:", f.check_name()))
238                    })
239                    .map(|f| f.check_name())
240            })
241            .collect();
242
243        let covered_owasp: HashSet<&str> = ConformanceFeature::all()
244            .iter()
245            .filter(|f| tested_check_names.contains(f.check_name()))
246            .flat_map(|f| f.related_owasp().iter().copied())
247            .collect();
248
249        for category in OwaspCategory::all() {
250            let id = category.identifier();
251            let name = category.short_name();
252            let covered = covered_owasp.contains(id);
253
254            let status = if covered {
255                "✓".green()
256            } else {
257                "-".bright_black()
258            };
259
260            // Find which conformance categories provide coverage
261            let via = if covered {
262                let categories: HashSet<&str> = ConformanceFeature::all()
263                    .iter()
264                    .filter(|f| {
265                        tested_check_names.contains(f.check_name())
266                            && f.related_owasp().contains(&id)
267                    })
268                    .map(|f| f.category())
269                    .collect();
270                let mut cats: Vec<&str> = categories.into_iter().collect();
271                cats.sort();
272                format!(" (via {})", cats.join(", "))
273            } else {
274                String::new()
275            };
276
277            println!("  {:<12} {:<40} {}{}", id, name, status, via);
278        }
279    }
280
281    /// Get raw per-check results (for SARIF conversion)
282    pub fn raw_check_results(&self) -> &HashMap<String, (u64, u64)> {
283        &self.check_results
284    }
285
286    /// Overall pass rate (0.0 - 100.0)
287    pub fn overall_rate(&self) -> f64 {
288        let categories = self.by_category();
289        let total_passed: usize = categories.values().map(|r| r.passed).sum();
290        let total: usize = categories.values().map(|r| r.total()).sum();
291        if total == 0 {
292            0.0
293        } else {
294            (total_passed as f64 / total as f64) * 100.0
295        }
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_parse_conformance_report() {
305        let json = r#"{
306            "checks": {
307                "param:path:string": { "passes": 1, "fails": 0 },
308                "param:path:integer": { "passes": 1, "fails": 0 },
309                "body:json": { "passes": 0, "fails": 1 },
310                "method:GET": { "passes": 1, "fails": 0 }
311            },
312            "overall": { "overall_pass_rate": 0.75 }
313        }"#;
314
315        let report = ConformanceReport::from_json(json).unwrap();
316        let categories = report.by_category();
317
318        let params = categories.get("Parameters").unwrap();
319        assert_eq!(params.passed, 2);
320
321        let bodies = categories.get("Request Bodies").unwrap();
322        assert_eq!(bodies.failed, 1);
323    }
324
325    #[test]
326    fn test_empty_report() {
327        let json = r#"{ "checks": {} }"#;
328        let report = ConformanceReport::from_json(json).unwrap();
329        assert_eq!(report.overall_rate(), 0.0);
330    }
331}