Skip to main content

testx/plugin/
script_adapter.rs

1//! Script-based custom adapter for user-defined test frameworks.
2//!
3//! Users can define custom adapters in testx.toml that run arbitrary commands
4//! and parse output in a standard format (JSON, JUnit XML, TAP, or line-based).
5
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9use crate::adapters::util::duration_from_secs_safe;
10use crate::adapters::{TestCase, TestError, TestRunResult, TestStatus, TestSuite};
11
12/// Output parser type for a script adapter.
13#[derive(Debug, Clone, PartialEq)]
14pub enum OutputParser {
15    /// Expects JSON matching TestRunResult schema
16    Json,
17    /// Expects JUnit XML output
18    Junit,
19    /// Expects TAP (Test Anything Protocol) output
20    Tap,
21    /// One test per line with status prefix
22    Lines,
23    /// Custom regex-based parser
24    Regex(RegexParserConfig),
25}
26
27/// Configuration for regex-based output parsing.
28#[derive(Debug, Clone, PartialEq)]
29pub struct RegexParserConfig {
30    /// Pattern to match a passing test line
31    pub pass_pattern: String,
32    /// Pattern to match a failing test line
33    pub fail_pattern: String,
34    /// Pattern to match a skipped test line
35    pub skip_pattern: Option<String>,
36    /// Capture group index for the test name (1-indexed)
37    pub name_group: usize,
38    /// Optional capture group for duration
39    pub duration_group: Option<usize>,
40}
41
42/// Definition of a custom script adapter from config.
43#[derive(Debug, Clone)]
44pub struct ScriptAdapterConfig {
45    /// Unique adapter name
46    pub name: String,
47    /// File whose presence triggers detection
48    pub detect_file: String,
49    /// Optional detect pattern (glob) for more specific detection
50    pub detect_pattern: Option<String>,
51    /// Command to run
52    pub command: String,
53    /// Default arguments
54    pub args: Vec<String>,
55    /// Output parser type
56    pub parser: OutputParser,
57    /// Working directory relative to project root (default: ".")
58    pub working_dir: Option<String>,
59    /// Environment variables to set
60    pub env: Vec<(String, String)>,
61}
62
63impl ScriptAdapterConfig {
64    /// Create a minimal script adapter config.
65    pub fn new(name: &str, detect_file: &str, command: &str) -> Self {
66        Self {
67            name: name.to_string(),
68            detect_file: detect_file.to_string(),
69            detect_pattern: None,
70            command: command.to_string(),
71            args: Vec::new(),
72            parser: OutputParser::Lines,
73            working_dir: None,
74            env: Vec::new(),
75        }
76    }
77
78    /// Set the output parser.
79    pub fn with_parser(mut self, parser: OutputParser) -> Self {
80        self.parser = parser;
81        self
82    }
83
84    /// Set default args.
85    pub fn with_args(mut self, args: Vec<String>) -> Self {
86        self.args = args;
87        self
88    }
89
90    /// Set working directory.
91    pub fn with_working_dir(mut self, dir: &str) -> Self {
92        self.working_dir = Some(dir.to_string());
93        self
94    }
95
96    /// Add an environment variable.
97    pub fn with_env(mut self, key: &str, value: &str) -> Self {
98        self.env.push((key.to_string(), value.to_string()));
99        self
100    }
101
102    /// Check if this adapter detects at the given project directory.
103    pub fn detect(&self, project_dir: &Path) -> bool {
104        let detect_path = project_dir.join(&self.detect_file);
105        if detect_path.exists() {
106            return true;
107        }
108
109        // Check detect_pattern if set
110        if let Some(ref pattern) = self.detect_pattern {
111            return glob_detect(project_dir, pattern);
112        }
113
114        false
115    }
116
117    /// Get the effective working directory, validated against path traversal.
118    pub fn effective_working_dir(&self, project_dir: &Path) -> PathBuf {
119        match &self.working_dir {
120            Some(dir) => {
121                let dir_path = std::path::Path::new(dir);
122                // Reject absolute paths and paths containing ".." components
123                // to prevent path traversal even when the target doesn't exist yet.
124                if dir_path.is_absolute()
125                    || dir_path
126                        .components()
127                        .any(|c| matches!(c, std::path::Component::ParentDir))
128                {
129                    return project_dir.to_path_buf();
130                }
131                let candidate = project_dir.join(dir);
132                // If both paths can be resolved, verify the result stays
133                // within project_dir.
134                if let (Ok(resolved), Ok(base)) =
135                    (candidate.canonicalize(), project_dir.canonicalize())
136                {
137                    if resolved.starts_with(&base) {
138                        return resolved;
139                    }
140                    return base;
141                }
142                // If canonicalize fails (target doesn't exist yet or permissions),
143                // return the candidate since we've already verified it has no
144                // ".." components above — so it cannot escape project_dir.
145                candidate
146            }
147            None => project_dir.to_path_buf(),
148        }
149    }
150
151    /// Build the command string with args.
152    pub fn full_command(&self) -> String {
153        let mut parts = vec![self.command.clone()];
154        parts.extend(self.args.clone());
155        parts.join(" ")
156    }
157}
158
159/// Simple glob detection — checks if any file matching the pattern exists.
160fn glob_detect(project_dir: &Path, pattern: &str) -> bool {
161    // Simple implementation: check common patterns
162    if pattern.contains('*') {
163        // For now, just check if the non-glob part exists as a directory
164        if let Some(base) = pattern.split('*').next() {
165            let base = base.trim_end_matches('/');
166            if !base.is_empty() {
167                return project_dir.join(base).exists();
168            }
169        }
170        // Fallback: try the pattern as-is
171        project_dir.join(pattern).exists()
172    } else {
173        project_dir.join(pattern).exists()
174    }
175}
176
177// ─── Output Parsers ─────────────────────────────────────────────────────
178
179/// Parse output from a script adapter using the configured parser.
180pub fn parse_script_output(
181    parser: &OutputParser,
182    stdout: &str,
183    stderr: &str,
184    exit_code: i32,
185) -> TestRunResult {
186    match parser {
187        OutputParser::Json => parse_json_output(stdout, stderr, exit_code),
188        OutputParser::Junit => parse_junit_output(stdout, exit_code),
189        OutputParser::Tap => parse_tap_output(stdout, exit_code),
190        OutputParser::Lines => parse_lines_output(stdout, exit_code),
191        OutputParser::Regex(config) => parse_regex_output(stdout, config, exit_code),
192    }
193}
194
195/// Parse JSON-formatted test output.
196fn parse_json_output(stdout: &str, _stderr: &str, exit_code: i32) -> TestRunResult {
197    // Try to parse as a TestRunResult JSON
198    if let Ok(result) = serde_json::from_str::<serde_json::Value>(stdout) {
199        let suites = parse_json_suites(&result);
200        if !suites.is_empty() {
201            return TestRunResult {
202                suites,
203                duration: Duration::ZERO,
204                raw_exit_code: exit_code,
205            };
206        }
207    }
208
209    // Fallback
210    fallback_result(stdout, exit_code, "json")
211}
212
213/// Extract test suites from a JSON value.
214fn parse_json_suites(value: &serde_json::Value) -> Vec<TestSuite> {
215    let mut suites = Vec::new();
216
217    // Handle {"suites": [...]} format
218    if let Some(arr) = value.get("suites").and_then(|v| v.as_array()) {
219        for suite_val in arr {
220            if let Some(suite) = parse_json_suite(suite_val) {
221                suites.push(suite);
222            }
223        }
224    }
225
226    // Handle {"tests": [...]} format (single suite)
227    if suites.is_empty()
228        && let Some(arr) = value.get("tests").and_then(|v| v.as_array())
229    {
230        let name = value
231            .get("name")
232            .and_then(|v| v.as_str())
233            .unwrap_or("tests");
234        let tests: Vec<TestCase> = arr.iter().filter_map(parse_json_test).collect();
235        if !tests.is_empty() {
236            suites.push(TestSuite {
237                name: name.to_string(),
238                tests,
239            });
240        }
241    }
242
243    // Handle [{"name": ..., "status": ...}, ...] format (flat array of tests)
244    if suites.is_empty()
245        && let Some(arr) = value.as_array()
246    {
247        let tests: Vec<TestCase> = arr.iter().filter_map(parse_json_test).collect();
248        if !tests.is_empty() {
249            suites.push(TestSuite {
250                name: "tests".to_string(),
251                tests,
252            });
253        }
254    }
255
256    suites
257}
258
259fn parse_json_suite(value: &serde_json::Value) -> Option<TestSuite> {
260    let name = value.get("name").and_then(|v| v.as_str())?;
261    let tests_arr = value.get("tests").and_then(|v| v.as_array())?;
262    let tests: Vec<TestCase> = tests_arr.iter().filter_map(parse_json_test).collect();
263    Some(TestSuite {
264        name: name.to_string(),
265        tests,
266    })
267}
268
269fn parse_json_test(value: &serde_json::Value) -> Option<TestCase> {
270    let name = value.get("name").and_then(|v| v.as_str())?;
271    let status_str = value.get("status").and_then(|v| v.as_str())?;
272
273    let status = match status_str.to_lowercase().as_str() {
274        "passed" | "pass" | "ok" | "success" => TestStatus::Passed,
275        "failed" | "fail" | "error" | "failure" => TestStatus::Failed,
276        "skipped" | "skip" | "pending" | "ignored" => TestStatus::Skipped,
277        _ => return None,
278    };
279
280    let duration = value
281        .get("duration")
282        .and_then(|v| v.as_f64())
283        .map(|ms| duration_from_secs_safe(ms / 1000.0))
284        .unwrap_or(Duration::ZERO);
285
286    let error = value.get("error").and_then(|v| {
287        let message = v.as_str().map(|s| s.to_string()).or_else(|| {
288            v.get("message")
289                .and_then(|m| m.as_str().map(|s| s.to_string()))
290        })?;
291        let location = v
292            .get("location")
293            .and_then(|l| l.as_str().map(|s| s.to_string()));
294        Some(TestError { message, location })
295    });
296
297    Some(TestCase {
298        name: name.to_string(),
299        status,
300        duration,
301        error,
302    })
303}
304
305/// Parse JUnit XML output.
306fn parse_junit_output(stdout: &str, exit_code: i32) -> TestRunResult {
307    let mut suites = Vec::new();
308
309    // Find all <testsuite> blocks
310    for line in stdout.lines() {
311        let trimmed = line.trim();
312        if trimmed.starts_with("<testsuite")
313            && !trimmed.starts_with("<testsuites")
314            && let Some(suite) = parse_junit_suite_tag(trimmed, stdout)
315        {
316            suites.push(suite);
317        }
318    }
319
320    // If no suites found, try to parse <testcase> elements directly
321    if suites.is_empty() {
322        let tests = parse_junit_testcases(stdout);
323        if !tests.is_empty() {
324            suites.push(TestSuite {
325                name: "tests".to_string(),
326                tests,
327            });
328        }
329    }
330
331    if suites.is_empty() {
332        return fallback_result(stdout, exit_code, "junit");
333    }
334
335    TestRunResult {
336        suites,
337        duration: Duration::ZERO,
338        raw_exit_code: exit_code,
339    }
340}
341
342fn parse_junit_suite_tag(tag: &str, full_output: &str) -> Option<TestSuite> {
343    let name = extract_xml_attr(tag, "name").unwrap_or_else(|| "tests".to_string());
344    let tests = parse_junit_testcases(full_output);
345    if tests.is_empty() {
346        return None;
347    }
348    Some(TestSuite { name, tests })
349}
350
351fn parse_junit_testcases(xml: &str) -> Vec<TestCase> {
352    let mut tests = Vec::new();
353    let lines: Vec<&str> = xml.lines().collect();
354
355    let mut i = 0;
356    while i < lines.len() {
357        let trimmed = lines[i].trim();
358        if trimmed.starts_with("<testcase") {
359            let name = extract_xml_attr(trimmed, "name").unwrap_or_else(|| "unknown".to_string());
360            let time = extract_xml_attr(trimmed, "time")
361                .and_then(|t| t.parse::<f64>().ok())
362                .map(duration_from_secs_safe)
363                .unwrap_or(Duration::ZERO);
364
365            // Check for failure/error/skipped in subsequent lines
366            let mut status = TestStatus::Passed;
367            let mut error = None;
368
369            if trimmed.ends_with("/>") {
370                // Self-closing, check for nested skipped/failure check
371                if trimmed.contains("<skipped") {
372                    status = TestStatus::Skipped;
373                }
374            } else {
375                // Look at following lines until </testcase>
376                let mut j = i + 1;
377                while j < lines.len() {
378                    let inner = lines[j].trim();
379                    if inner.starts_with("</testcase") {
380                        break;
381                    }
382                    if inner.starts_with("<failure") || inner.starts_with("<error") {
383                        status = TestStatus::Failed;
384                        let message = extract_xml_attr(inner, "message")
385                            .unwrap_or_else(|| "Test failed".to_string());
386                        error = Some(TestError {
387                            message,
388                            location: None,
389                        });
390                    }
391                    if inner.starts_with("<skipped") {
392                        status = TestStatus::Skipped;
393                    }
394                    j += 1;
395                }
396            }
397
398            tests.push(TestCase {
399                name,
400                status,
401                duration: time,
402                error,
403            });
404        }
405        i += 1;
406    }
407
408    tests
409}
410
411/// Extract an XML attribute value from an element tag.
412fn extract_xml_attr(tag: &str, attr: &str) -> Option<String> {
413    let search = format!("{attr}=\"");
414    let start = tag.find(&search)? + search.len();
415    let rest = &tag[start..];
416    let end = rest.find('"')?;
417    Some(rest[..end].to_string())
418}
419
420/// Parse TAP (Test Anything Protocol) output.
421fn parse_tap_output(stdout: &str, exit_code: i32) -> TestRunResult {
422    let mut tests = Vec::new();
423    let mut _plan_count = 0;
424
425    for line in stdout.lines() {
426        let trimmed = line.trim();
427
428        // Plan line: 1..N
429        if let Some(rest) = trimmed.strip_prefix("1..") {
430            if let Ok(n) = rest.parse::<usize>() {
431                _plan_count = n;
432            }
433            continue;
434        }
435
436        // ok N - description
437        if let Some(rest) = trimmed.strip_prefix("ok ") {
438            let (name, is_skip) = parse_tap_description(rest);
439            tests.push(TestCase {
440                name,
441                status: if is_skip {
442                    TestStatus::Skipped
443                } else {
444                    TestStatus::Passed
445                },
446                duration: Duration::ZERO,
447                error: None,
448            });
449            continue;
450        }
451
452        // not ok N - description
453        if let Some(rest) = trimmed.strip_prefix("not ok ") {
454            let (name, is_skip) = parse_tap_description(rest);
455            let is_todo = trimmed.contains("# TODO");
456            tests.push(TestCase {
457                name,
458                status: if is_skip || is_todo {
459                    TestStatus::Skipped
460                } else {
461                    TestStatus::Failed
462                },
463                duration: Duration::ZERO,
464                error: if !is_skip && !is_todo {
465                    Some(TestError {
466                        message: "Test failed".to_string(),
467                        location: None,
468                    })
469                } else {
470                    None
471                },
472            });
473        }
474    }
475
476    if tests.is_empty() {
477        return fallback_result(stdout, exit_code, "tap");
478    }
479
480    TestRunResult {
481        suites: vec![TestSuite {
482            name: "tests".to_string(),
483            tests,
484        }],
485        duration: Duration::ZERO,
486        raw_exit_code: exit_code,
487    }
488}
489
490/// Parse a TAP description, extracting the test name and directive.
491fn parse_tap_description(rest: &str) -> (String, bool) {
492    // Strip the test number
493    let after_num = rest
494        .find(|c: char| !c.is_ascii_digit())
495        .map(|i| rest[i..].trim_start())
496        .unwrap_or(rest);
497
498    // Strip leading " - "
499    let desc = after_num.strip_prefix("- ").unwrap_or(after_num);
500
501    // Check for # SKIP directive
502    let is_skip = desc.contains("# SKIP") || desc.contains("# skip");
503
504    // Remove directive from name
505    let name = if let Some(idx) = desc.find(" # ") {
506        desc[..idx].to_string()
507    } else {
508        desc.to_string()
509    };
510
511    (name, is_skip)
512}
513
514/// Parse line-based output (simplest format).
515///
516/// Expected format per line: `STATUS test_name` or `STATUS: test_name`
517/// STATUS can be: ok, pass, passed, fail, failed, error, skip, skipped, pending
518fn parse_lines_output(stdout: &str, exit_code: i32) -> TestRunResult {
519    let mut tests = Vec::new();
520
521    for line in stdout.lines() {
522        let trimmed = line.trim();
523        if trimmed.is_empty() {
524            continue;
525        }
526
527        if let Some(test) = parse_status_line(trimmed) {
528            tests.push(test);
529        }
530    }
531
532    if tests.is_empty() {
533        return fallback_result(stdout, exit_code, "lines");
534    }
535
536    TestRunResult {
537        suites: vec![TestSuite {
538            name: "tests".to_string(),
539            tests,
540        }],
541        duration: Duration::ZERO,
542        raw_exit_code: exit_code,
543    }
544}
545
546/// Parse a single status-prefixed line.
547fn parse_status_line(line: &str) -> Option<TestCase> {
548    let (status, rest) = parse_status_prefix(line)?;
549    let name = rest.trim().to_string();
550    if name.is_empty() {
551        return None;
552    }
553
554    let failed = status == TestStatus::Failed;
555    Some(TestCase {
556        name,
557        status,
558        duration: Duration::ZERO,
559        error: if failed {
560            Some(TestError {
561                message: "Test failed".into(),
562                location: None,
563            })
564        } else {
565            None
566        },
567    })
568}
569
570/// Try to extract a status prefix from a line.
571fn parse_status_prefix(line: &str) -> Option<(TestStatus, &str)> {
572    let patterns: &[(&str, TestStatus)] = &[
573        ("ok ", TestStatus::Passed),
574        ("pass ", TestStatus::Passed),
575        ("passed ", TestStatus::Passed),
576        ("PASS ", TestStatus::Passed),
577        ("PASSED ", TestStatus::Passed),
578        ("OK ", TestStatus::Passed),
579        ("✓ ", TestStatus::Passed),
580        ("✔ ", TestStatus::Passed),
581        ("fail ", TestStatus::Failed),
582        ("failed ", TestStatus::Failed),
583        ("error ", TestStatus::Failed),
584        ("FAIL ", TestStatus::Failed),
585        ("FAILED ", TestStatus::Failed),
586        ("ERROR ", TestStatus::Failed),
587        ("✗ ", TestStatus::Failed),
588        ("✘ ", TestStatus::Failed),
589        ("skip ", TestStatus::Skipped),
590        ("skipped ", TestStatus::Skipped),
591        ("pending ", TestStatus::Skipped),
592        ("SKIP ", TestStatus::Skipped),
593        ("SKIPPED ", TestStatus::Skipped),
594        ("PENDING ", TestStatus::Skipped),
595    ];
596
597    for (prefix, status) in patterns {
598        if let Some(rest) = line.strip_prefix(prefix) {
599            return Some((status.clone(), rest));
600        }
601    }
602
603    // Also try "status: name" format
604    let colon_patterns: &[(&str, TestStatus)] = &[
605        ("ok:", TestStatus::Passed),
606        ("pass:", TestStatus::Passed),
607        ("fail:", TestStatus::Failed),
608        ("error:", TestStatus::Failed),
609        ("skip:", TestStatus::Skipped),
610    ];
611
612    for (prefix, status) in colon_patterns {
613        if let Some(rest) = line.to_lowercase().strip_prefix(prefix) {
614            let idx = prefix.len();
615            let _ = rest; // use original line
616            return Some((status.clone(), line[idx..].trim_start()));
617        }
618    }
619
620    None
621}
622
623/// Parse output using custom regex patterns.
624fn parse_regex_output(stdout: &str, config: &RegexParserConfig, exit_code: i32) -> TestRunResult {
625    let mut tests = Vec::new();
626
627    for line in stdout.lines() {
628        let trimmed = line.trim();
629        if trimmed.is_empty() {
630            continue;
631        }
632
633        if let Some(test) =
634            try_regex_match(trimmed, &config.pass_pattern, TestStatus::Passed, config)
635        {
636            tests.push(test);
637        } else if let Some(test) =
638            try_regex_match(trimmed, &config.fail_pattern, TestStatus::Failed, config)
639        {
640            tests.push(test);
641        } else if let Some(ref skip_pattern) = config.skip_pattern
642            && let Some(test) = try_regex_match(trimmed, skip_pattern, TestStatus::Skipped, config)
643        {
644            tests.push(test);
645        }
646    }
647
648    if tests.is_empty() {
649        return fallback_result(stdout, exit_code, "regex");
650    }
651
652    TestRunResult {
653        suites: vec![TestSuite {
654            name: "tests".to_string(),
655            tests,
656        }],
657        duration: Duration::ZERO,
658        raw_exit_code: exit_code,
659    }
660}
661
662/// Try to match a line against a simple pattern with capture groups.
663///
664/// Pattern format uses `()` for capture groups and `.*` for wildcards.
665/// This is a simplified regex to avoid pulling in the regex crate.
666fn try_regex_match(
667    line: &str,
668    pattern: &str,
669    status: TestStatus,
670    config: &RegexParserConfig,
671) -> Option<TestCase> {
672    let captures = simple_pattern_match(pattern, line)?;
673
674    let name = captures.get(config.name_group.saturating_sub(1))?.clone();
675    if name.is_empty() {
676        return None;
677    }
678
679    let duration = config
680        .duration_group
681        .and_then(|g| captures.get(g.saturating_sub(1)))
682        .and_then(|d| d.parse::<f64>().ok())
683        .map(|ms| duration_from_secs_safe(ms / 1000.0))
684        .unwrap_or(Duration::ZERO);
685
686    Some(TestCase {
687        name,
688        status: status.clone(),
689        duration,
690        error: if status == TestStatus::Failed {
691            Some(TestError {
692                message: "Test failed".into(),
693                location: None,
694            })
695        } else {
696            None
697        },
698    })
699}
700
701/// Simple pattern matching with capture groups.
702///
703/// Supports: literal text, `(.*)` capture groups, `.*` wildcards.
704/// Returns captured groups as a Vec<String>.
705fn simple_pattern_match(pattern: &str, input: &str) -> Option<Vec<String>> {
706    let mut captures = Vec::new();
707    let mut pat_idx = 0;
708    let mut inp_idx = 0;
709    let pat_bytes = pattern.as_bytes();
710    let inp_bytes = input.as_bytes();
711
712    while pat_idx < pat_bytes.len() && inp_idx <= inp_bytes.len() {
713        if pat_idx + 4 <= pat_bytes.len() && &pat_bytes[pat_idx..pat_idx + 4] == b"(.*)" {
714            // Capture group: find the next literal after the group
715            pat_idx += 4;
716
717            // Find what comes after the capture group
718            let next_literal = find_next_literal(pattern, pat_idx);
719
720            match next_literal {
721                Some(lit) => {
722                    // Find the literal in the remaining input
723                    let remaining = &input[inp_idx..];
724                    if let Some(pos) = remaining.find(&lit) {
725                        captures.push(remaining[..pos].to_string());
726                        inp_idx += pos;
727                    } else {
728                        return None;
729                    }
730                }
731                None => {
732                    // Capture group at end of pattern, capture everything
733                    captures.push(input[inp_idx..].to_string());
734                    inp_idx = inp_bytes.len();
735                }
736            }
737        } else if pat_idx + 1 < pat_bytes.len()
738            && pat_bytes[pat_idx] == b'.'
739            && pat_bytes[pat_idx + 1] == b'*'
740        {
741            // Wildcard (non-capturing): skip to next literal
742            pat_idx += 2;
743            let next_literal = find_next_literal(pattern, pat_idx);
744            match next_literal {
745                Some(lit) => {
746                    let remaining = &input[inp_idx..];
747                    if let Some(pos) = remaining.find(&lit) {
748                        inp_idx += pos;
749                    } else {
750                        return None;
751                    }
752                }
753                None => {
754                    inp_idx = inp_bytes.len();
755                }
756            }
757        } else if inp_idx < inp_bytes.len() && pat_bytes[pat_idx] == inp_bytes[inp_idx] {
758            pat_idx += 1;
759            inp_idx += 1;
760        } else {
761            return None;
762        }
763    }
764
765    // Both pattern and input should be consumed
766    if pat_idx == pat_bytes.len() && inp_idx == inp_bytes.len() {
767        Some(captures)
768    } else {
769        None
770    }
771}
772
773/// Find the next literal string segment in a pattern after the given index.
774fn find_next_literal(pattern: &str, from: usize) -> Option<String> {
775    let rest = &pattern[from..];
776    if rest.is_empty() {
777        return None;
778    }
779
780    let mut lit = String::new();
781    let bytes = rest.as_bytes();
782    let mut i = 0;
783    while i < bytes.len() {
784        if i + 1 < bytes.len() && bytes[i] == b'.' && bytes[i + 1] == b'*' {
785            break;
786        }
787        if i + 4 <= bytes.len() && &bytes[i..i + 4] == b"(.*)" {
788            break;
789        }
790        lit.push(bytes[i] as char);
791        i += 1;
792    }
793
794    if lit.is_empty() { None } else { Some(lit) }
795}
796
797/// Generate a fallback result when parsing fails.
798fn fallback_result(stdout: &str, exit_code: i32, parser_name: &str) -> TestRunResult {
799    let status = if exit_code == 0 {
800        TestStatus::Passed
801    } else {
802        TestStatus::Failed
803    };
804
805    TestRunResult {
806        suites: vec![TestSuite {
807            name: format!("{parser_name}-output"),
808            tests: vec![TestCase {
809                name: format!("test run ({parser_name} parser)"),
810                status,
811                duration: Duration::ZERO,
812                error: if exit_code != 0 {
813                    Some(TestError {
814                        message: stdout.lines().next().unwrap_or("Test failed").to_string(),
815                        location: None,
816                    })
817                } else {
818                    None
819                },
820            }],
821        }],
822        duration: Duration::ZERO,
823        raw_exit_code: exit_code,
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use std::path::PathBuf;
831
832    // ─── ScriptAdapterConfig Tests ──────────────────────────────────────
833
834    #[test]
835    fn config_new() {
836        let config = ScriptAdapterConfig::new("mytest", "Makefile", "make test");
837        assert_eq!(config.name, "mytest");
838        assert_eq!(config.detect_file, "Makefile");
839        assert_eq!(config.command, "make test");
840        assert_eq!(config.parser, OutputParser::Lines);
841    }
842
843    #[test]
844    fn config_builder() {
845        let config = ScriptAdapterConfig::new("mytest", "Makefile", "make test")
846            .with_parser(OutputParser::Tap)
847            .with_args(vec!["--verbose".into()])
848            .with_working_dir("src")
849            .with_env("CI", "true");
850
851        assert_eq!(config.parser, OutputParser::Tap);
852        assert_eq!(config.args, vec!["--verbose"]);
853        assert_eq!(config.working_dir, Some("src".into()));
854        assert_eq!(config.env, vec![("CI".into(), "true".into())]);
855    }
856
857    #[test]
858    fn config_full_command() {
859        let config = ScriptAdapterConfig::new("test", "f", "make test")
860            .with_args(vec!["--verbose".into(), "--color".into()]);
861        assert_eq!(config.full_command(), "make test --verbose --color");
862    }
863
864    #[test]
865    fn config_effective_working_dir() {
866        let base = PathBuf::from("/project");
867
868        let config = ScriptAdapterConfig::new("test", "f", "cmd");
869        assert_eq!(
870            config.effective_working_dir(&base),
871            PathBuf::from("/project")
872        );
873
874        let config = config.with_working_dir("src");
875        assert_eq!(
876            config.effective_working_dir(&base),
877            PathBuf::from("/project/src")
878        );
879    }
880
881    // ─── TAP Parser Tests ───────────────────────────────────────────────
882
883    #[test]
884    fn parse_tap_basic() {
885        let output = "1..3\nok 1 - first test\nok 2 - second test\nnot ok 3 - third test\n";
886        let result = parse_tap_output(output, 1);
887        assert_eq!(result.total_tests(), 3);
888        assert_eq!(result.total_passed(), 2);
889        assert_eq!(result.total_failed(), 1);
890    }
891
892    #[test]
893    fn parse_tap_skip() {
894        let output = "1..2\nok 1 - test one\nok 2 - test two # SKIP not ready\n";
895        let result = parse_tap_output(output, 0);
896        assert_eq!(result.total_tests(), 2);
897        assert_eq!(result.total_passed(), 1);
898        assert_eq!(result.total_skipped(), 1);
899    }
900
901    #[test]
902    fn parse_tap_todo() {
903        let output = "1..1\nnot ok 1 - todo test # TODO implement later\n";
904        let result = parse_tap_output(output, 0);
905        assert_eq!(result.total_tests(), 1);
906        assert_eq!(result.total_skipped(), 1);
907    }
908
909    #[test]
910    fn parse_tap_empty() {
911        let result = parse_tap_output("", 0);
912        assert_eq!(result.total_tests(), 1); // fallback
913    }
914
915    #[test]
916    fn parse_tap_no_plan() {
917        let output = "ok 1 - works\nnot ok 2 - broken\n";
918        let result = parse_tap_output(output, 1);
919        assert_eq!(result.total_tests(), 2);
920    }
921
922    // ─── Lines Parser Tests ─────────────────────────────────────────────
923
924    #[test]
925    fn parse_lines_basic() {
926        let output = "ok test_one\nfail test_two\nskip test_three\n";
927        let result = parse_lines_output(output, 1);
928        assert_eq!(result.total_tests(), 3);
929        assert_eq!(result.total_passed(), 1);
930        assert_eq!(result.total_failed(), 1);
931        assert_eq!(result.total_skipped(), 1);
932    }
933
934    #[test]
935    fn parse_lines_uppercase() {
936        let output = "PASS test_one\nFAIL test_two\nSKIP test_three\n";
937        let result = parse_lines_output(output, 1);
938        assert_eq!(result.total_tests(), 3);
939    }
940
941    #[test]
942    fn parse_lines_unicode() {
943        let output = "✓ test_one\n✗ test_two\n";
944        let result = parse_lines_output(output, 1);
945        assert_eq!(result.total_tests(), 2);
946        assert_eq!(result.total_passed(), 1);
947        assert_eq!(result.total_failed(), 1);
948    }
949
950    #[test]
951    fn parse_lines_empty() {
952        let result = parse_lines_output("", 0);
953        assert_eq!(result.total_tests(), 1); // fallback
954    }
955
956    #[test]
957    fn parse_lines_ignores_non_matching() {
958        let output = "running tests...\nok test_one\nsome other output\nfail test_two\ndone";
959        let result = parse_lines_output(output, 1);
960        assert_eq!(result.total_tests(), 2);
961    }
962
963    // ─── JSON Parser Tests ──────────────────────────────────────────────
964
965    #[test]
966    fn parse_json_suites_format() {
967        let json = r#"{
968            "suites": [
969                {
970                    "name": "math",
971                    "tests": [
972                        {"name": "test_add", "status": "passed", "duration": 10},
973                        {"name": "test_sub", "status": "failed", "duration": 5}
974                    ]
975                }
976            ]
977        }"#;
978        let result = parse_json_output(json, "", 1);
979        assert_eq!(result.total_tests(), 2);
980        assert_eq!(result.total_passed(), 1);
981        assert_eq!(result.total_failed(), 1);
982    }
983
984    #[test]
985    fn parse_json_flat_tests() {
986        let json = r#"{"tests": [
987            {"name": "test1", "status": "pass"},
988            {"name": "test2", "status": "skip"}
989        ]}"#;
990        let result = parse_json_output(json, "", 0);
991        assert_eq!(result.total_tests(), 2);
992        assert_eq!(result.total_passed(), 1);
993        assert_eq!(result.total_skipped(), 1);
994    }
995
996    #[test]
997    fn parse_json_array_format() {
998        let json = r#"[
999            {"name": "test1", "status": "ok"},
1000            {"name": "test2", "status": "error"}
1001        ]"#;
1002        let result = parse_json_output(json, "", 1);
1003        assert_eq!(result.total_tests(), 2);
1004    }
1005
1006    #[test]
1007    fn parse_json_with_errors() {
1008        let json = r#"{"tests": [
1009            {"name": "test1", "status": "failed", "error": {"message": "expected 1 got 2", "location": "test.rs:10"}}
1010        ]}"#;
1011        let result = parse_json_output(json, "", 1);
1012        assert_eq!(result.total_failed(), 1);
1013        let test = &result.suites[0].tests[0];
1014        assert!(test.error.is_some());
1015        assert_eq!(test.error.as_ref().unwrap().message, "expected 1 got 2");
1016    }
1017
1018    #[test]
1019    fn parse_json_invalid() {
1020        let result = parse_json_output("not json {{{", "", 1);
1021        assert_eq!(result.total_tests(), 1); // fallback
1022    }
1023
1024    // ─── JUnit XML Parser Tests ─────────────────────────────────────────
1025
1026    #[test]
1027    fn parse_junit_basic() {
1028        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1029<testsuite name="math" tests="2" failures="1">
1030  <testcase name="test_add" classname="Math" time="0.01"/>
1031  <testcase name="test_div" classname="Math" time="0.02">
1032    <failure message="division by zero"/>
1033  </testcase>
1034</testsuite>"#;
1035        let result = parse_junit_output(xml, 1);
1036        assert_eq!(result.total_tests(), 2);
1037        assert_eq!(result.total_passed(), 1);
1038        assert_eq!(result.total_failed(), 1);
1039    }
1040
1041    #[test]
1042    fn parse_junit_skipped() {
1043        let xml = r#"<testsuite name="t" tests="1">
1044  <testcase name="test_skip" time="0.0">
1045    <skipped/>
1046  </testcase>
1047</testsuite>"#;
1048        let result = parse_junit_output(xml, 0);
1049        assert_eq!(result.total_skipped(), 1);
1050    }
1051
1052    #[test]
1053    fn parse_junit_empty() {
1054        let result = parse_junit_output("", 0);
1055        assert_eq!(result.total_tests(), 1); // fallback
1056    }
1057
1058    // ─── Regex Parser Tests ─────────────────────────────────────────────
1059
1060    #[test]
1061    fn parse_regex_basic() {
1062        let config = RegexParserConfig {
1063            pass_pattern: "PASS: (.*)".to_string(),
1064            fail_pattern: "FAIL: (.*)".to_string(),
1065            skip_pattern: None,
1066            name_group: 1,
1067            duration_group: None,
1068        };
1069        let output = "PASS: test_one\nFAIL: test_two\nsome output\n";
1070        let result = parse_regex_output(output, &config, 1);
1071        assert_eq!(result.total_tests(), 2);
1072        assert_eq!(result.total_passed(), 1);
1073        assert_eq!(result.total_failed(), 1);
1074    }
1075
1076    #[test]
1077    fn parse_regex_with_skip() {
1078        let config = RegexParserConfig {
1079            pass_pattern: "[OK] (.*)".to_string(),
1080            fail_pattern: "[ERR] (.*)".to_string(),
1081            skip_pattern: Some("[SKIP] (.*)".to_string()),
1082            name_group: 1,
1083            duration_group: None,
1084        };
1085        let output = "[OK] test_one\n[SKIP] test_two\n";
1086        let result = parse_regex_output(output, &config, 0);
1087        assert_eq!(result.total_tests(), 2);
1088    }
1089
1090    // ─── Simple Pattern Match Tests ─────────────────────────────────────
1091
1092    #[test]
1093    fn simple_match_literal() {
1094        let result = simple_pattern_match("hello world", "hello world");
1095        assert!(result.is_some());
1096        assert!(result.unwrap().is_empty());
1097    }
1098
1099    #[test]
1100    fn simple_match_capture() {
1101        let result = simple_pattern_match("PASS: (.*)", "PASS: test_one");
1102        assert!(result.is_some());
1103        assert_eq!(result.unwrap(), vec!["test_one"]);
1104    }
1105
1106    #[test]
1107    fn simple_match_multiple_captures() {
1108        let result = simple_pattern_match("(.*)=(.*)", "key=value");
1109        assert!(result.is_some());
1110        assert_eq!(result.unwrap(), vec!["key", "value"]);
1111    }
1112
1113    #[test]
1114    fn simple_match_wildcard() {
1115        let result = simple_pattern_match("hello .*!", "hello world!");
1116        assert!(result.is_some());
1117    }
1118
1119    #[test]
1120    fn simple_match_no_match() {
1121        let result = simple_pattern_match("hello", "world");
1122        assert!(result.is_none());
1123    }
1124
1125    #[test]
1126    fn simple_match_capture_with_context() {
1127        let result = simple_pattern_match("test (.*) in (.*)ms", "test add in 50ms");
1128        assert!(result.is_some());
1129        let caps = result.unwrap();
1130        assert_eq!(caps, vec!["add", "50"]);
1131    }
1132
1133    // ─── TAP Description Parsing ────────────────────────────────────────
1134
1135    #[test]
1136    fn tap_description_basic() {
1137        let (name, skip) = parse_tap_description("1 - my test");
1138        assert_eq!(name, "my test");
1139        assert!(!skip);
1140    }
1141
1142    #[test]
1143    fn tap_description_skip() {
1144        let (name, skip) = parse_tap_description("1 - my test # SKIP not implemented");
1145        assert_eq!(name, "my test");
1146        assert!(skip);
1147    }
1148
1149    #[test]
1150    fn tap_description_no_dash() {
1151        let (name, skip) = parse_tap_description("1 test name");
1152        assert_eq!(name, "test name");
1153        assert!(!skip);
1154    }
1155
1156    // ─── Status Line Parsing ────────────────────────────────────────────
1157
1158    #[test]
1159    fn status_line_pass() {
1160        let tc = parse_status_line("ok test_one").unwrap();
1161        assert_eq!(tc.name, "test_one");
1162        assert_eq!(tc.status, TestStatus::Passed);
1163    }
1164
1165    #[test]
1166    fn status_line_fail() {
1167        let tc = parse_status_line("fail test_two").unwrap();
1168        assert_eq!(tc.name, "test_two");
1169        assert_eq!(tc.status, TestStatus::Failed);
1170    }
1171
1172    #[test]
1173    fn status_line_skip() {
1174        let tc = parse_status_line("skip test_three").unwrap();
1175        assert_eq!(tc.name, "test_three");
1176        assert_eq!(tc.status, TestStatus::Skipped);
1177    }
1178
1179    #[test]
1180    fn status_line_no_match() {
1181        assert!(parse_status_line("some random text").is_none());
1182    }
1183
1184    #[test]
1185    fn status_line_empty_name() {
1186        assert!(parse_status_line("ok ").is_none());
1187    }
1188
1189    // ─── XML Attr Extraction ────────────────────────────────────────────
1190
1191    #[test]
1192    fn xml_attr_basic() {
1193        assert_eq!(
1194            extract_xml_attr(r#"<test name="hello" time="1.5">"#, "name"),
1195            Some("hello".into())
1196        );
1197    }
1198
1199    #[test]
1200    fn xml_attr_missing() {
1201        assert_eq!(extract_xml_attr("<test>", "name"), None);
1202    }
1203
1204    // ─── Fallback Result Tests ──────────────────────────────────────────
1205
1206    #[test]
1207    fn fallback_pass() {
1208        let result = fallback_result("all good", 0, "test");
1209        assert_eq!(result.total_passed(), 1);
1210        assert_eq!(result.raw_exit_code, 0);
1211    }
1212
1213    #[test]
1214    fn fallback_fail() {
1215        let result = fallback_result("something failed", 1, "test");
1216        assert_eq!(result.total_failed(), 1);
1217        assert!(result.suites[0].tests[0].error.is_some());
1218    }
1219
1220    // ─── Integration: parse_script_output ───────────────────────────────
1221
1222    #[test]
1223    fn script_output_delegates_to_tap() {
1224        let output = "1..2\nok 1 - a\nnot ok 2 - b\n";
1225        let result = parse_script_output(&OutputParser::Tap, output, "", 1);
1226        assert_eq!(result.total_tests(), 2);
1227    }
1228
1229    #[test]
1230    fn script_output_delegates_to_lines() {
1231        let output = "PASS test1\nFAIL test2\n";
1232        let result = parse_script_output(&OutputParser::Lines, output, "", 1);
1233        assert_eq!(result.total_tests(), 2);
1234    }
1235
1236    #[test]
1237    fn script_output_delegates_to_json() {
1238        let output = r#"[{"name": "t1", "status": "passed"}]"#;
1239        let result = parse_script_output(&OutputParser::Json, output, "", 0);
1240        assert_eq!(result.total_tests(), 1);
1241        assert_eq!(result.total_passed(), 1);
1242    }
1243
1244    // ─── Edge Case Tests ────────────────────────────────────────────────
1245
1246    // --- Config edge cases ---
1247
1248    #[test]
1249    fn config_detect_nonexistent_dir() {
1250        let config = ScriptAdapterConfig::new("test", "Makefile", "make");
1251        assert!(!config.detect(&PathBuf::from("/nonexistent/path/xyz")));
1252    }
1253
1254    #[test]
1255    fn config_detect_with_pattern_nonexistent() {
1256        let mut config = ScriptAdapterConfig::new("test", "nonexistent.xyz", "cmd");
1257        config.detect_pattern = Some("src/*.test".into());
1258        assert!(!config.detect(&PathBuf::from("/nonexistent/path")));
1259    }
1260
1261    #[test]
1262    fn config_empty_args() {
1263        let config = ScriptAdapterConfig::new("test", "f", "cmd");
1264        assert_eq!(config.full_command(), "cmd");
1265    }
1266
1267    #[test]
1268    fn config_multiple_env_vars() {
1269        let config = ScriptAdapterConfig::new("test", "f", "cmd")
1270            .with_env("A", "1")
1271            .with_env("B", "2")
1272            .with_env("C", "3");
1273        assert_eq!(config.env.len(), 3);
1274    }
1275
1276    #[test]
1277    fn config_chained_builders() {
1278        let config = ScriptAdapterConfig::new("test", "f", "cmd")
1279            .with_parser(OutputParser::Json)
1280            .with_args(vec!["--a".into()])
1281            .with_working_dir("build")
1282            .with_env("X", "Y")
1283            .with_parser(OutputParser::Tap); // override parser
1284        assert_eq!(config.parser, OutputParser::Tap);
1285        assert_eq!(config.working_dir, Some("build".into()));
1286    }
1287
1288    // --- JSON parser edge cases ---
1289
1290    #[test]
1291    fn parse_json_empty_object() {
1292        let result = parse_json_output("{}", "", 0);
1293        assert_eq!(result.total_tests(), 1); // fallback
1294        assert_eq!(result.total_passed(), 1);
1295    }
1296
1297    #[test]
1298    fn parse_json_empty_suites_array() {
1299        let result = parse_json_output(r#"{"suites": []}"#, "", 0);
1300        assert_eq!(result.total_tests(), 1); // fallback
1301    }
1302
1303    #[test]
1304    fn parse_json_empty_tests_array() {
1305        let result = parse_json_output(r#"{"tests": []}"#, "", 0);
1306        assert_eq!(result.total_tests(), 1); // fallback
1307    }
1308
1309    #[test]
1310    fn parse_json_empty_flat_array() {
1311        let result = parse_json_output("[]", "", 0);
1312        assert_eq!(result.total_tests(), 1); // fallback
1313    }
1314
1315    #[test]
1316    fn parse_json_unknown_status() {
1317        let json = r#"[{"name": "t1", "status": "wonky"}]"#;
1318        let result = parse_json_output(json, "", 0);
1319        // Unknown status → test filtered out → fallback
1320        assert_eq!(result.total_tests(), 1);
1321    }
1322
1323    #[test]
1324    fn parse_json_missing_name() {
1325        let json = r#"[{"status": "passed"}]"#;
1326        let result = parse_json_output(json, "", 0);
1327        assert_eq!(result.total_tests(), 1); // fallback: no name means filtered
1328    }
1329
1330    #[test]
1331    fn parse_json_missing_status() {
1332        let json = r#"[{"name": "t1"}]"#;
1333        let result = parse_json_output(json, "", 0);
1334        assert_eq!(result.total_tests(), 1); // fallback: no status means filtered
1335    }
1336
1337    #[test]
1338    fn parse_json_all_status_synonyms() {
1339        let json = r#"[
1340            {"name": "t1", "status": "pass"},
1341            {"name": "t2", "status": "ok"},
1342            {"name": "t3", "status": "success"},
1343            {"name": "t4", "status": "fail"},
1344            {"name": "t5", "status": "error"},
1345            {"name": "t6", "status": "failure"},
1346            {"name": "t7", "status": "skip"},
1347            {"name": "t8", "status": "pending"},
1348            {"name": "t9", "status": "ignored"}
1349        ]"#;
1350        let result = parse_json_output(json, "", 1);
1351        assert_eq!(result.total_tests(), 9);
1352        assert_eq!(result.total_passed(), 3);
1353        assert_eq!(result.total_failed(), 3);
1354        assert_eq!(result.total_skipped(), 3);
1355    }
1356
1357    #[test]
1358    fn parse_json_with_duration_ms() {
1359        let json = r#"[{"name": "slow", "status": "passed", "duration": 1500}]"#;
1360        let result = parse_json_output(json, "", 0);
1361        let test = &result.suites[0].tests[0];
1362        assert!(test.duration >= Duration::from_millis(1400)); // 1500ms / 1000 = 1.5s
1363    }
1364
1365    #[test]
1366    fn parse_json_error_as_string() {
1367        let json = r#"[{"name": "t1", "status": "failed", "error": "boom"}]"#;
1368        let result = parse_json_output(json, "", 1);
1369        let test = &result.suites[0].tests[0];
1370        assert!(test.error.is_some());
1371        assert_eq!(test.error.as_ref().unwrap().message, "boom");
1372    }
1373
1374    #[test]
1375    fn parse_json_error_as_object() {
1376        let json = r#"[{"name": "t1", "status": "failed", "error": {"message": "bad", "location": "foo.rs:5"}}]"#;
1377        let result = parse_json_output(json, "", 1);
1378        let test = &result.suites[0].tests[0];
1379        let err = test.error.as_ref().unwrap();
1380        assert_eq!(err.message, "bad");
1381        assert_eq!(err.location.as_deref(), Some("foo.rs:5"));
1382    }
1383
1384    #[test]
1385    fn parse_json_nested_suites_with_names() {
1386        let json = r#"{"suites": [
1387            {"name": "s1", "tests": [{"name": "t1", "status": "passed"}]},
1388            {"name": "s2", "tests": [{"name": "t2", "status": "failed"}]}
1389        ]}"#;
1390        let result = parse_json_output(json, "", 1);
1391        assert_eq!(result.suites.len(), 2);
1392        assert_eq!(result.suites[0].name, "s1");
1393        assert_eq!(result.suites[1].name, "s2");
1394    }
1395
1396    #[test]
1397    fn parse_json_tests_with_custom_suite_name() {
1398        let json = r#"{"name": "my-suite", "tests": [{"name": "t1", "status": "passed"}]}"#;
1399        let result = parse_json_output(json, "", 0);
1400        assert_eq!(result.suites[0].name, "my-suite");
1401    }
1402
1403    #[test]
1404    fn parse_json_stderr_ignored() {
1405        let json = r#"[{"name": "t", "status": "passed"}]"#;
1406        let result = parse_json_output(json, "STDERR NOISE", 0);
1407        assert_eq!(result.total_passed(), 1);
1408    }
1409
1410    // --- TAP parser edge cases ---
1411
1412    #[test]
1413    fn parse_tap_only_plan_no_tests() {
1414        let result = parse_tap_output("1..0\n", 0);
1415        assert_eq!(result.total_tests(), 1); // fallback
1416    }
1417
1418    #[test]
1419    fn parse_tap_plan_at_end() {
1420        let output = "ok 1 - first\nok 2 - second\n1..2\n";
1421        let result = parse_tap_output(output, 0);
1422        assert_eq!(result.total_tests(), 2);
1423        assert_eq!(result.total_passed(), 2);
1424    }
1425
1426    #[test]
1427    fn parse_tap_diagnostic_lines_ignored() {
1428        let output = "# running tests\nok 1 - test\n# end\n";
1429        let result = parse_tap_output(output, 0);
1430        assert_eq!(result.total_tests(), 1);
1431    }
1432
1433    #[test]
1434    fn parse_tap_mixed_pass_fail_skip() {
1435        let output =
1436            "1..4\nok 1 - a\nnot ok 2 - b\nok 3 - c # SKIP reason\nnot ok 4 - d # TODO later\n";
1437        let result = parse_tap_output(output, 1);
1438        assert_eq!(result.total_passed(), 1);
1439        assert_eq!(result.total_failed(), 1);
1440        assert_eq!(result.total_skipped(), 2); // SKIP + TODO
1441    }
1442
1443    #[test]
1444    fn parse_tap_lowercase_skip() {
1445        let output = "ok 1 - t # skip not ready\n";
1446        let result = parse_tap_output(output, 0);
1447        assert_eq!(result.total_skipped(), 1);
1448    }
1449
1450    #[test]
1451    fn parse_tap_no_description() {
1452        let output = "ok 1\nnot ok 2\n";
1453        let result = parse_tap_output(output, 1);
1454        assert_eq!(result.total_tests(), 2);
1455    }
1456
1457    #[test]
1458    fn parse_tap_large_test_numbers() {
1459        let output = "ok 999 - big number test\n";
1460        let result = parse_tap_output(output, 0);
1461        assert_eq!(result.total_tests(), 1);
1462    }
1463
1464    #[test]
1465    fn parse_tap_failed_test_has_error() {
1466        let output = "not ok 1 - broken\n";
1467        let result = parse_tap_output(output, 1);
1468        let test = &result.suites[0].tests[0];
1469        assert_eq!(test.status, TestStatus::Failed);
1470        assert!(test.error.is_some());
1471        assert_eq!(test.error.as_ref().unwrap().message, "Test failed");
1472    }
1473
1474    // --- Lines parser edge cases ---
1475
1476    #[test]
1477    fn parse_lines_blank_lines_ignored() {
1478        let output = "\n\nok test1\n\n\nfail test2\n\n";
1479        let result = parse_lines_output(output, 1);
1480        assert_eq!(result.total_tests(), 2);
1481    }
1482
1483    #[test]
1484    fn parse_lines_colon_format() {
1485        let output = "ok: test1\nfail: test2\nskip: test3\n";
1486        let result = parse_lines_output(output, 1);
1487        assert_eq!(result.total_tests(), 3);
1488    }
1489
1490    #[test]
1491    fn parse_lines_all_pass_variants() {
1492        let output = "ok t1\npass t2\npassed t3\nPASS t4\nPASSED t5\nOK t6\n✓ t7\n✔ t8\n";
1493        let result = parse_lines_output(output, 0);
1494        assert_eq!(result.total_passed(), 8);
1495    }
1496
1497    #[test]
1498    fn parse_lines_all_fail_variants() {
1499        let output = "fail t1\nfailed t2\nerror t3\nFAIL t4\nFAILED t5\nERROR t6\n✗ t7\n✘ t8\n";
1500        let result = parse_lines_output(output, 1);
1501        assert_eq!(result.total_failed(), 8);
1502    }
1503
1504    #[test]
1505    fn parse_lines_all_skip_variants() {
1506        let output = "skip t1\nskipped t2\npending t3\nSKIP t4\nSKIPPED t5\nPENDING t6\n";
1507        let result = parse_lines_output(output, 0);
1508        assert_eq!(result.total_skipped(), 6);
1509    }
1510
1511    #[test]
1512    fn parse_lines_failed_has_error() {
1513        let output = "fail broken_test\n";
1514        let result = parse_lines_output(output, 1);
1515        let test = &result.suites[0].tests[0];
1516        assert!(test.error.is_some());
1517    }
1518
1519    #[test]
1520    fn parse_lines_passed_has_no_error() {
1521        let output = "ok good_test\n";
1522        let result = parse_lines_output(output, 0);
1523        let test = &result.suites[0].tests[0];
1524        assert!(test.error.is_none());
1525    }
1526
1527    #[test]
1528    fn parse_lines_only_noise() {
1529        let output = "compiling...\nrunning tests...\ndone\n";
1530        let result = parse_lines_output(output, 0);
1531        assert_eq!(result.total_tests(), 1); // fallback
1532    }
1533
1534    // --- JUnit XML parser edge cases ---
1535
1536    #[test]
1537    fn parse_junit_multiple_suites() {
1538        let xml = r#"<testsuites>
1539  <testsuite name="s1" tests="1">
1540    <testcase name="t1" time="0.01"/>
1541  </testsuite>
1542  <testsuite name="s2" tests="1">
1543    <testcase name="t2" time="0.02"/>
1544  </testsuite>
1545</testsuites>"#;
1546        let result = parse_junit_output(xml, 0);
1547        // Note: current parser finds testcases regardless of suite nesting
1548        assert!(result.total_tests() >= 2);
1549    }
1550
1551    #[test]
1552    fn parse_junit_self_closing_testcase() {
1553        let xml = r#"<testsuite name="t" tests="1">
1554  <testcase name="fast" classname="Test" time="0.001"/>
1555</testsuite>"#;
1556        let result = parse_junit_output(xml, 0);
1557        assert_eq!(result.total_passed(), 1);
1558    }
1559
1560    #[test]
1561    fn parse_junit_error_element() {
1562        let xml = r#"<testsuite name="t" tests="1">
1563  <testcase name="crasher" time="0.01">
1564    <error message="segfault"/>
1565  </testcase>
1566</testsuite>"#;
1567        let result = parse_junit_output(xml, 1);
1568        assert_eq!(result.total_failed(), 1);
1569        let test = &result.suites[0].tests[0];
1570        assert_eq!(test.error.as_ref().unwrap().message, "segfault");
1571    }
1572
1573    #[test]
1574    fn parse_junit_no_time_attribute() {
1575        let xml = r#"<testsuite name="t" tests="1">
1576  <testcase name="notime"/>
1577</testsuite>"#;
1578        let result = parse_junit_output(xml, 0);
1579        assert_eq!(result.total_tests(), 1);
1580        assert_eq!(result.suites[0].tests[0].duration, Duration::ZERO);
1581    }
1582
1583    #[test]
1584    fn parse_junit_invalid_xml() {
1585        let result = parse_junit_output("not xml at all <<<>>>", 1);
1586        assert_eq!(result.total_tests(), 1); // fallback
1587    }
1588
1589    #[test]
1590    fn parse_junit_testcases_without_testsuite() {
1591        let xml = r#"<testcase name="orphan1" time="0.1"/>
1592<testcase name="orphan2" time="0.2"/>"#;
1593        let result = parse_junit_output(xml, 0);
1594        assert_eq!(result.total_tests(), 2);
1595    }
1596
1597    // --- Regex parser edge cases ---
1598
1599    #[test]
1600    fn parse_regex_no_matches() {
1601        let config = RegexParserConfig {
1602            pass_pattern: "PASS: (.*)".into(),
1603            fail_pattern: "FAIL: (.*)".into(),
1604            skip_pattern: None,
1605            name_group: 1,
1606            duration_group: None,
1607        };
1608        let result = parse_regex_output("no matching lines here", &config, 0);
1609        assert_eq!(result.total_tests(), 1); // fallback
1610    }
1611
1612    #[test]
1613    fn parse_regex_with_duration() {
1614        let config = RegexParserConfig {
1615            pass_pattern: "PASS (.*) (.*)ms".into(),
1616            fail_pattern: "FAIL (.*) (.*)ms".into(),
1617            skip_pattern: None,
1618            name_group: 1,
1619            duration_group: Some(2),
1620        };
1621        let output = "PASS test_one 150ms\nFAIL test_two 50ms\n";
1622        let result = parse_regex_output(output, &config, 1);
1623        assert_eq!(result.total_tests(), 2);
1624        // Duration from group: 150ms parsed
1625        let pass_test = &result.suites[0].tests[0];
1626        assert!(pass_test.duration > Duration::ZERO);
1627    }
1628
1629    #[test]
1630    fn parse_regex_empty_capture_filtered() {
1631        let config = RegexParserConfig {
1632            pass_pattern: "PASS:(.*)".into(),
1633            fail_pattern: "FAIL:(.*)".into(),
1634            skip_pattern: None,
1635            name_group: 1,
1636            duration_group: None,
1637        };
1638        // Empty name after "PASS:" → filtered out
1639        let result = parse_regex_output("PASS:", &config, 0);
1640        assert_eq!(result.total_tests(), 1); // fallback
1641    }
1642
1643    // --- Simple pattern match edge cases ---
1644
1645    #[test]
1646    fn simple_match_empty_pattern_empty_input() {
1647        let result = simple_pattern_match("", "");
1648        assert!(result.is_some());
1649        assert!(result.unwrap().is_empty());
1650    }
1651
1652    #[test]
1653    fn simple_match_empty_pattern_nonempty_input() {
1654        let result = simple_pattern_match("", "hello");
1655        assert!(result.is_none());
1656    }
1657
1658    #[test]
1659    fn simple_match_nonempty_pattern_empty_input() {
1660        let result = simple_pattern_match("hello", "");
1661        assert!(result.is_none());
1662    }
1663
1664    #[test]
1665    fn simple_match_capture_at_start() {
1666        let result = simple_pattern_match("(.*) done", "testing done");
1667        assert!(result.is_some());
1668        assert_eq!(result.unwrap(), vec!["testing"]);
1669    }
1670
1671    #[test]
1672    fn simple_match_capture_in_middle() {
1673        let result = simple_pattern_match("start (.*) end", "start middle end");
1674        assert!(result.is_some());
1675        assert_eq!(result.unwrap(), vec!["middle"]);
1676    }
1677
1678    #[test]
1679    fn simple_match_adjacent_groups() {
1680        // This tests the greedy behavior — first capture eats all before second literal
1681        let result = simple_pattern_match("(.*):(.*)!", "key:value!");
1682        assert!(result.is_some());
1683        let caps = result.unwrap();
1684        assert_eq!(caps[0], "key");
1685        assert_eq!(caps[1], "value");
1686    }
1687
1688    #[test]
1689    fn simple_match_wildcard_at_end() {
1690        let result = simple_pattern_match("hello .*", "hello world more stuff");
1691        assert!(result.is_some());
1692    }
1693
1694    #[test]
1695    fn simple_match_partial_mismatch() {
1696        let result = simple_pattern_match("abc", "abx");
1697        assert!(result.is_none());
1698    }
1699
1700    #[test]
1701    fn simple_match_pattern_longer_than_input() {
1702        let result = simple_pattern_match("hello world", "hello");
1703        assert!(result.is_none());
1704    }
1705
1706    // --- Fallback result edge cases ---
1707
1708    #[test]
1709    fn fallback_empty_stdout() {
1710        let result = fallback_result("", 1, "test");
1711        assert_eq!(result.total_failed(), 1);
1712        // Error message should be "Test failed" since no lines to take
1713        assert_eq!(
1714            result.suites[0].tests[0].error.as_ref().unwrap().message,
1715            "Test failed"
1716        );
1717    }
1718
1719    #[test]
1720    fn fallback_multiline_takes_first() {
1721        let result = fallback_result("first line\nsecond line", 1, "test");
1722        assert_eq!(
1723            result.suites[0].tests[0].error.as_ref().unwrap().message,
1724            "first line"
1725        );
1726    }
1727
1728    #[test]
1729    fn fallback_parser_name_in_suite() {
1730        let result = fallback_result("", 0, "myparser");
1731        assert_eq!(result.suites[0].name, "myparser-output");
1732        assert!(result.suites[0].tests[0].name.contains("myparser"));
1733    }
1734
1735    #[test]
1736    fn fallback_exit_zero_is_pass() {
1737        let result = fallback_result("anything", 0, "x");
1738        assert_eq!(result.total_passed(), 1);
1739        assert!(result.suites[0].tests[0].error.is_none());
1740    }
1741
1742    #[test]
1743    fn fallback_exit_nonzero_is_fail() {
1744        let result = fallback_result("anything", 42, "x");
1745        assert_eq!(result.total_failed(), 1);
1746        assert!(result.suites[0].tests[0].error.is_some());
1747    }
1748
1749    // --- parse_script_output delegation edge cases ---
1750
1751    #[test]
1752    fn script_output_delegates_to_junit() {
1753        let xml = r#"<testsuite name="t" tests="1">
1754  <testcase name="t1" time="0.01"/>
1755</testsuite>"#;
1756        let result = parse_script_output(&OutputParser::Junit, xml, "", 0);
1757        assert_eq!(result.total_passed(), 1);
1758    }
1759
1760    #[test]
1761    fn script_output_delegates_to_regex() {
1762        let config = RegexParserConfig {
1763            pass_pattern: "PASS (.*)".into(),
1764            fail_pattern: "FAIL (.*)".into(),
1765            skip_pattern: None,
1766            name_group: 1,
1767            duration_group: None,
1768        };
1769        let result = parse_script_output(
1770            &OutputParser::Regex(config),
1771            "PASS test1\nFAIL test2\n",
1772            "",
1773            1,
1774        );
1775        assert_eq!(result.total_tests(), 2);
1776    }
1777
1778    // --- XML attr edge cases ---
1779
1780    #[test]
1781    fn xml_attr_empty_value() {
1782        assert_eq!(
1783            extract_xml_attr(r#"<test name="">"#, "name"),
1784            Some("".into())
1785        );
1786    }
1787
1788    #[test]
1789    fn xml_attr_with_spaces() {
1790        assert_eq!(
1791            extract_xml_attr(r#"<test name="hello world">"#, "name"),
1792            Some("hello world".into())
1793        );
1794    }
1795
1796    #[test]
1797    fn xml_attr_multiple_attrs() {
1798        let tag = r#"<testcase name="add" classname="Math" time="1.5"/>"#;
1799        assert_eq!(extract_xml_attr(tag, "name"), Some("add".into()));
1800        assert_eq!(extract_xml_attr(tag, "classname"), Some("Math".into()));
1801        assert_eq!(extract_xml_attr(tag, "time"), Some("1.5".into()));
1802    }
1803
1804    // --- OutputParser enum equality ---
1805
1806    #[test]
1807    fn output_parser_equality() {
1808        assert_eq!(OutputParser::Json, OutputParser::Json);
1809        assert_eq!(OutputParser::Tap, OutputParser::Tap);
1810        assert_ne!(OutputParser::Json, OutputParser::Tap);
1811    }
1812
1813    #[test]
1814    fn output_parser_debug() {
1815        let dbg = format!("{:?}", OutputParser::Lines);
1816        assert_eq!(dbg, "Lines");
1817    }
1818
1819    #[test]
1820    fn regex_parser_config_clone() {
1821        let config = RegexParserConfig {
1822            pass_pattern: "P (.*)".into(),
1823            fail_pattern: "F (.*)".into(),
1824            skip_pattern: Some("S (.*)".into()),
1825            name_group: 1,
1826            duration_group: Some(2),
1827        };
1828        let cloned = config.clone();
1829        assert_eq!(cloned, config);
1830    }
1831}