Skip to main content

testx/adapters/
elixir.rs

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        // Try verbose/trace parsing first
66        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        // Enrich with failure details
74        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
87/// Parse ExUnit output.
88///
89/// Format:
90/// ```text
91/// Compiling 1 file (.ex)
92/// ...
93///
94///   1) test adds two numbers (MyApp.CalculatorTest)
95///      test/calculator_test.exs:5
96///      Assertion with == failed
97///      left:  3
98///      right: 4
99///
100/// Finished in 0.03 seconds (0.02s async, 0.01s sync)
101/// 3 tests, 1 failure
102/// ```
103fn 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        // Summary line: "3 tests, 1 failure" or "3 tests, 1 failure, 1 excluded"
110        // Also: "3 doctests, 3 tests, 0 failures"
111        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    // "Finished in 0.03 seconds (0.02s async, 0.01s sync)"
184    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
203// ─── ExUnit --trace Verbose Parser ──────────────────────────────────────────
204
205/// Parse ExUnit `--trace` output.
206///
207/// ```text
208///   * test greets the world (0.00ms) [L#4]
209///   * test adds two numbers (0.01ms) [L#8]
210///   * test handles nil input (1.2ms) [L#12]
211/// ```
212fn 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        // Module header line: "MyApp.CalculatorTest [test/calculator_test.exs]"
221        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        // Test line: "  * test greets the world (0.00ms) [L#4]"
233        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        // Doctest line: "  * doctest MyApp.Calculator.add/2 (1) (0.00ms) [L#3]"
247        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
271/// Parse a trace test line after "* test ".
272/// Input: "greets the world (0.00ms) [L#4]"
273/// Returns: (name, duration, status)
274fn parse_trace_test_line(s: &str) -> (String, Duration, TestStatus) {
275    // Check for "(excluded)" marker
276    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    // Extract duration from "(0.01ms)" or "(1.2ms)"
282    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    // Remove trailing "[L#N]" location marker
301    if let Some(bracket_idx) = name.rfind('[') {
302        name = name[..bracket_idx].trim().to_string();
303    }
304
305    // Check for failure marker in the original line
306    // Failed tests in trace mode still show duration but are followed by failure blocks
307    // We mark them as passed here; failures are enriched separately
308    if s.contains("** (ExUnit.AssertionError)") {
309        status = TestStatus::Failed;
310    }
311
312    (name, duration, status)
313}
314
315// ─── ExUnit Failure Block Parser ────────────────────────────────────────────
316
317/// A parsed failure from ExUnit output.
318#[derive(Debug, Clone)]
319#[allow(dead_code)]
320struct ExUnitFailure {
321    /// Test name
322    name: String,
323    /// Module name
324    module: String,
325    /// Error message
326    message: String,
327    /// Source location
328    location: Option<String>,
329}
330
331/// Parse ExUnit failure blocks.
332///
333/// ```text
334///   1) test adds two numbers (MyApp.CalculatorTest)
335///      test/calculator_test.exs:5
336///      Assertion with == failed
337///      code:  assert 1 + 1 == 3
338///      left:  2
339///      right: 3
340/// ```
341fn 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        // Failure header: "  1) test adds two numbers (MyApp.CalculatorTest)"
353        if let Some((num_rest, module_paren)) = parse_exunit_failure_header(trimmed) {
354            // Save previous
355            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            // Location line: "     test/calculator_test.exs:5"
374            if trimmed.starts_with("test/") || trimmed.starts_with("lib/") {
375                current_location = Some(trimmed.to_string());
376            }
377            // End of failure block (empty line or next numbered failure)
378            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    // Save last
406    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
418/// Parse failure header like "1) test adds two numbers (MyApp.CalculatorTest)".
419/// Returns (test_name, module_name).
420fn parse_exunit_failure_header(line: &str) -> Option<(String, String)> {
421    // Must start with a digit
422    let first = line.chars().next()?;
423    if !first.is_ascii_digit() {
424        return None;
425    }
426
427    // Find ") test " or ") doctest "
428    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    // Extract module from trailing parentheses
440    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
451/// Enrich ExUnit test cases with error details from failure blocks.
452fn 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                    // Check if this test has a matching failure
461                    if let Some(failure) = failures
462                        .iter()
463                        .find(|f| f.name.contains(&test.name) || test.name.contains(&f.name))
464                    {
465                        // Mark as failed if it was parsed as passed but has a failure
466                        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    // ─── Trace Parser Tests ─────────────────────────────────────────────
585
586    #[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    // ─── Failure Extraction Tests ────────────────────────────────────────
644
645    #[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    // ─── Integration Tests ──────────────────────────────────────────────
700
701    #[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}