1use 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#[derive(Debug, Clone, PartialEq)]
16pub enum OutputParser {
17 Json,
19 Junit,
21 Tap,
23 Lines,
25 Regex(RegexParserConfig),
27}
28
29#[derive(Debug, Clone, PartialEq)]
31pub struct RegexParserConfig {
32 pub pass_pattern: String,
34 pub fail_pattern: String,
36 pub skip_pattern: Option<String>,
38 pub name_group: usize,
40 pub duration_group: Option<usize>,
42}
43
44#[derive(Debug, Clone)]
46pub struct ScriptAdapterConfig {
47 pub name: String,
49 pub detect_file: String,
51 pub detect_pattern: Option<String>,
53 pub command: String,
55 pub args: Vec<String>,
57 pub parser: OutputParser,
59 pub working_dir: Option<String>,
61 pub env: Vec<(String, String)>,
63}
64
65impl ScriptAdapterConfig {
66 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 pub fn with_parser(mut self, parser: OutputParser) -> Self {
82 self.parser = parser;
83 self
84 }
85
86 pub fn with_args(mut self, args: Vec<String>) -> Self {
88 self.args = args;
89 self
90 }
91
92 pub fn with_working_dir(mut self, dir: &str) -> Self {
94 self.working_dir = Some(dir.to_string());
95 self
96 }
97
98 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 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 if let Some(ref pattern) = self.detect_pattern {
113 return glob_detect(project_dir, pattern);
114 }
115
116 false
117 }
118
119 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 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 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 candidate
148 }
149 None => project_dir.to_path_buf(),
150 }
151 }
152
153 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
161pub struct ScriptTestAdapter {
167 config: ScriptAdapterConfig,
168 confidence: f32,
170 check_command: Option<String>,
172 pub is_global: bool,
174 pub source: String,
176 detect_config: Option<crate::config::CustomDetectConfig>,
178}
179
180impl ScriptTestAdapter {
181 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 pub fn from_custom_config(cfg: &crate::config::CustomAdapterConfig) -> Self {
195 let detect_file = cfg.detect.files.first().cloned().unwrap_or_default();
199
200 let parser = parse_output_parser_str(&cfg.output);
202
203 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 pub fn with_confidence(mut self, confidence: f32) -> Self {
233 self.confidence = confidence.clamp(0.0, 1.0);
234 self
235 }
236
237 pub fn with_check(mut self, check: Option<String>) -> Self {
239 self.check_command = check;
240 self
241 }
242
243 pub fn with_source(mut self, source: &str) -> Self {
245 self.source = source.to_string();
246 self
247 }
248
249 pub fn with_global(mut self, is_global: bool) -> Self {
251 self.is_global = is_global;
252 self
253 }
254}
255
256fn 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 let mut pass = if !dc.files.is_empty() {
278 dc.files.iter().any(|f| project_dir.join(f).exists())
279 } else {
280 self.config.detect(project_dir)
282 };
283
284 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 if pass && !dc.env_vars.is_empty() {
304 pass = dc.env_vars.iter().all(|var| std::env::var(var).is_ok());
305 }
306
307 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 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 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 for arg in &self.config.args {
354 cmd.arg(arg);
355 }
356
357 for arg in extra_args {
359 cmd.arg(arg);
360 }
361
362 cmd.current_dir(&working_dir);
363
364 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
395fn glob_detect(project_dir: &Path, pattern: &str) -> bool {
397 if pattern.contains('*') {
399 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 project_dir.join(pattern).exists()
408 } else {
409 project_dir.join(pattern).exists()
410 }
411}
412
413fn 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
427pub 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
445fn parse_json_output(stdout: &str, _stderr: &str, exit_code: i32) -> TestRunResult {
447 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_result(stdout, exit_code, "json")
461}
462
463fn parse_json_suites(value: &serde_json::Value) -> Vec<TestSuite> {
465 let mut suites = Vec::new();
466
467 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 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 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
555fn parse_junit_output(stdout: &str, exit_code: i32) -> TestRunResult {
557 let mut suites = Vec::new();
558
559 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 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 let mut status = TestStatus::Passed;
617 let mut error = None;
618
619 if trimmed.ends_with("/>") {
620 if trimmed.contains("<skipped") {
622 status = TestStatus::Skipped;
623 }
624 } else {
625 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
661fn 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
670fn 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 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 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 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
740fn parse_tap_description(rest: &str) -> (String, bool) {
742 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 let desc = after_num.strip_prefix("- ").unwrap_or(after_num);
750
751 let is_skip = desc.contains("# SKIP") || desc.contains("# skip");
753
754 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
764fn 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
796fn 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
820fn 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 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; return Some((status.clone(), line[idx..].trim_start()));
867 }
868 }
869
870 None
871}
872
873fn 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
912fn 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
951fn 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 pat_idx += 4;
966
967 let next_literal = find_next_literal(pattern, pat_idx);
969
970 match next_literal {
971 Some(lit) => {
972 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 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 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 if pat_idx == pat_bytes.len() && inp_idx == inp_bytes.len() {
1017 Some(captures)
1018 } else {
1019 None
1020 }
1021}
1022
1023fn 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
1047fn 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 #[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 #[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); }
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 #[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); }
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 #[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); }
1273
1274 #[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); }
1307
1308 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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); assert_eq!(config.parser, OutputParser::Tap);
1535 assert_eq!(config.working_dir, Some("build".into()));
1536 }
1537
1538 #[test]
1541 fn parse_json_empty_object() {
1542 let result = parse_json_output("{}", "", 0);
1543 assert_eq!(result.total_tests(), 1); 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); }
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); }
1558
1559 #[test]
1560 fn parse_json_empty_flat_array() {
1561 let result = parse_json_output("[]", "", 0);
1562 assert_eq!(result.total_tests(), 1); }
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 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); }
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); }
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)); }
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 #[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); }
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); }
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 #[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); }
1783
1784 #[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 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); }
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 #[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); }
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 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 let result = parse_regex_output("PASS:", &config, 0);
1890 assert_eq!(result.total_tests(), 1); }
1892
1893 #[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 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 #[test]
1959 fn fallback_empty_stdout() {
1960 let result = fallback_result("", 1, "test");
1961 assert_eq!(result.total_failed(), 1);
1962 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 #[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 #[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 #[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 #[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 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 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}