Skip to main content

testx/adapters/
util.rs

1use std::path::Path;
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.
8#[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
17/// Combine stdout and stderr into a single string for parsing.
18pub 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
30/// Ensure a suite list is non-empty by adding a fallback suite based on exit code.
31///
32/// Replaces the identical 13-copy pattern:
33/// ```ignore
34/// if suites.is_empty() {
35///     suites.push(TestSuite { name: "tests".into(), tests: vec![...] });
36/// }
37/// ```
38pub 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
58/// Truncate a string to a max length, adding "..." if truncated.
59pub 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
68/// Build a fallback TestRunResult when output can't be parsed into individual tests.
69/// Uses exit code to determine pass/fail.
70pub 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            // Take last few lines as the error message
88            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
115/// Summary count patterns for different test frameworks.
116pub struct SummaryPatterns {
117    pub passed: &'static [&'static str],
118    pub failed: &'static [&'static str],
119    pub skipped: &'static [&'static str],
120}
121
122/// Check if any file matching `predicate` exists in immediate subdirectories of `dir`.
123///
124/// This enables detection of projects where the build/config file is one or two
125/// levels below the project root (e.g., .NET projects with `Src/*.csproj`,
126/// Java multi-module projects, etc.).
127///
128/// Only scans direct children that are directories. Skips hidden directories
129/// and common non-project directories (node_modules, vendor, target, etc.).
130pub 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        // Skip hidden and non-project dirs when descending
160        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        // Check files at this level
183        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/// Parsed summary counts from a test result line.
192#[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
215/// Generate synthetic test cases from summary counts.
216/// Used when the adapter can only extract totals, not individual test names.
217pub 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
253/// Parse a duration string in common formats:
254/// "5ms", "1.5s", "0.01 sec", "(5 ms)", "123ms", "1.23s", "0.001s", "5.2 seconds"
255pub fn parse_duration_str(s: &str) -> Option<Duration> {
256    let s = s.trim().trim_matches(|c| c == '(' || c == ')');
257
258    // Try milliseconds: "123ms", "5 ms"
259    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    // Try seconds: "1.5s", "0.01 sec", "1.23 seconds"
268    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    // Try minutes: "2m30s", "1.5 min"
280    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
292/// Check if a binary is available on PATH. Returns the full path if found.
293pub fn check_binary(name: &str) -> Option<String> {
294    which::which(name).ok().map(|p| p.display().to_string())
295}
296
297/// Check if a binary is available, returning the missing name if not.
298pub 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
306/// Extract a number from a string that appears before a keyword.
307/// E.g., extract_count("3 passed", "passed") => Some(3)
308pub fn extract_count(s: &str, keywords: &[&str]) -> Option<usize> {
309    for keyword in keywords {
310        if let Some(pos) = s.find(keyword) {
311            // Look backward from keyword to find the number
312            let before = &s[..pos].trim_end();
313            // Try to parse the last word as a number
314            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
326/// Parse counts from a summary line using the given patterns.
327pub 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
337/// Build a detection result with the given parameters.
338pub 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
346/// Build a Command with the given program and arguments, set to run in the project dir.
347pub 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
364/// Escape a string for safe XML output.
365pub fn xml_escape(s: &str) -> String {
366    // Strip control characters (U+0000–U+001F except TAB, LF, CR) which are
367    // illegal in XML 1.0. Keep \t (0x09), \n (0x0A), \r (0x0D).
368    // Single-pass implementation avoids multiple allocations from chained .replace().
369    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; // strip illegal XML control chars
373        }
374        match c {
375            '&' => out.push_str("&amp;"),
376            '<' => out.push_str("&lt;"),
377            '>' => out.push_str("&gt;"),
378            '"' => out.push_str("&quot;"),
379            '\'' => out.push_str("&apos;"),
380            _ => out.push(c),
381        }
382    }
383    out
384}
385
386/// Format a Duration as a human-readable string.
387pub 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
403/// Extract error context from output lines around a failure.
404/// Scans for common failure indicators and returns surrounding context.
405pub 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
433/// Count lines matching a pattern in the output.
434pub 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        // "sh" should exist on any Unix system
560        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()); // None = no missing runner
571    }
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 &amp; b");
725        assert_eq!(xml_escape("<tag>"), "&lt;tag&gt;");
726        assert_eq!(xml_escape("\"quoted\""), "&quot;quoted&quot;");
727        assert_eq!(xml_escape("it's"), "it&apos;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        // "café" is 5 bytes (é = 2 bytes). Truncating at byte 4 would split é.
776        // Must not panic.
777        let result = truncate("café latte", 7);
778        assert!(result.ends_with("..."));
779        assert!(result.len() <= 10); // graceful truncation
780    }
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        // Control chars (except tab/newline/cr) should be stripped
791        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}