Skip to main content

spice_framework/
report.rs

1use crate::assertion::AssertionResult;
2use crate::error::SpiceError;
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5use std::time::Duration;
6
7/// Result of a single test case.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct TestReport {
10    pub test_id: String,
11    pub test_name: Option<String>,
12    pub tags: Vec<String>,
13    pub passed: bool,
14    pub attempts: usize,
15    pub assertion_results: Vec<AssertionResult>,
16    pub duration: Duration,
17    pub error: Option<String>,
18}
19
20/// Result of an entire test suite.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct SuiteReport {
23    pub suite_name: String,
24    pub tests: Vec<TestReport>,
25    pub total: usize,
26    pub passed: usize,
27    pub failed: usize,
28    pub duration: Duration,
29    pub timestamp: chrono::DateTime<chrono::Utc>,
30}
31
32impl SuiteReport {
33    /// Print colored console output.
34    pub fn print_console(&self) {
35        println!();
36        println!(
37            "  \x1b[1m{}\x1b[0m  ({} tests)",
38            self.suite_name, self.total
39        );
40        println!("  {}", "─".repeat(50));
41
42        for test in &self.tests {
43            let display_name = test
44                .test_name
45                .as_deref()
46                .unwrap_or(&test.test_id);
47
48            if test.passed {
49                println!("  \x1b[32m✓ PASS\x1b[0m  {}", display_name);
50            } else {
51                println!("  \x1b[31m✗ FAIL\x1b[0m  {}", display_name);
52                for ar in &test.assertion_results {
53                    if !ar.passed {
54                        let prefix = if ar.is_security {
55                            "\x1b[33m🔒\x1b[0m"
56                        } else {
57                            " "
58                        };
59                        println!(
60                            "          {} {}",
61                            prefix,
62                            ar.message.as_deref().unwrap_or(&ar.description)
63                        );
64                    }
65                }
66                if let Some(err) = &test.error {
67                    println!("          error: {}", err);
68                }
69            }
70        }
71
72        // --- RBAC summary ---
73        let rbac_tests: Vec<_> = self
74            .tests
75            .iter()
76            .filter(|t| t.tags.iter().any(|tag| tag == "rbac"))
77            .collect();
78
79        if !rbac_tests.is_empty() {
80            println!("  {}", "─".repeat(50));
81            println!("  \x1b[1mRBAC Summary\x1b[0m");
82
83            // Group by role (extracted from test_id pattern rbac-*-ROLE or test name)
84            let mut role_results: std::collections::BTreeMap<String, (usize, usize)> =
85                std::collections::BTreeMap::new();
86            for t in &rbac_tests {
87                // Try to extract role from test name "ROLE — ..." or test_id
88                let role = t
89                    .test_name
90                    .as_deref()
91                    .and_then(|n| n.split(" — ").next())
92                    .unwrap_or(&t.test_id)
93                    .to_string();
94                let entry = role_results.entry(role).or_insert((0, 0));
95                entry.0 += 1;
96                if t.passed {
97                    entry.1 += 1;
98                }
99            }
100
101            for (role, (total, passed)) in &role_results {
102                let color = if passed == total {
103                    "\x1b[32m"
104                } else {
105                    "\x1b[31m"
106                };
107                println!(
108                    "    {}{}: {}/{} passed\x1b[0m",
109                    color, role, passed, total
110                );
111            }
112
113            let rbac_passed = rbac_tests.iter().filter(|t| t.passed).count();
114            let rbac_total = rbac_tests.len();
115            let rbac_color = if rbac_passed == rbac_total {
116                "\x1b[32m"
117            } else {
118                "\x1b[31m"
119            };
120            println!(
121                "  {}RBAC Total: {}/{} passed\x1b[0m",
122                rbac_color, rbac_passed, rbac_total
123            );
124        }
125
126        println!("  {}", "─".repeat(50));
127
128        let security_tests: Vec<_> = self
129            .tests
130            .iter()
131            .filter(|t| {
132                t.assertion_results.iter().any(|a| a.is_security)
133            })
134            .collect();
135
136        if !security_tests.is_empty() {
137            let sec_passed = security_tests.iter().filter(|t| t.passed).count();
138            let sec_total = security_tests.len();
139            let color = if sec_passed == sec_total {
140                "\x1b[32m"
141            } else {
142                "\x1b[31m"
143            };
144            println!(
145                "  {}Security: {}/{} passed\x1b[0m",
146                color, sec_passed, sec_total
147            );
148        }
149
150        let color = if self.failed == 0 {
151            "\x1b[32m"
152        } else {
153            "\x1b[31m"
154        };
155        println!(
156            "  {}Total: {}/{} passed\x1b[0m  ({:.1}s)",
157            color,
158            self.passed,
159            self.total,
160            self.duration.as_secs_f64()
161        );
162        println!();
163    }
164
165    /// Save report to a JSON file.
166    pub fn save_to_file(&self, path: &Path) -> Result<(), SpiceError> {
167        if let Some(parent) = path.parent() {
168            std::fs::create_dir_all(parent)?;
169        }
170        let json = serde_json::to_string_pretty(self)?;
171        std::fs::write(path, json)?;
172        Ok(())
173    }
174}