1use std::process::Command;
2use std::time::Duration;
3
4use crate::adapters::{DetectionResult, TestCase, TestError, TestRunResult, TestStatus, TestSuite};
5
6pub fn duration_from_secs_safe(secs: f64) -> Duration {
9 if secs.is_finite() && secs >= 0.0 {
10 Duration::from_secs_f64(secs)
11 } else {
12 Duration::ZERO
13 }
14}
15
16pub fn combined_output(stdout: &str, stderr: &str) -> String {
18 let stdout = stdout.trim();
19 let stderr = stderr.trim();
20 if stdout.is_empty() {
21 return stderr.to_string();
22 }
23 if stderr.is_empty() {
24 return stdout.to_string();
25 }
26 format!("{}\n{}", stdout, stderr)
27}
28
29pub fn fallback_result(
32 exit_code: i32,
33 adapter_name: &str,
34 stdout: &str,
35 stderr: &str,
36) -> TestRunResult {
37 let status = if exit_code == 0 {
38 TestStatus::Passed
39 } else {
40 TestStatus::Failed
41 };
42
43 let error = if exit_code != 0 {
44 let combined = combined_output(stdout, stderr);
45 let message = if combined.is_empty() {
46 format!("{} exited with code {}", adapter_name, exit_code)
47 } else {
48 let lines: Vec<&str> = combined.lines().collect();
50 let start = lines.len().saturating_sub(10);
51 lines[start..].join("\n")
52 };
53 Some(TestError {
54 message,
55 location: None,
56 })
57 } else {
58 None
59 };
60
61 TestRunResult {
62 suites: vec![TestSuite {
63 name: adapter_name.to_string(),
64 tests: vec![TestCase {
65 name: format!("{} tests", adapter_name),
66 status,
67 duration: Duration::ZERO,
68 error,
69 }],
70 }],
71 duration: Duration::ZERO,
72 raw_exit_code: exit_code,
73 }
74}
75
76pub struct SummaryPatterns {
78 pub passed: &'static [&'static str],
79 pub failed: &'static [&'static str],
80 pub skipped: &'static [&'static str],
81}
82
83#[derive(Debug, Clone, Default)]
85pub struct SummaryCounts {
86 pub passed: usize,
87 pub failed: usize,
88 pub skipped: usize,
89 pub total: usize,
90 pub duration: Option<Duration>,
91}
92
93impl SummaryCounts {
94 pub fn has_any(&self) -> bool {
95 self.passed > 0 || self.failed > 0 || self.skipped > 0 || self.total > 0
96 }
97
98 pub fn computed_total(&self) -> usize {
99 if self.total > 0 {
100 self.total
101 } else {
102 self.passed + self.failed + self.skipped
103 }
104 }
105}
106
107pub fn synthetic_tests_from_counts(counts: &SummaryCounts, suite_name: &str) -> Vec<TestCase> {
110 let mut tests = Vec::new();
111
112 for i in 0..counts.passed {
113 tests.push(TestCase {
114 name: format!("test {} (passed)", i + 1),
115 status: TestStatus::Passed,
116 duration: Duration::ZERO,
117 error: None,
118 });
119 }
120
121 for i in 0..counts.failed {
122 tests.push(TestCase {
123 name: format!("test {} (failed)", i + 1),
124 status: TestStatus::Failed,
125 duration: Duration::ZERO,
126 error: Some(TestError {
127 message: format!("Test failed in {}", suite_name),
128 location: None,
129 }),
130 });
131 }
132
133 for i in 0..counts.skipped {
134 tests.push(TestCase {
135 name: format!("test {} (skipped)", i + 1),
136 status: TestStatus::Skipped,
137 duration: Duration::ZERO,
138 error: None,
139 });
140 }
141
142 tests
143}
144
145pub fn parse_duration_str(s: &str) -> Option<Duration> {
148 let s = s.trim().trim_matches(|c| c == '(' || c == ')');
149
150 if let Some(num) = s
152 .strip_suffix("ms")
153 .map(|n| n.trim())
154 .and_then(|n| n.parse::<f64>().ok())
155 {
156 return Some(duration_from_secs_safe(num / 1000.0));
157 }
158
159 let s_stripped = s
161 .strip_suffix("seconds")
162 .or_else(|| s.strip_suffix("secs"))
163 .or_else(|| s.strip_suffix("sec"))
164 .or_else(|| s.strip_suffix('s'))
165 .map(|n| n.trim());
166
167 if let Some(num) = s_stripped.and_then(|n| n.parse::<f64>().ok()) {
168 return Some(duration_from_secs_safe(num));
169 }
170
171 if let Some(num) = s
173 .strip_suffix("min")
174 .or_else(|| s.strip_suffix('m'))
175 .map(|n| n.trim())
176 .and_then(|n| n.parse::<f64>().ok())
177 {
178 return Some(duration_from_secs_safe(num * 60.0));
179 }
180
181 None
182}
183
184pub fn check_binary(name: &str) -> Option<String> {
186 which::which(name).ok().map(|p| p.display().to_string())
187}
188
189pub fn check_runner_binary(name: &str) -> Option<String> {
191 if which::which(name).is_err() {
192 Some(name.into())
193 } else {
194 None
195 }
196}
197
198pub fn extract_count(s: &str, keywords: &[&str]) -> Option<usize> {
201 for keyword in keywords {
202 if let Some(pos) = s.find(keyword) {
203 let before = &s[..pos].trim_end();
205 if let Some(num_str) = before.rsplit_once(|c: char| !c.is_ascii_digit()) {
207 if let Ok(n) = num_str.1.parse() {
208 return Some(n);
209 }
210 } else if let Ok(n) = before.parse() {
211 return Some(n);
212 }
213 }
214 }
215 None
216}
217
218pub fn parse_summary_line(line: &str, patterns: &SummaryPatterns) -> SummaryCounts {
220 SummaryCounts {
221 passed: extract_count(line, patterns.passed).unwrap_or(0),
222 failed: extract_count(line, patterns.failed).unwrap_or(0),
223 skipped: extract_count(line, patterns.skipped).unwrap_or(0),
224 total: 0,
225 duration: None,
226 }
227}
228
229pub fn make_detection(language: &str, framework: &str, confidence: f32) -> DetectionResult {
231 DetectionResult {
232 language: language.into(),
233 framework: framework.into(),
234 confidence,
235 }
236}
237
238pub fn build_test_command(
240 program: &str,
241 project_dir: &std::path::Path,
242 base_args: &[&str],
243 extra_args: &[String],
244) -> Command {
245 let mut cmd = Command::new(program);
246 for arg in base_args {
247 cmd.arg(arg);
248 }
249 for arg in extra_args {
250 cmd.arg(arg);
251 }
252 cmd.current_dir(project_dir);
253 cmd
254}
255
256pub fn xml_escape(s: &str) -> String {
258 s.replace('&', "&")
259 .replace('<', "<")
260 .replace('>', ">")
261 .replace('"', """)
262 .replace('\'', "'")
263}
264
265pub fn format_duration(d: Duration) -> String {
267 let ms = d.as_millis();
268 if ms == 0 {
269 return String::new();
270 }
271 if ms < 1000 {
272 format!("{}ms", ms)
273 } else if d.as_secs() < 60 {
274 format!("{:.2}s", d.as_secs_f64())
275 } else {
276 let mins = d.as_secs() / 60;
277 let secs = d.as_secs() % 60;
278 format!("{}m{}s", mins, secs)
279 }
280}
281
282pub fn truncate(s: &str, max_len: usize) -> String {
284 if s.len() <= max_len {
285 s.to_string()
286 } else {
287 format!("{}...", &s[..max_len.saturating_sub(3)])
288 }
289}
290
291pub fn extract_error_context(output: &str, max_lines: usize) -> Option<String> {
294 let lines: Vec<&str> = output.lines().collect();
295 let error_indicators = [
296 "FAILED",
297 "FAIL:",
298 "Error:",
299 "error:",
300 "assertion failed",
301 "AssertionError",
302 "assert_eq!",
303 "Expected",
304 "expected",
305 "panic",
306 "PANIC",
307 "thread '",
308 ];
309
310 for (i, line) in lines.iter().enumerate() {
311 for indicator in &error_indicators {
312 if line.contains(indicator) {
313 let start = i.saturating_sub(2);
314 let end = (i + max_lines).min(lines.len());
315 return Some(lines[start..end].join("\n"));
316 }
317 }
318 }
319
320 None
321}
322
323pub fn count_pattern(output: &str, pattern: &str) -> usize {
325 output.lines().filter(|l| l.contains(pattern)).count()
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn combined_output_both() {
334 let result = combined_output("stdout text", "stderr text");
335 assert_eq!(result, "stdout text\nstderr text");
336 }
337
338 #[test]
339 fn combined_output_stdout_only() {
340 let result = combined_output("stdout text", "");
341 assert_eq!(result, "stdout text");
342 }
343
344 #[test]
345 fn combined_output_stderr_only() {
346 let result = combined_output("", "stderr text");
347 assert_eq!(result, "stderr text");
348 }
349
350 #[test]
351 fn combined_output_both_empty() {
352 let result = combined_output("", "");
353 assert_eq!(result, "");
354 }
355
356 #[test]
357 fn combined_output_trims_whitespace() {
358 let result = combined_output(" stdout ", " stderr ");
359 assert_eq!(result, "stdout\nstderr");
360 }
361
362 #[test]
363 fn fallback_result_pass() {
364 let result = fallback_result(0, "Rust", "all ok", "");
365 assert_eq!(result.total_tests(), 1);
366 assert!(result.is_success());
367 assert_eq!(result.suites[0].tests[0].error, None);
368 }
369
370 #[test]
371 fn fallback_result_fail() {
372 let result = fallback_result(1, "Python", "", "error happened");
373 assert_eq!(result.total_tests(), 1);
374 assert!(!result.is_success());
375 assert!(result.suites[0].tests[0].error.is_some());
376 }
377
378 #[test]
379 fn fallback_result_fail_no_output() {
380 let result = fallback_result(2, "Go", "", "");
381 assert!(
382 result.suites[0].tests[0]
383 .error
384 .as_ref()
385 .unwrap()
386 .message
387 .contains("exited with code 2")
388 );
389 }
390
391 #[test]
392 fn parse_duration_milliseconds() {
393 assert_eq!(parse_duration_str("5ms"), Some(Duration::from_millis(5)));
394 assert_eq!(
395 parse_duration_str("123ms"),
396 Some(Duration::from_millis(123))
397 );
398 assert_eq!(parse_duration_str("0ms"), Some(Duration::from_millis(0)));
399 }
400
401 #[test]
402 fn parse_duration_milliseconds_with_space() {
403 assert_eq!(parse_duration_str("5 ms"), Some(Duration::from_millis(5)));
404 }
405
406 #[test]
407 fn parse_duration_seconds() {
408 assert_eq!(
409 parse_duration_str("1.5s"),
410 Some(Duration::from_secs_f64(1.5))
411 );
412 assert_eq!(
413 parse_duration_str("0.01s"),
414 Some(Duration::from_secs_f64(0.01))
415 );
416 }
417
418 #[test]
419 fn parse_duration_seconds_long_form() {
420 assert_eq!(
421 parse_duration_str("2.5 sec"),
422 Some(Duration::from_secs_f64(2.5))
423 );
424 assert_eq!(
425 parse_duration_str("1 seconds"),
426 Some(Duration::from_secs_f64(1.0))
427 );
428 }
429
430 #[test]
431 fn parse_duration_with_parens() {
432 assert_eq!(parse_duration_str("(5ms)"), Some(Duration::from_millis(5)));
433 }
434
435 #[test]
436 fn parse_duration_minutes() {
437 assert_eq!(parse_duration_str("1.5min"), Some(Duration::from_secs(90)));
438 }
439
440 #[test]
441 fn parse_duration_invalid() {
442 assert_eq!(parse_duration_str("hello"), None);
443 assert_eq!(parse_duration_str(""), None);
444 assert_eq!(parse_duration_str("abc ms"), None);
445 }
446
447 #[test]
448 fn check_binary_exists() {
449 assert!(check_binary("sh").is_some());
451 }
452
453 #[test]
454 fn check_binary_not_found() {
455 assert!(check_binary("definitely_not_a_real_binary_12345").is_none());
456 }
457
458 #[test]
459 fn check_runner_binary_exists() {
460 assert!(check_runner_binary("sh").is_none()); }
462
463 #[test]
464 fn check_runner_binary_missing() {
465 let result = check_runner_binary("nonexistent_runner_xyz");
466 assert_eq!(result, Some("nonexistent_runner_xyz".into()));
467 }
468
469 #[test]
470 fn extract_count_simple() {
471 assert_eq!(extract_count("3 passed", &["passed"]), Some(3));
472 assert_eq!(extract_count("12 failed", &["failed"]), Some(12));
473 assert_eq!(extract_count("0 skipped", &["skipped"]), Some(0));
474 }
475
476 #[test]
477 fn extract_count_multiple_keywords() {
478 assert_eq!(extract_count("5 passed", &["passed", "ok"]), Some(5));
479 assert_eq!(extract_count("5 ok", &["passed", "ok"]), Some(5));
480 }
481
482 #[test]
483 fn extract_count_in_summary() {
484 let line = "3 passed, 1 failed, 2 skipped";
485 assert_eq!(extract_count(line, &["passed"]), Some(3));
486 assert_eq!(extract_count(line, &["failed"]), Some(1));
487 assert_eq!(extract_count(line, &["skipped"]), Some(2));
488 }
489
490 #[test]
491 fn extract_count_not_found() {
492 assert_eq!(extract_count("all fine", &["passed"]), None);
493 }
494
495 #[test]
496 fn parse_summary_line_full() {
497 let patterns = SummaryPatterns {
498 passed: &["passed"],
499 failed: &["failed"],
500 skipped: &["skipped"],
501 };
502 let counts = parse_summary_line("3 passed, 1 failed, 2 skipped", &patterns);
503 assert_eq!(counts.passed, 3);
504 assert_eq!(counts.failed, 1);
505 assert_eq!(counts.skipped, 2);
506 }
507
508 #[test]
509 fn summary_counts_has_any() {
510 let empty = SummaryCounts::default();
511 assert!(!empty.has_any());
512
513 let with_passed = SummaryCounts {
514 passed: 1,
515 ..Default::default()
516 };
517 assert!(with_passed.has_any());
518 }
519
520 #[test]
521 fn summary_counts_computed_total() {
522 let counts = SummaryCounts {
523 passed: 3,
524 failed: 1,
525 skipped: 2,
526 total: 0,
527 duration: None,
528 };
529 assert_eq!(counts.computed_total(), 6);
530
531 let with_total = SummaryCounts {
532 total: 10,
533 ..Default::default()
534 };
535 assert_eq!(with_total.computed_total(), 10);
536 }
537
538 #[test]
539 fn synthetic_tests_from_counts_all_types() {
540 let counts = SummaryCounts {
541 passed: 2,
542 failed: 1,
543 skipped: 1,
544 total: 4,
545 duration: None,
546 };
547 let tests = synthetic_tests_from_counts(&counts, "tests");
548 assert_eq!(tests.len(), 4);
549 assert_eq!(
550 tests
551 .iter()
552 .filter(|t| t.status == TestStatus::Passed)
553 .count(),
554 2
555 );
556 assert_eq!(
557 tests
558 .iter()
559 .filter(|t| t.status == TestStatus::Failed)
560 .count(),
561 1
562 );
563 assert_eq!(
564 tests
565 .iter()
566 .filter(|t| t.status == TestStatus::Skipped)
567 .count(),
568 1
569 );
570 }
571
572 #[test]
573 fn synthetic_tests_empty_counts() {
574 let counts = SummaryCounts::default();
575 let tests = synthetic_tests_from_counts(&counts, "tests");
576 assert!(tests.is_empty());
577 }
578
579 #[test]
580 fn make_detection_helper() {
581 let det = make_detection("Rust", "cargo test", 0.95);
582 assert_eq!(det.language, "Rust");
583 assert_eq!(det.framework, "cargo test");
584 assert!((det.confidence - 0.95).abs() < f32::EPSILON);
585 }
586
587 #[test]
588 fn build_test_command_basic() {
589 let dir = tempfile::tempdir().unwrap();
590 let cmd = build_test_command("echo", dir.path(), &["hello"], &[]);
591 let program = cmd.get_program().to_string_lossy();
592 assert_eq!(program, "echo");
593 let args: Vec<_> = cmd
594 .get_args()
595 .map(|a| a.to_string_lossy().to_string())
596 .collect();
597 assert_eq!(args, vec!["hello"]);
598 }
599
600 #[test]
601 fn build_test_command_with_extra_args() {
602 let dir = tempfile::tempdir().unwrap();
603 let extra = vec!["--verbose".to_string(), "--color".to_string()];
604 let cmd = build_test_command("cargo", dir.path(), &["test"], &extra);
605 let args: Vec<_> = cmd
606 .get_args()
607 .map(|a| a.to_string_lossy().to_string())
608 .collect();
609 assert_eq!(args, vec!["test", "--verbose", "--color"]);
610 }
611
612 #[test]
613 fn xml_escape_special_chars() {
614 assert_eq!(xml_escape("a & b"), "a & b");
615 assert_eq!(xml_escape("<tag>"), "<tag>");
616 assert_eq!(xml_escape("\"quoted\""), ""quoted"");
617 assert_eq!(xml_escape("it's"), "it's");
618 }
619
620 #[test]
621 fn xml_escape_no_special() {
622 assert_eq!(xml_escape("hello world"), "hello world");
623 }
624
625 #[test]
626 fn format_duration_zero() {
627 assert_eq!(format_duration(Duration::ZERO), "");
628 }
629
630 #[test]
631 fn format_duration_milliseconds() {
632 assert_eq!(format_duration(Duration::from_millis(42)), "42ms");
633 assert_eq!(format_duration(Duration::from_millis(999)), "999ms");
634 }
635
636 #[test]
637 fn format_duration_seconds() {
638 assert_eq!(format_duration(Duration::from_millis(1500)), "1.50s");
639 assert_eq!(format_duration(Duration::from_secs(5)), "5.00s");
640 }
641
642 #[test]
643 fn format_duration_minutes() {
644 assert_eq!(format_duration(Duration::from_secs(90)), "1m30s");
645 assert_eq!(format_duration(Duration::from_secs(120)), "2m0s");
646 }
647
648 #[test]
649 fn truncate_short_string() {
650 assert_eq!(truncate("hello", 10), "hello");
651 }
652
653 #[test]
654 fn truncate_long_string() {
655 assert_eq!(truncate("hello world foo bar", 10), "hello w...");
656 }
657
658 #[test]
659 fn truncate_exact_length() {
660 assert_eq!(truncate("hello", 5), "hello");
661 }
662
663 #[test]
664 fn extract_error_context_found() {
665 let output = "line 1\nline 2\nFAILED test_foo\nline 4\nline 5";
666 let ctx = extract_error_context(output, 3);
667 assert!(ctx.is_some());
668 assert!(ctx.unwrap().contains("FAILED test_foo"));
669 }
670
671 #[test]
672 fn extract_error_context_not_found() {
673 let output = "all tests passed\neverything is fine";
674 assert!(extract_error_context(output, 3).is_none());
675 }
676
677 #[test]
678 fn extract_error_context_at_start() {
679 let output = "FAILED immediately\nmore info\neven more";
680 let ctx = extract_error_context(output, 3).unwrap();
681 assert!(ctx.contains("FAILED immediately"));
682 }
683
684 #[test]
685 fn count_pattern_basic() {
686 let output = "ok test_1\nFAIL test_2\nok test_3\nFAIL test_4";
687 assert_eq!(count_pattern(output, "ok"), 2);
688 assert_eq!(count_pattern(output, "FAIL"), 2);
689 }
690
691 #[test]
692 fn count_pattern_none() {
693 assert_eq!(count_pattern("hello world", "FAIL"), 0);
694 }
695}