Skip to main content

cargo_test_json_2_html/
lib.rs

1use bon::bon;
2use handlebars::Handlebars;
3use serde::{Deserialize, Serialize};
4use std::fmt::Debug;
5
6/// Trait for generating source code links
7pub trait SourceLinker: Debug + 'static {
8    fn link(&self, file: &str, line: u32) -> Option<String>;
9}
10
11/// Configuration for HTML generation
12pub struct Config {
13    /// Source linker implementation
14    source_linker: Box<dyn SourceLinker>,
15}
16
17#[bon]
18impl Config {
19    #[builder]
20    pub fn new(source_linker: impl SourceLinker) -> Self {
21        Self {
22            source_linker: Box::new(source_linker),
23        }
24    }
25}
26
27/// Default no-op source linker
28#[derive(Default, Debug)]
29pub struct NoSourceLinker;
30
31impl SourceLinker for NoSourceLinker {
32    fn link(&self, _file: &str, _line: u32) -> Option<String> {
33        None
34    }
35}
36
37impl Default for Config {
38    fn default() -> Self {
39        Self {
40            source_linker: Box::new(NoSourceLinker),
41        }
42    }
43}
44
45/// Cargo test JSON event types
46#[derive(Debug, Clone, Deserialize, Serialize)]
47#[serde(tag = "type")]
48pub enum TestEvent {
49    #[serde(rename = "suite")]
50    Suite {
51        event: String,
52        test_count: Option<u32>,
53        passed: Option<u32>,
54        failed: Option<u32>,
55        ignored: Option<u32>,
56        measured: Option<u32>,
57        filtered_out: Option<u32>,
58        exec_time: Option<f64>,
59    },
60    #[serde(rename = "test")]
61    Test {
62        event: String,
63        name: String,
64        stdout: Option<String>,
65        exec_time: Option<f64>,
66    },
67}
68
69/// Parsed test results
70#[derive(Debug, Default)]
71pub struct TestResults {
72    pub passed: Vec<TestEvent>,
73    pub failed: Vec<TestEvent>,
74    pub ignored: Vec<TestEvent>,
75    pub suite_info: Option<TestEvent>,
76    pub errors: Vec<String>,
77    pub raw_lines: Vec<String>,
78}
79
80/// Template data for rendering
81#[derive(Serialize)]
82struct TemplateData {
83    passed: Vec<TestEvent>,
84    failed: Vec<TestEvent>,
85    ignored: Vec<TestEvent>,
86    suite_info: Option<TestEvent>,
87    errors: Vec<String>,
88    raw_lines: Vec<String>,
89    passed_count: usize,
90    failed_count: usize,
91    ignored_count: usize,
92}
93
94/// Convert cargo test JSON output to HTML report
95///
96/// # Arguments
97/// * `json_input` - Raw JSON string from cargo test output (may contain non-JSON lines)
98/// * `config` - Configuration for HTML generation
99///
100/// # Returns
101/// HTML string containing the test report, including any parsing errors
102pub fn convert_to_html(json_input: &str, config: Config) -> String {
103    let results = parse_test_output(json_input);
104    render_html(&results, &config)
105}
106
107fn parse_test_output(input: &str) -> TestResults {
108    let mut results = TestResults::default();
109
110    for line in input.lines() {
111        let line = line.trim();
112        if line.is_empty() {
113            continue;
114        }
115
116        match serde_json::from_str::<TestEvent>(line) {
117            Ok(event) => match &event {
118                TestEvent::Suite { .. } => {
119                    results.suite_info = Some(event);
120                }
121                TestEvent::Test { event: status, .. } => match status.as_str() {
122                    "ok" => results.passed.push(event),
123                    "failed" => results.failed.push(event),
124                    "ignored" => results.ignored.push(event),
125                    _ => results.raw_lines.push(line.to_string()),
126                },
127            },
128            Err(e) => {
129                // Not JSON or invalid JSON - could be compilation output
130                if line.starts_with('{') {
131                    results
132                        .errors
133                        .push(format!("Failed to parse JSON: {} - Line: {}", e, line));
134                } else {
135                    results.raw_lines.push(line.to_string());
136                }
137            }
138        }
139    }
140
141    results
142}
143
144fn render_html(results: &TestResults, config: &Config) -> String {
145    let template_str = include_str!("../templates/report.hbs");
146
147    let mut handlebars = Handlebars::new();
148    handlebars
149        .register_template_string("report", template_str)
150        .expect("Failed to register template");
151
152    // Process tests to add source links
153    let processed_passed = results
154        .passed
155        .iter()
156        .map(|test| process_test_for_links(test, config))
157        .collect();
158    let processed_failed = results
159        .failed
160        .iter()
161        .map(|test| process_test_for_links(test, config))
162        .collect();
163    let processed_ignored = results
164        .ignored
165        .iter()
166        .map(|test| process_test_for_links(test, config))
167        .collect();
168
169    let data = TemplateData {
170        passed_count: results.passed.len(),
171        failed_count: results.failed.len(),
172        ignored_count: results.ignored.len(),
173        passed: processed_passed,
174        failed: processed_failed,
175        ignored: processed_ignored,
176        suite_info: results.suite_info.clone(),
177        errors: results.errors.clone(),
178        raw_lines: results.raw_lines.clone(),
179    };
180
181    handlebars.render("report", &data).unwrap_or_else(|e| {
182        format!(
183            "<html><body><h1>Template Error</h1><p>{}</p></body></html>",
184            e
185        )
186    })
187}
188
189fn process_test_for_links(test: &TestEvent, config: &Config) -> TestEvent {
190    match test {
191        TestEvent::Test {
192            event,
193            name,
194            stdout,
195            exec_time,
196        } => {
197            let processed_stdout = stdout.as_ref().map(|s| add_source_links(s, config));
198            TestEvent::Test {
199                event: event.clone(),
200                name: name.clone(),
201                stdout: processed_stdout,
202                exec_time: *exec_time,
203            }
204        }
205        other => other.clone(),
206    }
207}
208
209fn add_source_links(text: &str, config: &Config) -> String {
210    // First HTML escape the entire text to prevent XSS
211    let escaped_text = Handlebars::new().get_escape_fn()(text);
212
213    // Then add source links to the escaped text
214    let re = regex::Regex::new(r"at ([^:\s]+\.rs):(\d+):(\d+):").unwrap();
215
216    re.replace_all(&escaped_text, |caps: &regex::Captures| {
217        let file = &caps[1];
218        let line: u32 = caps[2].parse().unwrap_or(0);
219        let line_str = &caps[2];
220        let col_str = &caps[3];
221
222        if let Some(url) = config.source_linker.link(file, line) {
223            // URL is already safe since it comes from our SourceLinker
224            // File path is already escaped from the initial escape_text call
225            format!(
226                "at <a href=\"{}\" target=\"_blank\">{}:{}:{}</a>:",
227                html_escape::encode_text(&url),
228                file,
229                line_str,
230                col_str
231            )
232        } else {
233            caps[0].to_string()
234        }
235    })
236    .to_string()
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_basic_parsing() {
245        let input = r#"{ "type": "suite", "event": "started", "test_count": 3 }
246{ "type": "test", "event": "started", "name": "tests::test_pass" }
247{ "type": "test", "name": "tests::test_pass", "event": "ok", "stdout": "This test passes\n" }
248{ "type": "test", "event": "started", "name": "tests::test_fail" }
249{ "type": "test", "name": "tests::test_fail", "event": "failed", "stdout": "This test fails\n" }
250{ "type": "suite", "event": "failed", "passed": 1, "failed": 1, "ignored": 0 }"#;
251
252        let results = parse_test_output(input);
253        assert_eq!(results.passed.len(), 1);
254        assert_eq!(results.failed.len(), 1);
255        assert_eq!(results.ignored.len(), 0);
256        assert!(results.suite_info.is_some());
257    }
258
259    #[test]
260    fn test_mixed_content() {
261        let input = r#"   Compiling test-project v0.1.0
262{ "type": "test", "name": "tests::test_pass", "event": "ok" }
263Some non-JSON output
264{ "type": "suite", "event": "ok", "passed": 1, "failed": 0 }"#;
265
266        let results = parse_test_output(input);
267        assert_eq!(results.passed.len(), 1);
268        assert_eq!(results.raw_lines.len(), 2); // Compilation line + non-JSON line
269    }
270
271    #[test]
272    #[ignore]
273    fn test_intentionally_fails() {
274        let input = r#"{ "type": "test", "name": "test", "event": "ok" }"#;
275        let config = Config::default();
276        let html = convert_to_html(input, config);
277        assert!(html.contains("<div class=\"stat-number\">1</div>"));
278        assert!(html.contains("<div class=\"stat-label\">Passed</div>"));
279        assert!(html.contains("<div class=\"stat-number\">0</div>"));
280        assert!(html.contains("<div class=\"stat-label\">Failed</div>"));
281    }
282}