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/// Markdown reporter configuration.
15#[derive(Debug, Clone)]
16pub struct MarkdownConfig {
17    /// Output file path (None = stdout)
18    pub output_path: Option<String>,
19    /// Whether to include individual test details
20    pub include_details: bool,
21    /// Whether to include the timestamp header
22    pub include_timestamp: bool,
23    /// Maximum number of slowest tests to show
24    pub show_slowest: usize,
25    /// Whether to show error messages for failed tests
26    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
41/// Markdown reporter plugin.
42pub 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    /// Get the generated markdown output.
56    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
80/// Generate a complete Markdown report from test results.
81pub fn generate_markdown(result: &TestRunResult, config: &MarkdownConfig) -> String {
82    let mut md = String::with_capacity(4096);
83
84    // Header
85    write_header(&mut md, result, config);
86
87    // Summary table
88    write_summary(&mut md, result);
89
90    // Suite details
91    if config.include_details {
92        write_suite_details(&mut md, result, config);
93    }
94
95    // Failures section
96    if config.show_errors && result.total_failed() > 0 {
97        write_failures(&mut md, result);
98    }
99
100    // Slowest tests
101    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        // Show inline errors if configured
198        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")); // only 1 suite
437    }
438}