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
14fn md_escape(s: &str) -> String {
16 s.replace('|', "\\|")
17 .replace('<', "<")
18 .replace('>', ">")
19 .replace('[', "\\[")
20 .replace(']', "\\]")
21}
22
23#[derive(Debug, Clone)]
25pub struct MarkdownConfig {
26 pub output_path: Option<String>,
28 pub include_details: bool,
30 pub include_timestamp: bool,
32 pub show_slowest: usize,
34 pub show_errors: bool,
36}
37
38impl Default for MarkdownConfig {
39 fn default() -> Self {
40 Self {
41 output_path: None,
42 include_details: true,
43 include_timestamp: true,
44 show_slowest: 5,
45 show_errors: true,
46 }
47 }
48}
49
50pub struct MarkdownReporter {
52 config: MarkdownConfig,
53 output: String,
54}
55
56impl MarkdownReporter {
57 pub fn new(config: MarkdownConfig) -> Self {
58 Self {
59 config,
60 output: String::new(),
61 }
62 }
63
64 pub fn output(&self) -> &str {
66 &self.output
67 }
68}
69
70impl Plugin for MarkdownReporter {
71 fn name(&self) -> &str {
72 "markdown"
73 }
74
75 fn version(&self) -> &str {
76 "1.0.0"
77 }
78
79 fn on_event(&mut self, _event: &TestEvent) -> error::Result<()> {
80 Ok(())
81 }
82
83 fn on_result(&mut self, result: &TestRunResult) -> error::Result<()> {
84 self.output = generate_markdown(result, &self.config);
85 Ok(())
86 }
87}
88
89pub fn generate_markdown(result: &TestRunResult, config: &MarkdownConfig) -> String {
91 let mut md = String::with_capacity(4096);
92
93 write_header(&mut md, result, config);
95
96 write_summary(&mut md, result);
98
99 if config.include_details {
101 write_suite_details(&mut md, result, config);
102 }
103
104 if config.show_errors && result.total_failed() > 0 {
106 write_failures(&mut md, result);
107 }
108
109 if config.show_slowest > 0 {
111 write_slowest(&mut md, result, config.show_slowest);
112 }
113
114 md
115}
116
117fn write_header(md: &mut String, result: &TestRunResult, config: &MarkdownConfig) {
118 let status_icon = if result.is_success() {
119 " PASS"
120 } else {
121 " FAIL"
122 };
123
124 let _ = writeln!(md, "# Test Results");
125 md.push('\n');
126
127 if config.include_timestamp {
128 let _ = writeln!(md, "> Generated by testx");
129 md.push('\n');
130 }
131
132 let _ = writeln!(
133 md,
134 "**Status**: {} | **Total**: {} | **Passed**: {} | **Failed**: {} | **Skipped**: {} | **Duration**: {}",
135 status_icon,
136 result.total_tests(),
137 result.total_passed(),
138 result.total_failed(),
139 result.total_skipped(),
140 format_duration(result.duration),
141 );
142 md.push('\n');
143}
144
145fn write_summary(md: &mut String, result: &TestRunResult) {
146 if result.suites.len() <= 1 {
147 return;
148 }
149
150 let _ = writeln!(md, "## Suites Overview");
151 md.push('\n');
152 let _ = writeln!(md, "| Suite | Tests | Passed | Failed | Skipped | Status |");
153 let _ = writeln!(md, "| ----- | ----- | ------ | ------ | ------- | ------ |");
154
155 for suite in &result.suites {
156 let status_icon = if suite.is_passed() { "✅" } else { "❌" };
157 let _ = writeln!(
158 md,
159 "| {} | {} | {} | {} | {} | {} |",
160 suite.name,
161 suite.tests.len(),
162 suite.passed(),
163 suite.failed(),
164 suite.skipped(),
165 status_icon,
166 );
167 }
168 md.push('\n');
169}
170
171fn write_suite_details(md: &mut String, result: &TestRunResult, config: &MarkdownConfig) {
172 let _ = writeln!(md, "## Test Details");
173 md.push('\n');
174
175 for suite in &result.suites {
176 let icon = if suite.is_passed() { "✅" } else { "❌" };
177 let _ = writeln!(
178 md,
179 "### {} {} ({} tests)",
180 icon,
181 md_escape(&suite.name),
182 suite.tests.len()
183 );
184 md.push('\n');
185
186 let _ = writeln!(md, "| Test | Status | Duration |");
187 let _ = writeln!(md, "| ---- | ------ | -------- |");
188
189 for test in &suite.tests {
190 let (icon, status) = match test.status {
191 TestStatus::Passed => ("", "Pass"),
192 TestStatus::Failed => ("", "Fail"),
193 TestStatus::Skipped => ("⏭️", "Skip"),
194 };
195 let _ = writeln!(
196 md,
197 "| {} | {} {} | {} |",
198 md_escape(&test.name),
199 icon,
200 status,
201 format_duration(test.duration),
202 );
203 }
204 md.push('\n');
205
206 if config.show_errors {
208 for test in suite.failures() {
209 if let Some(ref error) = test.error {
210 let _ = writeln!(md, "<details>");
211 let _ = writeln!(md, "<summary> {} — Error</summary>", md_escape(&test.name));
212 md.push('\n');
213 let _ = writeln!(md, "```");
214 let _ = writeln!(md, "{}", error.message);
215 if let Some(ref loc) = error.location {
216 let _ = writeln!(md, "at {loc}");
217 }
218 let _ = writeln!(md, "```");
219 let _ = writeln!(md, "</details>");
220 md.push('\n');
221 }
222 }
223 }
224 }
225}
226
227fn write_failures(md: &mut String, result: &TestRunResult) {
228 let _ = writeln!(md, "## Failures");
229 md.push('\n');
230
231 for suite in &result.suites {
232 for test in suite.failures() {
233 let _ = writeln!(md, "### {}::{}", suite.name, test.name);
234 md.push('\n');
235 if let Some(ref error) = test.error {
236 let _ = writeln!(md, "```");
237 let _ = writeln!(md, "{}", error.message);
238 if let Some(ref loc) = error.location {
239 let _ = writeln!(md, "at {loc}");
240 }
241 let _ = writeln!(md, "```");
242 md.push('\n');
243 }
244 }
245 }
246}
247
248fn write_slowest(md: &mut String, result: &TestRunResult, n: usize) {
249 let slowest = result.slowest_tests(n);
250 if slowest.is_empty() {
251 return;
252 }
253
254 let _ = writeln!(md, "## Slowest Tests");
255 md.push('\n');
256 let _ = writeln!(md, "| # | Test | Suite | Duration |");
257 let _ = writeln!(md, "| - | ---- | ----- | -------- |");
258
259 for (i, (suite, test)) in slowest.iter().enumerate() {
260 let _ = writeln!(
261 md,
262 "| {} | {} | {} | {} |",
263 i + 1,
264 test.name,
265 suite.name,
266 format_duration(test.duration),
267 );
268 }
269 md.push('\n');
270}
271
272fn format_duration(d: Duration) -> String {
273 let ms = d.as_millis();
274 if ms == 0 {
275 "<1ms".to_string()
276 } else if ms < 1000 {
277 format!("{ms}ms")
278 } else {
279 format!("{:.2}s", d.as_secs_f64())
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use crate::adapters::{TestCase, TestError, TestSuite};
287
288 fn make_test(name: &str, status: TestStatus, ms: u64) -> TestCase {
289 TestCase {
290 name: name.into(),
291 status,
292 duration: Duration::from_millis(ms),
293 error: None,
294 }
295 }
296
297 fn make_failed_test(name: &str, ms: u64, msg: &str) -> TestCase {
298 TestCase {
299 name: name.into(),
300 status: TestStatus::Failed,
301 duration: Duration::from_millis(ms),
302 error: Some(TestError {
303 message: msg.into(),
304 location: Some("test.rs:10".into()),
305 }),
306 }
307 }
308
309 fn make_result() -> TestRunResult {
310 TestRunResult {
311 suites: vec![
312 TestSuite {
313 name: "math".into(),
314 tests: vec![
315 make_test("test_add", TestStatus::Passed, 10),
316 make_test("test_sub", TestStatus::Passed, 20),
317 make_failed_test("test_div", 5, "division by zero"),
318 ],
319 },
320 TestSuite {
321 name: "strings".into(),
322 tests: vec![
323 make_test("test_concat", TestStatus::Passed, 15),
324 make_test("test_upper", TestStatus::Skipped, 0),
325 ],
326 },
327 ],
328 duration: Duration::from_millis(500),
329 raw_exit_code: 1,
330 }
331 }
332
333 #[test]
334 fn markdown_header() {
335 let md = generate_markdown(&make_result(), &MarkdownConfig::default());
336 assert!(md.contains("# Test Results"));
337 assert!(md.contains(" FAIL"));
338 assert!(md.contains("**Total**: 5"));
339 assert!(md.contains("**Passed**: 3"));
340 assert!(md.contains("**Failed**: 1"));
341 assert!(md.contains("**Skipped**: 1"));
342 }
343
344 #[test]
345 fn markdown_pass_status() {
346 let result = TestRunResult {
347 suites: vec![TestSuite {
348 name: "t".into(),
349 tests: vec![make_test("t1", TestStatus::Passed, 1)],
350 }],
351 duration: Duration::from_millis(10),
352 raw_exit_code: 0,
353 };
354 let md = generate_markdown(&result, &MarkdownConfig::default());
355 assert!(md.contains(" PASS"));
356 }
357
358 #[test]
359 fn markdown_suites_overview() {
360 let md = generate_markdown(&make_result(), &MarkdownConfig::default());
361 assert!(md.contains("## Suites Overview"));
362 assert!(md.contains("| math |"));
363 assert!(md.contains("| strings |"));
364 }
365
366 #[test]
367 fn markdown_test_details() {
368 let md = generate_markdown(&make_result(), &MarkdownConfig::default());
369 assert!(md.contains("## Test Details"));
370 assert!(md.contains("| test_add |"));
371 assert!(md.contains("| test_div |"));
372 }
373
374 #[test]
375 fn markdown_failures_section() {
376 let md = generate_markdown(&make_result(), &MarkdownConfig::default());
377 assert!(md.contains("## Failures"));
378 assert!(md.contains("division by zero"));
379 }
380
381 #[test]
382 fn markdown_no_failures_no_section() {
383 let result = TestRunResult {
384 suites: vec![TestSuite {
385 name: "t".into(),
386 tests: vec![make_test("t1", TestStatus::Passed, 1)],
387 }],
388 duration: Duration::from_millis(10),
389 raw_exit_code: 0,
390 };
391 let md = generate_markdown(&result, &MarkdownConfig::default());
392 assert!(!md.contains("## Failures"));
393 }
394
395 #[test]
396 fn markdown_slowest() {
397 let md = generate_markdown(&make_result(), &MarkdownConfig::default());
398 assert!(md.contains("## Slowest Tests"));
399 }
400
401 #[test]
402 fn markdown_no_details() {
403 let config = MarkdownConfig {
404 include_details: false,
405 ..Default::default()
406 };
407 let md = generate_markdown(&make_result(), &config);
408 assert!(!md.contains("## Test Details"));
409 }
410
411 #[test]
412 fn markdown_duration_format() {
413 assert_eq!(format_duration(Duration::ZERO), "<1ms");
414 assert_eq!(format_duration(Duration::from_millis(42)), "42ms");
415 assert_eq!(format_duration(Duration::from_millis(1500)), "1.50s");
416 }
417
418 #[test]
419 fn markdown_plugin_trait() {
420 let mut reporter = MarkdownReporter::new(MarkdownConfig::default());
421 assert_eq!(reporter.name(), "markdown");
422 assert_eq!(reporter.version(), "1.0.0");
423
424 reporter.on_result(&make_result()).unwrap();
425 assert!(reporter.output().contains("# Test Results"));
426 }
427
428 #[test]
429 fn markdown_error_location() {
430 let md = generate_markdown(&make_result(), &MarkdownConfig::default());
431 assert!(md.contains("test.rs:10"));
432 }
433
434 #[test]
435 fn markdown_single_suite_no_overview() {
436 let result = TestRunResult {
437 suites: vec![TestSuite {
438 name: "single".into(),
439 tests: vec![make_test("t", TestStatus::Passed, 1)],
440 }],
441 duration: Duration::from_millis(10),
442 raw_exit_code: 0,
443 };
444 let md = generate_markdown(&result, &MarkdownConfig::default());
445 assert!(!md.contains("## Suites Overview")); }
447
448 #[test]
451 fn markdown_empty_result() {
452 let result = TestRunResult {
453 suites: vec![],
454 duration: Duration::ZERO,
455 raw_exit_code: 0,
456 };
457 let md = generate_markdown(&result, &MarkdownConfig::default());
458 assert!(md.contains("# Test Results"));
459 assert!(md.contains("**Total**: 0"));
460 assert!(md.contains(" PASS")); }
462
463 #[test]
464 fn markdown_all_skipped() {
465 let result = TestRunResult {
466 suites: vec![TestSuite {
467 name: "skip-suite".into(),
468 tests: vec![
469 make_test("s1", TestStatus::Skipped, 0),
470 make_test("s2", TestStatus::Skipped, 0),
471 ],
472 }],
473 duration: Duration::from_millis(5),
474 raw_exit_code: 0,
475 };
476 let md = generate_markdown(&result, &MarkdownConfig::default());
477 assert!(md.contains("**Skipped**: 2"));
478 assert!(md.contains("**Passed**: 0"));
479 assert!(!md.contains("## Failures"));
480 }
481
482 #[test]
483 fn markdown_no_timestamp() {
484 let config = MarkdownConfig {
485 include_timestamp: false,
486 ..Default::default()
487 };
488 let md = generate_markdown(&make_result(), &config);
489 assert!(!md.contains("Generated by testx"));
490 }
491
492 #[test]
493 fn markdown_no_errors_shown() {
494 let config = MarkdownConfig {
495 show_errors: false,
496 ..Default::default()
497 };
498 let md = generate_markdown(&make_result(), &config);
499 assert!(!md.contains("## Failures"));
500 assert!(!md.contains("<details>"));
501 }
502
503 #[test]
504 fn markdown_zero_slowest() {
505 let config = MarkdownConfig {
506 show_slowest: 0,
507 ..Default::default()
508 };
509 let md = generate_markdown(&make_result(), &config);
510 assert!(!md.contains("## Slowest Tests"));
511 }
512
513 #[test]
514 fn markdown_large_slowest_limit() {
515 let config = MarkdownConfig {
516 show_slowest: 100, ..Default::default()
518 };
519 let md = generate_markdown(&make_result(), &config);
520 assert!(md.contains("## Slowest Tests"));
522 }
523
524 #[test]
525 fn markdown_special_chars_in_names() {
526 let result = TestRunResult {
527 suites: vec![TestSuite {
528 name: "suite<special>&\"chars\"".into(),
529 tests: vec![make_test("test|pipe|name", TestStatus::Passed, 1)],
530 }],
531 duration: Duration::from_millis(10),
532 raw_exit_code: 0,
533 };
534 let md = generate_markdown(&result, &MarkdownConfig::default());
535 assert!(md.contains("pipe"));
537 }
538
539 #[test]
540 fn markdown_very_long_duration() {
541 let result = TestRunResult {
542 suites: vec![TestSuite {
543 name: "t".into(),
544 tests: vec![make_test("slow", TestStatus::Passed, 600_000)], }],
546 duration: Duration::from_secs(600),
547 raw_exit_code: 0,
548 };
549 let md = generate_markdown(&result, &MarkdownConfig::default());
550 assert!(md.contains("600.00s"));
551 }
552
553 #[test]
554 fn markdown_plugin_on_event_is_noop() {
555 let mut r = MarkdownReporter::new(MarkdownConfig::default());
556 assert!(
557 r.on_event(&crate::events::TestEvent::Warning {
558 message: "x".into()
559 })
560 .is_ok()
561 );
562 assert!(r.output().is_empty());
563 }
564
565 #[test]
566 fn markdown_plugin_shutdown() {
567 let mut r = MarkdownReporter::new(MarkdownConfig::default());
568 assert!(r.shutdown().is_ok());
569 }
570
571 #[test]
572 fn markdown_multiple_failures_different_suites() {
573 let result = TestRunResult {
574 suites: vec![
575 TestSuite {
576 name: "a".into(),
577 tests: vec![make_failed_test("f1", 1, "err1")],
578 },
579 TestSuite {
580 name: "b".into(),
581 tests: vec![make_failed_test("f2", 2, "err2")],
582 },
583 ],
584 duration: Duration::from_millis(10),
585 raw_exit_code: 1,
586 };
587 let md = generate_markdown(&result, &MarkdownConfig::default());
588 assert!(md.contains("a::f1"));
589 assert!(md.contains("b::f2"));
590 assert!(md.contains("err1"));
591 assert!(md.contains("err2"));
592 }
593
594 #[test]
595 fn markdown_error_without_location() {
596 let result = TestRunResult {
597 suites: vec![TestSuite {
598 name: "s".into(),
599 tests: vec![TestCase {
600 name: "t".into(),
601 status: TestStatus::Failed,
602 duration: Duration::from_millis(1),
603 error: Some(TestError {
604 message: "no loc".into(),
605 location: None,
606 }),
607 }],
608 }],
609 duration: Duration::from_millis(10),
610 raw_exit_code: 1,
611 };
612 let md = generate_markdown(&result, &MarkdownConfig::default());
613 assert!(md.contains("no loc"));
614 }
615
616 #[test]
617 fn markdown_suite_with_all_status_types() {
618 let result = TestRunResult {
619 suites: vec![TestSuite {
620 name: "mixed".into(),
621 tests: vec![
622 make_test("pass", TestStatus::Passed, 1),
623 make_failed_test("fail", 2, "oops"),
624 make_test("skip", TestStatus::Skipped, 0),
625 ],
626 }],
627 duration: Duration::from_millis(10),
628 raw_exit_code: 1,
629 };
630 let md = generate_markdown(&result, &MarkdownConfig::default());
631 assert!(md.contains("Pass"));
632 assert!(md.contains("Fail"));
633 assert!(md.contains("Skip"));
634 }
635}