spice_framework/
report.rs1use crate::assertion::AssertionResult;
2use crate::error::SpiceError;
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5use std::time::Duration;
6
7#[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#[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 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 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 let mut role_results: std::collections::BTreeMap<String, (usize, usize)> =
85 std::collections::BTreeMap::new();
86 for t in &rbac_tests {
87 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 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}