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