cli_testing_specialist/reporter/
markdown.rs1use crate::error::Result;
2use crate::types::{TestReport, TestStatus};
3use std::fs;
4use std::path::Path;
5
6pub struct MarkdownReporter;
8
9impl MarkdownReporter {
10 pub fn generate(report: &TestReport, output_path: &Path) -> Result<()> {
12 let mut content = String::new();
13
14 content.push_str(&format!("# Test Report: {}\n\n", report.binary_name));
16
17 if let Some(version) = &report.binary_version {
18 content.push_str(&format!("**Version:** {}\n\n", version));
19 }
20
21 content.push_str(&format!(
22 "**Generated:** {}\n\n",
23 report.finished_at.format("%Y-%m-%d %H:%M:%S UTC")
24 ));
25
26 content.push_str("## Summary\n\n");
28
29 let success_rate = (report.success_rate() * 100.0) as u32;
30 let status_emoji = if report.all_passed() {
31 "✅"
32 } else if success_rate >= 80 {
33 "⚠️"
34 } else {
35 "❌"
36 };
37
38 content.push_str(&format!(
39 "**Overall Status:** {} {}% passed\n\n",
40 status_emoji, success_rate
41 ));
42
43 content.push_str("| Metric | Value |\n");
44 content.push_str("|--------|-------|\n");
45 content.push_str(&format!("| Total Tests | {} |\n", report.total_tests()));
46 content.push_str(&format!("| Passed | ✅ {} |\n", report.total_passed()));
47 content.push_str(&format!("| Failed | ❌ {} |\n", report.total_failed()));
48 content.push_str(&format!("| Skipped | ⏭️ {} |\n", report.total_skipped()));
49 content.push_str(&format!(
50 "| Duration | {:.2}s |\n",
51 report.total_duration.as_secs_f64()
52 ));
53 content.push_str(&format!("| Suites | {} |\n\n", report.suites.len()));
54
55 content.push_str("## Test Suites\n\n");
57
58 for suite in &report.suites {
59 let suite_success_rate = (suite.success_rate() * 100.0) as u32;
60 let suite_status = if suite.failed_count() == 0 {
61 "✅"
62 } else {
63 "❌"
64 };
65
66 content.push_str(&format!(
67 "### {} {} ({}%)\n\n",
68 suite_status, suite.name, suite_success_rate
69 ));
70
71 content.push_str(&format!("**File:** `{}`\n\n", suite.file_path));
72 content.push_str(&format!(
73 "**Duration:** {:.2}s\n\n",
74 suite.duration.as_secs_f64()
75 ));
76
77 content.push_str("| Status | Count |\n");
79 content.push_str("|--------|-------|\n");
80 content.push_str(&format!("| Passed | {} |\n", suite.passed_count()));
81 content.push_str(&format!("| Failed | {} |\n", suite.failed_count()));
82 content.push_str(&format!("| Skipped | {} |\n", suite.skipped_count()));
83 content.push_str(&format!("| Total | {} |\n\n", suite.total_count()));
84
85 let failed_tests: Vec<_> = suite
87 .tests
88 .iter()
89 .filter(|t| t.status.is_failure())
90 .collect();
91
92 if !failed_tests.is_empty() {
93 content.push_str("#### Failed Tests\n\n");
94
95 for test in failed_tests {
96 content.push_str(&format!("- ❌ **{}**\n", test.name));
97 if let Some(error) = &test.error_message {
98 content.push_str(&format!(" - Error: `{}`\n", error));
99 }
100 if !test.output.is_empty() {
101 content.push_str(&format!(
102 " - Output:\n ```\n {}\n ```\n",
103 test.output
104 ));
105 }
106 }
107 content.push('\n');
108 }
109 }
110
111 content.push_str("## Environment\n\n");
113 content.push_str("| Property | Value |\n");
114 content.push_str("|----------|-------|\n");
115 content.push_str(&format!(
116 "| OS | {} {} |\n",
117 report.environment.os, report.environment.os_version
118 ));
119 content.push_str(&format!(
120 "| Shell | {} |\n",
121 report.environment.shell_version
122 ));
123 content.push_str(&format!("| BATS | {} |\n", report.environment.bats_version));
124 content.push_str(&format!("| Hostname | {} |\n", report.environment.hostname));
125 content.push_str(&format!("| User | {} |\n", report.environment.user));
126
127 content.push_str("\n## Detailed Results\n\n");
129
130 for suite in &report.suites {
131 content.push_str(&format!("### {}\n\n", suite.name));
132
133 content.push_str("| # | Test Name | Status | Duration |\n");
134 content.push_str("|---|-----------|--------|----------|\n");
135
136 for (idx, test) in suite.tests.iter().enumerate() {
137 let status_str = match test.status {
138 TestStatus::Passed => "✅ Passed",
139 TestStatus::Failed => "❌ Failed",
140 TestStatus::Skipped => "⏭️ Skipped",
141 TestStatus::Timeout => "⏱️ Timeout",
142 };
143
144 content.push_str(&format!(
145 "| {} | {} | {} | {:.0}ms |\n",
146 idx + 1,
147 test.name,
148 status_str,
149 test.duration.as_millis()
150 ));
151 }
152 content.push('\n');
153 }
154
155 fs::write(output_path, content)?;
157
158 Ok(())
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::types::{EnvironmentInfo, TestResult, TestSuite};
166 use chrono::Utc;
167 use std::time::Duration;
168 use tempfile::NamedTempFile;
169
170 fn create_test_report() -> TestReport {
171 let suite = TestSuite {
172 name: "test_suite".to_string(),
173 file_path: "/path/to/test.bats".to_string(),
174 tests: vec![
175 TestResult {
176 name: "successful test".to_string(),
177 status: TestStatus::Passed,
178 duration: Duration::from_millis(150),
179 output: String::new(),
180 error_message: None,
181 file_path: "/path/to/test.bats".to_string(),
182 line_number: Some(5),
183 tags: vec![],
184 priority: crate::types::TestPriority::Important,
185 },
186 TestResult {
187 name: "failed test".to_string(),
188 status: TestStatus::Failed,
189 duration: Duration::from_millis(200),
190 output: "error output".to_string(),
191 error_message: Some("assertion failed".to_string()),
192 file_path: "/path/to/test.bats".to_string(),
193 line_number: Some(10),
194 tags: vec![],
195 priority: crate::types::TestPriority::Important,
196 },
197 ],
198 duration: Duration::from_millis(350),
199 started_at: Utc::now(),
200 finished_at: Utc::now(),
201 };
202
203 TestReport {
204 binary_name: "test-cli".to_string(),
205 binary_version: Some("1.0.0".to_string()),
206 suites: vec![suite],
207 total_duration: Duration::from_millis(350),
208 started_at: Utc::now(),
209 finished_at: Utc::now(),
210 environment: EnvironmentInfo::default(),
211 security_findings: vec![],
212 }
213 }
214
215 #[test]
216 fn test_markdown_generation() {
217 let report = create_test_report();
218 let temp_file = NamedTempFile::new().unwrap();
219 let output_path = temp_file.path();
220
221 MarkdownReporter::generate(&report, output_path).unwrap();
222
223 let content = fs::read_to_string(output_path).unwrap();
224
225 assert!(content.contains("# Test Report: test-cli"));
227 assert!(content.contains("**Version:** 1.0.0"));
228
229 assert!(content.contains("## Summary"));
231 assert!(content.contains("Total Tests"));
232 assert!(content.contains("| 2 |"));
233
234 assert!(content.contains("## Test Suites"));
236 assert!(content.contains("test_suite"));
237
238 assert!(content.contains("## Detailed Results"));
240 assert!(content.contains("successful test"));
241 assert!(content.contains("failed test"));
242 assert!(content.contains("✅ Passed"));
243 assert!(content.contains("❌ Failed"));
244
245 assert!(content.contains("## Environment"));
247 }
248
249 #[test]
250 fn test_markdown_all_passed() {
251 let suite = TestSuite {
252 name: "all_pass".to_string(),
253 file_path: "/test.bats".to_string(),
254 tests: vec![TestResult {
255 name: "test".to_string(),
256 status: TestStatus::Passed,
257 duration: Duration::from_millis(100),
258 output: String::new(),
259 error_message: None,
260 file_path: "/test.bats".to_string(),
261 line_number: None,
262 tags: vec![],
263 priority: crate::types::TestPriority::Important,
264 }],
265 duration: Duration::from_millis(100),
266 started_at: Utc::now(),
267 finished_at: Utc::now(),
268 };
269
270 let report = TestReport {
271 binary_name: "cli".to_string(),
272 binary_version: None,
273 suites: vec![suite],
274 total_duration: Duration::from_millis(100),
275 started_at: Utc::now(),
276 finished_at: Utc::now(),
277 environment: EnvironmentInfo::default(),
278 security_findings: vec![],
279 };
280
281 let temp_file = NamedTempFile::new().unwrap();
282 MarkdownReporter::generate(&report, temp_file.path()).unwrap();
283
284 let content = fs::read_to_string(temp_file.path()).unwrap();
285 assert!(content.contains("✅ 100% passed"));
286 }
287}