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::{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        // mix test uses --only for tag filter
71        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        // Try verbose/trace parsing first
78        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        // Enrich with failure details
86        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
99/// Parse ExUnit output.
100///
101/// Format:
102/// ```text
103/// Compiling 1 file (.ex)
104/// ...
105///
106///   1) test adds two numbers (MyApp.CalculatorTest)
107///      test/calculator_test.exs:5
108///      Assertion with == failed
109///      left:  3
110///      right: 4
111///
112/// Finished in 0.03 seconds (0.02s async, 0.01s sync)
113/// 3 tests, 1 failure
114/// ```
115fn 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        // Summary line: "3 tests, 1 failure" or "3 tests, 1 failure, 1 excluded"
122        // Also: "3 doctests, 3 tests, 0 failures"
123        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    // "Finished in 0.03 seconds (0.02s async, 0.01s sync)"
196    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
215// ─── ExUnit --trace Verbose Parser ──────────────────────────────────────────
216
217/// Parse ExUnit `--trace` output.
218///
219/// ```text
220///   * test greets the world (0.00ms) [L#4]
221///   * test adds two numbers (0.01ms) [L#8]
222///   * test handles nil input (1.2ms) [L#12]
223/// ```
224fn 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        // Module header line: "MyApp.CalculatorTest [test/calculator_test.exs]"
233        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        // Test line: "  * test greets the world (0.00ms) [L#4]"
245        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        // Doctest line: "  * doctest MyApp.Calculator.add/2 (1) (0.00ms) [L#3]"
259        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
283/// Parse a trace test line after "* test ".
284/// Input: "greets the world (0.00ms) [L#4]"
285/// Returns: (name, duration, status)
286fn parse_trace_test_line(s: &str) -> (String, Duration, TestStatus) {
287    // Check for "(excluded)" marker
288    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    // Extract duration from "(0.01ms)" or "(1.2ms)"
294    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    // Remove trailing "[L#N]" location marker
313    if let Some(bracket_idx) = name.rfind('[') {
314        name = name[..bracket_idx].trim().to_string();
315    }
316
317    // Check for failure marker in the original line
318    // Failed tests in trace mode still show duration but are followed by failure blocks
319    // We mark them as passed here; failures are enriched separately
320    if s.contains("** (ExUnit.AssertionError)") {
321        status = TestStatus::Failed;
322    }
323
324    (name, duration, status)
325}
326
327// ─── ExUnit Failure Block Parser ────────────────────────────────────────────
328
329/// A parsed failure from ExUnit output.
330#[derive(Debug, Clone)]
331#[allow(dead_code)]
332struct ExUnitFailure {
333    /// Test name
334    name: String,
335    /// Module name
336    module: String,
337    /// Error message
338    message: String,
339    /// Source location
340    location: Option<String>,
341}
342
343/// Parse ExUnit failure blocks.
344///
345/// ```text
346///   1) test adds two numbers (MyApp.CalculatorTest)
347///      test/calculator_test.exs:5
348///      Assertion with == failed
349///      code:  assert 1 + 1 == 3
350///      left:  2
351///      right: 3
352/// ```
353fn 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        // Failure header: "  1) test adds two numbers (MyApp.CalculatorTest)"
365        if let Some((num_rest, module_paren)) = parse_exunit_failure_header(trimmed) {
366            // Save previous
367            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            // Location line: "     test/calculator_test.exs:5"
386            if trimmed.starts_with("test/") || trimmed.starts_with("lib/") {
387                current_location = Some(trimmed.to_string());
388            }
389            // End of failure block (empty line or next numbered failure)
390            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    // Save last
418    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
430/// Parse failure header like "1) test adds two numbers (MyApp.CalculatorTest)".
431/// Returns (test_name, module_name).
432fn parse_exunit_failure_header(line: &str) -> Option<(String, String)> {
433    // Must start with a digit
434    let first = line.chars().next()?;
435    if !first.is_ascii_digit() {
436        return None;
437    }
438
439    // Find ") test " or ") doctest "
440    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    // Extract module from trailing parentheses
452    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
463/// Enrich ExUnit test cases with error details from failure blocks.
464fn 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                    // Check if this test has a matching failure
473                    if let Some(failure) = failures
474                        .iter()
475                        .find(|f| f.name.contains(&test.name) || test.name.contains(&f.name))
476                    {
477                        // Mark as failed if it was parsed as passed but has a failure
478                        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    // ─── Trace Parser Tests ─────────────────────────────────────────────
593
594    #[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    // ─── Failure Extraction Tests ────────────────────────────────────────
652
653    #[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    // ─── Integration Tests ──────────────────────────────────────────────
708
709    #[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}