1use std::path::Path;
2use std::process::Command;
3use std::time::Duration;
4
5use anyhow::Result;
6
7use super::util::duration_from_secs_safe;
8use super::{
9 DetectionResult, TestAdapter, TestCase, TestError, TestRunResult, TestStatus, TestSuite,
10};
11
12pub struct ElixirAdapter;
13
14impl Default for ElixirAdapter {
15 fn default() -> Self {
16 Self::new()
17 }
18}
19
20impl ElixirAdapter {
21 pub fn new() -> Self {
22 Self
23 }
24}
25
26impl TestAdapter for ElixirAdapter {
27 fn name(&self) -> &str {
28 "Elixir"
29 }
30
31 fn check_runner(&self) -> Option<String> {
32 if which::which("mix").is_err() {
33 return Some("mix not found. Install Elixir.".into());
34 }
35 None
36 }
37
38 fn detect(&self, project_dir: &Path) -> Option<DetectionResult> {
39 if !project_dir.join("mix.exs").exists() {
40 return None;
41 }
42
43 Some(DetectionResult {
44 language: "Elixir".into(),
45 framework: "ExUnit".into(),
46 confidence: 0.95,
47 })
48 }
49
50 fn build_command(&self, project_dir: &Path, extra_args: &[String]) -> Result<Command> {
51 let mut cmd = Command::new("mix");
52 cmd.arg("test");
53
54 for arg in extra_args {
55 cmd.arg(arg);
56 }
57
58 cmd.current_dir(project_dir);
59 Ok(cmd)
60 }
61
62 fn parse_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> TestRunResult {
63 let combined = format!("{}\n{}", stdout, stderr);
64
65 let trace_tests = parse_exunit_trace(&combined);
67 let suites = if trace_tests.iter().any(|s| !s.tests.is_empty()) {
68 trace_tests
69 } else {
70 parse_exunit_output(&combined, exit_code)
71 };
72
73 let failures = parse_exunit_failures(&combined);
75 let suites = enrich_exunit_errors(suites, &failures);
76
77 let duration = parse_exunit_duration(&combined).unwrap_or(Duration::from_secs(0));
78
79 TestRunResult {
80 suites,
81 duration,
82 raw_exit_code: exit_code,
83 }
84 }
85}
86
87fn parse_exunit_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
104 let mut tests = Vec::new();
105
106 for line in output.lines() {
107 let trimmed = line.trim();
108
109 if (trimmed.contains("test") || trimmed.contains("doctest")) && trimmed.contains("failure")
112 {
113 let mut total = 0usize;
114 let mut failures = 0usize;
115 let mut excluded = 0usize;
116
117 for part in trimmed.split(',') {
118 let part = part.trim();
119 let words: Vec<&str> = part.split_whitespace().collect();
120 if words.len() >= 2 {
121 let count: usize = words[0].parse().unwrap_or(0);
122 if words[1].starts_with("test") || words[1].starts_with("doctest") {
123 total += count;
124 } else if words[1].starts_with("failure") {
125 failures = count;
126 } else if words[1].starts_with("excluded") || words[1].starts_with("skipped") {
127 excluded = count;
128 }
129 }
130 }
131
132 if total > 0 || failures > 0 {
133 let passed = total.saturating_sub(failures + excluded);
134 for i in 0..passed {
135 tests.push(TestCase {
136 name: format!("test_{}", i + 1),
137 status: TestStatus::Passed,
138 duration: Duration::from_millis(0),
139 error: None,
140 });
141 }
142 for i in 0..failures {
143 tests.push(TestCase {
144 name: format!("failed_test_{}", i + 1),
145 status: TestStatus::Failed,
146 duration: Duration::from_millis(0),
147 error: None,
148 });
149 }
150 for i in 0..excluded {
151 tests.push(TestCase {
152 name: format!("excluded_test_{}", i + 1),
153 status: TestStatus::Skipped,
154 duration: Duration::from_millis(0),
155 error: None,
156 });
157 }
158 break;
159 }
160 }
161 }
162
163 if tests.is_empty() {
164 tests.push(TestCase {
165 name: "test_suite".into(),
166 status: if exit_code == 0 {
167 TestStatus::Passed
168 } else {
169 TestStatus::Failed
170 },
171 duration: Duration::from_millis(0),
172 error: None,
173 });
174 }
175
176 vec![TestSuite {
177 name: "tests".into(),
178 tests,
179 }]
180}
181
182fn parse_exunit_duration(output: &str) -> Option<Duration> {
183 for line in output.lines() {
185 if line.contains("Finished in")
186 && line.contains("second")
187 && let Some(idx) = line.find("Finished in")
188 {
189 let after = &line[idx + 12..];
190 let num_str: String = after
191 .trim()
192 .chars()
193 .take_while(|c| c.is_ascii_digit() || *c == '.')
194 .collect();
195 if let Ok(secs) = num_str.parse::<f64>() {
196 return Some(duration_from_secs_safe(secs));
197 }
198 }
199 }
200 None
201}
202
203fn parse_exunit_trace(output: &str) -> Vec<TestSuite> {
213 let mut suites_map: std::collections::HashMap<String, Vec<TestCase>> =
214 std::collections::HashMap::new();
215 let mut current_module = String::from("tests");
216
217 for line in output.lines() {
218 let trimmed = line.trim();
219
220 if !trimmed.starts_with('*')
222 && !trimmed.is_empty()
223 && trimmed.contains('[')
224 && trimmed.contains("test/")
225 {
226 if let Some(bracket_idx) = trimmed.find('[') {
227 current_module = trimmed[..bracket_idx].trim().to_string();
228 }
229 continue;
230 }
231
232 if let Some(rest) = trimmed.strip_prefix("* test ") {
234 let (name, duration, status) = parse_trace_test_line(rest);
235
236 suites_map
237 .entry(current_module.clone())
238 .or_default()
239 .push(TestCase {
240 name,
241 status,
242 duration,
243 error: None,
244 });
245 }
246 else if let Some(rest) = trimmed.strip_prefix("* doctest ") {
248 let (name, duration, status) = parse_trace_test_line(rest);
249
250 suites_map
251 .entry(current_module.clone())
252 .or_default()
253 .push(TestCase {
254 name: format!("doctest {}", name),
255 status,
256 duration,
257 error: None,
258 });
259 }
260 }
261
262 let mut suites: Vec<TestSuite> = suites_map
263 .into_iter()
264 .map(|(name, tests)| TestSuite { name, tests })
265 .collect();
266 suites.sort_by(|a, b| a.name.cmp(&b.name));
267
268 suites
269}
270
271fn parse_trace_test_line(s: &str) -> (String, Duration, TestStatus) {
275 if s.contains("(excluded)") {
277 let name = s.split("(excluded)").next().unwrap_or(s).trim().to_string();
278 return (name, Duration::from_millis(0), TestStatus::Skipped);
279 }
280
281 let mut name = s.to_string();
283 let mut duration = Duration::from_millis(0);
284 let mut status = TestStatus::Passed;
285
286 if let Some(paren_start) = s.find('(')
287 && let Some(paren_end) = s[paren_start..].find(')')
288 {
289 let time_str = &s[paren_start + 1..paren_start + paren_end];
290
291 if let Some(num) = time_str.strip_suffix("ms")
292 && let Ok(ms) = num.parse::<f64>()
293 {
294 duration = duration_from_secs_safe(ms / 1000.0);
295 }
296
297 name = s[..paren_start].trim().to_string();
298 }
299
300 if let Some(bracket_idx) = name.rfind('[') {
302 name = name[..bracket_idx].trim().to_string();
303 }
304
305 if s.contains("** (ExUnit.AssertionError)") {
309 status = TestStatus::Failed;
310 }
311
312 (name, duration, status)
313}
314
315#[derive(Debug, Clone)]
319#[allow(dead_code)]
320struct ExUnitFailure {
321 name: String,
323 module: String,
325 message: String,
327 location: Option<String>,
329}
330
331fn parse_exunit_failures(output: &str) -> Vec<ExUnitFailure> {
342 let mut failures = Vec::new();
343 let mut current_name: Option<String> = None;
344 let mut current_module = String::new();
345 let mut current_message = Vec::new();
346 let mut current_location: Option<String> = None;
347 let mut in_failure = false;
348
349 for line in output.lines() {
350 let trimmed = line.trim();
351
352 if let Some((num_rest, module_paren)) = parse_exunit_failure_header(trimmed) {
354 if let Some(name) = current_name.take() {
356 failures.push(ExUnitFailure {
357 name,
358 module: current_module.clone(),
359 message: current_message.join("\n").trim().to_string(),
360 location: current_location.take(),
361 });
362 }
363
364 current_name = Some(num_rest);
365 current_module = module_paren;
366 current_message.clear();
367 current_location = None;
368 in_failure = true;
369 continue;
370 }
371
372 if in_failure {
373 if trimmed.starts_with("test/") || trimmed.starts_with("lib/") {
375 current_location = Some(trimmed.to_string());
376 }
377 else if trimmed.is_empty() && !current_message.is_empty() {
379 if let Some(name) = current_name.take() {
380 failures.push(ExUnitFailure {
381 name,
382 module: current_module.clone(),
383 message: current_message.join("\n").trim().to_string(),
384 location: current_location.take(),
385 });
386 }
387 in_failure = false;
388 current_message.clear();
389 } else if trimmed.starts_with("Finished in") {
390 if let Some(name) = current_name.take() {
391 failures.push(ExUnitFailure {
392 name,
393 module: current_module.clone(),
394 message: current_message.join("\n").trim().to_string(),
395 location: current_location.take(),
396 });
397 }
398 break;
399 } else if !trimmed.is_empty() {
400 current_message.push(trimmed.to_string());
401 }
402 }
403 }
404
405 if let Some(name) = current_name {
407 failures.push(ExUnitFailure {
408 name,
409 module: current_module,
410 message: current_message.join("\n").trim().to_string(),
411 location: current_location,
412 });
413 }
414
415 failures
416}
417
418fn parse_exunit_failure_header(line: &str) -> Option<(String, String)> {
421 let first = line.chars().next()?;
423 if !first.is_ascii_digit() {
424 return None;
425 }
426
427 let test_marker = if line.contains(") test ") {
429 ") test "
430 } else if line.contains(") doctest ") {
431 ") doctest "
432 } else {
433 return None;
434 };
435
436 let marker_idx = line.find(test_marker)?;
437 let after_marker = &line[marker_idx + test_marker.len()..];
438
439 if let Some(paren_start) = after_marker.rfind('(') {
441 let name = after_marker[..paren_start].trim().to_string();
442 let module = after_marker[paren_start + 1..]
443 .trim_end_matches(')')
444 .to_string();
445 Some((name, module))
446 } else {
447 Some((after_marker.trim().to_string(), String::new()))
448 }
449}
450
451fn enrich_exunit_errors(suites: Vec<TestSuite>, failures: &[ExUnitFailure]) -> Vec<TestSuite> {
453 suites
454 .into_iter()
455 .map(|suite| {
456 let tests = suite
457 .tests
458 .into_iter()
459 .map(|mut test| {
460 if let Some(failure) = failures
462 .iter()
463 .find(|f| f.name.contains(&test.name) || test.name.contains(&f.name))
464 {
465 test.status = TestStatus::Failed;
467 if test.error.is_none() {
468 test.error = Some(TestError {
469 message: if failure.message.len() > 500 {
470 format!("{}...", &failure.message[..500])
471 } else {
472 failure.message.clone()
473 },
474 location: failure.location.clone(),
475 });
476 }
477 }
478 test
479 })
480 .collect();
481 TestSuite {
482 name: suite.name,
483 tests,
484 }
485 })
486 .collect()
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 #[test]
494 fn detect_elixir_project() {
495 let dir = tempfile::tempdir().unwrap();
496 std::fs::write(
497 dir.path().join("mix.exs"),
498 "defmodule MyApp.MixProject do\nend\n",
499 )
500 .unwrap();
501 let adapter = ElixirAdapter::new();
502 let det = adapter.detect(dir.path()).unwrap();
503 assert_eq!(det.language, "Elixir");
504 assert_eq!(det.framework, "ExUnit");
505 }
506
507 #[test]
508 fn detect_no_elixir() {
509 let dir = tempfile::tempdir().unwrap();
510 let adapter = ElixirAdapter::new();
511 assert!(adapter.detect(dir.path()).is_none());
512 }
513
514 #[test]
515 fn parse_exunit_with_failures() {
516 let stdout = r#"
517Compiling 1 file (.ex)
518..
519
520 1) test adds two numbers (MyApp.CalculatorTest)
521 test/calculator_test.exs:5
522 Assertion with == failed
523
524Finished in 0.03 seconds (0.02s async, 0.01s sync)
5253 tests, 1 failure
526"#;
527 let adapter = ElixirAdapter::new();
528 let result = adapter.parse_output(stdout, "", 1);
529
530 assert_eq!(result.total_tests(), 3);
531 assert_eq!(result.total_passed(), 2);
532 assert_eq!(result.total_failed(), 1);
533 }
534
535 #[test]
536 fn parse_exunit_all_pass() {
537 let stdout = "Finished in 0.01 seconds\n5 tests, 0 failures\n";
538 let adapter = ElixirAdapter::new();
539 let result = adapter.parse_output(stdout, "", 0);
540
541 assert_eq!(result.total_tests(), 5);
542 assert_eq!(result.total_passed(), 5);
543 assert!(result.is_success());
544 }
545
546 #[test]
547 fn parse_exunit_with_excluded() {
548 let stdout = "3 tests, 0 failures, 1 excluded\n";
549 let adapter = ElixirAdapter::new();
550 let result = adapter.parse_output(stdout, "", 0);
551
552 assert_eq!(result.total_tests(), 3);
553 assert_eq!(result.total_passed(), 2);
554 assert_eq!(result.total_skipped(), 1);
555 }
556
557 #[test]
558 fn parse_exunit_with_doctests() {
559 let stdout = "3 doctests, 5 tests, 0 failures\n";
560 let adapter = ElixirAdapter::new();
561 let result = adapter.parse_output(stdout, "", 0);
562
563 assert_eq!(result.total_tests(), 8);
564 assert_eq!(result.total_passed(), 8);
565 }
566
567 #[test]
568 fn parse_exunit_empty_output() {
569 let adapter = ElixirAdapter::new();
570 let result = adapter.parse_output("", "", 0);
571
572 assert_eq!(result.total_tests(), 1);
573 assert!(result.is_success());
574 }
575
576 #[test]
577 fn parse_exunit_duration_test() {
578 assert_eq!(
579 parse_exunit_duration("Finished in 0.03 seconds (0.02s async, 0.01s sync)"),
580 Some(Duration::from_millis(30))
581 );
582 }
583
584 #[test]
587 fn parse_exunit_trace_basic() {
588 let output = r#"
589MyApp.CalculatorTest [test/calculator_test.exs]
590 * test greets the world (0.00ms) [L#4]
591 * test adds two numbers (0.01ms) [L#8]
592 * test handles nil input (1.2ms) [L#12]
593
594Finished in 0.02 seconds
5953 tests, 0 failures
596"#;
597 let suites = parse_exunit_trace(output);
598 assert!(!suites.is_empty());
599
600 let suite = &suites[0];
601 assert_eq!(suite.tests.len(), 3);
602 assert_eq!(suite.tests[0].name, "greets the world");
603 assert_eq!(suite.tests[1].name, "adds two numbers");
604 }
605
606 #[test]
607 fn parse_exunit_trace_with_excluded() {
608 let output = " * test slow test (excluded) [L#20]\n * test fast test (0.01ms) [L#5]\n";
609 let suites = parse_exunit_trace(output);
610 let all_tests: Vec<_> = suites.iter().flat_map(|s| &s.tests).collect();
611
612 let excluded: Vec<_> = all_tests
613 .iter()
614 .filter(|t| t.status == TestStatus::Skipped)
615 .collect();
616 assert_eq!(excluded.len(), 1);
617 }
618
619 #[test]
620 fn parse_trace_test_line_with_duration() {
621 let (name, dur, status) = parse_trace_test_line("greets the world (0.50ms) [L#4]");
622 assert_eq!(name, "greets the world");
623 assert_eq!(status, TestStatus::Passed);
624 assert!(dur.as_micros() >= 490);
625 }
626
627 #[test]
628 fn parse_trace_test_line_excluded() {
629 let (name, _dur, status) = parse_trace_test_line("slow test (excluded) [L#20]");
630 assert_eq!(name, "slow test");
631 assert_eq!(status, TestStatus::Skipped);
632 }
633
634 #[test]
635 fn parse_exunit_trace_doctest() {
636 let output = " * doctest MyApp.Calculator.add/2 (1) (0.01ms) [L#3]\n";
637 let suites = parse_exunit_trace(output);
638 let all_tests: Vec<_> = suites.iter().flat_map(|s| &s.tests).collect();
639 assert_eq!(all_tests.len(), 1);
640 assert!(all_tests[0].name.starts_with("doctest"));
641 }
642
643 #[test]
646 fn parse_exunit_failure_blocks() {
647 let output = r#"
648 1) test adds two numbers (MyApp.CalculatorTest)
649 test/calculator_test.exs:5
650 Assertion with == failed
651 code: assert 1 + 1 == 3
652 left: 2
653 right: 3
654
655 2) test subtracts (MyApp.CalculatorTest)
656 test/calculator_test.exs:10
657 Assertion with == failed
658 left: 5
659 right: 3
660
661Finished in 0.03 seconds
662"#;
663 let failures = parse_exunit_failures(output);
664 assert_eq!(failures.len(), 2);
665
666 assert_eq!(failures[0].name, "adds two numbers");
667 assert_eq!(failures[0].module, "MyApp.CalculatorTest");
668 assert!(failures[0].message.contains("Assertion with == failed"));
669 assert_eq!(
670 failures[0].location.as_ref().unwrap(),
671 "test/calculator_test.exs:5"
672 );
673
674 assert_eq!(failures[1].name, "subtracts");
675 }
676
677 #[test]
678 fn parse_exunit_failure_header_parsing() {
679 let result = parse_exunit_failure_header("1) test adds numbers (MyApp.CalcTest)");
680 assert!(result.is_some());
681 let (name, module) = result.unwrap();
682 assert_eq!(name, "adds numbers");
683 assert_eq!(module, "MyApp.CalcTest");
684 }
685
686 #[test]
687 fn parse_exunit_failure_header_no_match() {
688 assert!(parse_exunit_failure_header("not a failure header").is_none());
689 assert!(parse_exunit_failure_header("Finished in 0.03 seconds").is_none());
690 }
691
692 #[test]
693 fn parse_exunit_failures_empty() {
694 let output = "Finished in 0.01 seconds\n5 tests, 0 failures\n";
695 let failures = parse_exunit_failures(output);
696 assert!(failures.is_empty());
697 }
698
699 #[test]
702 fn full_exunit_trace_with_failures() {
703 let stdout = r#"
704MyApp.CalculatorTest [test/calculator_test.exs]
705 * test adds two numbers (0.01ms) [L#4]
706 * test subtracts (0.01ms) [L#8]
707
708 1) test adds two numbers (MyApp.CalculatorTest)
709 test/calculator_test.exs:5
710 Assertion with == failed
711 left: 2
712 right: 3
713
714Finished in 0.03 seconds (0.02s async, 0.01s sync)
7152 tests, 1 failure
716"#;
717 let adapter = ElixirAdapter::new();
718 let result = adapter.parse_output(stdout, "", 1);
719
720 assert_eq!(result.total_failed(), 1);
721 }
722
723 #[test]
724 fn enrich_exunit_error_details() {
725 let suites = vec![TestSuite {
726 name: "tests".into(),
727 tests: vec![TestCase {
728 name: "failed_test_1".into(),
729 status: TestStatus::Failed,
730 duration: Duration::from_millis(0),
731 error: None,
732 }],
733 }];
734
735 let failures = vec![ExUnitFailure {
736 name: "failed_test_1".to_string(),
737 module: "MyApp.Test".to_string(),
738 message: "Assertion failed".to_string(),
739 location: Some("test/my_test.exs:5".to_string()),
740 }];
741
742 let enriched = enrich_exunit_errors(suites, &failures);
743 let test = &enriched[0].tests[0];
744 assert!(test.error.is_some());
745 assert!(
746 test.error
747 .as_ref()
748 .unwrap()
749 .message
750 .contains("Assertion failed")
751 );
752 }
753
754 #[test]
755 fn parse_exunit_trace_multiple_modules() {
756 let output = r#"
757MyApp.UserTest [test/user_test.exs]
758 * test create user (0.01ms) [L#4]
759
760MyApp.AdminTest [test/admin_test.exs]
761 * test admin access (0.02ms) [L#4]
762"#;
763 let suites = parse_exunit_trace(output);
764 assert_eq!(suites.len(), 2);
765 }
766}