1use 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_results(
124 check_results: HashMap<String, (u64, u64)>,
125 failure_details: Vec<FailureDetail>,
126 ) -> Self {
127 Self {
128 check_results,
129 failure_details,
130 }
131 }
132
133 pub fn to_json(&self) -> serde_json::Value {
138 let mut checks = serde_json::Map::new();
139 for (name, (passes, fails)) in &self.check_results {
140 checks.insert(
141 name.clone(),
142 serde_json::json!({
143 "passes": passes,
144 "fails": fails,
145 }),
146 );
147 }
148
149 let by_cat = self.by_category();
151 let mut categories_json = serde_json::Map::new();
152 for (cat_name, cat_result) in &by_cat {
153 categories_json.insert(
154 (*cat_name).to_string(),
155 serde_json::json!({
156 "passed": cat_result.passed,
157 "total": cat_result.total(),
158 "rate": cat_result.rate(),
159 }),
160 );
161 }
162
163 let total_passed: usize = by_cat.values().map(|r| r.passed).sum();
165 let total: usize = by_cat.values().map(|r| r.total()).sum();
166 let overall_rate = if total == 0 {
167 0.0
168 } else {
169 (total_passed as f64 / total as f64) * 100.0
170 };
171
172 let failures: Vec<serde_json::Value> = self
174 .failure_details
175 .iter()
176 .map(|d| {
177 let category = Self::category_for_check(&d.check);
178 serde_json::json!({
179 "check_name": d.check,
180 "category": category,
181 "expected": d.expected,
182 "actual": format!("status {}", d.response.status),
183 "details": format!("{} {}", d.request.method, d.request.url),
184 })
185 })
186 .collect();
187
188 let mut result = serde_json::json!({
189 "checks": checks,
190 "summary": {
191 "total_checks": total,
192 "passed": total_passed,
193 "failed": total - total_passed,
194 "overall_rate": overall_rate,
195 },
196 "categories": categories_json,
197 "failures": failures,
198 });
199
200 if !self.failure_details.is_empty() {
202 result["failure_details"] = serde_json::to_value(&self.failure_details)
203 .unwrap_or(serde_json::Value::Array(Vec::new()));
204 }
205 result
206 }
207
208 fn category_for_check(check_name: &str) -> &'static str {
210 let prefix = check_name.split(':').next().unwrap_or("");
211 match prefix {
212 "param" => "Parameters",
213 "body" => "Request Bodies",
214 "response" => "Response Codes",
215 "schema" => "Schema Types",
216 "compose" => "Composition",
217 "format" => "String Formats",
218 "constraint" => "Constraints",
219 "security" => "Security",
220 "method" => "HTTP Methods",
221 "content" => "Content Types",
222 "validation" | "response_validation" => "Response Validation",
223 "custom" => "Custom",
224 _ => "Other",
225 }
226 }
227
228 pub fn failure_details(&self) -> &[FailureDetail] {
230 &self.failure_details
231 }
232
233 pub fn from_file(path: &Path) -> Result<Self> {
237 let content = std::fs::read_to_string(path)
238 .map_err(|e| BenchError::Other(format!("Failed to read conformance report: {}", e)))?;
239 let mut report = Self::from_json(&content)?;
240
241 if let Some(parent) = path.parent() {
243 let details_path = parent.join("conformance-failure-details.json");
244 if details_path.exists() {
245 if let Ok(details_json) = std::fs::read_to_string(&details_path) {
246 if let Ok(details) = serde_json::from_str::<Vec<FailureDetail>>(&details_json) {
247 report.failure_details = details;
248 }
249 }
250 }
251 }
252
253 Ok(report)
254 }
255
256 pub fn from_json(json_str: &str) -> Result<Self> {
258 let json: serde_json::Value = serde_json::from_str(json_str)
259 .map_err(|e| BenchError::Other(format!("Failed to parse conformance JSON: {}", e)))?;
260
261 let mut check_results = HashMap::new();
262
263 if let Some(checks) = json.get("checks").and_then(|c| c.as_object()) {
264 for (name, result) in checks {
265 let passes = result.get("passes").and_then(|v| v.as_u64()).unwrap_or(0);
266 let fails = result.get("fails").and_then(|v| v.as_u64()).unwrap_or(0);
267 check_results.insert(name.clone(), (passes, fails));
268 }
269 }
270
271 Ok(Self {
272 check_results,
273 failure_details: Vec::new(),
274 })
275 }
276
277 pub fn by_category(&self) -> HashMap<&'static str, CategoryResult> {
282 let mut categories: HashMap<&'static str, CategoryResult> = HashMap::new();
283
284 for cat in ConformanceFeature::categories() {
286 categories.insert(cat, CategoryResult::default());
287 }
288
289 for feature in ConformanceFeature::all() {
293 let check_name = feature.check_name();
294 let category = feature.category();
295
296 let entry = categories.entry(category).or_default();
297
298 if let Some((passes, fails)) = self.check_results.get(check_name) {
300 if *fails == 0 && *passes > 0 {
301 entry.passed += 1;
302 } else {
303 entry.failed += 1;
304 }
305 } else {
306 let prefix = format!("{}:", check_name);
308 for (name, (passes, fails)) in &self.check_results {
309 if name.starts_with(&prefix) {
310 if *fails == 0 && *passes > 0 {
311 entry.passed += 1;
312 } else {
313 entry.failed += 1;
314 }
315 }
316 }
317 }
319 }
320
321 let custom_entry = categories.entry("Custom").or_default();
323 let mut counted_custom: HashSet<String> = HashSet::new();
325 for (name, (passes, fails)) in &self.check_results {
326 if name.starts_with("custom:") {
327 let base_name = extract_custom_base_name(name);
332 if counted_custom.insert(base_name) {
333 if *fails == 0 && *passes > 0 {
334 custom_entry.passed += 1;
335 } else {
336 custom_entry.failed += 1;
337 }
338 }
339 }
340 }
341
342 categories
343 }
344
345 pub fn print_report(&self) {
347 self.print_report_with_options(false);
348 }
349
350 pub fn print_report_with_options(&self, all_operations: bool) {
352 let categories = self.by_category();
353
354 let total_possible = ConformanceFeature::all().len();
356 let active_cats: usize = ConformanceFeature::categories()
357 .iter()
358 .filter(|c| categories.get(*c).is_some_and(|r| r.total() > 0))
359 .count();
360 let detected: usize =
361 categories.iter().filter(|(k, _)| *k != &"Custom").map(|(_, v)| v.total()).sum();
362
363 println!("\n{}", "OpenAPI 3.0.0 Conformance Report".bold());
364 println!("{}", "=".repeat(64).bright_green());
365
366 println!(
367 "{}",
368 format!(
369 "Spec Analysis: {} of {} features detected across {} categories",
370 detected, total_possible, active_cats
371 )
372 .bright_cyan()
373 );
374 println!();
375
376 println!(
377 "{:<20} {:>8} {:>8} {:>8} {:>8}",
378 "Category".bold(),
379 "Passed".green().bold(),
380 "Failed".red().bold(),
381 "Total".bold(),
382 "Rate".bold()
383 );
384 println!("{}", "-".repeat(64));
385
386 let mut total_passed = 0usize;
387 let mut total_failed = 0usize;
388 let mut empty_categories: Vec<&str> = Vec::new();
389
390 let all_cat_names: Vec<&str> = {
392 let mut cats: Vec<&str> = ConformanceFeature::categories().to_vec();
393 if categories.get("Custom").is_some_and(|r| r.total() > 0) {
394 cats.push("Custom");
395 }
396 cats
397 };
398
399 for cat_name in &all_cat_names {
400 if let Some(result) = categories.get(cat_name) {
401 let total = result.total();
402 if total == 0 {
403 println!(
405 "{:<20} {:>8} {:>8} {:>8} {:>8}",
406 cat_name.bright_black(),
407 "-".bright_black(),
408 "-".bright_black(),
409 "-".bright_black(),
410 "not in spec".bright_black()
411 );
412 empty_categories.push(cat_name);
413 continue;
414 }
415 total_passed += result.passed;
416 total_failed += result.failed;
417
418 let rate_str = format!("{:.0}%", result.rate());
419 let rate_colored = if result.rate() >= 100.0 {
420 rate_str.green()
421 } else if result.rate() >= 80.0 {
422 rate_str.yellow()
423 } else {
424 rate_str.red()
425 };
426
427 println!(
428 "{:<20} {:>8} {:>8} {:>8} {:>8}",
429 cat_name,
430 result.passed.to_string().green(),
431 result.failed.to_string().red(),
432 total,
433 rate_colored
434 );
435 }
436 }
437
438 println!("{}", "=".repeat(64).bright_green());
439
440 let grand_total = total_passed + total_failed;
441 let overall_rate = if grand_total > 0 {
442 (total_passed as f64 / grand_total as f64) * 100.0
443 } else {
444 0.0
445 };
446 let rate_str = format!("{:.0}%", overall_rate);
447 let rate_colored = if overall_rate >= 100.0 {
448 rate_str.green()
449 } else if overall_rate >= 80.0 {
450 rate_str.yellow()
451 } else {
452 rate_str.red()
453 };
454
455 println!(
456 "{:<20} {:>8} {:>8} {:>8} {:>8}",
457 "Total:".bold(),
458 total_passed.to_string().green(),
459 total_failed.to_string().red(),
460 grand_total,
461 rate_colored
462 );
463
464 let failed_checks: Vec<_> =
466 self.check_results.iter().filter(|(_, (_, fails))| *fails > 0).collect();
467
468 if !failed_checks.is_empty() {
469 println!();
470 println!("{}", "Failed Checks:".red().bold());
471 let mut sorted_failures: Vec<_> = failed_checks.into_iter().collect();
472 sorted_failures.sort_by_key(|(name, _)| (*name).clone());
473 for (name, (passes, fails)) in sorted_failures {
474 println!(
475 " {} ({} passed, {} failed)",
476 name.red(),
477 passes.to_string().green(),
478 fails.to_string().red()
479 );
480
481 for detail in &self.failure_details {
483 if detail.check == *name {
484 println!(
485 " {} {} {}",
486 "→".bright_black(),
487 detail.request.method.yellow(),
488 detail.request.url.bright_black()
489 );
490 println!(
491 " Expected: {} Actual status: {}",
492 detail.expected.yellow(),
493 detail.response.status.to_string().red()
494 );
495 if !detail.response.body.is_empty() {
496 let body_preview = if detail.response.body.len() > 200 {
497 format!("{}...", &detail.response.body[..200])
498 } else {
499 detail.response.body.clone()
500 };
501 println!(" Response body: {}", body_preview.bright_black());
502 }
503 }
504 }
505 }
506
507 if !all_operations {
508 println!();
509 println!(
510 "{}",
511 "Tip: Use --conformance-all-operations (without --conformance-categories) to see which specific endpoints failed across all categories."
512 .yellow()
513 );
514 }
515
516 if !self.failure_details.is_empty() {
517 println!();
518 println!(
519 "{}",
520 "Full failure details saved to conformance-report.json (see failure_details array)."
521 .bright_black()
522 );
523 }
524 }
525
526 self.print_owasp_coverage();
528
529 if !empty_categories.is_empty() {
531 println!();
532 println!("{}", "Coverage Tips".bold());
533 println!("{}", "-".repeat(64));
534 for cat in &empty_categories {
535 if *cat == "Custom" {
536 continue;
537 }
538 println!(
539 " {} {}: {}",
540 "->".bright_cyan(),
541 cat,
542 ConformanceFeature::category_hint(cat).bright_black()
543 );
544 }
545 println!();
546 println!(
547 "{}",
548 "Use --conformance-custom <file.yaml> to add custom checks for any category."
549 .bright_black()
550 );
551 }
552
553 println!();
554 }
555
556 fn print_owasp_coverage(&self) {
558 println!();
559 println!("{}", "OWASP API Security Top 10 Coverage".bold());
560 println!("{}", "=".repeat(64).bright_green());
561
562 let mut feature_status: HashMap<&str, bool> = HashMap::new(); for feature in ConformanceFeature::all() {
565 let check_name = feature.check_name();
566
567 if let Some((passes, fails)) = self.check_results.get(check_name) {
569 let passed = *fails == 0 && *passes > 0;
570 feature_status
571 .entry(check_name)
572 .and_modify(|prev| *prev = *prev && passed)
573 .or_insert(passed);
574 } else {
575 let prefix = format!("{}:", check_name);
577 for (name, (passes, fails)) in &self.check_results {
578 if name.starts_with(&prefix) {
579 let passed = *fails == 0 && *passes > 0;
580 feature_status
581 .entry(check_name)
582 .and_modify(|prev| *prev = *prev && passed)
583 .or_insert(passed);
584 }
585 }
586 }
587 }
588
589 for category in OwaspCategory::all() {
590 let id = category.identifier();
591 let name = category.short_name();
592
593 let mut tested = false;
595 let mut all_passed = true;
596 let mut via_categories: HashSet<&str> = HashSet::new();
597
598 for feature in ConformanceFeature::all() {
599 if !feature.related_owasp().contains(&id) {
600 continue;
601 }
602 if let Some(&passed) = feature_status.get(feature.check_name()) {
603 tested = true;
604 if !passed {
605 all_passed = false;
606 }
607 via_categories.insert(feature.category());
608 }
609 }
610
611 let (status, via) = if !tested {
612 ("-".bright_black(), String::new())
613 } else {
614 let mut cats: Vec<&str> = via_categories.into_iter().collect();
615 cats.sort();
616 let via_str = format!(" (via {})", cats.join(", "));
617 if all_passed {
618 ("✓".green(), via_str)
619 } else {
620 ("⚠".yellow(), format!("{} — has failures", via_str))
621 }
622 };
623
624 println!(" {:<12} {:<40} {}{}", id, name, status, via);
625 }
626 }
627
628 pub fn raw_check_results(&self) -> &HashMap<String, (u64, u64)> {
630 &self.check_results
631 }
632
633 pub fn overall_rate(&self) -> f64 {
635 let categories = self.by_category();
636 let total_passed: usize = categories.values().map(|r| r.passed).sum();
637 let total: usize = categories.values().map(|r| r.total()).sum();
638 if total == 0 {
639 0.0
640 } else {
641 (total_passed as f64 / total as f64) * 100.0
642 }
643 }
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 #[test]
651 fn test_parse_conformance_report() {
652 let json = r#"{
653 "checks": {
654 "param:path:string": { "passes": 1, "fails": 0 },
655 "param:path:integer": { "passes": 1, "fails": 0 },
656 "body:json": { "passes": 0, "fails": 1 },
657 "method:GET": { "passes": 1, "fails": 0 }
658 },
659 "overall": { "overall_pass_rate": 0.75 }
660 }"#;
661
662 let report = ConformanceReport::from_json(json).unwrap();
663 let categories = report.by_category();
664
665 let params = categories.get("Parameters").unwrap();
666 assert_eq!(params.passed, 2);
667
668 let bodies = categories.get("Request Bodies").unwrap();
669 assert_eq!(bodies.failed, 1);
670 }
671
672 #[test]
673 fn test_empty_report() {
674 let json = r#"{ "checks": {} }"#;
675 let report = ConformanceReport::from_json(json).unwrap();
676 assert_eq!(report.overall_rate(), 0.0);
677 }
678
679 #[test]
680 fn test_owasp_coverage_with_failures() {
681 let json = r#"{
685 "checks": {
686 "response:404": { "passes": 0, "fails": 1 },
687 "body:json": { "passes": 1, "fails": 0 },
688 "method:GET": { "passes": 1, "fails": 0 }
689 },
690 "overall": {}
691 }"#;
692
693 let report = ConformanceReport::from_json(json).unwrap();
694 report.print_report_with_options(false);
696 }
697}