1use std::path::Path;
2use std::time::Duration;
3
4use crate::adapters::{DetectionResult, TestCase, TestError, TestRunResult, TestStatus, TestSuite};
5
6#[inline]
9pub fn duration_from_secs_safe(secs: f64) -> Duration {
10 if secs.is_finite() && secs >= 0.0 {
11 Duration::from_secs_f64(secs)
12 } else {
13 Duration::ZERO
14 }
15}
16
17pub fn combined_output(stdout: &str, stderr: &str) -> String {
19 let stdout = stdout.trim();
20 let stderr = stderr.trim();
21 if stdout.is_empty() {
22 return stderr.to_string();
23 }
24 if stderr.is_empty() {
25 return stdout.to_string();
26 }
27 format!("{}\n{}", stdout, stderr)
28}
29
30pub fn ensure_non_empty(suites: &mut Vec<TestSuite>, exit_code: i32, suite_name: &str) {
39 if !suites.is_empty() {
40 return;
41 }
42 let status = if exit_code == 0 {
43 TestStatus::Passed
44 } else {
45 TestStatus::Failed
46 };
47 suites.push(TestSuite {
48 name: suite_name.into(),
49 tests: vec![TestCase {
50 name: "test_suite".into(),
51 status,
52 duration: Duration::ZERO,
53 error: None,
54 }],
55 });
56}
57
58pub fn truncate(s: &str, max_len: usize) -> String {
60 if s.len() <= max_len {
61 s.to_string()
62 } else {
63 let end = s.floor_char_boundary(max_len.saturating_sub(3));
64 format!("{}...", &s[..end])
65 }
66}
67
68pub fn fallback_result(
71 exit_code: i32,
72 adapter_name: &str,
73 stdout: &str,
74 stderr: &str,
75) -> TestRunResult {
76 let status = if exit_code == 0 {
77 TestStatus::Passed
78 } else {
79 TestStatus::Failed
80 };
81
82 let error = if exit_code != 0 {
83 let combined = combined_output(stdout, stderr);
84 let message = if combined.is_empty() {
85 format!("{} exited with code {}", adapter_name, exit_code)
86 } else {
87 let lines: Vec<&str> = combined.lines().collect();
89 let start = lines.len().saturating_sub(10);
90 lines[start..].join("\n")
91 };
92 Some(TestError {
93 message,
94 location: None,
95 })
96 } else {
97 None
98 };
99
100 TestRunResult {
101 suites: vec![TestSuite {
102 name: adapter_name.to_string(),
103 tests: vec![TestCase {
104 name: format!("{} tests", adapter_name),
105 status,
106 duration: Duration::ZERO,
107 error,
108 }],
109 }],
110 duration: Duration::ZERO,
111 raw_exit_code: exit_code,
112 }
113}
114
115pub struct SummaryPatterns {
117 pub passed: &'static [&'static str],
118 pub failed: &'static [&'static str],
119 pub skipped: &'static [&'static str],
120}
121
122pub fn has_marker_in_subdirs<F>(dir: &Path, max_depth: u8, predicate: F) -> bool
131where
132 F: Fn(&str) -> bool,
133{
134 has_marker_in_subdirs_inner(dir, max_depth, 0, &predicate)
135}
136
137fn has_marker_in_subdirs_inner<F>(
138 dir: &Path,
139 max_depth: u8,
140 current_depth: u8,
141 predicate: &F,
142) -> bool
143where
144 F: Fn(&str) -> bool,
145{
146 if current_depth > max_depth {
147 return false;
148 }
149
150 let entries = match std::fs::read_dir(dir) {
151 Ok(e) => e,
152 Err(_) => return false,
153 };
154
155 for entry in entries.flatten() {
156 let name = entry.file_name();
157 let name_str = name.to_string_lossy();
158
159 if current_depth < max_depth && entry.file_type().is_ok_and(|t| t.is_dir()) {
161 if name_str.starts_with('.')
162 || matches!(
163 name_str.as_ref(),
164 "node_modules"
165 | "vendor"
166 | "target"
167 | "build"
168 | "bin"
169 | "obj"
170 | "_build"
171 | "deps"
172 | "__pycache__"
173 )
174 {
175 continue;
176 }
177 if has_marker_in_subdirs_inner(&entry.path(), max_depth, current_depth + 1, predicate) {
178 return true;
179 }
180 }
181
182 if entry.file_type().is_ok_and(|t| t.is_file()) && predicate(&name_str) {
184 return true;
185 }
186 }
187
188 false
189}
190
191#[derive(Debug, Clone, Default)]
193pub struct SummaryCounts {
194 pub passed: usize,
195 pub failed: usize,
196 pub skipped: usize,
197 pub total: usize,
198 pub duration: Option<Duration>,
199}
200
201impl SummaryCounts {
202 pub fn has_any(&self) -> bool {
203 self.passed > 0 || self.failed > 0 || self.skipped > 0 || self.total > 0
204 }
205
206 pub fn computed_total(&self) -> usize {
207 if self.total > 0 {
208 self.total
209 } else {
210 self.passed + self.failed + self.skipped
211 }
212 }
213}
214
215pub fn synthetic_tests_from_counts(counts: &SummaryCounts, suite_name: &str) -> Vec<TestCase> {
218 let mut tests = Vec::new();
219
220 for i in 0..counts.passed {
221 tests.push(TestCase {
222 name: format!("test {} (passed)", i + 1),
223 status: TestStatus::Passed,
224 duration: Duration::ZERO,
225 error: None,
226 });
227 }
228
229 for i in 0..counts.failed {
230 tests.push(TestCase {
231 name: format!("test {} (failed)", i + 1),
232 status: TestStatus::Failed,
233 duration: Duration::ZERO,
234 error: Some(TestError {
235 message: format!("Test failed in {}", suite_name),
236 location: None,
237 }),
238 });
239 }
240
241 for i in 0..counts.skipped {
242 tests.push(TestCase {
243 name: format!("test {} (skipped)", i + 1),
244 status: TestStatus::Skipped,
245 duration: Duration::ZERO,
246 error: None,
247 });
248 }
249
250 tests
251}
252
253pub fn parse_duration_str(s: &str) -> Option<Duration> {
256 let s = s.trim().trim_matches(|c| c == '(' || c == ')');
257
258 if let Some(num) = s
260 .strip_suffix("ms")
261 .map(|n| n.trim())
262 .and_then(|n| n.parse::<f64>().ok())
263 {
264 return Some(duration_from_secs_safe(num / 1000.0));
265 }
266
267 let s_stripped = s
269 .strip_suffix("seconds")
270 .or_else(|| s.strip_suffix("secs"))
271 .or_else(|| s.strip_suffix("sec"))
272 .or_else(|| s.strip_suffix('s'))
273 .map(|n| n.trim());
274
275 if let Some(num) = s_stripped.and_then(|n| n.parse::<f64>().ok()) {
276 return Some(duration_from_secs_safe(num));
277 }
278
279 if let Some(num) = s
281 .strip_suffix("min")
282 .or_else(|| s.strip_suffix('m'))
283 .map(|n| n.trim())
284 .and_then(|n| n.parse::<f64>().ok())
285 {
286 return Some(duration_from_secs_safe(num * 60.0));
287 }
288
289 None
290}
291
292pub fn check_binary(name: &str) -> Option<String> {
294 which::which(name).ok().map(|p| p.display().to_string())
295}
296
297pub fn check_runner_binary(name: &str) -> Option<String> {
299 if which::which(name).is_err() {
300 Some(name.into())
301 } else {
302 None
303 }
304}
305
306pub fn extract_count(s: &str, keywords: &[&str]) -> Option<usize> {
309 for keyword in keywords {
310 if let Some(pos) = s.find(keyword) {
311 let before = &s[..pos].trim_end();
313 if let Some(num_str) = before.rsplit_once(|c: char| !c.is_ascii_digit()) {
315 if let Ok(n) = num_str.1.parse() {
316 return Some(n);
317 }
318 } else if let Ok(n) = before.parse() {
319 return Some(n);
320 }
321 }
322 }
323 None
324}
325
326pub fn parse_summary_line(line: &str, patterns: &SummaryPatterns) -> SummaryCounts {
328 SummaryCounts {
329 passed: extract_count(line, patterns.passed).unwrap_or(0),
330 failed: extract_count(line, patterns.failed).unwrap_or(0),
331 skipped: extract_count(line, patterns.skipped).unwrap_or(0),
332 total: 0,
333 duration: None,
334 }
335}
336
337pub fn make_detection(language: &str, framework: &str, confidence: f32) -> DetectionResult {
339 DetectionResult {
340 language: language.into(),
341 framework: framework.into(),
342 confidence,
343 }
344}
345
346pub fn build_test_command(
348 program: &str,
349 project_dir: &std::path::Path,
350 base_args: &[&str],
351 extra_args: &[String],
352) -> std::process::Command {
353 let mut cmd = std::process::Command::new(program);
354 for arg in base_args {
355 cmd.arg(arg);
356 }
357 for arg in extra_args {
358 cmd.arg(arg);
359 }
360 cmd.current_dir(project_dir);
361 cmd
362}
363
364pub fn xml_escape(s: &str) -> String {
366 let mut out = String::with_capacity(s.len());
370 for c in s.chars() {
371 if c.is_control() && c != '\t' && c != '\n' && c != '\r' {
372 continue; }
374 match c {
375 '&' => out.push_str("&"),
376 '<' => out.push_str("<"),
377 '>' => out.push_str(">"),
378 '"' => out.push_str("""),
379 '\'' => out.push_str("'"),
380 _ => out.push(c),
381 }
382 }
383 out
384}
385
386pub fn format_duration(d: Duration) -> String {
388 let ms = d.as_millis();
389 if ms == 0 {
390 return String::new();
391 }
392 if ms < 1000 {
393 format!("{}ms", ms)
394 } else if d.as_secs() < 60 {
395 format!("{:.2}s", d.as_secs_f64())
396 } else {
397 let mins = d.as_secs() / 60;
398 let secs = d.as_secs() % 60;
399 format!("{}m{}s", mins, secs)
400 }
401}
402
403pub fn extract_error_context(output: &str, max_lines: usize) -> Option<String> {
406 let lines: Vec<&str> = output.lines().collect();
407 const ERROR_INDICATORS: &[&str] = &[
408 "FAILED",
409 "FAIL:",
410 "Error:",
411 "error:",
412 "assertion failed",
413 "AssertionError",
414 "assert_eq!",
415 "Expected",
416 "expected",
417 "panic",
418 "PANIC",
419 "thread '",
420 ];
421
422 for (i, line) in lines.iter().enumerate() {
423 if ERROR_INDICATORS.iter().any(|ind| line.contains(ind)) {
424 let start = i.saturating_sub(2);
425 let end = (i + max_lines).min(lines.len());
426 return Some(lines[start..end].join("\n"));
427 }
428 }
429
430 None
431}
432
433pub fn count_pattern(output: &str, pattern: &str) -> usize {
435 output.lines().filter(|l| l.contains(pattern)).count()
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn combined_output_both() {
444 let result = combined_output("stdout text", "stderr text");
445 assert_eq!(result, "stdout text\nstderr text");
446 }
447
448 #[test]
449 fn combined_output_stdout_only() {
450 let result = combined_output("stdout text", "");
451 assert_eq!(result, "stdout text");
452 }
453
454 #[test]
455 fn combined_output_stderr_only() {
456 let result = combined_output("", "stderr text");
457 assert_eq!(result, "stderr text");
458 }
459
460 #[test]
461 fn combined_output_both_empty() {
462 let result = combined_output("", "");
463 assert_eq!(result, "");
464 }
465
466 #[test]
467 fn combined_output_trims_whitespace() {
468 let result = combined_output(" stdout ", " stderr ");
469 assert_eq!(result, "stdout\nstderr");
470 }
471
472 #[test]
473 fn fallback_result_pass() {
474 let result = fallback_result(0, "Rust", "all ok", "");
475 assert_eq!(result.total_tests(), 1);
476 assert!(result.is_success());
477 assert_eq!(result.suites[0].tests[0].error, None);
478 }
479
480 #[test]
481 fn fallback_result_fail() {
482 let result = fallback_result(1, "Python", "", "error happened");
483 assert_eq!(result.total_tests(), 1);
484 assert!(!result.is_success());
485 assert!(result.suites[0].tests[0].error.is_some());
486 }
487
488 #[test]
489 fn fallback_result_fail_no_output() {
490 let result = fallback_result(2, "Go", "", "");
491 assert!(
492 result.suites[0].tests[0]
493 .error
494 .as_ref()
495 .unwrap()
496 .message
497 .contains("exited with code 2")
498 );
499 }
500
501 #[test]
502 fn parse_duration_milliseconds() {
503 assert_eq!(parse_duration_str("5ms"), Some(Duration::from_millis(5)));
504 assert_eq!(
505 parse_duration_str("123ms"),
506 Some(Duration::from_millis(123))
507 );
508 assert_eq!(parse_duration_str("0ms"), Some(Duration::from_millis(0)));
509 }
510
511 #[test]
512 fn parse_duration_milliseconds_with_space() {
513 assert_eq!(parse_duration_str("5 ms"), Some(Duration::from_millis(5)));
514 }
515
516 #[test]
517 fn parse_duration_seconds() {
518 assert_eq!(
519 parse_duration_str("1.5s"),
520 Some(Duration::from_secs_f64(1.5))
521 );
522 assert_eq!(
523 parse_duration_str("0.01s"),
524 Some(Duration::from_secs_f64(0.01))
525 );
526 }
527
528 #[test]
529 fn parse_duration_seconds_long_form() {
530 assert_eq!(
531 parse_duration_str("2.5 sec"),
532 Some(Duration::from_secs_f64(2.5))
533 );
534 assert_eq!(
535 parse_duration_str("1 seconds"),
536 Some(Duration::from_secs_f64(1.0))
537 );
538 }
539
540 #[test]
541 fn parse_duration_with_parens() {
542 assert_eq!(parse_duration_str("(5ms)"), Some(Duration::from_millis(5)));
543 }
544
545 #[test]
546 fn parse_duration_minutes() {
547 assert_eq!(parse_duration_str("1.5min"), Some(Duration::from_secs(90)));
548 }
549
550 #[test]
551 fn parse_duration_invalid() {
552 assert_eq!(parse_duration_str("hello"), None);
553 assert_eq!(parse_duration_str(""), None);
554 assert_eq!(parse_duration_str("abc ms"), None);
555 }
556
557 #[test]
558 fn check_binary_exists() {
559 assert!(check_binary("sh").is_some());
561 }
562
563 #[test]
564 fn check_binary_not_found() {
565 assert!(check_binary("definitely_not_a_real_binary_12345").is_none());
566 }
567
568 #[test]
569 fn check_runner_binary_exists() {
570 assert!(check_runner_binary("sh").is_none()); }
572
573 #[test]
574 fn check_runner_binary_missing() {
575 let result = check_runner_binary("nonexistent_runner_xyz");
576 assert_eq!(result, Some("nonexistent_runner_xyz".into()));
577 }
578
579 #[test]
580 fn extract_count_simple() {
581 assert_eq!(extract_count("3 passed", &["passed"]), Some(3));
582 assert_eq!(extract_count("12 failed", &["failed"]), Some(12));
583 assert_eq!(extract_count("0 skipped", &["skipped"]), Some(0));
584 }
585
586 #[test]
587 fn extract_count_multiple_keywords() {
588 assert_eq!(extract_count("5 passed", &["passed", "ok"]), Some(5));
589 assert_eq!(extract_count("5 ok", &["passed", "ok"]), Some(5));
590 }
591
592 #[test]
593 fn extract_count_in_summary() {
594 let line = "3 passed, 1 failed, 2 skipped";
595 assert_eq!(extract_count(line, &["passed"]), Some(3));
596 assert_eq!(extract_count(line, &["failed"]), Some(1));
597 assert_eq!(extract_count(line, &["skipped"]), Some(2));
598 }
599
600 #[test]
601 fn extract_count_not_found() {
602 assert_eq!(extract_count("all fine", &["passed"]), None);
603 }
604
605 #[test]
606 fn parse_summary_line_full() {
607 let patterns = SummaryPatterns {
608 passed: &["passed"],
609 failed: &["failed"],
610 skipped: &["skipped"],
611 };
612 let counts = parse_summary_line("3 passed, 1 failed, 2 skipped", &patterns);
613 assert_eq!(counts.passed, 3);
614 assert_eq!(counts.failed, 1);
615 assert_eq!(counts.skipped, 2);
616 }
617
618 #[test]
619 fn summary_counts_has_any() {
620 let empty = SummaryCounts::default();
621 assert!(!empty.has_any());
622
623 let with_passed = SummaryCounts {
624 passed: 1,
625 ..Default::default()
626 };
627 assert!(with_passed.has_any());
628 }
629
630 #[test]
631 fn summary_counts_computed_total() {
632 let counts = SummaryCounts {
633 passed: 3,
634 failed: 1,
635 skipped: 2,
636 total: 0,
637 duration: None,
638 };
639 assert_eq!(counts.computed_total(), 6);
640
641 let with_total = SummaryCounts {
642 total: 10,
643 ..Default::default()
644 };
645 assert_eq!(with_total.computed_total(), 10);
646 }
647
648 #[test]
649 fn synthetic_tests_from_counts_all_types() {
650 let counts = SummaryCounts {
651 passed: 2,
652 failed: 1,
653 skipped: 1,
654 total: 4,
655 duration: None,
656 };
657 let tests = synthetic_tests_from_counts(&counts, "tests");
658 assert_eq!(tests.len(), 4);
659 assert_eq!(
660 tests
661 .iter()
662 .filter(|t| t.status == TestStatus::Passed)
663 .count(),
664 2
665 );
666 assert_eq!(
667 tests
668 .iter()
669 .filter(|t| t.status == TestStatus::Failed)
670 .count(),
671 1
672 );
673 assert_eq!(
674 tests
675 .iter()
676 .filter(|t| t.status == TestStatus::Skipped)
677 .count(),
678 1
679 );
680 }
681
682 #[test]
683 fn synthetic_tests_empty_counts() {
684 let counts = SummaryCounts::default();
685 let tests = synthetic_tests_from_counts(&counts, "tests");
686 assert!(tests.is_empty());
687 }
688
689 #[test]
690 fn make_detection_helper() {
691 let det = make_detection("Rust", "cargo test", 0.95);
692 assert_eq!(det.language, "Rust");
693 assert_eq!(det.framework, "cargo test");
694 assert!((det.confidence - 0.95).abs() < f32::EPSILON);
695 }
696
697 #[test]
698 fn build_test_command_basic() {
699 let dir = tempfile::tempdir().unwrap();
700 let cmd = build_test_command("echo", dir.path(), &["hello"], &[]);
701 let program = cmd.get_program().to_string_lossy();
702 assert_eq!(program, "echo");
703 let args: Vec<_> = cmd
704 .get_args()
705 .map(|a| a.to_string_lossy().to_string())
706 .collect();
707 assert_eq!(args, vec!["hello"]);
708 }
709
710 #[test]
711 fn build_test_command_with_extra_args() {
712 let dir = tempfile::tempdir().unwrap();
713 let extra = vec!["--verbose".to_string(), "--color".to_string()];
714 let cmd = build_test_command("cargo", dir.path(), &["test"], &extra);
715 let args: Vec<_> = cmd
716 .get_args()
717 .map(|a| a.to_string_lossy().to_string())
718 .collect();
719 assert_eq!(args, vec!["test", "--verbose", "--color"]);
720 }
721
722 #[test]
723 fn xml_escape_special_chars() {
724 assert_eq!(xml_escape("a & b"), "a & b");
725 assert_eq!(xml_escape("<tag>"), "<tag>");
726 assert_eq!(xml_escape("\"quoted\""), ""quoted"");
727 assert_eq!(xml_escape("it's"), "it's");
728 }
729
730 #[test]
731 fn xml_escape_no_special() {
732 assert_eq!(xml_escape("hello world"), "hello world");
733 }
734
735 #[test]
736 fn format_duration_zero() {
737 assert_eq!(format_duration(Duration::ZERO), "");
738 }
739
740 #[test]
741 fn format_duration_milliseconds() {
742 assert_eq!(format_duration(Duration::from_millis(42)), "42ms");
743 assert_eq!(format_duration(Duration::from_millis(999)), "999ms");
744 }
745
746 #[test]
747 fn format_duration_seconds() {
748 assert_eq!(format_duration(Duration::from_millis(1500)), "1.50s");
749 assert_eq!(format_duration(Duration::from_secs(5)), "5.00s");
750 }
751
752 #[test]
753 fn format_duration_minutes() {
754 assert_eq!(format_duration(Duration::from_secs(90)), "1m30s");
755 assert_eq!(format_duration(Duration::from_secs(120)), "2m0s");
756 }
757
758 #[test]
759 fn truncate_short_string() {
760 assert_eq!(truncate("hello", 10), "hello");
761 }
762
763 #[test]
764 fn truncate_long_string() {
765 assert_eq!(truncate("hello world foo bar", 10), "hello w...");
766 }
767
768 #[test]
769 fn truncate_exact_length() {
770 assert_eq!(truncate("hello", 5), "hello");
771 }
772
773 #[test]
774 fn truncate_multibyte_utf8() {
775 let result = truncate("café latte", 7);
778 assert!(result.ends_with("..."));
779 assert!(result.len() <= 10); }
781
782 #[test]
783 fn truncate_tiny_max() {
784 assert_eq!(truncate("hello world", 3), "...");
785 assert_eq!(truncate("hello world", 0), "...");
786 }
787
788 #[test]
789 fn xml_escape_control_chars() {
790 let input = "hello\x00world\x01\tfoo\nbar";
792 let result = xml_escape(input);
793 assert_eq!(result, "helloworld\tfoo\nbar");
794 }
795
796 #[test]
797 fn extract_error_context_found() {
798 let output = "line 1\nline 2\nFAILED test_foo\nline 4\nline 5";
799 let ctx = extract_error_context(output, 3);
800 assert!(ctx.is_some());
801 assert!(ctx.unwrap().contains("FAILED test_foo"));
802 }
803
804 #[test]
805 fn extract_error_context_not_found() {
806 let output = "all tests passed\neverything is fine";
807 assert!(extract_error_context(output, 3).is_none());
808 }
809
810 #[test]
811 fn extract_error_context_at_start() {
812 let output = "FAILED immediately\nmore info\neven more";
813 let ctx = extract_error_context(output, 3).unwrap();
814 assert!(ctx.contains("FAILED immediately"));
815 }
816
817 #[test]
818 fn count_pattern_basic() {
819 let output = "ok test_1\nFAIL test_2\nok test_3\nFAIL test_4";
820 assert_eq!(count_pattern(output, "ok"), 2);
821 assert_eq!(count_pattern(output, "FAIL"), 2);
822 }
823
824 #[test]
825 fn count_pattern_none() {
826 assert_eq!(count_pattern("hello world", "FAIL"), 0);
827 }
828}