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/// Detail of a single conformance check failure
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33pub struct FailureDetail {
34    /// Check name that failed
35    pub check: String,
36    /// Request information
37    pub request: FailureRequest,
38    /// Response information
39    pub response: FailureResponse,
40    /// What the check expected
41    pub expected: String,
42}
43
44/// Request details for a failed check
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct FailureRequest {
47    /// HTTP method
48    #[serde(default)]
49    pub method: String,
50    /// Full URL
51    #[serde(default)]
52    pub url: String,
53    /// Request headers (k6 sends arrays per header, we flatten to first value)
54    #[serde(default, deserialize_with = "deserialize_headers")]
55    pub headers: HashMap<String, String>,
56    /// Request body (truncated)
57    #[serde(default)]
58    pub body: String,
59}
60
61/// Response details for a failed check
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct FailureResponse {
64    /// HTTP status code
65    #[serde(default)]
66    pub status: u16,
67    /// Response headers (k6 may send arrays or strings)
68    #[serde(default, deserialize_with = "deserialize_headers")]
69    pub headers: HashMap<String, String>,
70    /// Response body (truncated)
71    #[serde(default)]
72    pub body: String,
73}
74
75/// Deserialize headers that may be `{key: "value"}` or `{key: ["value"]}` (k6 format)
76fn 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
99/// Extract the base name of a custom check.
100/// Custom sub-checks have format "custom:name:header:..." or "custom:name:body:..."
101/// The base name is just the primary check (e.g., "custom:pets-returns-200").
102fn extract_custom_base_name(check_name: &str) -> String {
103    // "custom:" prefix is 7 chars. Find the next colon after that.
104    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
112/// Conformance test report
113pub struct ConformanceReport {
114    /// Per-check results: check_name -> (passes, fails)
115    check_results: HashMap<String, (u64, u64)>,
116    /// Detailed failure information
117    failure_details: Vec<FailureDetail>,
118}
119
120impl ConformanceReport {
121    /// Construct a report directly from check results and failure details.
122    /// Used by `NativeConformanceExecutor` to build a report without k6.
123    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    /// Serialize the report to JSON.
134    ///
135    /// Includes both the raw `checks` map (for CLI/k6 compat) and structured
136    /// `summary`, `categories`, and `failures` fields (for UI consumption).
137    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        // Compute structured category results for UI
150        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        // Compute summary
164        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        // Transform failure details into UI-friendly format
173        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        // Keep raw failure_details for backward compat
201        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    /// Determine the category for a check name based on its prefix
209    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    /// Get the failure details
229    pub fn failure_details(&self) -> &[FailureDetail] {
230        &self.failure_details
231    }
232
233    /// Parse a conformance report from k6's handleSummary JSON output
234    ///
235    /// Also loads failure details from `conformance-failure-details.json` in the same directory.
236    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        // Load failure details from sibling file
242        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    /// Parse from JSON string
257    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    /// Get results grouped by category.
278    ///
279    /// Includes all standard categories plus a synthetic "Custom" category
280    /// for any check names starting with "custom:".
281    pub fn by_category(&self) -> HashMap<&'static str, CategoryResult> {
282        let mut categories: HashMap<&'static str, CategoryResult> = HashMap::new();
283
284        // Initialize all categories
285        for cat in ConformanceFeature::categories() {
286            categories.insert(cat, CategoryResult::default());
287        }
288
289        // Map check results to features.
290        // Check names are path-qualified (e.g. "constraint:required:/users")
291        // so we match by prefix as well as exact name.
292        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            // First try exact match (reference mode)
299            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                // Try prefix match (path-qualified: "constraint:required:/path")
307                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                // Features not in results are not counted
318            }
319        }
320
321        // Aggregate custom checks (check names starting with "custom:")
322        let custom_entry = categories.entry("Custom").or_default();
323        // Track which top-level custom check names we've already counted
324        let mut counted_custom: HashSet<String> = HashSet::new();
325        for (name, (passes, fails)) in &self.check_results {
326            if name.starts_with("custom:") {
327                // Only count the primary check (status), not sub-checks (header/body)
328                // Sub-checks have format "custom:name:header:..." or "custom:name:body:..."
329                // Primary checks are just "custom:something" with exactly one colon after "custom"
330                // We count each unique top-level custom check once
331                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    /// Print the conformance report to stdout
346    pub fn print_report(&self) {
347        self.print_report_with_options(false);
348    }
349
350    /// Print the conformance report with options controlling detail level
351    pub fn print_report_with_options(&self, all_operations: bool) {
352        let categories = self.by_category();
353
354        // Count detected features and active categories
355        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        // Build the list of categories to display (standard + Custom if present)
391        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                    // Show empty categories with dimmed "not in spec" indicator
404                    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        // Print failed checks detail section
465        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                // Show failure details if available
482                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        // OWASP API Top 10 coverage section
527        self.print_owasp_coverage();
528
529        // Coverage tips for empty categories
530        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    /// Print OWASP API Security Top 10 coverage based on tested features
557    fn print_owasp_coverage(&self) {
558        println!();
559        println!("{}", "OWASP API Security Top 10 Coverage".bold());
560        println!("{}", "=".repeat(64).bright_green());
561
562        // Build a map of feature check_name → passed/failed status
563        let mut feature_status: HashMap<&str, bool> = HashMap::new(); // true = all passed
564        for feature in ConformanceFeature::all() {
565            let check_name = feature.check_name();
566
567            // Exact match (reference mode)
568            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                // Prefix match (path-qualified check names)
576                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            // Find features that map to this OWASP category and were tested
594            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    /// Get raw per-check results (for SARIF conversion)
629    pub fn raw_check_results(&self) -> &HashMap<String, (u64, u64)> {
630        &self.check_results
631    }
632
633    /// Overall pass rate (0.0 - 100.0)
634    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        // response:404 maps to API8 + API9, body:json maps to API4 + API8
682        // response:404 fails, so API8 and API9 should show as having failures
683        // body:json passes, so API4 should show as passing
684        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        // Print the report to verify visually (--nocapture)
695        report.print_report_with_options(false);
696    }
697}