1use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9use crate::adapters::util::duration_from_secs_safe;
10use crate::adapters::{TestCase, TestError, TestRunResult, TestStatus, TestSuite};
11
12#[derive(Debug, Clone, PartialEq)]
14pub enum OutputParser {
15 Json,
17 Junit,
19 Tap,
21 Lines,
23 Regex(RegexParserConfig),
25}
26
27#[derive(Debug, Clone, PartialEq)]
29pub struct RegexParserConfig {
30 pub pass_pattern: String,
32 pub fail_pattern: String,
34 pub skip_pattern: Option<String>,
36 pub name_group: usize,
38 pub duration_group: Option<usize>,
40}
41
42#[derive(Debug, Clone)]
44pub struct ScriptAdapterConfig {
45 pub name: String,
47 pub detect_file: String,
49 pub detect_pattern: Option<String>,
51 pub command: String,
53 pub args: Vec<String>,
55 pub parser: OutputParser,
57 pub working_dir: Option<String>,
59 pub env: Vec<(String, String)>,
61}
62
63impl ScriptAdapterConfig {
64 pub fn new(name: &str, detect_file: &str, command: &str) -> Self {
66 Self {
67 name: name.to_string(),
68 detect_file: detect_file.to_string(),
69 detect_pattern: None,
70 command: command.to_string(),
71 args: Vec::new(),
72 parser: OutputParser::Lines,
73 working_dir: None,
74 env: Vec::new(),
75 }
76 }
77
78 pub fn with_parser(mut self, parser: OutputParser) -> Self {
80 self.parser = parser;
81 self
82 }
83
84 pub fn with_args(mut self, args: Vec<String>) -> Self {
86 self.args = args;
87 self
88 }
89
90 pub fn with_working_dir(mut self, dir: &str) -> Self {
92 self.working_dir = Some(dir.to_string());
93 self
94 }
95
96 pub fn with_env(mut self, key: &str, value: &str) -> Self {
98 self.env.push((key.to_string(), value.to_string()));
99 self
100 }
101
102 pub fn detect(&self, project_dir: &Path) -> bool {
104 let detect_path = project_dir.join(&self.detect_file);
105 if detect_path.exists() {
106 return true;
107 }
108
109 if let Some(ref pattern) = self.detect_pattern {
111 return glob_detect(project_dir, pattern);
112 }
113
114 false
115 }
116
117 pub fn effective_working_dir(&self, project_dir: &Path) -> PathBuf {
119 match &self.working_dir {
120 Some(dir) => project_dir.join(dir),
121 None => project_dir.to_path_buf(),
122 }
123 }
124
125 pub fn full_command(&self) -> String {
127 let mut parts = vec![self.command.clone()];
128 parts.extend(self.args.clone());
129 parts.join(" ")
130 }
131}
132
133fn glob_detect(project_dir: &Path, pattern: &str) -> bool {
135 if pattern.contains('*') {
137 if let Some(base) = pattern.split('*').next() {
139 let base = base.trim_end_matches('/');
140 if !base.is_empty() {
141 return project_dir.join(base).exists();
142 }
143 }
144 project_dir.join(pattern).exists()
146 } else {
147 project_dir.join(pattern).exists()
148 }
149}
150
151pub fn parse_script_output(
155 parser: &OutputParser,
156 stdout: &str,
157 stderr: &str,
158 exit_code: i32,
159) -> TestRunResult {
160 match parser {
161 OutputParser::Json => parse_json_output(stdout, stderr, exit_code),
162 OutputParser::Junit => parse_junit_output(stdout, exit_code),
163 OutputParser::Tap => parse_tap_output(stdout, exit_code),
164 OutputParser::Lines => parse_lines_output(stdout, exit_code),
165 OutputParser::Regex(config) => parse_regex_output(stdout, config, exit_code),
166 }
167}
168
169fn parse_json_output(stdout: &str, _stderr: &str, exit_code: i32) -> TestRunResult {
171 if let Ok(result) = serde_json::from_str::<serde_json::Value>(stdout) {
173 let suites = parse_json_suites(&result);
174 if !suites.is_empty() {
175 return TestRunResult {
176 suites,
177 duration: Duration::ZERO,
178 raw_exit_code: exit_code,
179 };
180 }
181 }
182
183 fallback_result(stdout, exit_code, "json")
185}
186
187fn parse_json_suites(value: &serde_json::Value) -> Vec<TestSuite> {
189 let mut suites = Vec::new();
190
191 if let Some(arr) = value.get("suites").and_then(|v| v.as_array()) {
193 for suite_val in arr {
194 if let Some(suite) = parse_json_suite(suite_val) {
195 suites.push(suite);
196 }
197 }
198 }
199
200 if suites.is_empty()
202 && let Some(arr) = value.get("tests").and_then(|v| v.as_array())
203 {
204 let name = value
205 .get("name")
206 .and_then(|v| v.as_str())
207 .unwrap_or("tests");
208 let tests: Vec<TestCase> = arr.iter().filter_map(parse_json_test).collect();
209 if !tests.is_empty() {
210 suites.push(TestSuite {
211 name: name.to_string(),
212 tests,
213 });
214 }
215 }
216
217 if suites.is_empty()
219 && let Some(arr) = value.as_array()
220 {
221 let tests: Vec<TestCase> = arr.iter().filter_map(parse_json_test).collect();
222 if !tests.is_empty() {
223 suites.push(TestSuite {
224 name: "tests".to_string(),
225 tests,
226 });
227 }
228 }
229
230 suites
231}
232
233fn parse_json_suite(value: &serde_json::Value) -> Option<TestSuite> {
234 let name = value.get("name").and_then(|v| v.as_str())?;
235 let tests_arr = value.get("tests").and_then(|v| v.as_array())?;
236 let tests: Vec<TestCase> = tests_arr.iter().filter_map(parse_json_test).collect();
237 Some(TestSuite {
238 name: name.to_string(),
239 tests,
240 })
241}
242
243fn parse_json_test(value: &serde_json::Value) -> Option<TestCase> {
244 let name = value.get("name").and_then(|v| v.as_str())?;
245 let status_str = value.get("status").and_then(|v| v.as_str())?;
246
247 let status = match status_str.to_lowercase().as_str() {
248 "passed" | "pass" | "ok" | "success" => TestStatus::Passed,
249 "failed" | "fail" | "error" | "failure" => TestStatus::Failed,
250 "skipped" | "skip" | "pending" | "ignored" => TestStatus::Skipped,
251 _ => return None,
252 };
253
254 let duration = value
255 .get("duration")
256 .and_then(|v| v.as_f64())
257 .map(|ms| duration_from_secs_safe(ms / 1000.0))
258 .unwrap_or(Duration::ZERO);
259
260 let error = value.get("error").and_then(|v| {
261 let message = v.as_str().map(|s| s.to_string()).or_else(|| {
262 v.get("message")
263 .and_then(|m| m.as_str().map(|s| s.to_string()))
264 })?;
265 let location = v
266 .get("location")
267 .and_then(|l| l.as_str().map(|s| s.to_string()));
268 Some(TestError { message, location })
269 });
270
271 Some(TestCase {
272 name: name.to_string(),
273 status,
274 duration,
275 error,
276 })
277}
278
279fn parse_junit_output(stdout: &str, exit_code: i32) -> TestRunResult {
281 let mut suites = Vec::new();
282
283 for line in stdout.lines() {
285 let trimmed = line.trim();
286 if trimmed.starts_with("<testsuite")
287 && !trimmed.starts_with("<testsuites")
288 && let Some(suite) = parse_junit_suite_tag(trimmed, stdout)
289 {
290 suites.push(suite);
291 }
292 }
293
294 if suites.is_empty() {
296 let tests = parse_junit_testcases(stdout);
297 if !tests.is_empty() {
298 suites.push(TestSuite {
299 name: "tests".to_string(),
300 tests,
301 });
302 }
303 }
304
305 if suites.is_empty() {
306 return fallback_result(stdout, exit_code, "junit");
307 }
308
309 TestRunResult {
310 suites,
311 duration: Duration::ZERO,
312 raw_exit_code: exit_code,
313 }
314}
315
316fn parse_junit_suite_tag(tag: &str, full_output: &str) -> Option<TestSuite> {
317 let name = extract_xml_attr(tag, "name").unwrap_or_else(|| "tests".to_string());
318 let tests = parse_junit_testcases(full_output);
319 if tests.is_empty() {
320 return None;
321 }
322 Some(TestSuite { name, tests })
323}
324
325fn parse_junit_testcases(xml: &str) -> Vec<TestCase> {
326 let mut tests = Vec::new();
327 let lines: Vec<&str> = xml.lines().collect();
328
329 let mut i = 0;
330 while i < lines.len() {
331 let trimmed = lines[i].trim();
332 if trimmed.starts_with("<testcase") {
333 let name = extract_xml_attr(trimmed, "name").unwrap_or_else(|| "unknown".to_string());
334 let time = extract_xml_attr(trimmed, "time")
335 .and_then(|t| t.parse::<f64>().ok())
336 .map(duration_from_secs_safe)
337 .unwrap_or(Duration::ZERO);
338
339 let mut status = TestStatus::Passed;
341 let mut error = None;
342
343 if trimmed.ends_with("/>") {
344 if trimmed.contains("<skipped") {
346 status = TestStatus::Skipped;
347 }
348 } else {
349 let mut j = i + 1;
351 while j < lines.len() {
352 let inner = lines[j].trim();
353 if inner.starts_with("</testcase") {
354 break;
355 }
356 if inner.starts_with("<failure") || inner.starts_with("<error") {
357 status = TestStatus::Failed;
358 let message = extract_xml_attr(inner, "message")
359 .unwrap_or_else(|| "Test failed".to_string());
360 error = Some(TestError {
361 message,
362 location: None,
363 });
364 }
365 if inner.starts_with("<skipped") {
366 status = TestStatus::Skipped;
367 }
368 j += 1;
369 }
370 }
371
372 tests.push(TestCase {
373 name,
374 status,
375 duration: time,
376 error,
377 });
378 }
379 i += 1;
380 }
381
382 tests
383}
384
385fn extract_xml_attr(tag: &str, attr: &str) -> Option<String> {
387 let search = format!("{attr}=\"");
388 let start = tag.find(&search)? + search.len();
389 let rest = &tag[start..];
390 let end = rest.find('"')?;
391 Some(rest[..end].to_string())
392}
393
394fn parse_tap_output(stdout: &str, exit_code: i32) -> TestRunResult {
396 let mut tests = Vec::new();
397 let mut _plan_count = 0;
398
399 for line in stdout.lines() {
400 let trimmed = line.trim();
401
402 if let Some(rest) = trimmed.strip_prefix("1..") {
404 if let Ok(n) = rest.parse::<usize>() {
405 _plan_count = n;
406 }
407 continue;
408 }
409
410 if let Some(rest) = trimmed.strip_prefix("ok ") {
412 let (name, is_skip) = parse_tap_description(rest);
413 tests.push(TestCase {
414 name,
415 status: if is_skip {
416 TestStatus::Skipped
417 } else {
418 TestStatus::Passed
419 },
420 duration: Duration::ZERO,
421 error: None,
422 });
423 continue;
424 }
425
426 if let Some(rest) = trimmed.strip_prefix("not ok ") {
428 let (name, is_skip) = parse_tap_description(rest);
429 let is_todo = trimmed.contains("# TODO");
430 tests.push(TestCase {
431 name,
432 status: if is_skip || is_todo {
433 TestStatus::Skipped
434 } else {
435 TestStatus::Failed
436 },
437 duration: Duration::ZERO,
438 error: if !is_skip && !is_todo {
439 Some(TestError {
440 message: "Test failed".to_string(),
441 location: None,
442 })
443 } else {
444 None
445 },
446 });
447 }
448 }
449
450 if tests.is_empty() {
451 return fallback_result(stdout, exit_code, "tap");
452 }
453
454 TestRunResult {
455 suites: vec![TestSuite {
456 name: "tests".to_string(),
457 tests,
458 }],
459 duration: Duration::ZERO,
460 raw_exit_code: exit_code,
461 }
462}
463
464fn parse_tap_description(rest: &str) -> (String, bool) {
466 let after_num = rest
468 .find(|c: char| !c.is_ascii_digit())
469 .map(|i| rest[i..].trim_start())
470 .unwrap_or(rest);
471
472 let desc = after_num.strip_prefix("- ").unwrap_or(after_num);
474
475 let is_skip = desc.contains("# SKIP") || desc.contains("# skip");
477
478 let name = if let Some(idx) = desc.find(" # ") {
480 desc[..idx].to_string()
481 } else {
482 desc.to_string()
483 };
484
485 (name, is_skip)
486}
487
488fn parse_lines_output(stdout: &str, exit_code: i32) -> TestRunResult {
493 let mut tests = Vec::new();
494
495 for line in stdout.lines() {
496 let trimmed = line.trim();
497 if trimmed.is_empty() {
498 continue;
499 }
500
501 if let Some(test) = parse_status_line(trimmed) {
502 tests.push(test);
503 }
504 }
505
506 if tests.is_empty() {
507 return fallback_result(stdout, exit_code, "lines");
508 }
509
510 TestRunResult {
511 suites: vec![TestSuite {
512 name: "tests".to_string(),
513 tests,
514 }],
515 duration: Duration::ZERO,
516 raw_exit_code: exit_code,
517 }
518}
519
520fn parse_status_line(line: &str) -> Option<TestCase> {
522 let (status, rest) = parse_status_prefix(line)?;
523 let name = rest.trim().to_string();
524 if name.is_empty() {
525 return None;
526 }
527
528 let failed = status == TestStatus::Failed;
529 Some(TestCase {
530 name,
531 status,
532 duration: Duration::ZERO,
533 error: if failed {
534 Some(TestError {
535 message: "Test failed".into(),
536 location: None,
537 })
538 } else {
539 None
540 },
541 })
542}
543
544fn parse_status_prefix(line: &str) -> Option<(TestStatus, &str)> {
546 let patterns: &[(&str, TestStatus)] = &[
547 ("ok ", TestStatus::Passed),
548 ("pass ", TestStatus::Passed),
549 ("passed ", TestStatus::Passed),
550 ("PASS ", TestStatus::Passed),
551 ("PASSED ", TestStatus::Passed),
552 ("OK ", TestStatus::Passed),
553 ("✓ ", TestStatus::Passed),
554 ("✔ ", TestStatus::Passed),
555 ("fail ", TestStatus::Failed),
556 ("failed ", TestStatus::Failed),
557 ("error ", TestStatus::Failed),
558 ("FAIL ", TestStatus::Failed),
559 ("FAILED ", TestStatus::Failed),
560 ("ERROR ", TestStatus::Failed),
561 ("✗ ", TestStatus::Failed),
562 ("✘ ", TestStatus::Failed),
563 ("skip ", TestStatus::Skipped),
564 ("skipped ", TestStatus::Skipped),
565 ("pending ", TestStatus::Skipped),
566 ("SKIP ", TestStatus::Skipped),
567 ("SKIPPED ", TestStatus::Skipped),
568 ("PENDING ", TestStatus::Skipped),
569 ];
570
571 for (prefix, status) in patterns {
572 if let Some(rest) = line.strip_prefix(prefix) {
573 return Some((status.clone(), rest));
574 }
575 }
576
577 let colon_patterns: &[(&str, TestStatus)] = &[
579 ("ok:", TestStatus::Passed),
580 ("pass:", TestStatus::Passed),
581 ("fail:", TestStatus::Failed),
582 ("error:", TestStatus::Failed),
583 ("skip:", TestStatus::Skipped),
584 ];
585
586 for (prefix, status) in colon_patterns {
587 if let Some(rest) = line.to_lowercase().strip_prefix(prefix) {
588 let idx = prefix.len();
589 let _ = rest; return Some((status.clone(), line[idx..].trim_start()));
591 }
592 }
593
594 None
595}
596
597fn parse_regex_output(stdout: &str, config: &RegexParserConfig, exit_code: i32) -> TestRunResult {
599 let mut tests = Vec::new();
600
601 for line in stdout.lines() {
602 let trimmed = line.trim();
603 if trimmed.is_empty() {
604 continue;
605 }
606
607 if let Some(test) =
608 try_regex_match(trimmed, &config.pass_pattern, TestStatus::Passed, config)
609 {
610 tests.push(test);
611 } else if let Some(test) =
612 try_regex_match(trimmed, &config.fail_pattern, TestStatus::Failed, config)
613 {
614 tests.push(test);
615 } else if let Some(ref skip_pattern) = config.skip_pattern
616 && let Some(test) = try_regex_match(trimmed, skip_pattern, TestStatus::Skipped, config)
617 {
618 tests.push(test);
619 }
620 }
621
622 if tests.is_empty() {
623 return fallback_result(stdout, exit_code, "regex");
624 }
625
626 TestRunResult {
627 suites: vec![TestSuite {
628 name: "tests".to_string(),
629 tests,
630 }],
631 duration: Duration::ZERO,
632 raw_exit_code: exit_code,
633 }
634}
635
636fn try_regex_match(
641 line: &str,
642 pattern: &str,
643 status: TestStatus,
644 config: &RegexParserConfig,
645) -> Option<TestCase> {
646 let captures = simple_pattern_match(pattern, line)?;
647
648 let name = captures.get(config.name_group.saturating_sub(1))?.clone();
649 if name.is_empty() {
650 return None;
651 }
652
653 let duration = config
654 .duration_group
655 .and_then(|g| captures.get(g.saturating_sub(1)))
656 .and_then(|d| d.parse::<f64>().ok())
657 .map(|ms| duration_from_secs_safe(ms / 1000.0))
658 .unwrap_or(Duration::ZERO);
659
660 Some(TestCase {
661 name,
662 status: status.clone(),
663 duration,
664 error: if status == TestStatus::Failed {
665 Some(TestError {
666 message: "Test failed".into(),
667 location: None,
668 })
669 } else {
670 None
671 },
672 })
673}
674
675fn simple_pattern_match(pattern: &str, input: &str) -> Option<Vec<String>> {
680 let mut captures = Vec::new();
681 let mut pat_idx = 0;
682 let mut inp_idx = 0;
683 let pat_bytes = pattern.as_bytes();
684 let inp_bytes = input.as_bytes();
685
686 while pat_idx < pat_bytes.len() && inp_idx <= inp_bytes.len() {
687 if pat_idx + 4 <= pat_bytes.len() && &pat_bytes[pat_idx..pat_idx + 4] == b"(.*)" {
688 pat_idx += 4;
690
691 let next_literal = find_next_literal(pattern, pat_idx);
693
694 match next_literal {
695 Some(lit) => {
696 let remaining = &input[inp_idx..];
698 if let Some(pos) = remaining.find(&lit) {
699 captures.push(remaining[..pos].to_string());
700 inp_idx += pos;
701 } else {
702 return None;
703 }
704 }
705 None => {
706 captures.push(input[inp_idx..].to_string());
708 inp_idx = inp_bytes.len();
709 }
710 }
711 } else if pat_idx + 1 < pat_bytes.len()
712 && pat_bytes[pat_idx] == b'.'
713 && pat_bytes[pat_idx + 1] == b'*'
714 {
715 pat_idx += 2;
717 let next_literal = find_next_literal(pattern, pat_idx);
718 match next_literal {
719 Some(lit) => {
720 let remaining = &input[inp_idx..];
721 if let Some(pos) = remaining.find(&lit) {
722 inp_idx += pos;
723 } else {
724 return None;
725 }
726 }
727 None => {
728 inp_idx = inp_bytes.len();
729 }
730 }
731 } else if inp_idx < inp_bytes.len() && pat_bytes[pat_idx] == inp_bytes[inp_idx] {
732 pat_idx += 1;
733 inp_idx += 1;
734 } else {
735 return None;
736 }
737 }
738
739 if pat_idx == pat_bytes.len() && inp_idx == inp_bytes.len() {
741 Some(captures)
742 } else {
743 None
744 }
745}
746
747fn find_next_literal(pattern: &str, from: usize) -> Option<String> {
749 let rest = &pattern[from..];
750 if rest.is_empty() {
751 return None;
752 }
753
754 let mut lit = String::new();
755 let bytes = rest.as_bytes();
756 let mut i = 0;
757 while i < bytes.len() {
758 if i + 1 < bytes.len() && bytes[i] == b'.' && bytes[i + 1] == b'*' {
759 break;
760 }
761 if i + 4 <= bytes.len() && &bytes[i..i + 4] == b"(.*)" {
762 break;
763 }
764 lit.push(bytes[i] as char);
765 i += 1;
766 }
767
768 if lit.is_empty() { None } else { Some(lit) }
769}
770
771fn fallback_result(stdout: &str, exit_code: i32, parser_name: &str) -> TestRunResult {
773 let status = if exit_code == 0 {
774 TestStatus::Passed
775 } else {
776 TestStatus::Failed
777 };
778
779 TestRunResult {
780 suites: vec![TestSuite {
781 name: format!("{parser_name}-output"),
782 tests: vec![TestCase {
783 name: format!("test run ({parser_name} parser)"),
784 status,
785 duration: Duration::ZERO,
786 error: if exit_code != 0 {
787 Some(TestError {
788 message: stdout.lines().next().unwrap_or("Test failed").to_string(),
789 location: None,
790 })
791 } else {
792 None
793 },
794 }],
795 }],
796 duration: Duration::ZERO,
797 raw_exit_code: exit_code,
798 }
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
804 use std::path::PathBuf;
805
806 #[test]
809 fn config_new() {
810 let config = ScriptAdapterConfig::new("mytest", "Makefile", "make test");
811 assert_eq!(config.name, "mytest");
812 assert_eq!(config.detect_file, "Makefile");
813 assert_eq!(config.command, "make test");
814 assert_eq!(config.parser, OutputParser::Lines);
815 }
816
817 #[test]
818 fn config_builder() {
819 let config = ScriptAdapterConfig::new("mytest", "Makefile", "make test")
820 .with_parser(OutputParser::Tap)
821 .with_args(vec!["--verbose".into()])
822 .with_working_dir("src")
823 .with_env("CI", "true");
824
825 assert_eq!(config.parser, OutputParser::Tap);
826 assert_eq!(config.args, vec!["--verbose"]);
827 assert_eq!(config.working_dir, Some("src".into()));
828 assert_eq!(config.env, vec![("CI".into(), "true".into())]);
829 }
830
831 #[test]
832 fn config_full_command() {
833 let config = ScriptAdapterConfig::new("test", "f", "make test")
834 .with_args(vec!["--verbose".into(), "--color".into()]);
835 assert_eq!(config.full_command(), "make test --verbose --color");
836 }
837
838 #[test]
839 fn config_effective_working_dir() {
840 let base = PathBuf::from("/project");
841
842 let config = ScriptAdapterConfig::new("test", "f", "cmd");
843 assert_eq!(
844 config.effective_working_dir(&base),
845 PathBuf::from("/project")
846 );
847
848 let config = config.with_working_dir("src");
849 assert_eq!(
850 config.effective_working_dir(&base),
851 PathBuf::from("/project/src")
852 );
853 }
854
855 #[test]
858 fn parse_tap_basic() {
859 let output = "1..3\nok 1 - first test\nok 2 - second test\nnot ok 3 - third test\n";
860 let result = parse_tap_output(output, 1);
861 assert_eq!(result.total_tests(), 3);
862 assert_eq!(result.total_passed(), 2);
863 assert_eq!(result.total_failed(), 1);
864 }
865
866 #[test]
867 fn parse_tap_skip() {
868 let output = "1..2\nok 1 - test one\nok 2 - test two # SKIP not ready\n";
869 let result = parse_tap_output(output, 0);
870 assert_eq!(result.total_tests(), 2);
871 assert_eq!(result.total_passed(), 1);
872 assert_eq!(result.total_skipped(), 1);
873 }
874
875 #[test]
876 fn parse_tap_todo() {
877 let output = "1..1\nnot ok 1 - todo test # TODO implement later\n";
878 let result = parse_tap_output(output, 0);
879 assert_eq!(result.total_tests(), 1);
880 assert_eq!(result.total_skipped(), 1);
881 }
882
883 #[test]
884 fn parse_tap_empty() {
885 let result = parse_tap_output("", 0);
886 assert_eq!(result.total_tests(), 1); }
888
889 #[test]
890 fn parse_tap_no_plan() {
891 let output = "ok 1 - works\nnot ok 2 - broken\n";
892 let result = parse_tap_output(output, 1);
893 assert_eq!(result.total_tests(), 2);
894 }
895
896 #[test]
899 fn parse_lines_basic() {
900 let output = "ok test_one\nfail test_two\nskip test_three\n";
901 let result = parse_lines_output(output, 1);
902 assert_eq!(result.total_tests(), 3);
903 assert_eq!(result.total_passed(), 1);
904 assert_eq!(result.total_failed(), 1);
905 assert_eq!(result.total_skipped(), 1);
906 }
907
908 #[test]
909 fn parse_lines_uppercase() {
910 let output = "PASS test_one\nFAIL test_two\nSKIP test_three\n";
911 let result = parse_lines_output(output, 1);
912 assert_eq!(result.total_tests(), 3);
913 }
914
915 #[test]
916 fn parse_lines_unicode() {
917 let output = "✓ test_one\n✗ test_two\n";
918 let result = parse_lines_output(output, 1);
919 assert_eq!(result.total_tests(), 2);
920 assert_eq!(result.total_passed(), 1);
921 assert_eq!(result.total_failed(), 1);
922 }
923
924 #[test]
925 fn parse_lines_empty() {
926 let result = parse_lines_output("", 0);
927 assert_eq!(result.total_tests(), 1); }
929
930 #[test]
931 fn parse_lines_ignores_non_matching() {
932 let output = "running tests...\nok test_one\nsome other output\nfail test_two\ndone";
933 let result = parse_lines_output(output, 1);
934 assert_eq!(result.total_tests(), 2);
935 }
936
937 #[test]
940 fn parse_json_suites_format() {
941 let json = r#"{
942 "suites": [
943 {
944 "name": "math",
945 "tests": [
946 {"name": "test_add", "status": "passed", "duration": 10},
947 {"name": "test_sub", "status": "failed", "duration": 5}
948 ]
949 }
950 ]
951 }"#;
952 let result = parse_json_output(json, "", 1);
953 assert_eq!(result.total_tests(), 2);
954 assert_eq!(result.total_passed(), 1);
955 assert_eq!(result.total_failed(), 1);
956 }
957
958 #[test]
959 fn parse_json_flat_tests() {
960 let json = r#"{"tests": [
961 {"name": "test1", "status": "pass"},
962 {"name": "test2", "status": "skip"}
963 ]}"#;
964 let result = parse_json_output(json, "", 0);
965 assert_eq!(result.total_tests(), 2);
966 assert_eq!(result.total_passed(), 1);
967 assert_eq!(result.total_skipped(), 1);
968 }
969
970 #[test]
971 fn parse_json_array_format() {
972 let json = r#"[
973 {"name": "test1", "status": "ok"},
974 {"name": "test2", "status": "error"}
975 ]"#;
976 let result = parse_json_output(json, "", 1);
977 assert_eq!(result.total_tests(), 2);
978 }
979
980 #[test]
981 fn parse_json_with_errors() {
982 let json = r#"{"tests": [
983 {"name": "test1", "status": "failed", "error": {"message": "expected 1 got 2", "location": "test.rs:10"}}
984 ]}"#;
985 let result = parse_json_output(json, "", 1);
986 assert_eq!(result.total_failed(), 1);
987 let test = &result.suites[0].tests[0];
988 assert!(test.error.is_some());
989 assert_eq!(test.error.as_ref().unwrap().message, "expected 1 got 2");
990 }
991
992 #[test]
993 fn parse_json_invalid() {
994 let result = parse_json_output("not json {{{", "", 1);
995 assert_eq!(result.total_tests(), 1); }
997
998 #[test]
1001 fn parse_junit_basic() {
1002 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1003<testsuite name="math" tests="2" failures="1">
1004 <testcase name="test_add" classname="Math" time="0.01"/>
1005 <testcase name="test_div" classname="Math" time="0.02">
1006 <failure message="division by zero"/>
1007 </testcase>
1008</testsuite>"#;
1009 let result = parse_junit_output(xml, 1);
1010 assert_eq!(result.total_tests(), 2);
1011 assert_eq!(result.total_passed(), 1);
1012 assert_eq!(result.total_failed(), 1);
1013 }
1014
1015 #[test]
1016 fn parse_junit_skipped() {
1017 let xml = r#"<testsuite name="t" tests="1">
1018 <testcase name="test_skip" time="0.0">
1019 <skipped/>
1020 </testcase>
1021</testsuite>"#;
1022 let result = parse_junit_output(xml, 0);
1023 assert_eq!(result.total_skipped(), 1);
1024 }
1025
1026 #[test]
1027 fn parse_junit_empty() {
1028 let result = parse_junit_output("", 0);
1029 assert_eq!(result.total_tests(), 1); }
1031
1032 #[test]
1035 fn parse_regex_basic() {
1036 let config = RegexParserConfig {
1037 pass_pattern: "PASS: (.*)".to_string(),
1038 fail_pattern: "FAIL: (.*)".to_string(),
1039 skip_pattern: None,
1040 name_group: 1,
1041 duration_group: None,
1042 };
1043 let output = "PASS: test_one\nFAIL: test_two\nsome output\n";
1044 let result = parse_regex_output(output, &config, 1);
1045 assert_eq!(result.total_tests(), 2);
1046 assert_eq!(result.total_passed(), 1);
1047 assert_eq!(result.total_failed(), 1);
1048 }
1049
1050 #[test]
1051 fn parse_regex_with_skip() {
1052 let config = RegexParserConfig {
1053 pass_pattern: "[OK] (.*)".to_string(),
1054 fail_pattern: "[ERR] (.*)".to_string(),
1055 skip_pattern: Some("[SKIP] (.*)".to_string()),
1056 name_group: 1,
1057 duration_group: None,
1058 };
1059 let output = "[OK] test_one\n[SKIP] test_two\n";
1060 let result = parse_regex_output(output, &config, 0);
1061 assert_eq!(result.total_tests(), 2);
1062 }
1063
1064 #[test]
1067 fn simple_match_literal() {
1068 let result = simple_pattern_match("hello world", "hello world");
1069 assert!(result.is_some());
1070 assert!(result.unwrap().is_empty());
1071 }
1072
1073 #[test]
1074 fn simple_match_capture() {
1075 let result = simple_pattern_match("PASS: (.*)", "PASS: test_one");
1076 assert!(result.is_some());
1077 assert_eq!(result.unwrap(), vec!["test_one"]);
1078 }
1079
1080 #[test]
1081 fn simple_match_multiple_captures() {
1082 let result = simple_pattern_match("(.*)=(.*)", "key=value");
1083 assert!(result.is_some());
1084 assert_eq!(result.unwrap(), vec!["key", "value"]);
1085 }
1086
1087 #[test]
1088 fn simple_match_wildcard() {
1089 let result = simple_pattern_match("hello .*!", "hello world!");
1090 assert!(result.is_some());
1091 }
1092
1093 #[test]
1094 fn simple_match_no_match() {
1095 let result = simple_pattern_match("hello", "world");
1096 assert!(result.is_none());
1097 }
1098
1099 #[test]
1100 fn simple_match_capture_with_context() {
1101 let result = simple_pattern_match("test (.*) in (.*)ms", "test add in 50ms");
1102 assert!(result.is_some());
1103 let caps = result.unwrap();
1104 assert_eq!(caps, vec!["add", "50"]);
1105 }
1106
1107 #[test]
1110 fn tap_description_basic() {
1111 let (name, skip) = parse_tap_description("1 - my test");
1112 assert_eq!(name, "my test");
1113 assert!(!skip);
1114 }
1115
1116 #[test]
1117 fn tap_description_skip() {
1118 let (name, skip) = parse_tap_description("1 - my test # SKIP not implemented");
1119 assert_eq!(name, "my test");
1120 assert!(skip);
1121 }
1122
1123 #[test]
1124 fn tap_description_no_dash() {
1125 let (name, skip) = parse_tap_description("1 test name");
1126 assert_eq!(name, "test name");
1127 assert!(!skip);
1128 }
1129
1130 #[test]
1133 fn status_line_pass() {
1134 let tc = parse_status_line("ok test_one").unwrap();
1135 assert_eq!(tc.name, "test_one");
1136 assert_eq!(tc.status, TestStatus::Passed);
1137 }
1138
1139 #[test]
1140 fn status_line_fail() {
1141 let tc = parse_status_line("fail test_two").unwrap();
1142 assert_eq!(tc.name, "test_two");
1143 assert_eq!(tc.status, TestStatus::Failed);
1144 }
1145
1146 #[test]
1147 fn status_line_skip() {
1148 let tc = parse_status_line("skip test_three").unwrap();
1149 assert_eq!(tc.name, "test_three");
1150 assert_eq!(tc.status, TestStatus::Skipped);
1151 }
1152
1153 #[test]
1154 fn status_line_no_match() {
1155 assert!(parse_status_line("some random text").is_none());
1156 }
1157
1158 #[test]
1159 fn status_line_empty_name() {
1160 assert!(parse_status_line("ok ").is_none());
1161 }
1162
1163 #[test]
1166 fn xml_attr_basic() {
1167 assert_eq!(
1168 extract_xml_attr(r#"<test name="hello" time="1.5">"#, "name"),
1169 Some("hello".into())
1170 );
1171 }
1172
1173 #[test]
1174 fn xml_attr_missing() {
1175 assert_eq!(extract_xml_attr("<test>", "name"), None);
1176 }
1177
1178 #[test]
1181 fn fallback_pass() {
1182 let result = fallback_result("all good", 0, "test");
1183 assert_eq!(result.total_passed(), 1);
1184 assert_eq!(result.raw_exit_code, 0);
1185 }
1186
1187 #[test]
1188 fn fallback_fail() {
1189 let result = fallback_result("something failed", 1, "test");
1190 assert_eq!(result.total_failed(), 1);
1191 assert!(result.suites[0].tests[0].error.is_some());
1192 }
1193
1194 #[test]
1197 fn script_output_delegates_to_tap() {
1198 let output = "1..2\nok 1 - a\nnot ok 2 - b\n";
1199 let result = parse_script_output(&OutputParser::Tap, output, "", 1);
1200 assert_eq!(result.total_tests(), 2);
1201 }
1202
1203 #[test]
1204 fn script_output_delegates_to_lines() {
1205 let output = "PASS test1\nFAIL test2\n";
1206 let result = parse_script_output(&OutputParser::Lines, output, "", 1);
1207 assert_eq!(result.total_tests(), 2);
1208 }
1209
1210 #[test]
1211 fn script_output_delegates_to_json() {
1212 let output = r#"[{"name": "t1", "status": "passed"}]"#;
1213 let result = parse_script_output(&OutputParser::Json, output, "", 0);
1214 assert_eq!(result.total_tests(), 1);
1215 assert_eq!(result.total_passed(), 1);
1216 }
1217}