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