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