1use std::fmt::Write;
7use std::time::Duration;
8
9use crate::adapters::{TestRunResult, TestStatus};
10use crate::error;
11use crate::events::TestEvent;
12use crate::plugin::Plugin;
13
14#[derive(Debug, Clone)]
16pub struct MarkdownConfig {
17 pub output_path: Option<String>,
19 pub include_details: bool,
21 pub include_timestamp: bool,
23 pub show_slowest: usize,
25 pub show_errors: bool,
27}
28
29impl Default for MarkdownConfig {
30 fn default() -> Self {
31 Self {
32 output_path: None,
33 include_details: true,
34 include_timestamp: true,
35 show_slowest: 5,
36 show_errors: true,
37 }
38 }
39}
40
41pub struct MarkdownReporter {
43 config: MarkdownConfig,
44 output: String,
45}
46
47impl MarkdownReporter {
48 pub fn new(config: MarkdownConfig) -> Self {
49 Self {
50 config,
51 output: String::new(),
52 }
53 }
54
55 pub fn output(&self) -> &str {
57 &self.output
58 }
59}
60
61impl Plugin for MarkdownReporter {
62 fn name(&self) -> &str {
63 "markdown"
64 }
65
66 fn version(&self) -> &str {
67 "1.0.0"
68 }
69
70 fn on_event(&mut self, _event: &TestEvent) -> error::Result<()> {
71 Ok(())
72 }
73
74 fn on_result(&mut self, result: &TestRunResult) -> error::Result<()> {
75 self.output = generate_markdown(result, &self.config);
76 Ok(())
77 }
78}
79
80pub fn generate_markdown(result: &TestRunResult, config: &MarkdownConfig) -> String {
82 let mut md = String::with_capacity(4096);
83
84 write_header(&mut md, result, config);
86
87 write_summary(&mut md, result);
89
90 if config.include_details {
92 write_suite_details(&mut md, result, config);
93 }
94
95 if config.show_errors && result.total_failed() > 0 {
97 write_failures(&mut md, result);
98 }
99
100 if config.show_slowest > 0 {
102 write_slowest(&mut md, result, config.show_slowest);
103 }
104
105 md
106}
107
108fn write_header(md: &mut String, result: &TestRunResult, config: &MarkdownConfig) {
109 let status_icon = if result.is_success() {
110 " PASS"
111 } else {
112 " FAIL"
113 };
114
115 let _ = writeln!(md, "# Test Results");
116 md.push('\n');
117
118 if config.include_timestamp {
119 let _ = writeln!(md, "> Generated by testx");
120 md.push('\n');
121 }
122
123 let _ = writeln!(
124 md,
125 "**Status**: {} | **Total**: {} | **Passed**: {} | **Failed**: {} | **Skipped**: {} | **Duration**: {}",
126 status_icon,
127 result.total_tests(),
128 result.total_passed(),
129 result.total_failed(),
130 result.total_skipped(),
131 format_duration(result.duration),
132 );
133 md.push('\n');
134}
135
136fn write_summary(md: &mut String, result: &TestRunResult) {
137 if result.suites.len() <= 1 {
138 return;
139 }
140
141 let _ = writeln!(md, "## Suites Overview");
142 md.push('\n');
143 let _ = writeln!(md, "| Suite | Tests | Passed | Failed | Skipped | Status |");
144 let _ = writeln!(md, "| ----- | ----- | ------ | ------ | ------- | ------ |");
145
146 for suite in &result.suites {
147 let status_icon = if suite.is_passed() { "✅" } else { "❌" };
148 let _ = writeln!(
149 md,
150 "| {} | {} | {} | {} | {} | {} |",
151 suite.name,
152 suite.tests.len(),
153 suite.passed(),
154 suite.failed(),
155 suite.skipped(),
156 status_icon,
157 );
158 }
159 md.push('\n');
160}
161
162fn write_suite_details(md: &mut String, result: &TestRunResult, config: &MarkdownConfig) {
163 let _ = writeln!(md, "## Test Details");
164 md.push('\n');
165
166 for suite in &result.suites {
167 let icon = if suite.is_passed() { "✅" } else { "❌" };
168 let _ = writeln!(
169 md,
170 "### {} {} ({} tests)",
171 icon,
172 suite.name,
173 suite.tests.len()
174 );
175 md.push('\n');
176
177 let _ = writeln!(md, "| Test | Status | Duration |");
178 let _ = writeln!(md, "| ---- | ------ | -------- |");
179
180 for test in &suite.tests {
181 let (icon, status) = match test.status {
182 TestStatus::Passed => ("", "Pass"),
183 TestStatus::Failed => ("", "Fail"),
184 TestStatus::Skipped => ("⏭️", "Skip"),
185 };
186 let _ = writeln!(
187 md,
188 "| {} | {} {} | {} |",
189 test.name,
190 icon,
191 status,
192 format_duration(test.duration),
193 );
194 }
195 md.push('\n');
196
197 if config.show_errors {
199 for test in suite.failures() {
200 if let Some(ref error) = test.error {
201 let _ = writeln!(md, "<details>");
202 let _ = writeln!(md, "<summary> {} — Error</summary>", test.name);
203 md.push('\n');
204 let _ = writeln!(md, "```");
205 let _ = writeln!(md, "{}", error.message);
206 if let Some(ref loc) = error.location {
207 let _ = writeln!(md, "at {loc}");
208 }
209 let _ = writeln!(md, "```");
210 let _ = writeln!(md, "</details>");
211 md.push('\n');
212 }
213 }
214 }
215 }
216}
217
218fn write_failures(md: &mut String, result: &TestRunResult) {
219 let _ = writeln!(md, "## Failures");
220 md.push('\n');
221
222 for suite in &result.suites {
223 for test in suite.failures() {
224 let _ = writeln!(md, "### {}::{}", suite.name, test.name);
225 md.push('\n');
226 if let Some(ref error) = test.error {
227 let _ = writeln!(md, "```");
228 let _ = writeln!(md, "{}", error.message);
229 if let Some(ref loc) = error.location {
230 let _ = writeln!(md, "at {loc}");
231 }
232 let _ = writeln!(md, "```");
233 md.push('\n');
234 }
235 }
236 }
237}
238
239fn write_slowest(md: &mut String, result: &TestRunResult, n: usize) {
240 let slowest = result.slowest_tests(n);
241 if slowest.is_empty() {
242 return;
243 }
244
245 let _ = writeln!(md, "## Slowest Tests");
246 md.push('\n');
247 let _ = writeln!(md, "| # | Test | Suite | Duration |");
248 let _ = writeln!(md, "| - | ---- | ----- | -------- |");
249
250 for (i, (suite, test)) in slowest.iter().enumerate() {
251 let _ = writeln!(
252 md,
253 "| {} | {} | {} | {} |",
254 i + 1,
255 test.name,
256 suite.name,
257 format_duration(test.duration),
258 );
259 }
260 md.push('\n');
261}
262
263fn format_duration(d: Duration) -> String {
264 let ms = d.as_millis();
265 if ms == 0 {
266 "<1ms".to_string()
267 } else if ms < 1000 {
268 format!("{ms}ms")
269 } else {
270 format!("{:.2}s", d.as_secs_f64())
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::adapters::{TestCase, TestError, TestSuite};
278
279 fn make_test(name: &str, status: TestStatus, ms: u64) -> TestCase {
280 TestCase {
281 name: name.into(),
282 status,
283 duration: Duration::from_millis(ms),
284 error: None,
285 }
286 }
287
288 fn make_failed_test(name: &str, ms: u64, msg: &str) -> TestCase {
289 TestCase {
290 name: name.into(),
291 status: TestStatus::Failed,
292 duration: Duration::from_millis(ms),
293 error: Some(TestError {
294 message: msg.into(),
295 location: Some("test.rs:10".into()),
296 }),
297 }
298 }
299
300 fn make_result() -> TestRunResult {
301 TestRunResult {
302 suites: vec![
303 TestSuite {
304 name: "math".into(),
305 tests: vec![
306 make_test("test_add", TestStatus::Passed, 10),
307 make_test("test_sub", TestStatus::Passed, 20),
308 make_failed_test("test_div", 5, "division by zero"),
309 ],
310 },
311 TestSuite {
312 name: "strings".into(),
313 tests: vec![
314 make_test("test_concat", TestStatus::Passed, 15),
315 make_test("test_upper", TestStatus::Skipped, 0),
316 ],
317 },
318 ],
319 duration: Duration::from_millis(500),
320 raw_exit_code: 1,
321 }
322 }
323
324 #[test]
325 fn markdown_header() {
326 let md = generate_markdown(&make_result(), &MarkdownConfig::default());
327 assert!(md.contains("# Test Results"));
328 assert!(md.contains(" FAIL"));
329 assert!(md.contains("**Total**: 5"));
330 assert!(md.contains("**Passed**: 3"));
331 assert!(md.contains("**Failed**: 1"));
332 assert!(md.contains("**Skipped**: 1"));
333 }
334
335 #[test]
336 fn markdown_pass_status() {
337 let result = TestRunResult {
338 suites: vec![TestSuite {
339 name: "t".into(),
340 tests: vec![make_test("t1", TestStatus::Passed, 1)],
341 }],
342 duration: Duration::from_millis(10),
343 raw_exit_code: 0,
344 };
345 let md = generate_markdown(&result, &MarkdownConfig::default());
346 assert!(md.contains(" PASS"));
347 }
348
349 #[test]
350 fn markdown_suites_overview() {
351 let md = generate_markdown(&make_result(), &MarkdownConfig::default());
352 assert!(md.contains("## Suites Overview"));
353 assert!(md.contains("| math |"));
354 assert!(md.contains("| strings |"));
355 }
356
357 #[test]
358 fn markdown_test_details() {
359 let md = generate_markdown(&make_result(), &MarkdownConfig::default());
360 assert!(md.contains("## Test Details"));
361 assert!(md.contains("| test_add |"));
362 assert!(md.contains("| test_div |"));
363 }
364
365 #[test]
366 fn markdown_failures_section() {
367 let md = generate_markdown(&make_result(), &MarkdownConfig::default());
368 assert!(md.contains("## Failures"));
369 assert!(md.contains("division by zero"));
370 }
371
372 #[test]
373 fn markdown_no_failures_no_section() {
374 let result = TestRunResult {
375 suites: vec![TestSuite {
376 name: "t".into(),
377 tests: vec![make_test("t1", TestStatus::Passed, 1)],
378 }],
379 duration: Duration::from_millis(10),
380 raw_exit_code: 0,
381 };
382 let md = generate_markdown(&result, &MarkdownConfig::default());
383 assert!(!md.contains("## Failures"));
384 }
385
386 #[test]
387 fn markdown_slowest() {
388 let md = generate_markdown(&make_result(), &MarkdownConfig::default());
389 assert!(md.contains("## Slowest Tests"));
390 }
391
392 #[test]
393 fn markdown_no_details() {
394 let config = MarkdownConfig {
395 include_details: false,
396 ..Default::default()
397 };
398 let md = generate_markdown(&make_result(), &config);
399 assert!(!md.contains("## Test Details"));
400 }
401
402 #[test]
403 fn markdown_duration_format() {
404 assert_eq!(format_duration(Duration::ZERO), "<1ms");
405 assert_eq!(format_duration(Duration::from_millis(42)), "42ms");
406 assert_eq!(format_duration(Duration::from_millis(1500)), "1.50s");
407 }
408
409 #[test]
410 fn markdown_plugin_trait() {
411 let mut reporter = MarkdownReporter::new(MarkdownConfig::default());
412 assert_eq!(reporter.name(), "markdown");
413 assert_eq!(reporter.version(), "1.0.0");
414
415 reporter.on_result(&make_result()).unwrap();
416 assert!(reporter.output().contains("# Test Results"));
417 }
418
419 #[test]
420 fn markdown_error_location() {
421 let md = generate_markdown(&make_result(), &MarkdownConfig::default());
422 assert!(md.contains("test.rs:10"));
423 }
424
425 #[test]
426 fn markdown_single_suite_no_overview() {
427 let result = TestRunResult {
428 suites: vec![TestSuite {
429 name: "single".into(),
430 tests: vec![make_test("t", TestStatus::Passed, 1)],
431 }],
432 duration: Duration::from_millis(10),
433 raw_exit_code: 0,
434 };
435 let md = generate_markdown(&result, &MarkdownConfig::default());
436 assert!(!md.contains("## Suites Overview")); }
438}