1use super::executor::SchemaViolation;
4use super::spec::ConformanceFeature;
5use crate::error::{BenchError, Result};
6use crate::owasp_api::categories::OwaspCategory;
7use colored::*;
8use std::collections::{HashMap, HashSet};
9use std::path::Path;
10
11#[derive(Debug, Clone, Default)]
13pub struct CategoryResult {
14 pub passed: usize,
15 pub failed: usize,
16}
17
18impl CategoryResult {
19 pub fn total(&self) -> usize {
20 self.passed + self.failed
21 }
22
23 pub fn rate(&self) -> f64 {
24 if self.total() == 0 {
25 0.0
26 } else {
27 (self.passed as f64 / self.total() as f64) * 100.0
28 }
29 }
30}
31
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
34pub struct FailureDetail {
35 pub check: String,
37 pub request: FailureRequest,
39 pub response: FailureResponse,
41 pub expected: String,
43 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 pub schema_violations: Vec<SchemaViolation>,
46}
47
48#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
50pub struct FailureRequest {
51 #[serde(default)]
53 pub method: String,
54 #[serde(default)]
56 pub url: String,
57 #[serde(default, deserialize_with = "deserialize_headers")]
59 pub headers: HashMap<String, String>,
60 #[serde(default)]
62 pub body: String,
63}
64
65#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
67pub struct FailureResponse {
68 #[serde(default)]
70 pub status: u16,
71 #[serde(default, deserialize_with = "deserialize_headers")]
73 pub headers: HashMap<String, String>,
74 #[serde(default)]
76 pub body: String,
77}
78
79fn deserialize_headers<'de, D>(
81 deserializer: D,
82) -> std::result::Result<HashMap<String, String>, D::Error>
83where
84 D: serde::Deserializer<'de>,
85{
86 use serde::Deserialize;
87 let map: HashMap<String, serde_json::Value> = HashMap::deserialize(deserializer)?;
88 Ok(map
89 .into_iter()
90 .map(|(k, v)| {
91 let val = match &v {
92 serde_json::Value::String(s) => s.clone(),
93 serde_json::Value::Array(arr) => {
94 arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")
95 }
96 other => other.to_string(),
97 };
98 (k, val)
99 })
100 .collect())
101}
102
103fn extract_custom_base_name(check_name: &str) -> String {
107 let after_prefix = &check_name[7..];
109 if let Some(pos) = after_prefix.find(":header:").or(after_prefix.find(":body:")) {
110 check_name[..7 + pos].to_string()
111 } else {
112 check_name.to_string()
113 }
114}
115
116pub struct ConformanceReport {
118 check_results: HashMap<String, (u64, u64)>,
120 failure_details: Vec<FailureDetail>,
122}
123
124impl ConformanceReport {
125 pub fn from_results(
128 check_results: HashMap<String, (u64, u64)>,
129 failure_details: Vec<FailureDetail>,
130 ) -> Self {
131 Self {
132 check_results,
133 failure_details,
134 }
135 }
136
137 pub fn to_json(&self) -> serde_json::Value {
142 let mut checks = serde_json::Map::new();
143 for (name, (passes, fails)) in &self.check_results {
144 checks.insert(
145 name.clone(),
146 serde_json::json!({
147 "passes": passes,
148 "fails": fails,
149 }),
150 );
151 }
152
153 let by_cat = self.by_category();
155 let mut categories_json = serde_json::Map::new();
156 for (cat_name, cat_result) in &by_cat {
157 categories_json.insert(
158 (*cat_name).to_string(),
159 serde_json::json!({
160 "passed": cat_result.passed,
161 "total": cat_result.total(),
162 "rate": cat_result.rate(),
163 }),
164 );
165 }
166
167 let total_passed: usize = by_cat.values().map(|r| r.passed).sum();
169 let total: usize = by_cat.values().map(|r| r.total()).sum();
170 let overall_rate = if total == 0 {
171 0.0
172 } else {
173 (total_passed as f64 / total as f64) * 100.0
174 };
175
176 let failures: Vec<serde_json::Value> = self
178 .failure_details
179 .iter()
180 .map(|d| {
181 let category = Self::category_for_check(&d.check);
182 serde_json::json!({
183 "check_name": d.check,
184 "category": category,
185 "expected": d.expected,
186 "actual": format!("status {}", d.response.status),
187 "details": format!("{} {}", d.request.method, d.request.url),
188 })
189 })
190 .collect();
191
192 let mut result = serde_json::json!({
193 "checks": checks,
194 "summary": {
195 "total_checks": total,
196 "passed": total_passed,
197 "failed": total - total_passed,
198 "overall_rate": overall_rate,
199 },
200 "categories": categories_json,
201 "failures": failures,
202 });
203
204 if !self.failure_details.is_empty() {
206 result["failure_details"] = serde_json::to_value(&self.failure_details)
207 .unwrap_or(serde_json::Value::Array(Vec::new()));
208 }
209 result
210 }
211
212 fn category_for_check(check_name: &str) -> &'static str {
214 let prefix = check_name.split(':').next().unwrap_or("");
215 match prefix {
216 "param" => "Parameters",
217 "body" => "Request Bodies",
218 "response" => "Response Codes",
219 "schema" => "Schema Types",
220 "compose" => "Composition",
221 "format" => "String Formats",
222 "constraint" => "Constraints",
223 "security" => "Security",
224 "method" => "HTTP Methods",
225 "content" => "Content Types",
226 "validation" | "response_validation" => "Response Validation",
227 "custom" => "Custom",
228 _ => "Other",
229 }
230 }
231
232 pub fn failure_details(&self) -> &[FailureDetail] {
234 &self.failure_details
235 }
236
237 pub fn from_file(path: &Path) -> Result<Self> {
241 let content = std::fs::read_to_string(path)
242 .map_err(|e| BenchError::Other(format!("Failed to read conformance report: {}", e)))?;
243 let mut report = Self::from_json(&content)?;
244
245 if let Some(parent) = path.parent() {
247 let details_path = parent.join("conformance-failure-details.json");
248 if details_path.exists() {
249 if let Ok(details_json) = std::fs::read_to_string(&details_path) {
250 if let Ok(details) = serde_json::from_str::<Vec<FailureDetail>>(&details_json) {
251 report.failure_details = details;
252 }
253 }
254 }
255 }
256
257 Ok(report)
258 }
259
260 pub fn from_json(json_str: &str) -> Result<Self> {
262 let json: serde_json::Value = serde_json::from_str(json_str)
263 .map_err(|e| BenchError::Other(format!("Failed to parse conformance JSON: {}", e)))?;
264
265 let mut check_results = HashMap::new();
266
267 if let Some(checks) = json.get("checks").and_then(|c| c.as_object()) {
268 for (name, result) in checks {
269 let passes = result.get("passes").and_then(|v| v.as_u64()).unwrap_or(0);
270 let fails = result.get("fails").and_then(|v| v.as_u64()).unwrap_or(0);
271 check_results.insert(name.clone(), (passes, fails));
272 }
273 }
274
275 Ok(Self {
276 check_results,
277 failure_details: Vec::new(),
278 })
279 }
280
281 pub fn by_category(&self) -> HashMap<&'static str, CategoryResult> {
286 let mut categories: HashMap<&'static str, CategoryResult> = HashMap::new();
287
288 for cat in ConformanceFeature::categories() {
290 categories.insert(cat, CategoryResult::default());
291 }
292
293 for feature in ConformanceFeature::all() {
297 let check_name = feature.check_name();
298 let category = feature.category();
299
300 let entry = categories.entry(category).or_default();
301
302 if let Some((passes, fails)) = self.check_results.get(check_name) {
304 if *fails == 0 && *passes > 0 {
305 entry.passed += 1;
306 } else {
307 entry.failed += 1;
308 }
309 } else {
310 let prefix = format!("{}:", check_name);
312 for (name, (passes, fails)) in &self.check_results {
313 if name.starts_with(&prefix) {
314 if *fails == 0 && *passes > 0 {
315 entry.passed += 1;
316 } else {
317 entry.failed += 1;
318 }
319 }
320 }
321 }
323 }
324
325 let custom_entry = categories.entry("Custom").or_default();
327 let mut counted_custom: HashSet<String> = HashSet::new();
329 for (name, (passes, fails)) in &self.check_results {
330 if name.starts_with("custom:") {
331 let base_name = extract_custom_base_name(name);
336 if counted_custom.insert(base_name) {
337 if *fails == 0 && *passes > 0 {
338 custom_entry.passed += 1;
339 } else {
340 custom_entry.failed += 1;
341 }
342 }
343 }
344 }
345
346 categories
347 }
348
349 pub fn print_report(&self) {
351 self.print_report_with_options(false);
352 }
353
354 pub fn print_report_with_options(&self, all_operations: bool) {
356 let categories = self.by_category();
357
358 let total_possible = ConformanceFeature::all().len();
360 let active_cats: usize = ConformanceFeature::categories()
361 .iter()
362 .filter(|c| categories.get(*c).is_some_and(|r| r.total() > 0))
363 .count();
364 let detected: usize =
365 categories.iter().filter(|(k, _)| *k != &"Custom").map(|(_, v)| v.total()).sum();
366
367 println!("\n{}", "OpenAPI 3.0.0 Conformance Report".bold());
368 println!("{}", "=".repeat(64).bright_green());
369
370 println!(
371 "{}",
372 format!(
373 "Spec Analysis: {} of {} features detected across {} categories",
374 detected, total_possible, active_cats
375 )
376 .bright_cyan()
377 );
378 println!();
379
380 println!(
381 "{:<20} {:>8} {:>8} {:>8} {:>8}",
382 "Category".bold(),
383 "Passed".green().bold(),
384 "Failed".red().bold(),
385 "Total".bold(),
386 "Rate".bold()
387 );
388 println!("{}", "-".repeat(64));
389
390 let mut total_passed = 0usize;
391 let mut total_failed = 0usize;
392 let mut empty_categories: Vec<&str> = Vec::new();
393
394 let all_cat_names: Vec<&str> = {
396 let mut cats: Vec<&str> = ConformanceFeature::categories().to_vec();
397 if categories.get("Custom").is_some_and(|r| r.total() > 0) {
398 cats.push("Custom");
399 }
400 cats
401 };
402
403 for cat_name in &all_cat_names {
404 if let Some(result) = categories.get(cat_name) {
405 let total = result.total();
406 if total == 0 {
407 println!(
409 "{:<20} {:>8} {:>8} {:>8} {:>8}",
410 cat_name.bright_black(),
411 "-".bright_black(),
412 "-".bright_black(),
413 "-".bright_black(),
414 "not in spec".bright_black()
415 );
416 empty_categories.push(cat_name);
417 continue;
418 }
419 total_passed += result.passed;
420 total_failed += result.failed;
421
422 let rate_str = format!("{:.0}%", result.rate());
423 let rate_colored = if result.rate() >= 100.0 {
424 rate_str.green()
425 } else if result.rate() >= 80.0 {
426 rate_str.yellow()
427 } else {
428 rate_str.red()
429 };
430
431 println!(
432 "{:<20} {:>8} {:>8} {:>8} {:>8}",
433 cat_name,
434 result.passed.to_string().green(),
435 result.failed.to_string().red(),
436 total,
437 rate_colored
438 );
439 }
440 }
441
442 println!("{}", "=".repeat(64).bright_green());
443
444 let grand_total = total_passed + total_failed;
445 let overall_rate = if grand_total > 0 {
446 (total_passed as f64 / grand_total as f64) * 100.0
447 } else {
448 0.0
449 };
450 let rate_str = format!("{:.0}%", overall_rate);
451 let rate_colored = if overall_rate >= 100.0 {
452 rate_str.green()
453 } else if overall_rate >= 80.0 {
454 rate_str.yellow()
455 } else {
456 rate_str.red()
457 };
458
459 println!(
460 "{:<20} {:>8} {:>8} {:>8} {:>8}",
461 "Total:".bold(),
462 total_passed.to_string().green(),
463 total_failed.to_string().red(),
464 grand_total,
465 rate_colored
466 );
467
468 let failed_checks: Vec<_> =
470 self.check_results.iter().filter(|(_, (_, fails))| *fails > 0).collect();
471
472 if !failed_checks.is_empty() {
473 println!();
474 println!("{}", "Failed Checks:".red().bold());
475 let mut sorted_failures: Vec<_> = failed_checks.into_iter().collect();
476 sorted_failures.sort_by_key(|(name, _)| (*name).clone());
477 for (name, (passes, fails)) in sorted_failures {
478 println!(
479 " {} ({} passed, {} failed)",
480 name.red(),
481 passes.to_string().green(),
482 fails.to_string().red()
483 );
484
485 for detail in &self.failure_details {
487 if detail.check == *name {
488 println!(
489 " {} {} {}",
490 "→".bright_black(),
491 detail.request.method.yellow(),
492 detail.request.url.bright_black()
493 );
494 println!(
495 " Expected: {} Actual status: {}",
496 detail.expected.yellow(),
497 detail.response.status.to_string().red()
498 );
499 if !detail.response.body.is_empty() {
500 let body_preview = if detail.response.body.len() > 200 {
501 format!("{}...", &detail.response.body[..200])
502 } else {
503 detail.response.body.clone()
504 };
505 println!(" Response body: {}", body_preview.bright_black());
506 }
507 if !detail.schema_violations.is_empty() {
509 println!(
510 " {} ({} violation{}):",
511 "Schema violations".red(),
512 detail.schema_violations.len(),
513 if detail.schema_violations.len() == 1 {
514 ""
515 } else {
516 "s"
517 }
518 );
519 for violation in &detail.schema_violations {
520 println!(
521 " {} {}: {} (expected: {}, actual: {})",
522 "·".bright_black(),
523 violation.field_path.yellow(),
524 violation.violation_type.red(),
525 violation.expected.bright_black(),
526 violation.actual.bright_black()
527 );
528 }
529 }
530 }
531 }
532 }
533
534 if !all_operations {
535 println!();
536 println!(
537 "{}",
538 "Tip: Use --conformance-all-operations (without --conformance-categories) to see which specific endpoints failed across all categories."
539 .yellow()
540 );
541 }
542
543 if !self.failure_details.is_empty() {
544 println!();
545 println!(
546 "{}",
547 "Full failure details saved to conformance-report.json (see failure_details array)."
548 .bright_black()
549 );
550 }
551
552 let rate_limited_count =
554 self.failure_details.iter().filter(|d| d.response.status == 429).count();
555 if rate_limited_count > 0 {
556 println!();
557 println!(
558 "{}",
559 format!(
560 "Warning: {} check(s) received HTTP 429 (Too Many Requests) from the target API.",
561 rate_limited_count
562 )
563 .yellow()
564 .bold()
565 );
566 println!(
567 "{}",
568 "The target server is rate-limiting requests. Use --conformance-delay <ms> to add a pause"
569 .yellow()
570 );
571 println!(
572 "{}",
573 "between requests (e.g., --conformance-delay 200 for 200ms between each check)."
574 .yellow()
575 );
576 }
577
578 println!();
580 println!(
581 "{}",
582 "Note: \"Failed\" = the API response did not match the expected conformance check"
583 .bright_black()
584 );
585 println!(
586 "{}",
587 "(e.g., wrong status code, missing field). These are NOT infrastructure errors."
588 .bright_black()
589 );
590 }
591
592 self.print_owasp_coverage();
594
595 if !empty_categories.is_empty() {
597 println!();
598 println!("{}", "Coverage Tips".bold());
599 println!("{}", "-".repeat(64));
600 for cat in &empty_categories {
601 if *cat == "Custom" {
602 continue;
603 }
604 println!(
605 " {} {}: {}",
606 "->".bright_cyan(),
607 cat,
608 ConformanceFeature::category_hint(cat).bright_black()
609 );
610 }
611 println!();
612 println!(
613 "{}",
614 "Use --conformance-custom <file.yaml> to add custom checks for any category."
615 .bright_black()
616 );
617 }
618
619 println!();
620 }
621
622 fn print_owasp_coverage(&self) {
624 println!();
625 println!("{}", "OWASP API Security Top 10 Coverage".bold());
626 println!("{}", "=".repeat(64).bright_green());
627
628 let mut feature_status: HashMap<&str, bool> = HashMap::new(); for feature in ConformanceFeature::all() {
631 let check_name = feature.check_name();
632
633 if let Some((passes, fails)) = self.check_results.get(check_name) {
635 let passed = *fails == 0 && *passes > 0;
636 feature_status
637 .entry(check_name)
638 .and_modify(|prev| *prev = *prev && passed)
639 .or_insert(passed);
640 } else {
641 let prefix = format!("{}:", check_name);
643 for (name, (passes, fails)) in &self.check_results {
644 if name.starts_with(&prefix) {
645 let passed = *fails == 0 && *passes > 0;
646 feature_status
647 .entry(check_name)
648 .and_modify(|prev| *prev = *prev && passed)
649 .or_insert(passed);
650 }
651 }
652 }
653 }
654
655 for category in OwaspCategory::all() {
656 let id = category.identifier();
657 let name = category.short_name();
658
659 let mut tested = false;
661 let mut all_passed = true;
662 let mut via_categories: HashSet<&str> = HashSet::new();
663
664 for feature in ConformanceFeature::all() {
665 if !feature.related_owasp().contains(&id) {
666 continue;
667 }
668 if let Some(&passed) = feature_status.get(feature.check_name()) {
669 tested = true;
670 if !passed {
671 all_passed = false;
672 }
673 via_categories.insert(feature.category());
674 }
675 }
676
677 let (status, via) = if !tested {
678 ("-".bright_black(), String::new())
679 } else {
680 let mut cats: Vec<&str> = via_categories.into_iter().collect();
681 cats.sort();
682 let via_str = format!(" (via {})", cats.join(", "));
683 if all_passed {
684 ("✓".green(), via_str)
685 } else {
686 ("⚠".yellow(), format!("{} — has failures", via_str))
687 }
688 };
689
690 println!(" {:<12} {:<40} {}{}", id, name, status, via);
691 }
692 }
693
694 pub fn raw_check_results(&self) -> &HashMap<String, (u64, u64)> {
696 &self.check_results
697 }
698
699 pub fn overall_rate(&self) -> f64 {
701 let categories = self.by_category();
702 let total_passed: usize = categories.values().map(|r| r.passed).sum();
703 let total: usize = categories.values().map(|r| r.total()).sum();
704 if total == 0 {
705 0.0
706 } else {
707 (total_passed as f64 / total as f64) * 100.0
708 }
709 }
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715
716 #[test]
717 fn test_parse_conformance_report() {
718 let json = r#"{
719 "checks": {
720 "param:path:string": { "passes": 1, "fails": 0 },
721 "param:path:integer": { "passes": 1, "fails": 0 },
722 "body:json": { "passes": 0, "fails": 1 },
723 "method:GET": { "passes": 1, "fails": 0 }
724 },
725 "overall": { "overall_pass_rate": 0.75 }
726 }"#;
727
728 let report = ConformanceReport::from_json(json).unwrap();
729 let categories = report.by_category();
730
731 let params = categories.get("Parameters").unwrap();
732 assert_eq!(params.passed, 2);
733
734 let bodies = categories.get("Request Bodies").unwrap();
735 assert_eq!(bodies.failed, 1);
736 }
737
738 #[test]
739 fn test_empty_report() {
740 let json = r#"{ "checks": {} }"#;
741 let report = ConformanceReport::from_json(json).unwrap();
742 assert_eq!(report.overall_rate(), 0.0);
743 }
744
745 #[test]
746 fn test_owasp_coverage_with_failures() {
747 let json = r#"{
751 "checks": {
752 "response:404": { "passes": 0, "fails": 1 },
753 "body:json": { "passes": 1, "fails": 0 },
754 "method:GET": { "passes": 1, "fails": 0 }
755 },
756 "overall": {}
757 }"#;
758
759 let report = ConformanceReport::from_json(json).unwrap();
760 report.print_report_with_options(false);
762 }
763}