Skip to main content

testx/plugin/reporters/
markdown.rs

1//! Markdown reporter plugin.
2//!
3//! Generates a Markdown-formatted test report suitable for
4//! embedding in READMEs, GitHub PR comments, or CI artifacts.
5
6use 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/// Escape text for safe use in Markdown table cells and headings.
15fn md_escape(s: &str) -> String {
16    s.replace('|', "\\|")
17        .replace('<', "&lt;")
18        .replace('>', "&gt;")
19        .replace('[', "\\[")
20        .replace(']', "\\]")
21}
22
23/// Markdown reporter configuration.
24#[derive(Debug, Clone)]
25pub struct MarkdownConfig {
26    /// Output file path (None = stdout)
27    pub output_path: Option<String>,
28    /// Whether to include individual test details
29    pub include_details: bool,
30    /// Whether to include the timestamp header
31    pub include_timestamp: bool,
32    /// Maximum number of slowest tests to show
33    pub show_slowest: usize,
34    /// Whether to show error messages for failed tests
35    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
50/// Markdown reporter plugin.
51pub 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    /// Get the generated markdown output.
65    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
89/// Generate a complete Markdown report from test results.
90pub fn generate_markdown(result: &TestRunResult, config: &MarkdownConfig) -> String {
91    let mut md = String::with_capacity(4096);
92
93    // Header
94    write_header(&mut md, result, config);
95
96    // Summary table
97    write_summary(&mut md, result);
98
99    // Suite details
100    if config.include_details {
101        write_suite_details(&mut md, result, config);
102    }
103
104    // Failures section
105    if config.show_errors && result.total_failed() > 0 {
106        write_failures(&mut md, result);
107    }
108
109    // Slowest tests
110    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        // Show inline errors if configured
207        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")); // only 1 suite
446    }
447
448    // ─── Edge Case Tests ────────────────────────────────────────────────
449
450    #[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")); // 0 failed = pass
461    }
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, // more than available
517            ..Default::default()
518        };
519        let md = generate_markdown(&make_result(), &config);
520        // Should still show section with available tests
521        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        // Markdown doesn't need HTML escaping but names should be present
536        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)], // 10 minutes
545            }],
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}