1use std::fmt::Write;
7use std::time::Duration;
8
9use crate::adapters::{TestRunResult, TestStatus, TestSuite};
10use crate::error;
11use crate::events::TestEvent;
12use crate::plugin::Plugin;
13
14#[derive(Debug, Clone)]
16pub struct GithubConfig {
17 pub annotations: bool,
19 pub groups: bool,
21 pub step_summary: bool,
23 pub problem_matcher: bool,
25}
26
27impl Default for GithubConfig {
28 fn default() -> Self {
29 Self {
30 annotations: true,
31 groups: true,
32 step_summary: true,
33 problem_matcher: false,
34 }
35 }
36}
37
38pub struct GithubReporter {
40 config: GithubConfig,
41 collected: Vec<String>,
42}
43
44impl GithubReporter {
45 pub fn new(config: GithubConfig) -> Self {
46 Self {
47 config,
48 collected: Vec::new(),
49 }
50 }
51
52 pub fn output(&self) -> &[String] {
54 &self.collected
55 }
56}
57
58impl Plugin for GithubReporter {
59 fn name(&self) -> &str {
60 "github"
61 }
62
63 fn version(&self) -> &str {
64 "1.0.0"
65 }
66
67 fn on_event(&mut self, _event: &TestEvent) -> error::Result<()> {
68 Ok(())
69 }
70
71 fn on_result(&mut self, result: &TestRunResult) -> error::Result<()> {
72 self.collected = generate_github_output(result, &self.config);
73 Ok(())
74 }
75}
76
77pub fn generate_github_output(result: &TestRunResult, config: &GithubConfig) -> Vec<String> {
79 let mut lines = Vec::new();
80
81 if config.problem_matcher {
82 write_problem_matcher(&mut lines);
83 }
84
85 for suite in &result.suites {
86 if config.groups {
87 write_group(&mut lines, suite);
88 }
89 if config.annotations {
90 write_annotations(&mut lines, suite);
91 }
92 }
93
94 if config.step_summary {
95 write_step_summary_commands(&mut lines, result);
96 }
97
98 let status = if result.is_success() {
100 "passed"
101 } else {
102 "failed"
103 };
104 lines.push(format!(
105 "::notice::testx: {} tests {status} ({} passed, {} failed, {} skipped) in {}",
106 result.total_tests(),
107 result.total_passed(),
108 result.total_failed(),
109 result.total_skipped(),
110 format_duration(result.duration),
111 ));
112
113 lines
114}
115
116fn write_problem_matcher(lines: &mut Vec<String>) {
117 lines.push("::add-matcher::testx-matcher.json".into());
118}
119
120fn write_group(lines: &mut Vec<String>, suite: &TestSuite) {
121 let icon = if suite.is_passed() { "✅" } else { "❌" };
122 lines.push(format!(
123 "::group::{icon} {} ({} tests, {} failed)",
124 suite.name,
125 suite.tests.len(),
126 suite.failed(),
127 ));
128
129 for test in &suite.tests {
130 let icon = match test.status {
131 TestStatus::Passed => "",
132 TestStatus::Failed => "",
133 TestStatus::Skipped => "⏭️",
134 };
135 lines.push(format!(" {icon} {} ({:?})", test.name, test.duration));
136 }
137
138 lines.push("::endgroup::".into());
139}
140
141fn write_annotations(lines: &mut Vec<String>, suite: &TestSuite) {
142 for test in suite.failures() {
143 let msg = test
144 .error
145 .as_ref()
146 .map(|e| e.message.clone())
147 .unwrap_or_else(|| "test failed".into());
148
149 if let Some(ref error) = test.error
151 && let Some(ref loc) = error.location
152 && let Some((file, line)) = parse_location(loc)
153 {
154 lines.push(format!(
155 "::error file={file},line={line},title={}::{}",
156 escape_workflow_value(&test.name),
157 escape_workflow_value(&msg),
158 ));
159 continue;
160 }
161
162 lines.push(format!(
163 "::error title={} ({})::{msg}",
164 escape_workflow_value(&test.name),
165 suite.name,
166 ));
167 }
168}
169
170fn write_step_summary_commands(lines: &mut Vec<String>, result: &TestRunResult) {
171 let mut md = String::with_capacity(1024);
172 let icon = if result.is_success() {
173 " Passed"
174 } else {
175 " Failed"
176 };
177
178 let _ = writeln!(md, "### Test Results — {icon}");
179 md.push('\n');
180 let _ = writeln!(md, "| Total | Passed | Failed | Skipped | Duration |");
181 let _ = writeln!(md, "| ----- | ------ | ------ | ------- | -------- |");
182 let _ = writeln!(
183 md,
184 "| {} | {} | {} | {} | {} |",
185 result.total_tests(),
186 result.total_passed(),
187 result.total_failed(),
188 result.total_skipped(),
189 format_duration(result.duration),
190 );
191
192 if result.total_failed() > 0 {
193 md.push('\n');
194 let _ = writeln!(md, "#### Failures");
195 md.push('\n');
196 for suite in &result.suites {
197 for test in suite.failures() {
198 let msg = test
199 .error
200 .as_ref()
201 .map(|e| e.message.clone())
202 .unwrap_or_else(|| "test failed".into());
203 let _ = writeln!(md, "- **{}::{}**: {}", suite.name, test.name, msg);
204 }
205 }
206 }
207
208 for line in md.lines() {
210 let escaped = line.replace('`', "\\`");
211 lines.push(format!("echo '{escaped}' >> $GITHUB_STEP_SUMMARY"));
212 }
213}
214
215fn parse_location(loc: &str) -> Option<(String, String)> {
217 let parts: Vec<&str> = loc.rsplitn(3, ':').collect();
219 if parts.len() == 3
220 && parts[0].chars().all(|c| c.is_ascii_digit())
221 && parts[1].chars().all(|c| c.is_ascii_digit())
222 {
223 return Some((parts[2].to_string(), parts[1].to_string()));
225 }
226 if parts.len() >= 2 && parts[0].chars().all(|c| c.is_ascii_digit()) && !parts[0].is_empty() {
227 let line = parts[0];
229 let file = &loc[..loc.len() - line.len() - 1];
230 return Some((file.to_string(), line.to_string()));
231 }
232 None
233}
234
235fn escape_workflow_value(s: &str) -> String {
237 s.replace('%', "%25")
238 .replace('\r', "%0D")
239 .replace('\n', "%0A")
240}
241
242fn format_duration(d: Duration) -> String {
243 let ms = d.as_millis();
244 if ms == 0 {
245 "<1ms".to_string()
246 } else if ms < 1000 {
247 format!("{ms}ms")
248 } else {
249 format!("{:.2}s", d.as_secs_f64())
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use crate::adapters::{TestCase, TestError, TestSuite};
257
258 fn make_test(name: &str, status: TestStatus, ms: u64) -> TestCase {
259 TestCase {
260 name: name.into(),
261 status,
262 duration: Duration::from_millis(ms),
263 error: None,
264 }
265 }
266
267 fn make_failed_test(name: &str, ms: u64, msg: &str, loc: Option<&str>) -> TestCase {
268 TestCase {
269 name: name.into(),
270 status: TestStatus::Failed,
271 duration: Duration::from_millis(ms),
272 error: Some(TestError {
273 message: msg.into(),
274 location: loc.map(String::from),
275 }),
276 }
277 }
278
279 fn make_result() -> TestRunResult {
280 TestRunResult {
281 suites: vec![
282 TestSuite {
283 name: "math".into(),
284 tests: vec![
285 make_test("add", TestStatus::Passed, 10),
286 make_failed_test("div", 5, "divide by zero", Some("math.rs:42")),
287 ],
288 },
289 TestSuite {
290 name: "strings".into(),
291 tests: vec![
292 make_test("concat", TestStatus::Passed, 15),
293 make_test("upper", TestStatus::Skipped, 0),
294 ],
295 },
296 ],
297 duration: Duration::from_millis(300),
298 raw_exit_code: 1,
299 }
300 }
301
302 #[test]
303 fn github_groups() {
304 let lines = generate_github_output(&make_result(), &GithubConfig::default());
305 assert!(lines.iter().any(|l| l.starts_with("::group::")));
306 assert!(lines.iter().any(|l| l == "::endgroup::"));
307 }
308
309 #[test]
310 fn github_annotations() {
311 let lines = generate_github_output(&make_result(), &GithubConfig::default());
312 let error_lines: Vec<_> = lines.iter().filter(|l| l.starts_with("::error")).collect();
313 assert_eq!(error_lines.len(), 1);
314 assert!(error_lines[0].contains("file=math.rs"));
315 assert!(error_lines[0].contains("line=42"));
316 }
317
318 #[test]
319 fn github_annotation_without_location() {
320 let result = TestRunResult {
321 suites: vec![TestSuite {
322 name: "t".into(),
323 tests: vec![make_failed_test("f1", 1, "boom", None)],
324 }],
325 duration: Duration::from_millis(10),
326 raw_exit_code: 1,
327 };
328 let lines = generate_github_output(&result, &GithubConfig::default());
329 let error_lines: Vec<_> = lines.iter().filter(|l| l.starts_with("::error")).collect();
330 assert_eq!(error_lines.len(), 1);
331 assert!(error_lines[0].contains("title=f1"));
332 }
333
334 #[test]
335 fn github_step_summary() {
336 let lines = generate_github_output(&make_result(), &GithubConfig::default());
337 let summary_lines: Vec<_> = lines
338 .iter()
339 .filter(|l| l.contains("GITHUB_STEP_SUMMARY"))
340 .collect();
341 assert!(!summary_lines.is_empty());
342 assert!(summary_lines.iter().any(|l| l.contains("Test Results")));
343 }
344
345 #[test]
346 fn github_notice_line() {
347 let lines = generate_github_output(&make_result(), &GithubConfig::default());
348 let notice = lines.iter().find(|l| l.starts_with("::notice::")).unwrap();
349 assert!(notice.contains("4 tests failed"));
350 }
351
352 #[test]
353 fn github_passing_notice() {
354 let result = TestRunResult {
355 suites: vec![TestSuite {
356 name: "t".into(),
357 tests: vec![make_test("t1", TestStatus::Passed, 1)],
358 }],
359 duration: Duration::from_millis(10),
360 raw_exit_code: 0,
361 };
362 let lines = generate_github_output(&result, &GithubConfig::default());
363 let notice = lines.iter().find(|l| l.starts_with("::notice::")).unwrap();
364 assert!(notice.contains("passed"));
365 }
366
367 #[test]
368 fn github_problem_matcher() {
369 let config = GithubConfig {
370 problem_matcher: true,
371 ..Default::default()
372 };
373 let lines = generate_github_output(&make_result(), &config);
374 assert!(lines[0].contains("add-matcher"));
375 }
376
377 #[test]
378 fn github_no_groups() {
379 let config = GithubConfig {
380 groups: false,
381 ..Default::default()
382 };
383 let lines = generate_github_output(&make_result(), &config);
384 assert!(!lines.iter().any(|l| l.starts_with("::group::")));
385 }
386
387 #[test]
388 fn github_plugin_trait() {
389 let mut reporter = GithubReporter::new(GithubConfig::default());
390 assert_eq!(reporter.name(), "github");
391 reporter.on_result(&make_result()).unwrap();
392 assert!(!reporter.output().is_empty());
393 }
394
395 #[test]
396 fn parse_location_simple() {
397 let (file, line) = parse_location("test.rs:42").unwrap();
398 assert_eq!(file, "test.rs");
399 assert_eq!(line, "42");
400 }
401
402 #[test]
403 fn parse_location_with_column() {
404 let (file, line) = parse_location("test.rs:42:10").unwrap();
405 assert_eq!(file, "test.rs");
406 assert_eq!(line, "42");
407 }
408
409 #[test]
410 fn parse_location_invalid() {
411 assert!(parse_location("no_colon").is_none());
412 }
413
414 #[test]
415 fn escape_workflow_newlines() {
416 let escaped = escape_workflow_value("line1\nline2");
417 assert_eq!(escaped, "line1%0Aline2");
418 }
419
420 #[test]
421 fn escape_workflow_percent() {
422 let escaped = escape_workflow_value("100%");
423 assert_eq!(escaped, "100%25");
424 }
425}