1use bon::bon;
2use handlebars::Handlebars;
3use serde::{Deserialize, Serialize};
4use std::fmt::Debug;
5
6pub trait SourceLinker: Debug + 'static {
8 fn link(&self, file: &str, line: u32) -> Option<String>;
9}
10
11pub struct Config {
13 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#[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#[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#[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#[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
94pub 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 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 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 let escaped_text = Handlebars::new().get_escape_fn()(text);
212
213 let re = regex::Regex::new(r"at ([^:\s]+\.rs):(\d+):(\d+):").unwrap();
215
216 re.replace_all(&escaped_text, |caps: ®ex::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 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); }
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}