Skip to main content

testx/adapters/
rust.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, ensure_non_empty};
8use super::{
9    ConfidenceScore, DetectionResult, TestAdapter, TestCase, TestRunResult, TestStatus, TestSuite,
10};
11
12pub struct RustAdapter;
13
14impl Default for RustAdapter {
15    fn default() -> Self {
16        Self::new()
17    }
18}
19
20impl RustAdapter {
21    pub fn new() -> Self {
22        Self
23    }
24}
25
26impl TestAdapter for RustAdapter {
27    fn name(&self) -> &str {
28        "Rust"
29    }
30
31    fn check_runner(&self) -> Option<String> {
32        if which::which("cargo").is_err() {
33            Some("cargo".into())
34        } else {
35            None
36        }
37    }
38
39    fn detect(&self, project_dir: &Path) -> Option<DetectionResult> {
40        let cargo_toml = project_dir.join("Cargo.toml");
41        if !cargo_toml.exists() {
42            return None;
43        }
44
45        // Distinguish workspace roots from package roots
46        let is_workspace = std::fs::read_to_string(&cargo_toml)
47            .map(|content| content.contains("[workspace]"))
48            .unwrap_or(false);
49
50        let has_package = std::fs::read_to_string(&cargo_toml)
51            .map(|content| content.contains("[package]"))
52            .unwrap_or(false);
53
54        // Pure workspace root with no [package] — cargo test still works
55        // (runs all member tests) but confidence is lower
56        let framework = if is_workspace && !has_package {
57            "cargo test (workspace)"
58        } else if is_workspace {
59            "cargo test (workspace+package)"
60        } else {
61            "cargo test"
62        };
63
64        let confidence = ConfidenceScore::base(0.50)
65            .signal(0.20, project_dir.join("tests").is_dir())
66            .signal(0.10, project_dir.join("Cargo.lock").exists())
67            .signal(0.10, which::which("cargo").is_ok())
68            .signal(0.05, project_dir.join("src").is_dir())
69            // Pure workspace roots without src/ are less likely to be "the" test target
70            .signal(
71                -0.10,
72                is_workspace && !has_package && !project_dir.join("src").is_dir(),
73            )
74            .finish();
75
76        Some(DetectionResult {
77            language: "Rust".into(),
78            framework: framework.into(),
79            confidence,
80        })
81    }
82
83    fn build_command(&self, project_dir: &Path, extra_args: &[String]) -> Result<Command> {
84        let mut cmd = Command::new("cargo");
85        cmd.arg("test");
86
87        // Try to enable per-test timing on nightly (silently ignored on stable
88        // since the caller's extra_args might conflict). We detect nightly by
89        // probing `cargo +nightly` availability, but that's too expensive.
90        // Instead we rely on users passing `-- -Z unstable-options --report-time`
91        // manually if on nightly.
92
93        for arg in extra_args {
94            cmd.arg(arg);
95        }
96
97        cmd.current_dir(project_dir);
98        Ok(cmd)
99    }
100
101    fn filter_args(&self, pattern: &str) -> Vec<String> {
102        // cargo test uses positional args as substring/regex filters
103        vec![pattern.to_string()]
104    }
105
106    fn parse_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> TestRunResult {
107        let combined = combined_output(stdout, stderr);
108        let mut suites: Vec<TestSuite> = Vec::new();
109        let mut current_suite_name = String::from("tests");
110        let mut current_tests: Vec<TestCase> = Vec::new();
111
112        // First pass: collect failure messages
113        let failure_messages = parse_cargo_failures(&combined);
114
115        for line in combined.lines() {
116            let trimmed = line.trim();
117
118            // "running X tests" or "running X test"
119            if trimmed.starts_with("running ")
120                && (trimmed.ends_with(" tests") || trimmed.ends_with(" test"))
121            {
122                // If we had tests from a previous suite, flush
123                if !current_tests.is_empty() {
124                    suites.push(TestSuite {
125                        name: current_suite_name.clone(),
126                        tests: std::mem::take(&mut current_tests),
127                    });
128                }
129                continue;
130            }
131
132            // "test result: ok. X passed; Y failed; Z ignored;"
133            if trimmed.starts_with("test result:") {
134                continue;
135            }
136
137            // "test module::test_name ... ok"
138            // "test module::test_name ... ok <0.001s>"  (--report-time on nightly)
139            // "test module::test_name ... FAILED"
140            // "test module::test_name ... ignored"
141            if let Some(without_prefix) = trimmed.strip_prefix("test ") {
142                let (status, time_suffix) = if let Some(rest) = trimmed.strip_suffix(" ok") {
143                    (Some(TestStatus::Passed), rest)
144                } else if trimmed.ends_with(" FAILED") {
145                    (Some(TestStatus::Failed), trimmed)
146                } else if trimmed.ends_with(" ignored") {
147                    (Some(TestStatus::Skipped), trimmed)
148                } else {
149                    (None, trimmed)
150                };
151
152                let Some(status) = status else {
153                    continue;
154                };
155
156                // Parse test name — strip " ... ok" / " ... FAILED" / " ... ignored"
157                let name = if let Some(idx) = without_prefix.rfind(" ... ") {
158                    without_prefix[..idx].to_string()
159                } else {
160                    without_prefix.to_string()
161                };
162
163                // Extract module name as suite
164                if let Some(last_sep) = name.rfind("::") {
165                    current_suite_name = name[..last_sep].to_string();
166                }
167
168                // Try to parse per-test duration from --report-time output
169                // Format: "test name ... ok <0.123s>"
170                let duration = parse_report_time(time_suffix).unwrap_or(Duration::from_millis(0));
171
172                // Attach error message if this test failed
173                let error = if status == TestStatus::Failed {
174                    failure_messages
175                        .get(name.as_str())
176                        .map(|msg| super::TestError {
177                            message: msg.clone(),
178                            location: None,
179                        })
180                } else {
181                    None
182                };
183
184                current_tests.push(TestCase {
185                    name,
186                    status,
187                    duration,
188                    error,
189                });
190                continue;
191            }
192
193            // Compilation target: "Running unittests src/main.rs (target/debug/deps/testx-xxx)"
194            if trimmed.starts_with("Running ") {
195                if !current_tests.is_empty() {
196                    suites.push(TestSuite {
197                        name: current_suite_name.clone(),
198                        tests: std::mem::take(&mut current_tests),
199                    });
200                }
201
202                // Extract the source file as suite name
203                let parts: Vec<&str> = trimmed.split_whitespace().collect();
204                if parts.len() >= 3 {
205                    current_suite_name = parts[1..parts.len() - 1].join(" ");
206                }
207                continue;
208            }
209        }
210
211        // Flush remaining
212        if !current_tests.is_empty() {
213            suites.push(TestSuite {
214                name: current_suite_name,
215                tests: current_tests,
216            });
217        }
218
219        ensure_non_empty(&mut suites, exit_code, "tests");
220
221        // Parse total duration from "test result: ... finished in X.XXs"
222        let duration = parse_cargo_duration(&combined).unwrap_or(Duration::from_secs(0));
223
224        TestRunResult {
225            suites,
226            duration,
227            raw_exit_code: exit_code,
228        }
229    }
230}
231
232fn parse_cargo_duration(output: &str) -> Option<Duration> {
233    // "test result: ok. 3 passed; 0 failed; 0 ignored; finished in 0.00s"
234    // or just look for "finished in X.XXs"
235    for line in output.lines() {
236        if let Some(idx) = line.find("finished in ") {
237            let after = &line[idx + 12..];
238            let num_str: String = after
239                .chars()
240                .take_while(|c| c.is_ascii_digit() || *c == '.')
241                .collect();
242            if let Ok(secs) = num_str.parse::<f64>() {
243                return Some(duration_from_secs_safe(secs));
244            }
245        }
246    }
247    None
248}
249
250/// Parse per-test execution time from `--report-time` output (nightly).
251/// Format: "test name ... ok <0.123s>" — we look for `<X.XXXs>` at line end.
252fn parse_report_time(line: &str) -> Option<Duration> {
253    let trimmed = line.trim();
254    if let Some(start) = trimmed.rfind('<')
255        && let Some(end) = trimmed.rfind('>')
256        && start < end
257    {
258        let inner = &trimmed[start + 1..end];
259        let num_str = inner.trim_end_matches('s');
260        if let Ok(secs) = num_str.parse::<f64>() {
261            return Some(duration_from_secs_safe(secs));
262        }
263    }
264    None
265}
266
267/// Parse cargo test failure blocks to extract error messages per test.
268/// Looks for patterns like:
269/// ```text
270/// ---- tests::test_name stdout ----
271/// thread 'tests::test_name' panicked at 'assertion failed: ...'
272/// ```
273fn parse_cargo_failures(output: &str) -> std::collections::HashMap<&str, String> {
274    let mut failures = std::collections::HashMap::new();
275    let lines: Vec<&str> = output.lines().collect();
276
277    let mut i = 0;
278    while i < lines.len() {
279        let trimmed = lines[i].trim();
280        // Match "---- test_name stdout ----"
281        if trimmed.starts_with("---- ") && trimmed.ends_with(" stdout ----") {
282            let test_name = &trimmed[5..trimmed.len() - 12].trim();
283            // Collect the panic message from subsequent lines
284            let mut msg_lines = Vec::new();
285            i += 1;
286            while i < lines.len() {
287                let l = lines[i].trim();
288                if l.starts_with("---- ") || l == "failures:" || l.starts_with("test result:") {
289                    break;
290                }
291                if l.starts_with("thread '") && l.contains("panicked at") {
292                    // Extract just the panic message
293                    if let Some(at_idx) = l.find("panicked at ") {
294                        let msg = &l[at_idx + 12..];
295                        let msg = msg.trim_matches('\'').trim_matches('"');
296                        msg_lines.push(msg.to_string());
297                    }
298                } else if !l.is_empty()
299                    && !l.starts_with("note:")
300                    && !l.starts_with("stack backtrace:")
301                {
302                    msg_lines.push(l.to_string());
303                }
304                i += 1;
305            }
306            if !msg_lines.is_empty() {
307                failures.insert(*test_name, msg_lines.join(" | "));
308            }
309            continue;
310        }
311        i += 1;
312    }
313    failures
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn parse_cargo_test_output() {
322        let stdout = r#"
323running 3 tests
324test tests::test_add ... ok
325test tests::test_subtract ... ok
326test tests::test_multiply ... FAILED
327
328failures:
329
330---- tests::test_multiply stdout ----
331thread 'tests::test_multiply' panicked at 'assertion failed'
332
333failures:
334    tests::test_multiply
335
336test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
337"#;
338        let adapter = RustAdapter::new();
339        let result = adapter.parse_output(stdout, "", 101);
340
341        assert_eq!(result.total_tests(), 3);
342        assert_eq!(result.total_passed(), 2);
343        assert_eq!(result.total_failed(), 1);
344        assert!(!result.is_success());
345        assert_eq!(result.duration, Duration::from_millis(10));
346
347        // Verify error message was captured
348        let failed = &result.suites[0].failures();
349        assert_eq!(failed.len(), 1);
350        assert!(failed[0].error.is_some());
351        assert!(
352            failed[0]
353                .error
354                .as_ref()
355                .unwrap()
356                .message
357                .contains("assertion failed")
358        );
359    }
360
361    #[test]
362    fn parse_cargo_all_pass() {
363        let stdout = r#"
364running 2 tests
365test tests::test_a ... ok
366test tests::test_b ... ok
367
368test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
369"#;
370        let adapter = RustAdapter::new();
371        let result = adapter.parse_output(stdout, "", 0);
372
373        assert_eq!(result.total_passed(), 2);
374        assert!(result.is_success());
375    }
376
377    #[test]
378    fn parse_cargo_with_ignored() {
379        let stdout = r#"
380running 3 tests
381test tests::test_a ... ok
382test tests::test_b ... ignored
383test tests::test_c ... ok
384
385test result: ok. 2 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
386"#;
387        let adapter = RustAdapter::new();
388        let result = adapter.parse_output(stdout, "", 0);
389
390        assert_eq!(result.total_passed(), 2);
391        assert_eq!(result.total_skipped(), 1);
392        assert!(result.is_success());
393    }
394
395    #[test]
396    fn parse_cargo_duration_extraction() {
397        assert_eq!(
398            parse_cargo_duration(
399                "test result: ok. 2 passed; 0 failed; 0 ignored; finished in 1.23s"
400            ),
401            Some(Duration::from_millis(1230))
402        );
403        assert_eq!(parse_cargo_duration("no duration"), None);
404    }
405
406    #[test]
407    fn parse_cargo_empty_output() {
408        let adapter = RustAdapter::new();
409        let result = adapter.parse_output("", "", 0);
410
411        assert_eq!(result.total_tests(), 1);
412        assert!(result.is_success());
413    }
414
415    #[test]
416    fn detect_rust_project() {
417        let dir = tempfile::tempdir().unwrap();
418        std::fs::write(
419            dir.path().join("Cargo.toml"),
420            "[package]\nname = \"test\"\n",
421        )
422        .unwrap();
423        let adapter = RustAdapter::new();
424        let det = adapter.detect(dir.path()).unwrap();
425        assert_eq!(det.framework, "cargo test");
426    }
427
428    #[test]
429    fn detect_no_rust() {
430        let dir = tempfile::tempdir().unwrap();
431        let adapter = RustAdapter::new();
432        assert!(adapter.detect(dir.path()).is_none());
433    }
434
435    #[test]
436    fn parse_cargo_multiple_targets() {
437        let stdout = r#"
438   Compiling testx v0.1.0
439     Running unittests src/lib.rs (target/debug/deps/testx-abc123)
440
441running 2 tests
442test lib_test_a ... ok
443test lib_test_b ... ok
444
445test result: ok. 2 passed; 0 failed; 0 ignored; finished in 0.01s
446
447     Running unittests src/main.rs (target/debug/deps/testx-def456)
448
449running 1 test
450test main_test ... ok
451
452test result: ok. 1 passed; 0 failed; 0 ignored; finished in 0.00s
453"#;
454        let adapter = RustAdapter::new();
455        let result = adapter.parse_output(stdout, "", 0);
456
457        assert_eq!(result.total_tests(), 3);
458        assert_eq!(result.total_passed(), 3);
459        assert!(result.is_success());
460        assert!(result.suites.len() >= 2);
461    }
462
463    #[test]
464    fn parse_cargo_all_failures() {
465        let stdout = r#"
466running 2 tests
467test tests::test_x ... FAILED
468test tests::test_y ... FAILED
469
470failures:
471
472---- tests::test_x stdout ----
473thread 'tests::test_x' panicked at 'not yet implemented'
474
475---- tests::test_y stdout ----
476thread 'tests::test_y' panicked at 'todo'
477
478failures:
479    tests::test_x
480    tests::test_y
481
482test result: FAILED. 0 passed; 2 failed; 0 ignored; finished in 0.02s
483"#;
484        let adapter = RustAdapter::new();
485        let result = adapter.parse_output(stdout, "", 101);
486
487        assert_eq!(result.total_tests(), 2);
488        assert_eq!(result.total_failed(), 2);
489        assert_eq!(result.total_passed(), 0);
490        assert!(!result.is_success());
491
492        // Both should have error messages
493        let suite = &result.suites[0];
494        for tc in suite.failures() {
495            assert!(tc.error.is_some());
496        }
497    }
498
499    #[test]
500    fn parse_cargo_stderr_output() {
501        // Some output goes to stderr (compilation messages)
502        let stderr = r#"
503running 1 test
504test basic ... ok
505
506test result: ok. 1 passed; 0 failed; 0 ignored; finished in 0.00s
507"#;
508        let adapter = RustAdapter::new();
509        let result = adapter.parse_output("", stderr, 0);
510
511        assert_eq!(result.total_tests(), 1);
512        assert!(result.is_success());
513    }
514
515    #[test]
516    fn slowest_tests_ordering() {
517        let result = TestRunResult {
518            suites: vec![TestSuite {
519                name: "tests".into(),
520                tests: vec![
521                    TestCase {
522                        name: "fast".into(),
523                        status: TestStatus::Passed,
524                        duration: Duration::from_millis(10),
525                        error: None,
526                    },
527                    TestCase {
528                        name: "slow".into(),
529                        status: TestStatus::Passed,
530                        duration: Duration::from_millis(500),
531                        error: None,
532                    },
533                    TestCase {
534                        name: "medium".into(),
535                        status: TestStatus::Passed,
536                        duration: Duration::from_millis(100),
537                        error: None,
538                    },
539                ],
540            }],
541            duration: Duration::from_millis(610),
542            raw_exit_code: 0,
543        };
544
545        let slowest = result.slowest_tests(2);
546        assert_eq!(slowest.len(), 2);
547        assert_eq!(slowest[0].1.name, "slow");
548        assert_eq!(slowest[1].1.name, "medium");
549    }
550
551    #[test]
552    fn test_suite_helpers() {
553        let suite = TestSuite {
554            name: "test".into(),
555            tests: vec![
556                TestCase {
557                    name: "a".into(),
558                    status: TestStatus::Passed,
559                    duration: Duration::from_millis(0),
560                    error: None,
561                },
562                TestCase {
563                    name: "b".into(),
564                    status: TestStatus::Failed,
565                    duration: Duration::from_millis(0),
566                    error: Some(crate::adapters::TestError {
567                        message: "boom".into(),
568                        location: None,
569                    }),
570                },
571                TestCase {
572                    name: "c".into(),
573                    status: TestStatus::Skipped,
574                    duration: Duration::from_millis(0),
575                    error: None,
576                },
577            ],
578        };
579
580        assert_eq!(suite.passed(), 1);
581        assert_eq!(suite.failed(), 1);
582        assert_eq!(suite.skipped(), 1);
583        assert!(!suite.is_passed());
584        assert_eq!(suite.failures().len(), 1);
585        assert_eq!(suite.failures()[0].name, "b");
586    }
587}