mockforge_bench/conformance/
report.rs1use 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#[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
31pub struct ConformanceReport {
33 check_results: HashMap<String, (u64, u64)>,
35}
36
37impl ConformanceReport {
38 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 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 pub fn by_category(&self) -> HashMap<&'static str, CategoryResult> {
65 let mut categories: HashMap<&'static str, CategoryResult> = HashMap::new();
66
67 for cat in ConformanceFeature::categories() {
69 categories.insert(cat, CategoryResult::default());
70 }
71
72 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 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 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 }
103 }
104
105 categories
106 }
107
108 pub fn print_report(&self) {
110 self.print_report_with_options(false);
111 }
112
113 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 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 (without --conformance-categories) to see which specific endpoints failed across all categories."
211 .yellow()
212 );
213 }
214 }
215
216 self.print_owasp_coverage();
218
219 println!();
220 }
221
222 fn print_owasp_coverage(&self) {
224 println!();
225 println!("{}", "OWASP API Security Top 10 Coverage".bold());
226 println!("{}", "=".repeat(64).bright_green());
227
228 let mut feature_status: HashMap<&str, bool> = HashMap::new(); for feature in ConformanceFeature::all() {
231 let check_name = feature.check_name();
232
233 if let Some((passes, fails)) = self.check_results.get(check_name) {
235 let passed = *fails == 0 && *passes > 0;
236 feature_status
237 .entry(check_name)
238 .and_modify(|prev| *prev = *prev && passed)
239 .or_insert(passed);
240 } else {
241 let prefix = format!("{}:", check_name);
243 for (name, (passes, fails)) in &self.check_results {
244 if name.starts_with(&prefix) {
245 let passed = *fails == 0 && *passes > 0;
246 feature_status
247 .entry(check_name)
248 .and_modify(|prev| *prev = *prev && passed)
249 .or_insert(passed);
250 }
251 }
252 }
253 }
254
255 for category in OwaspCategory::all() {
256 let id = category.identifier();
257 let name = category.short_name();
258
259 let mut tested = false;
261 let mut all_passed = true;
262 let mut via_categories: HashSet<&str> = HashSet::new();
263
264 for feature in ConformanceFeature::all() {
265 if !feature.related_owasp().contains(&id) {
266 continue;
267 }
268 if let Some(&passed) = feature_status.get(feature.check_name()) {
269 tested = true;
270 if !passed {
271 all_passed = false;
272 }
273 via_categories.insert(feature.category());
274 }
275 }
276
277 let (status, via) = if !tested {
278 ("-".bright_black(), String::new())
279 } else {
280 let mut cats: Vec<&str> = via_categories.into_iter().collect();
281 cats.sort();
282 let via_str = format!(" (via {})", cats.join(", "));
283 if all_passed {
284 ("✓".green(), via_str)
285 } else {
286 ("⚠".yellow(), format!("{} — has failures", via_str))
287 }
288 };
289
290 println!(" {:<12} {:<40} {}{}", id, name, status, via);
291 }
292 }
293
294 pub fn raw_check_results(&self) -> &HashMap<String, (u64, u64)> {
296 &self.check_results
297 }
298
299 pub fn overall_rate(&self) -> f64 {
301 let categories = self.by_category();
302 let total_passed: usize = categories.values().map(|r| r.passed).sum();
303 let total: usize = categories.values().map(|r| r.total()).sum();
304 if total == 0 {
305 0.0
306 } else {
307 (total_passed as f64 / total as f64) * 100.0
308 }
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn test_parse_conformance_report() {
318 let json = r#"{
319 "checks": {
320 "param:path:string": { "passes": 1, "fails": 0 },
321 "param:path:integer": { "passes": 1, "fails": 0 },
322 "body:json": { "passes": 0, "fails": 1 },
323 "method:GET": { "passes": 1, "fails": 0 }
324 },
325 "overall": { "overall_pass_rate": 0.75 }
326 }"#;
327
328 let report = ConformanceReport::from_json(json).unwrap();
329 let categories = report.by_category();
330
331 let params = categories.get("Parameters").unwrap();
332 assert_eq!(params.passed, 2);
333
334 let bodies = categories.get("Request Bodies").unwrap();
335 assert_eq!(bodies.failed, 1);
336 }
337
338 #[test]
339 fn test_empty_report() {
340 let json = r#"{ "checks": {} }"#;
341 let report = ConformanceReport::from_json(json).unwrap();
342 assert_eq!(report.overall_rate(), 0.0);
343 }
344
345 #[test]
346 fn test_owasp_coverage_with_failures() {
347 let json = r#"{
351 "checks": {
352 "response:404": { "passes": 0, "fails": 1 },
353 "body:json": { "passes": 1, "fails": 0 },
354 "method:GET": { "passes": 1, "fails": 0 }
355 },
356 "overall": {}
357 }"#;
358
359 let report = ConformanceReport::from_json(json).unwrap();
360 report.print_report_with_options(false);
362 }
363}