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 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 let failure_messages = parse_cargo_failures(&combined);
70
71 for line in combined.lines() {
72 let trimmed = line.trim();
73
74 if trimmed.starts_with("running ")
76 && (trimmed.ends_with(" tests") || trimmed.ends_with(" test"))
77 {
78 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 if trimmed.starts_with("test result:") {
90 continue;
91 }
92
93 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..]; 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 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 if let Some(last_sep) = name.rfind("::") {
120 current_suite_name = name[..last_sep].to_string();
121 }
122
123 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 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 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 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 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 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
216fn 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 if trimmed.starts_with("---- ") && trimmed.ends_with(" stdout ----") {
231 let test_name = &trimmed[5..trimmed.len() - 12].trim();
232 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 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 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 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 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}