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 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 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 .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 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 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 let failure_messages = parse_cargo_failures(&combined);
114
115 for line in combined.lines() {
116 let trimmed = line.trim();
117
118 if trimmed.starts_with("running ")
120 && (trimmed.ends_with(" tests") || trimmed.ends_with(" test"))
121 {
122 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 if trimmed.starts_with("test result:") {
134 continue;
135 }
136
137 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 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 if let Some(last_sep) = name.rfind("::") {
165 current_suite_name = name[..last_sep].to_string();
166 }
167
168 let duration = parse_report_time(time_suffix).unwrap_or(Duration::from_millis(0));
171
172 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 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 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 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 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 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
250fn 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
267fn 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 if trimmed.starts_with("---- ") && trimmed.ends_with(" stdout ----") {
282 let test_name = &trimmed[5..trimmed.len() - 12].trim();
283 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 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 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 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 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}