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
31#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33pub struct FailureDetail {
34 pub check: String,
36 pub request: FailureRequest,
38 pub response: FailureResponse,
40 pub expected: String,
42}
43
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct FailureRequest {
47 #[serde(default)]
49 pub method: String,
50 #[serde(default)]
52 pub url: String,
53 #[serde(default, deserialize_with = "deserialize_headers")]
55 pub headers: HashMap<String, String>,
56 #[serde(default)]
58 pub body: String,
59}
60
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct FailureResponse {
64 #[serde(default)]
66 pub status: u16,
67 #[serde(default, deserialize_with = "deserialize_headers")]
69 pub headers: HashMap<String, String>,
70 #[serde(default)]
72 pub body: String,
73}
74
75fn deserialize_headers<'de, D>(
77 deserializer: D,
78) -> std::result::Result<HashMap<String, String>, D::Error>
79where
80 D: serde::Deserializer<'de>,
81{
82 use serde::Deserialize;
83 let map: HashMap<String, serde_json::Value> = HashMap::deserialize(deserializer)?;
84 Ok(map
85 .into_iter()
86 .map(|(k, v)| {
87 let val = match &v {
88 serde_json::Value::String(s) => s.clone(),
89 serde_json::Value::Array(arr) => {
90 arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")
91 }
92 other => other.to_string(),
93 };
94 (k, val)
95 })
96 .collect())
97}
98
99fn extract_custom_base_name(check_name: &str) -> String {
103 let after_prefix = &check_name[7..];
105 if let Some(pos) = after_prefix.find(":header:").or(after_prefix.find(":body:")) {
106 check_name[..7 + pos].to_string()
107 } else {
108 check_name.to_string()
109 }
110}
111
112pub struct ConformanceReport {
114 check_results: HashMap<String, (u64, u64)>,
116 failure_details: Vec<FailureDetail>,
118}
119
120impl ConformanceReport {
121 pub fn from_file(path: &Path) -> Result<Self> {
125 let content = std::fs::read_to_string(path)
126 .map_err(|e| BenchError::Other(format!("Failed to read conformance report: {}", e)))?;
127 let mut report = Self::from_json(&content)?;
128
129 if let Some(parent) = path.parent() {
131 let details_path = parent.join("conformance-failure-details.json");
132 if details_path.exists() {
133 if let Ok(details_json) = std::fs::read_to_string(&details_path) {
134 if let Ok(details) = serde_json::from_str::<Vec<FailureDetail>>(&details_json) {
135 report.failure_details = details;
136 }
137 }
138 }
139 }
140
141 Ok(report)
142 }
143
144 pub fn from_json(json_str: &str) -> Result<Self> {
146 let json: serde_json::Value = serde_json::from_str(json_str)
147 .map_err(|e| BenchError::Other(format!("Failed to parse conformance JSON: {}", e)))?;
148
149 let mut check_results = HashMap::new();
150
151 if let Some(checks) = json.get("checks").and_then(|c| c.as_object()) {
152 for (name, result) in checks {
153 let passes = result.get("passes").and_then(|v| v.as_u64()).unwrap_or(0);
154 let fails = result.get("fails").and_then(|v| v.as_u64()).unwrap_or(0);
155 check_results.insert(name.clone(), (passes, fails));
156 }
157 }
158
159 Ok(Self {
160 check_results,
161 failure_details: Vec::new(),
162 })
163 }
164
165 pub fn by_category(&self) -> HashMap<&'static str, CategoryResult> {
170 let mut categories: HashMap<&'static str, CategoryResult> = HashMap::new();
171
172 for cat in ConformanceFeature::categories() {
174 categories.insert(cat, CategoryResult::default());
175 }
176
177 for feature in ConformanceFeature::all() {
181 let check_name = feature.check_name();
182 let category = feature.category();
183
184 let entry = categories.entry(category).or_default();
185
186 if let Some((passes, fails)) = self.check_results.get(check_name) {
188 if *fails == 0 && *passes > 0 {
189 entry.passed += 1;
190 } else {
191 entry.failed += 1;
192 }
193 } else {
194 let prefix = format!("{}:", check_name);
196 for (name, (passes, fails)) in &self.check_results {
197 if name.starts_with(&prefix) {
198 if *fails == 0 && *passes > 0 {
199 entry.passed += 1;
200 } else {
201 entry.failed += 1;
202 }
203 }
204 }
205 }
207 }
208
209 let custom_entry = categories.entry("Custom").or_default();
211 let mut counted_custom: HashSet<String> = HashSet::new();
213 for (name, (passes, fails)) in &self.check_results {
214 if name.starts_with("custom:") {
215 let base_name = extract_custom_base_name(name);
220 if counted_custom.insert(base_name) {
221 if *fails == 0 && *passes > 0 {
222 custom_entry.passed += 1;
223 } else {
224 custom_entry.failed += 1;
225 }
226 }
227 }
228 }
229
230 categories
231 }
232
233 pub fn print_report(&self) {
235 self.print_report_with_options(false);
236 }
237
238 pub fn print_report_with_options(&self, all_operations: bool) {
240 let categories = self.by_category();
241
242 let total_possible = ConformanceFeature::all().len();
244 let active_cats: usize = ConformanceFeature::categories()
245 .iter()
246 .filter(|c| categories.get(*c).is_some_and(|r| r.total() > 0))
247 .count();
248 let detected: usize =
249 categories.iter().filter(|(k, _)| *k != &"Custom").map(|(_, v)| v.total()).sum();
250
251 println!("\n{}", "OpenAPI 3.0.0 Conformance Report".bold());
252 println!("{}", "=".repeat(64).bright_green());
253
254 println!(
255 "{}",
256 format!(
257 "Spec Analysis: {} of {} features detected across {} categories",
258 detected, total_possible, active_cats
259 )
260 .bright_cyan()
261 );
262 println!();
263
264 println!(
265 "{:<20} {:>8} {:>8} {:>8} {:>8}",
266 "Category".bold(),
267 "Passed".green().bold(),
268 "Failed".red().bold(),
269 "Total".bold(),
270 "Rate".bold()
271 );
272 println!("{}", "-".repeat(64));
273
274 let mut total_passed = 0usize;
275 let mut total_failed = 0usize;
276 let mut empty_categories: Vec<&str> = Vec::new();
277
278 let all_cat_names: Vec<&str> = {
280 let mut cats: Vec<&str> = ConformanceFeature::categories().to_vec();
281 if categories.get("Custom").is_some_and(|r| r.total() > 0) {
282 cats.push("Custom");
283 }
284 cats
285 };
286
287 for cat_name in &all_cat_names {
288 if let Some(result) = categories.get(cat_name) {
289 let total = result.total();
290 if total == 0 {
291 println!(
293 "{:<20} {:>8} {:>8} {:>8} {:>8}",
294 cat_name.bright_black(),
295 "-".bright_black(),
296 "-".bright_black(),
297 "-".bright_black(),
298 "not in spec".bright_black()
299 );
300 empty_categories.push(cat_name);
301 continue;
302 }
303 total_passed += result.passed;
304 total_failed += result.failed;
305
306 let rate_str = format!("{:.0}%", result.rate());
307 let rate_colored = if result.rate() >= 100.0 {
308 rate_str.green()
309 } else if result.rate() >= 80.0 {
310 rate_str.yellow()
311 } else {
312 rate_str.red()
313 };
314
315 println!(
316 "{:<20} {:>8} {:>8} {:>8} {:>8}",
317 cat_name,
318 result.passed.to_string().green(),
319 result.failed.to_string().red(),
320 total,
321 rate_colored
322 );
323 }
324 }
325
326 println!("{}", "=".repeat(64).bright_green());
327
328 let grand_total = total_passed + total_failed;
329 let overall_rate = if grand_total > 0 {
330 (total_passed as f64 / grand_total as f64) * 100.0
331 } else {
332 0.0
333 };
334 let rate_str = format!("{:.0}%", overall_rate);
335 let rate_colored = if overall_rate >= 100.0 {
336 rate_str.green()
337 } else if overall_rate >= 80.0 {
338 rate_str.yellow()
339 } else {
340 rate_str.red()
341 };
342
343 println!(
344 "{:<20} {:>8} {:>8} {:>8} {:>8}",
345 "Total:".bold(),
346 total_passed.to_string().green(),
347 total_failed.to_string().red(),
348 grand_total,
349 rate_colored
350 );
351
352 let failed_checks: Vec<_> =
354 self.check_results.iter().filter(|(_, (_, fails))| *fails > 0).collect();
355
356 if !failed_checks.is_empty() {
357 println!();
358 println!("{}", "Failed Checks:".red().bold());
359 let mut sorted_failures: Vec<_> = failed_checks.into_iter().collect();
360 sorted_failures.sort_by_key(|(name, _)| (*name).clone());
361 for (name, (passes, fails)) in sorted_failures {
362 println!(
363 " {} ({} passed, {} failed)",
364 name.red(),
365 passes.to_string().green(),
366 fails.to_string().red()
367 );
368
369 for detail in &self.failure_details {
371 if detail.check == *name {
372 println!(
373 " {} {} {}",
374 "→".bright_black(),
375 detail.request.method.yellow(),
376 detail.request.url.bright_black()
377 );
378 println!(
379 " Expected: {} Actual status: {}",
380 detail.expected.yellow(),
381 detail.response.status.to_string().red()
382 );
383 if !detail.response.body.is_empty() {
384 let body_preview = if detail.response.body.len() > 200 {
385 format!("{}...", &detail.response.body[..200])
386 } else {
387 detail.response.body.clone()
388 };
389 println!(" Response body: {}", body_preview.bright_black());
390 }
391 }
392 }
393 }
394
395 if !all_operations {
396 println!();
397 println!(
398 "{}",
399 "Tip: Use --conformance-all-operations (without --conformance-categories) to see which specific endpoints failed across all categories."
400 .yellow()
401 );
402 }
403
404 if !self.failure_details.is_empty() {
405 println!();
406 println!(
407 "{}",
408 "Full failure details saved to conformance-report.json (see failure_details array)."
409 .bright_black()
410 );
411 }
412 }
413
414 self.print_owasp_coverage();
416
417 if !empty_categories.is_empty() {
419 println!();
420 println!("{}", "Coverage Tips".bold());
421 println!("{}", "-".repeat(64));
422 for cat in &empty_categories {
423 if *cat == "Custom" {
424 continue;
425 }
426 println!(
427 " {} {}: {}",
428 "->".bright_cyan(),
429 cat,
430 ConformanceFeature::category_hint(cat).bright_black()
431 );
432 }
433 println!();
434 println!(
435 "{}",
436 "Use --conformance-custom <file.yaml> to add custom checks for any category."
437 .bright_black()
438 );
439 }
440
441 println!();
442 }
443
444 fn print_owasp_coverage(&self) {
446 println!();
447 println!("{}", "OWASP API Security Top 10 Coverage".bold());
448 println!("{}", "=".repeat(64).bright_green());
449
450 let mut feature_status: HashMap<&str, bool> = HashMap::new(); for feature in ConformanceFeature::all() {
453 let check_name = feature.check_name();
454
455 if let Some((passes, fails)) = self.check_results.get(check_name) {
457 let passed = *fails == 0 && *passes > 0;
458 feature_status
459 .entry(check_name)
460 .and_modify(|prev| *prev = *prev && passed)
461 .or_insert(passed);
462 } else {
463 let prefix = format!("{}:", check_name);
465 for (name, (passes, fails)) in &self.check_results {
466 if name.starts_with(&prefix) {
467 let passed = *fails == 0 && *passes > 0;
468 feature_status
469 .entry(check_name)
470 .and_modify(|prev| *prev = *prev && passed)
471 .or_insert(passed);
472 }
473 }
474 }
475 }
476
477 for category in OwaspCategory::all() {
478 let id = category.identifier();
479 let name = category.short_name();
480
481 let mut tested = false;
483 let mut all_passed = true;
484 let mut via_categories: HashSet<&str> = HashSet::new();
485
486 for feature in ConformanceFeature::all() {
487 if !feature.related_owasp().contains(&id) {
488 continue;
489 }
490 if let Some(&passed) = feature_status.get(feature.check_name()) {
491 tested = true;
492 if !passed {
493 all_passed = false;
494 }
495 via_categories.insert(feature.category());
496 }
497 }
498
499 let (status, via) = if !tested {
500 ("-".bright_black(), String::new())
501 } else {
502 let mut cats: Vec<&str> = via_categories.into_iter().collect();
503 cats.sort();
504 let via_str = format!(" (via {})", cats.join(", "));
505 if all_passed {
506 ("✓".green(), via_str)
507 } else {
508 ("⚠".yellow(), format!("{} — has failures", via_str))
509 }
510 };
511
512 println!(" {:<12} {:<40} {}{}", id, name, status, via);
513 }
514 }
515
516 pub fn raw_check_results(&self) -> &HashMap<String, (u64, u64)> {
518 &self.check_results
519 }
520
521 pub fn overall_rate(&self) -> f64 {
523 let categories = self.by_category();
524 let total_passed: usize = categories.values().map(|r| r.passed).sum();
525 let total: usize = categories.values().map(|r| r.total()).sum();
526 if total == 0 {
527 0.0
528 } else {
529 (total_passed as f64 / total as f64) * 100.0
530 }
531 }
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 #[test]
539 fn test_parse_conformance_report() {
540 let json = r#"{
541 "checks": {
542 "param:path:string": { "passes": 1, "fails": 0 },
543 "param:path:integer": { "passes": 1, "fails": 0 },
544 "body:json": { "passes": 0, "fails": 1 },
545 "method:GET": { "passes": 1, "fails": 0 }
546 },
547 "overall": { "overall_pass_rate": 0.75 }
548 }"#;
549
550 let report = ConformanceReport::from_json(json).unwrap();
551 let categories = report.by_category();
552
553 let params = categories.get("Parameters").unwrap();
554 assert_eq!(params.passed, 2);
555
556 let bodies = categories.get("Request Bodies").unwrap();
557 assert_eq!(bodies.failed, 1);
558 }
559
560 #[test]
561 fn test_empty_report() {
562 let json = r#"{ "checks": {} }"#;
563 let report = ConformanceReport::from_json(json).unwrap();
564 assert_eq!(report.overall_rate(), 0.0);
565 }
566
567 #[test]
568 fn test_owasp_coverage_with_failures() {
569 let json = r#"{
573 "checks": {
574 "response:404": { "passes": 0, "fails": 1 },
575 "body:json": { "passes": 1, "fails": 0 },
576 "method:GET": { "passes": 1, "fails": 0 }
577 },
578 "overall": {}
579 }"#;
580
581 let report = ConformanceReport::from_json(json).unwrap();
582 report.print_report_with_options(false);
584 }
585}