1use std::path::Path;
2use std::process::Command;
3use std::time::Duration;
4
5use anyhow::Result;
6
7use super::util::{combined_output, duration_from_secs_safe, has_marker_in_subdirs, truncate};
8use super::{
9 ConfidenceScore, DetectionResult, TestAdapter, TestCase, TestError, TestRunResult, TestStatus,
10 TestSuite,
11};
12
13pub struct JavaAdapter;
14
15impl Default for JavaAdapter {
16 fn default() -> Self {
17 Self::new()
18 }
19}
20
21impl JavaAdapter {
22 pub fn new() -> Self {
23 Self
24 }
25
26 fn detect_build_tool(project_dir: &Path) -> Option<&'static str> {
28 if project_dir.join("build.gradle.kts").exists()
30 || project_dir.join("build.gradle").exists()
31 {
32 return Some("gradle");
33 }
34 if project_dir.join("pom.xml").exists() {
35 return Some("maven");
36 }
37 if has_marker_in_subdirs(project_dir, 1, |name| {
39 name == "build.gradle.kts" || name == "build.gradle"
40 }) {
41 return Some("gradle");
42 }
43 if has_marker_in_subdirs(project_dir, 1, |name| name == "pom.xml") {
44 return Some("maven");
45 }
46 None
47 }
48
49 fn has_gradle_wrapper(project_dir: &Path) -> bool {
51 project_dir.join("gradlew").exists()
52 }
53
54 fn find_build_file(project_dir: &Path) -> Option<std::path::PathBuf> {
60 if project_dir.join("pom.xml").exists()
62 || project_dir.join("build.gradle").exists()
63 || project_dir.join("build.gradle.kts").exists()
64 {
65 return None;
66 }
67
68 if let Ok(entries) = std::fs::read_dir(project_dir) {
70 for entry in entries.flatten() {
71 let path = entry.path();
72 if !path.is_dir() {
73 continue;
74 }
75 let name = entry.file_name();
76 let name_str = name.to_string_lossy();
77 if name_str.starts_with('.') {
78 continue;
79 }
80 let pom = path.join("pom.xml");
82 if pom.exists() {
83 return Some(pom);
84 }
85 let gradle_kts = path.join("build.gradle.kts");
86 if gradle_kts.exists() {
87 return Some(gradle_kts);
88 }
89 let gradle = path.join("build.gradle");
90 if gradle.exists() {
91 return Some(gradle);
92 }
93 }
94 }
95 None
96 }
97}
98
99impl TestAdapter for JavaAdapter {
100 fn name(&self) -> &str {
101 "Java/Kotlin"
102 }
103
104 fn check_runner(&self) -> Option<String> {
105 if which::which("gradle").is_ok()
113 || which::which("mvn").is_ok()
114 || which::which("ant").is_ok()
115 {
116 return None;
117 }
118 None
122 }
123
124 fn detect(&self, project_dir: &Path) -> Option<DetectionResult> {
125 let build_tool = Self::detect_build_tool(project_dir)?;
126
127 let framework = match build_tool {
128 "gradle" => {
129 if project_dir.join("build.gradle.kts").exists() {
130 "gradle (kotlin dsl)"
131 } else {
132 "gradle"
133 }
134 }
135 "maven" => "maven surefire",
136 _ => "unknown",
137 };
138
139 let has_wrapper =
140 Self::has_gradle_wrapper(project_dir) || project_dir.join(".mvn").is_dir();
141 let has_test_dir = project_dir.join("src/test").is_dir();
142 let has_runner = which::which("gradle").is_ok()
143 || which::which("mvn").is_ok()
144 || Self::has_gradle_wrapper(project_dir);
145
146 let confidence = ConfidenceScore::base(0.50)
147 .signal(0.15, has_wrapper)
148 .signal(0.15, has_test_dir)
149 .signal(0.10, has_runner)
150 .finish();
151
152 Some(DetectionResult {
153 language: "Java".into(),
154 framework: framework.into(),
155 confidence,
156 })
157 }
158
159 fn build_command(&self, project_dir: &Path, extra_args: &[String]) -> Result<Command> {
160 let build_tool = Self::detect_build_tool(project_dir).unwrap_or("maven");
161 let subdir_build_file = Self::find_build_file(project_dir);
162
163 let mut cmd;
164
165 match build_tool {
166 "gradle" => {
167 if Self::has_gradle_wrapper(project_dir) {
168 cmd = Command::new("./gradlew");
169 } else {
170 cmd = Command::new("gradle");
171 }
172 cmd.arg("test");
173 if let Some(ref build_file) = subdir_build_file
175 && build_file
176 .file_name()
177 .is_some_and(|n| n.to_string_lossy().starts_with("build.gradle"))
178 {
179 cmd.arg("-b");
180 cmd.arg(build_file);
181 }
182 }
183 _ => {
184 cmd = Command::new("mvn");
186 cmd.arg("test");
187 cmd.arg("-B"); if let Some(ref build_file) = subdir_build_file
190 && build_file.file_name().is_some_and(|n| n == "pom.xml")
191 {
192 cmd.arg("-f");
193 cmd.arg(build_file);
194 }
195 }
196 }
197
198 for arg in extra_args {
199 cmd.arg(arg);
200 }
201
202 cmd.current_dir(project_dir);
203 Ok(cmd)
204 }
205
206 fn filter_args(&self, pattern: &str) -> Vec<String> {
207 vec![format!("-Dtest={}", pattern)]
209 }
210
211 fn parse_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> TestRunResult {
212 let combined = combined_output(stdout, stderr);
213
214 let mut suites = if combined.contains("Tests run:") {
216 parse_maven_output(&combined, exit_code)
217 } else {
218 parse_gradle_output(&combined, exit_code)
219 };
220
221 let failures = parse_java_failures(&combined);
223 if !failures.is_empty() {
224 enrich_with_errors(&mut suites, &failures);
225 }
226
227 let duration = parse_java_duration(&combined).unwrap_or(Duration::from_secs(0));
228
229 TestRunResult {
230 suites,
231 duration,
232 raw_exit_code: exit_code,
233 }
234 }
235}
236
237fn parse_maven_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
252 let mut suites = Vec::new();
253 let mut current_suite = String::new();
254
255 for line in output.lines() {
256 let trimmed = line.trim();
257 let clean = trimmed
259 .strip_prefix("[INFO] ")
260 .or_else(|| trimmed.strip_prefix("[ERROR] "))
261 .unwrap_or(trimmed)
262 .trim();
263
264 if let Some(rest) = clean.strip_prefix("Running ") {
266 current_suite = rest.trim().to_string();
267 continue;
268 }
269
270 if clean.starts_with("Tests run:")
272 && !current_suite.is_empty()
273 && let Some(suite) = parse_surefire_result_line(clean, ¤t_suite)
274 {
275 suites.push(suite);
276 }
277 }
279
280 if suites.len() > 1 {
284 let mut seen = std::collections::HashSet::new();
286 suites.retain(|s| seen.insert(s.name.clone()));
287 }
288
289 if suites.is_empty() {
290 let status = if exit_code == 0 {
291 TestStatus::Passed
292 } else {
293 TestStatus::Failed
294 };
295 suites.push(TestSuite {
296 name: "tests".into(),
297 tests: vec![TestCase {
298 name: "test_suite".into(),
299 status,
300 duration: Duration::from_millis(0),
301 error: None,
302 }],
303 });
304 }
305
306 suites
307}
308
309fn parse_surefire_result_line(line: &str, suite_name: &str) -> Option<TestSuite> {
310 let mut total = 0usize;
312 let mut failures = 0usize;
313 let mut errors = 0usize;
314 let mut skipped = 0usize;
315
316 for part in line.split(',') {
317 let part = part.trim();
318 if let Some(rest) = part.strip_prefix("Tests run:") {
319 total = rest.trim().parse().unwrap_or(0);
320 } else if let Some(rest) = part.strip_prefix("Failures:") {
321 failures = rest.trim().parse().unwrap_or(0);
322 } else if let Some(rest) = part.strip_prefix("Errors:") {
323 errors = rest.trim().parse().unwrap_or(0);
324 } else if let Some(rest) = part.strip_prefix("Skipped:") {
325 skipped = rest.trim().parse().unwrap_or(0);
326 }
327 }
328
329 if total == 0 && failures == 0 {
330 return None;
331 }
332
333 let failed = failures + errors;
334 let passed = total.saturating_sub(failed + skipped);
335
336 let mut tests = Vec::new();
337 for i in 0..passed {
338 tests.push(TestCase {
339 name: format!("test_{}", i + 1),
340 status: TestStatus::Passed,
341 duration: Duration::from_millis(0),
342 error: None,
343 });
344 }
345 for i in 0..failed {
346 tests.push(TestCase {
347 name: format!("failed_test_{}", i + 1),
348 status: TestStatus::Failed,
349 duration: Duration::from_millis(0),
350 error: None,
351 });
352 }
353 for i in 0..skipped {
354 tests.push(TestCase {
355 name: format!("skipped_test_{}", i + 1),
356 status: TestStatus::Skipped,
357 duration: Duration::from_millis(0),
358 error: None,
359 });
360 }
361
362 Some(TestSuite {
363 name: suite_name.to_string(),
364 tests,
365 })
366}
367
368fn parse_gradle_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
380 let mut suites_map: std::collections::HashMap<String, Vec<TestCase>> =
381 std::collections::HashMap::new();
382
383 for line in output.lines() {
384 let trimmed = line.trim();
385
386 if trimmed.contains(" > ")
388 && (trimmed.ends_with("PASSED")
389 || trimmed.ends_with("FAILED")
390 || trimmed.ends_with("SKIPPED"))
391 {
392 let status = if trimmed.ends_with("PASSED") {
393 TestStatus::Passed
394 } else if trimmed.ends_with("FAILED") {
395 TestStatus::Failed
396 } else {
397 TestStatus::Skipped
398 };
399
400 if let Some(arrow_idx) = trimmed.find(" > ") {
401 let suite_name = trimmed[..arrow_idx].trim().to_string();
402 let rest = &trimmed[arrow_idx + 3..];
403 let test_name = rest
405 .rsplit_once(' ')
406 .map(|(name, _)| name.trim())
407 .unwrap_or(rest)
408 .to_string();
409
410 suites_map.entry(suite_name).or_default().push(TestCase {
411 name: test_name,
412 status,
413 duration: Duration::from_millis(0),
414 error: None,
415 });
416 }
417 }
418 }
419
420 let mut suites: Vec<TestSuite> = suites_map
421 .into_iter()
422 .map(|(name, tests)| TestSuite { name, tests })
423 .collect();
424 suites.sort_by(|a, b| a.name.cmp(&b.name));
425
426 if suites.is_empty() {
428 suites.push(parse_gradle_summary(output, exit_code));
429 }
430
431 suites
432}
433
434fn parse_gradle_summary(output: &str, exit_code: i32) -> TestSuite {
435 let mut passed = 0usize;
436 let mut failed = 0usize;
437
438 for line in output.lines() {
439 let trimmed = line.trim();
440 if trimmed.contains("tests completed") {
442 for part in trimmed.split(',') {
443 let part = part.trim();
444 if part.contains("completed")
445 && let Some(n) = part.split_whitespace().next().and_then(|s| s.parse().ok())
446 {
447 passed = n;
448 }
449 if part.contains("failed")
450 && let Some(n) = part.split_whitespace().next().and_then(|s| s.parse().ok())
451 {
452 failed = n;
453 passed = passed.saturating_sub(failed);
454 }
455 }
456 }
457 }
458
459 let mut tests = Vec::new();
460 for i in 0..passed {
461 tests.push(TestCase {
462 name: format!("test_{}", i + 1),
463 status: TestStatus::Passed,
464 duration: Duration::from_millis(0),
465 error: None,
466 });
467 }
468 for i in 0..failed {
469 tests.push(TestCase {
470 name: format!("failed_test_{}", i + 1),
471 status: TestStatus::Failed,
472 duration: Duration::from_millis(0),
473 error: None,
474 });
475 }
476
477 if tests.is_empty() {
478 tests.push(TestCase {
479 name: "test_suite".into(),
480 status: if exit_code == 0 {
481 TestStatus::Passed
482 } else {
483 TestStatus::Failed
484 },
485 duration: Duration::from_millis(0),
486 error: None,
487 });
488 }
489
490 TestSuite {
491 name: "tests".into(),
492 tests,
493 }
494}
495
496fn parse_java_duration(output: &str) -> Option<Duration> {
497 for line in output.lines() {
499 if let Some(idx) = line.find("Time elapsed:") {
500 let after = &line[idx + 13..];
501 let num_str: String = after
502 .trim()
503 .chars()
504 .take_while(|c| c.is_ascii_digit() || *c == '.')
505 .collect();
506 if let Ok(secs) = num_str.parse::<f64>() {
507 return Some(duration_from_secs_safe(secs));
508 }
509 }
510 if (line.contains("BUILD SUCCESSFUL") || line.contains("BUILD FAILED"))
512 && line.contains(" in ")
513 && let Some(idx) = line.rfind(" in ")
514 {
515 let after = &line[idx + 4..];
516 let num_str: String = after
517 .trim()
518 .chars()
519 .take_while(|c| c.is_ascii_digit() || *c == '.')
520 .collect();
521 if let Ok(secs) = num_str.parse::<f64>() {
522 return Some(duration_from_secs_safe(secs));
523 }
524 }
525 }
526 None
527}
528
529#[derive(Debug, Clone)]
531struct JavaTestFailure {
532 test_name: String,
534 method_name: String,
536 message: String,
538 stack_trace: Option<String>,
540}
541
542fn parse_java_failures(output: &str) -> Vec<JavaTestFailure> {
567 let failures = Vec::new();
568
569 let maven_failures = parse_maven_failed_tests_section(output);
571 if !maven_failures.is_empty() {
572 return maven_failures;
573 }
574
575 let gradle_failures = parse_gradle_failures(output);
577 if !gradle_failures.is_empty() {
578 return gradle_failures;
579 }
580
581 let error_failures = parse_maven_error_failures(output);
583 if !error_failures.is_empty() {
584 return error_failures;
585 }
586
587 failures
588}
589
590fn parse_maven_failed_tests_section(output: &str) -> Vec<JavaTestFailure> {
592 let mut failures = Vec::new();
593 let lines: Vec<&str> = output.lines().collect();
594 let mut i = 0;
595 let mut in_section = false;
596
597 while i < lines.len() {
598 let trimmed = lines[i].trim();
599 let clean = strip_maven_prefix(trimmed);
600
601 if clean == "Failed tests:" || clean == "Tests in error:" {
602 in_section = true;
603 i += 1;
604 continue;
605 }
606
607 if in_section {
608 if clean.is_empty()
610 || clean.starts_with("Tests run:")
611 || clean == "Failed tests:"
612 || clean == "Tests in error:"
613 {
614 if clean == "Failed tests:" || clean == "Tests in error:" {
615 continue;
616 }
617 in_section = false;
618 i += 1;
619 continue;
620 }
621
622 if let Some(failure) = parse_maven_failure_line(clean) {
624 failures.push(failure);
625 }
626 }
627
628 i += 1;
629 }
630
631 failures
632}
633
634fn parse_maven_failure_line(line: &str) -> Option<JavaTestFailure> {
637 let paren_open = line.find('(')?;
639 let paren_close = line.find(')')?;
640 if paren_close <= paren_open {
641 return None;
642 }
643
644 let method_name = line[..paren_open].trim().to_string();
645 let _class_name = &line[paren_open + 1..paren_close];
646 let test_name = line[..paren_close + 1].trim().to_string();
647
648 let message = if paren_close + 1 < line.len() {
649 let rest = &line[paren_close + 1..];
650 rest.strip_prefix(':')
651 .or_else(|| rest.strip_prefix(": "))
652 .unwrap_or(rest)
653 .trim()
654 .to_string()
655 } else {
656 String::new()
657 };
658
659 Some(JavaTestFailure {
660 test_name,
661 method_name,
662 message,
663 stack_trace: None,
664 })
665}
666
667fn parse_gradle_failures(output: &str) -> Vec<JavaTestFailure> {
674 let mut failures = Vec::new();
675 let lines: Vec<&str> = output.lines().collect();
676 let mut i = 0;
677
678 while i < lines.len() {
679 let trimmed = lines[i].trim();
680
681 if trimmed.contains(" > ") && trimmed.ends_with("FAILED") {
683 let arrow_idx = trimmed.find(" > ").unwrap();
684 let class_name = &trimmed[..arrow_idx];
685 let rest = &trimmed[arrow_idx + 3..];
686 let method_name = rest
687 .strip_suffix(" FAILED")
688 .unwrap_or(rest)
689 .trim()
690 .to_string();
691
692 let test_name = format!("{}.{}", class_name, method_name);
693
694 let mut message_lines = Vec::new();
696 let mut stack_lines = Vec::new();
697 i += 1;
698
699 while i < lines.len() {
700 let line = lines[i];
701 if !line.starts_with(" ") && !line.starts_with('\t') {
702 break;
703 }
704 let content = line.trim();
705 if content.starts_with("at ") {
706 stack_lines.push(content.to_string());
707 } else if !content.is_empty() {
708 message_lines.push(content.to_string());
709 }
710 i += 1;
711 }
712
713 let message = message_lines.join("\n");
714 let stack_trace = if stack_lines.is_empty() {
715 None
716 } else {
717 Some(
718 stack_lines
719 .into_iter()
720 .take(5)
721 .collect::<Vec<_>>()
722 .join("\n"),
723 )
724 };
725
726 failures.push(JavaTestFailure {
727 test_name,
728 method_name,
729 message: truncate(&message, 500),
730 stack_trace,
731 });
732 continue;
733 }
734
735 i += 1;
736 }
737
738 failures
739}
740
741fn parse_maven_error_failures(output: &str) -> Vec<JavaTestFailure> {
744 let mut failures = Vec::new();
745 let lines: Vec<&str> = output.lines().collect();
746 let mut in_failures = false;
747
748 for line in &lines {
749 let trimmed = line.trim();
750 let clean = strip_maven_prefix(trimmed);
751
752 if clean == "Failures:" || clean == "Errors:" {
753 in_failures = true;
754 continue;
755 }
756
757 if in_failures && !clean.is_empty() {
758 if clean.contains('.') && (clean.contains(':') || clean.contains(' ')) {
760 let parts: Vec<&str> = clean.splitn(2, ' ').collect();
761 if !parts.is_empty() {
762 let test_ref = parts[0];
763 let message = if parts.len() > 1 {
764 parts[1].to_string()
765 } else {
766 String::new()
767 };
768
769 let method_name = test_ref
771 .split('.')
772 .next_back()
773 .unwrap_or(test_ref)
774 .split(':')
775 .next()
776 .unwrap_or(test_ref)
777 .to_string();
778
779 failures.push(JavaTestFailure {
780 test_name: test_ref.to_string(),
781 method_name,
782 message: truncate(&message, 500),
783 stack_trace: None,
784 });
785 }
786 } else if clean.starts_with("Tests run:") || clean.starts_with("[") {
787 in_failures = false;
788 }
789 }
790 }
791
792 failures
793}
794
795fn strip_maven_prefix(line: &str) -> &str {
797 line.strip_prefix("[INFO] ")
798 .or_else(|| line.strip_prefix("[ERROR] "))
799 .or_else(|| line.strip_prefix("[WARNING] "))
800 .unwrap_or(line)
801 .trim()
802}
803
804fn enrich_with_errors(suites: &mut [TestSuite], failures: &[JavaTestFailure]) {
806 for suite in suites.iter_mut() {
807 for test in suite.tests.iter_mut() {
808 if test.status != TestStatus::Failed || test.error.is_some() {
809 continue;
810 }
811 if let Some(failure) = find_matching_java_failure(&test.name, &suite.name, failures) {
812 let location = failure
813 .stack_trace
814 .as_ref()
815 .and_then(|st| st.lines().next())
816 .map(|s| s.to_string());
817 test.error = Some(TestError {
818 message: failure.message.clone(),
819 location,
820 });
821 }
822 }
823 }
824}
825
826fn find_matching_java_failure<'a>(
828 test_name: &str,
829 suite_name: &str,
830 failures: &'a [JavaTestFailure],
831) -> Option<&'a JavaTestFailure> {
832 for failure in failures {
833 if test_name == failure.method_name {
835 return Some(failure);
836 }
837 if failure.test_name.ends_with(&format!(".{}", test_name)) {
839 return Some(failure);
840 }
841 if failure.test_name.contains(test_name) {
843 return Some(failure);
844 }
845 if failure.test_name.contains(suite_name) && failure.method_name == test_name {
847 return Some(failure);
848 }
849 }
850 if failures.len() == 1 && test_name.starts_with("failed_test_") {
852 return Some(&failures[0]);
853 }
854 None
855}
856
857pub fn parse_surefire_xml(project_dir: &Path) -> Vec<TestSuite> {
862 let report_dirs = [
863 project_dir.join("target/surefire-reports"),
864 project_dir.join("build/test-results/test"),
865 project_dir.join("build/test-results"),
866 ];
867
868 let mut suites = Vec::new();
869
870 for dir in &report_dirs {
871 if !dir.is_dir() {
872 continue;
873 }
874 if let Ok(entries) = std::fs::read_dir(dir) {
875 for entry in entries.flatten() {
876 let name = entry.file_name();
877 let name = name.to_string_lossy();
878 if name.starts_with("TEST-")
879 && name.ends_with(".xml")
880 && let Ok(content) = std::fs::read_to_string(entry.path())
881 && let Some(suite) = parse_single_surefire_xml(&content)
882 {
883 suites.push(suite);
884 }
885 }
886 }
887 }
888
889 suites
890}
891
892fn parse_single_surefire_xml(content: &str) -> Option<TestSuite> {
908 let suite_name = extract_xml_attr(content, "testsuite", "name")?;
910
911 let mut tests = Vec::new();
912
913 let mut search_from = 0;
915 while let Some(tc_start) = content[search_from..].find("<testcase") {
916 let absolute_start = search_from + tc_start;
917
918 let tc_content_start = absolute_start + 9; let (tc_end, is_self_closing) =
921 if let Some(self_close) = find_self_closing_end(content, tc_content_start) {
922 (self_close, true)
923 } else if let Some(close) = content[tc_content_start..].find("</testcase>") {
924 (tc_content_start + close + 11, false)
925 } else {
926 break;
927 };
928
929 let tc_text = &content[absolute_start..tc_end];
930
931 let name =
932 extract_xml_attr(tc_text, "testcase", "name").unwrap_or_else(|| "unknown".into());
933 let time_str = extract_xml_attr(tc_text, "testcase", "time").unwrap_or_default();
934 let duration = time_str
935 .parse::<f64>()
936 .map(duration_from_secs_safe)
937 .unwrap_or(Duration::from_millis(0));
938
939 let (status, error) = if is_self_closing {
940 (TestStatus::Passed, None)
942 } else if tc_text.contains("<failure") {
943 let msg = extract_xml_attr(tc_text, "failure", "message")
944 .unwrap_or_else(|| "Test failed".into());
945 let error_type = extract_xml_attr(tc_text, "failure", "type");
946 let location = error_type.map(|t| format!("type: {}", t));
947 (
948 TestStatus::Failed,
949 Some(TestError {
950 message: xml_unescape(&msg),
951 location,
952 }),
953 )
954 } else if tc_text.contains("<error") {
955 let msg = extract_xml_attr(tc_text, "error", "message")
956 .unwrap_or_else(|| "Test error".into());
957 (
958 TestStatus::Failed,
959 Some(TestError {
960 message: xml_unescape(&msg),
961 location: None,
962 }),
963 )
964 } else if tc_text.contains("<skipped") {
965 (TestStatus::Skipped, None)
966 } else {
967 (TestStatus::Passed, None)
968 };
969
970 tests.push(TestCase {
971 name,
972 status,
973 duration,
974 error,
975 });
976
977 search_from = tc_end;
978 }
979
980 if tests.is_empty() {
981 return None;
982 }
983
984 Some(TestSuite {
985 name: suite_name,
986 tests,
987 })
988}
989
990fn find_self_closing_end(content: &str, from: usize) -> Option<usize> {
994 let remaining = &content[from..];
995 let first_close = remaining.find('>')?;
997 if first_close > 0 && remaining.as_bytes()[first_close - 1] == b'/' {
999 Some(from + first_close + 1)
1000 } else {
1001 None
1002 }
1003}
1004
1005fn extract_xml_attr(content: &str, tag: &str, attr: &str) -> Option<String> {
1008 let tag_start = content.find(&format!("<{}", tag))?;
1009 let tag_content = &content[tag_start..];
1010 let tag_end = tag_content.find('>')?;
1011 let tag_text = &tag_content[..tag_end];
1012
1013 let attr_pattern = format!("{}=\"", attr);
1014 let attr_start = tag_text.find(&attr_pattern)?;
1015 let value_start = attr_start + attr_pattern.len();
1016 if value_start >= tag_text.len() {
1017 return None;
1018 }
1019 let value_end = tag_text[value_start..].find('"')?;
1020 if value_start + value_end > tag_text.len() {
1021 return None;
1022 }
1023 Some(tag_text[value_start..value_start + value_end].to_string())
1024}
1025
1026fn xml_unescape(s: &str) -> String {
1028 s.replace("<", "<")
1029 .replace(">", ">")
1030 .replace("&", "&")
1031 .replace(""", "\"")
1032 .replace("'", "'")
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037 use super::*;
1038
1039 #[test]
1040 fn detect_maven_project() {
1041 let dir = tempfile::tempdir().unwrap();
1042 std::fs::write(
1043 dir.path().join("pom.xml"),
1044 "<project><modelVersion>4.0.0</modelVersion></project>",
1045 )
1046 .unwrap();
1047 let adapter = JavaAdapter::new();
1048 let det = adapter.detect(dir.path()).unwrap();
1049 assert_eq!(det.language, "Java");
1050 assert_eq!(det.framework, "maven surefire");
1051 }
1052
1053 #[test]
1054 fn detect_gradle_project() {
1055 let dir = tempfile::tempdir().unwrap();
1056 std::fs::write(dir.path().join("build.gradle"), "apply plugin: 'java'\n").unwrap();
1057 let adapter = JavaAdapter::new();
1058 let det = adapter.detect(dir.path()).unwrap();
1059 assert_eq!(det.language, "Java");
1060 assert_eq!(det.framework, "gradle");
1061 }
1062
1063 #[test]
1064 fn detect_gradle_kts_project() {
1065 let dir = tempfile::tempdir().unwrap();
1066 std::fs::write(dir.path().join("build.gradle.kts"), "plugins { java }\n").unwrap();
1067 let adapter = JavaAdapter::new();
1068 let det = adapter.detect(dir.path()).unwrap();
1069 assert_eq!(det.framework, "gradle (kotlin dsl)");
1070 }
1071
1072 #[test]
1073 fn detect_no_java() {
1074 let dir = tempfile::tempdir().unwrap();
1075 let adapter = JavaAdapter::new();
1076 assert!(adapter.detect(dir.path()).is_none());
1077 }
1078
1079 #[test]
1080 fn parse_maven_surefire_output() {
1081 let stdout = r#"
1082[INFO] -------------------------------------------------------
1083[INFO] T E S T S
1084[INFO] -------------------------------------------------------
1085[INFO] Running com.example.AppTest
1086[INFO] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s
1087[INFO]
1088[INFO] Results:
1089[INFO]
1090[INFO] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
1091[INFO]
1092[INFO] BUILD FAILURE
1093"#;
1094 let adapter = JavaAdapter::new();
1095 let result = adapter.parse_output(stdout, "", 1);
1096
1097 assert_eq!(result.total_tests(), 3);
1098 assert_eq!(result.total_passed(), 2);
1099 assert_eq!(result.total_failed(), 1);
1100 assert!(!result.is_success());
1101 }
1102
1103 #[test]
1104 fn parse_maven_all_pass() {
1105 let stdout = r#"
1106[INFO] Running com.example.MathTest
1107[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.12 s
1108[INFO] BUILD SUCCESS
1109"#;
1110 let adapter = JavaAdapter::new();
1111 let result = adapter.parse_output(stdout, "", 0);
1112
1113 assert_eq!(result.total_tests(), 5);
1114 assert_eq!(result.total_passed(), 5);
1115 assert!(result.is_success());
1116 }
1117
1118 #[test]
1119 fn parse_maven_with_skipped() {
1120 let stdout = r#"
1121[INFO] Running com.example.AppTest
1122[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 0.03 s
1123"#;
1124 let adapter = JavaAdapter::new();
1125 let result = adapter.parse_output(stdout, "", 0);
1126
1127 assert_eq!(result.total_passed(), 2);
1128 assert_eq!(result.total_skipped(), 2);
1129 assert!(result.is_success());
1130 }
1131
1132 #[test]
1133 fn parse_maven_with_errors() {
1134 let stdout = r#"
1135[INFO] Running com.example.AppTest
1136[INFO] Tests run: 3, Failures: 0, Errors: 2, Skipped: 0, Time elapsed: 0.01 s
1137"#;
1138 let adapter = JavaAdapter::new();
1139 let result = adapter.parse_output(stdout, "", 1);
1140
1141 assert_eq!(result.total_failed(), 2);
1142 assert!(!result.is_success());
1143 }
1144
1145 #[test]
1146 fn parse_gradle_test_output() {
1147 let stdout = r#"
1148> Task :test
1149
1150com.example.AppTest > testAdd PASSED
1151com.example.AppTest > testSubtract PASSED
1152com.example.AppTest > testDivide FAILED
1153
11543 tests completed, 1 failed
1155
1156BUILD FAILED in 2s
1157"#;
1158 let adapter = JavaAdapter::new();
1159 let result = adapter.parse_output(stdout, "", 1);
1160
1161 assert_eq!(result.total_tests(), 3);
1162 assert_eq!(result.total_passed(), 2);
1163 assert_eq!(result.total_failed(), 1);
1164 assert!(!result.is_success());
1165 }
1166
1167 #[test]
1168 fn parse_gradle_all_pass() {
1169 let stdout = r#"
1170> Task :test
1171
1172com.example.MathTest > testAdd PASSED
1173com.example.MathTest > testMultiply PASSED
1174
1175BUILD SUCCESSFUL in 3s
1176"#;
1177 let adapter = JavaAdapter::new();
1178 let result = adapter.parse_output(stdout, "", 0);
1179
1180 assert_eq!(result.total_tests(), 2);
1181 assert_eq!(result.total_passed(), 2);
1182 assert!(result.is_success());
1183 }
1184
1185 #[test]
1186 fn parse_gradle_multiple_suites() {
1187 let stdout = r#"
1188com.example.MathTest > testAdd PASSED
1189com.example.StringTest > testUpper PASSED
1190com.example.StringTest > testLower FAILED
1191
11923 tests completed, 1 failed
1193"#;
1194 let adapter = JavaAdapter::new();
1195 let result = adapter.parse_output(stdout, "", 1);
1196
1197 assert_eq!(result.suites.len(), 2);
1198 assert_eq!(result.total_tests(), 3);
1199 }
1200
1201 #[test]
1202 fn parse_java_empty_output() {
1203 let adapter = JavaAdapter::new();
1204 let result = adapter.parse_output("", "", 0);
1205
1206 assert_eq!(result.total_tests(), 1);
1207 assert!(result.is_success());
1208 }
1209
1210 #[test]
1211 fn parse_java_duration_maven() {
1212 assert_eq!(
1213 parse_java_duration(
1214 "[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.23 s"
1215 ),
1216 Some(Duration::from_millis(1230))
1217 );
1218 }
1219
1220 #[test]
1221 fn parse_java_duration_gradle() {
1222 assert_eq!(
1223 parse_java_duration("BUILD SUCCESSFUL in 5s"),
1224 Some(Duration::from_secs(5))
1225 );
1226 }
1227
1228 #[test]
1229 fn gradle_wrapper_detection() {
1230 let dir = tempfile::tempdir().unwrap();
1231 std::fs::write(dir.path().join("build.gradle"), "").unwrap();
1232 assert!(!JavaAdapter::has_gradle_wrapper(dir.path()));
1233 std::fs::write(dir.path().join("gradlew"), "#!/bin/bash\n").unwrap();
1234 assert!(JavaAdapter::has_gradle_wrapper(dir.path()));
1235 }
1236
1237 #[test]
1238 fn parse_maven_failed_tests_section_test() {
1239 let output = r#"
1240[ERROR] Failed tests:
1241[ERROR] testDivide(com.example.MathTest): expected:<4> but was:<3>
1242[ERROR] testModulo(com.example.MathTest): ArithmeticException
1243[ERROR]
1244[ERROR] Tests run: 5, Failures: 2, Errors: 0, Skipped: 0
1245"#;
1246 let failures = parse_maven_failed_tests_section(output);
1247 assert_eq!(failures.len(), 2);
1248 assert_eq!(failures[0].method_name, "testDivide");
1249 assert!(failures[0].message.contains("expected:<4>"));
1250 assert_eq!(failures[1].method_name, "testModulo");
1251 }
1252
1253 #[test]
1254 fn parse_maven_failure_line_test() {
1255 let failure =
1256 parse_maven_failure_line("testAdd(com.example.MathTest): expected 5 got 4").unwrap();
1257 assert_eq!(failure.method_name, "testAdd");
1258 assert_eq!(failure.test_name, "testAdd(com.example.MathTest)");
1259 assert_eq!(failure.message, "expected 5 got 4");
1260 }
1261
1262 #[test]
1263 fn parse_maven_failure_line_no_message() {
1264 let failure = parse_maven_failure_line("testAdd(com.example.MathTest)").unwrap();
1265 assert_eq!(failure.method_name, "testAdd");
1266 assert!(failure.message.is_empty());
1267 }
1268
1269 #[test]
1270 fn parse_gradle_failure_blocks() {
1271 let output = r#"
1272> Task :test
1273
1274com.example.AppTest > testAdd PASSED
1275com.example.AppTest > testDivide FAILED
1276 org.opentest4j.AssertionFailedError: expected: <4> but was: <3>
1277 at com.example.AppTest.testDivide(AppTest.java:42)
1278
12793 tests completed, 1 failed
1280"#;
1281 let failures = parse_gradle_failures(output);
1282 assert_eq!(failures.len(), 1);
1283 assert_eq!(failures[0].method_name, "testDivide");
1284 assert!(failures[0].message.contains("AssertionFailedError"));
1285 assert!(failures[0].stack_trace.is_some());
1286 }
1287
1288 #[test]
1289 fn parse_gradle_multiple_failures() {
1290 let output = r#"
1291com.example.Test > methodA FAILED
1292 java.lang.RuntimeException: boom
1293com.example.Test > methodB FAILED
1294 java.lang.NullPointerException
1295 at com.example.Test.methodB(Test.java:10)
1296"#;
1297 let failures = parse_gradle_failures(output);
1298 assert_eq!(failures.len(), 2);
1299 assert_eq!(failures[0].method_name, "methodA");
1300 assert_eq!(failures[1].method_name, "methodB");
1301 }
1302
1303 #[test]
1304 fn parse_maven_error_failures_test() {
1305 let output = r#"
1306[ERROR] Failures:
1307[ERROR] AppTest.testDivide:42 expected:<4> but was:<3>
1308[ERROR]
1309"#;
1310 let failures = parse_maven_error_failures(output);
1311 assert_eq!(failures.len(), 1);
1312 assert_eq!(failures[0].method_name, "testDivide");
1313 }
1314
1315 #[test]
1316 fn strip_maven_prefix_test() {
1317 assert_eq!(strip_maven_prefix("[INFO] Hello"), "Hello");
1318 assert_eq!(strip_maven_prefix("[ERROR] Fail"), "Fail");
1319 assert_eq!(strip_maven_prefix("[WARNING] Warn"), "Warn");
1320 assert_eq!(strip_maven_prefix("No prefix"), "No prefix");
1321 }
1322
1323 #[test]
1324 fn enrich_with_errors_test() {
1325 let mut suites = vec![TestSuite {
1326 name: "com.example.AppTest".into(),
1327 tests: vec![
1328 TestCase {
1329 name: "testAdd".into(),
1330 status: TestStatus::Passed,
1331 duration: Duration::from_millis(0),
1332 error: None,
1333 },
1334 TestCase {
1335 name: "testDivide".into(),
1336 status: TestStatus::Failed,
1337 duration: Duration::from_millis(0),
1338 error: None,
1339 },
1340 ],
1341 }];
1342 let failures = vec![JavaTestFailure {
1343 test_name: "com.example.AppTest.testDivide".into(),
1344 method_name: "testDivide".into(),
1345 message: "expected 4 got 3".into(),
1346 stack_trace: Some("at com.example.AppTest.testDivide(AppTest.java:42)".into()),
1347 }];
1348 enrich_with_errors(&mut suites, &failures);
1349 assert!(suites[0].tests[0].error.is_none());
1350 let err = suites[0].tests[1].error.as_ref().unwrap();
1351 assert_eq!(err.message, "expected 4 got 3");
1352 assert!(err.location.is_some());
1353 }
1354
1355 #[test]
1356 fn truncate_test() {
1357 assert_eq!(truncate("short", 100), "short");
1358 let long = "x".repeat(600);
1359 let truncated = truncate(&long, 500);
1360 assert!(truncated.ends_with("..."));
1361 assert_eq!(truncated.len(), 500);
1362 }
1363
1364 #[test]
1365 fn parse_surefire_xml_basic() {
1366 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1367<testsuite name="com.example.MathTest" tests="3" failures="1" errors="0" skipped="0" time="0.05">
1368 <testcase name="testAdd" classname="com.example.MathTest" time="0.01"/>
1369 <testcase name="testSub" classname="com.example.MathTest" time="0.02"/>
1370 <testcase name="testDiv" classname="com.example.MathTest" time="0.02">
1371 <failure message="expected:<4> but was:<3>" type="AssertionError">
1372 stack trace here
1373 </failure>
1374 </testcase>
1375</testsuite>"#;
1376 let suite = parse_single_surefire_xml(xml).unwrap();
1377 assert_eq!(suite.name, "com.example.MathTest");
1378 assert_eq!(suite.tests.len(), 3);
1379 assert_eq!(suite.tests[0].status, TestStatus::Passed);
1380 assert_eq!(suite.tests[0].name, "testAdd");
1381 assert_eq!(suite.tests[2].status, TestStatus::Failed);
1382 let err = suite.tests[2].error.as_ref().unwrap();
1383 assert!(err.message.contains("expected:<4>"));
1384 }
1385
1386 #[test]
1387 fn parse_surefire_xml_with_skipped() {
1388 let xml = r#"<testsuite name="Test" tests="2" failures="0" errors="0" skipped="1" time="0.01">
1389 <testcase name="testA" classname="Test" time="0.005"/>
1390 <testcase name="testB" classname="Test" time="0.005">
1391 <skipped/>
1392 </testcase>
1393</testsuite>"#;
1394 let suite = parse_single_surefire_xml(xml).unwrap();
1395 assert_eq!(suite.tests.len(), 2);
1396 assert_eq!(suite.tests[0].status, TestStatus::Passed);
1397 assert_eq!(suite.tests[1].status, TestStatus::Skipped);
1398 }
1399
1400 #[test]
1401 fn parse_surefire_xml_with_error() {
1402 let xml = r#"<testsuite name="Test" tests="1" failures="0" errors="1" time="0.01">
1403 <testcase name="testBroken" classname="Test" time="0.001">
1404 <error message="NullPointerException" type="java.lang.NullPointerException">
1405 at Test.testBroken(Test.java:5)
1406 </error>
1407 </testcase>
1408</testsuite>"#;
1409 let suite = parse_single_surefire_xml(xml).unwrap();
1410 assert_eq!(suite.tests[0].status, TestStatus::Failed);
1411 assert!(
1412 suite.tests[0]
1413 .error
1414 .as_ref()
1415 .unwrap()
1416 .message
1417 .contains("NullPointerException")
1418 );
1419 }
1420
1421 #[test]
1422 fn parse_surefire_xml_empty() {
1423 assert!(parse_single_surefire_xml("<testsuite name=\"Test\"></testsuite>").is_none());
1424 }
1425
1426 #[test]
1427 fn extract_xml_attr_test() {
1428 assert_eq!(
1429 extract_xml_attr(r#"<tag name="value">"#, "tag", "name"),
1430 Some("value".into())
1431 );
1432 assert_eq!(
1433 extract_xml_attr(r#"<tag foo="bar" baz="qux">"#, "tag", "baz"),
1434 Some("qux".into())
1435 );
1436 assert!(extract_xml_attr(r#"<tag>"#, "tag", "name").is_none());
1437 }
1438
1439 #[test]
1440 fn xml_unescape_test() {
1441 assert_eq!(
1442 xml_unescape("expected:<4> but was:<3>"),
1443 "expected:<4> but was:<3>"
1444 );
1445 assert_eq!(xml_unescape("&"'"), "&\"'");
1446 }
1447
1448 #[test]
1449 fn parse_java_failures_integration() {
1450 let output = r#"
1451[INFO] -------------------------------------------------------
1452[INFO] T E S T S
1453[INFO] -------------------------------------------------------
1454[INFO] Running com.example.AppTest
1455[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
1456
1457Failed tests:
1458 testDivide(com.example.AppTest): expected:<4> but was:<3>
1459
1460[INFO] BUILD FAILURE
1461"#;
1462 let adapter = JavaAdapter::new();
1463 let result = adapter.parse_output(output, "", 1);
1464 assert_eq!(result.total_tests(), 3);
1465 assert_eq!(result.total_failed(), 1);
1466 }
1467}