Skip to main content

testx/adapters/
util.rs

1use std::process::Command;
2use std::time::Duration;
3
4use crate::adapters::{DetectionResult, TestCase, TestError, TestRunResult, TestStatus, TestSuite};
5
6/// Create a Duration from seconds, returning Duration::ZERO for NaN, infinity, or negative values.
7/// This is a safe wrapper around `Duration::from_secs_f64` which panics on such inputs.
8pub 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
16/// Combine stdout and stderr into a single string for parsing.
17pub 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
29/// Build a fallback TestRunResult when output can't be parsed into individual tests.
30/// Uses exit code to determine pass/fail.
31pub 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            // Take last few lines as the error message
49            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
76/// Summary count patterns for different test frameworks.
77pub struct SummaryPatterns {
78    pub passed: &'static [&'static str],
79    pub failed: &'static [&'static str],
80    pub skipped: &'static [&'static str],
81}
82
83/// Parsed summary counts from a test result line.
84#[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
107/// Generate synthetic test cases from summary counts.
108/// Used when the adapter can only extract totals, not individual test names.
109pub 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
145/// Parse a duration string in common formats:
146/// "5ms", "1.5s", "0.01 sec", "(5 ms)", "123ms", "1.23s", "0.001s", "5.2 seconds"
147pub fn parse_duration_str(s: &str) -> Option<Duration> {
148    let s = s.trim().trim_matches(|c| c == '(' || c == ')');
149
150    // Try milliseconds: "123ms", "5 ms"
151    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    // Try seconds: "1.5s", "0.01 sec", "1.23 seconds"
160    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    // Try minutes: "2m30s", "1.5 min"
172    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
184/// Check if a binary is available on PATH. Returns the full path if found.
185pub fn check_binary(name: &str) -> Option<String> {
186    which::which(name).ok().map(|p| p.display().to_string())
187}
188
189/// Check if a binary is available, returning the missing name if not.
190pub 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
198/// Extract a number from a string that appears before a keyword.
199/// E.g., extract_count("3 passed", "passed") => Some(3)
200pub fn extract_count(s: &str, keywords: &[&str]) -> Option<usize> {
201    for keyword in keywords {
202        if let Some(pos) = s.find(keyword) {
203            // Look backward from keyword to find the number
204            let before = &s[..pos].trim_end();
205            // Try to parse the last word as a number
206            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
218/// Parse counts from a summary line using the given patterns.
219pub 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
229/// Build a detection result with the given parameters.
230pub 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
238/// Build a Command with the given program and arguments, set to run in the project dir.
239pub 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
256/// Escape a string for safe XML output.
257pub fn xml_escape(s: &str) -> String {
258    s.replace('&', "&amp;")
259        .replace('<', "&lt;")
260        .replace('>', "&gt;")
261        .replace('"', "&quot;")
262        .replace('\'', "&apos;")
263}
264
265/// Format a Duration as a human-readable string.
266pub 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
282/// Truncate a string to a max length, adding "..." if truncated.
283pub 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
291/// Extract error context from output lines around a failure.
292/// Scans for common failure indicators and returns surrounding context.
293pub 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
323/// Count lines matching a pattern in the output.
324pub 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        // "sh" should exist on any Unix system
450        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()); // None = no missing runner
461    }
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 &amp; b");
615        assert_eq!(xml_escape("<tag>"), "&lt;tag&gt;");
616        assert_eq!(xml_escape("\"quoted\""), "&quot;quoted&quot;");
617        assert_eq!(xml_escape("it's"), "it&apos;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}