Skip to main content

testx/adapters/
java.rs

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    /// Detect build tool: Maven or Gradle
27    fn detect_build_tool(project_dir: &Path) -> Option<&'static str> {
28        // Gradle takes priority (more modern)
29        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        // Fallback: check subdirectories (multi-module projects)
38        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    /// Check for Gradle wrapper
50    fn has_gradle_wrapper(project_dir: &Path) -> bool {
51        project_dir.join("gradlew").exists()
52    }
53
54    /// Find the build file when it's not at root (multi-module project).
55    ///
56    /// Returns None if build files exist at root (tool discovers automatically).
57    /// For Maven, returns path to the pom.xml in a subdirectory.
58    /// For Gradle, returns path to the build.gradle(.kts) in a subdirectory.
59    fn find_build_file(project_dir: &Path) -> Option<std::path::PathBuf> {
60        // If root has build files, tool auto-discovers — no path needed
61        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        // Search 1 level of subdirs
69        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                // Check for build files in this subdirectory
81                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        // check_runner() has no access to project_dir, so we can't check for
106        // ./gradlew. We check for system-installed tools; build_command will
107        // fall back to ./gradlew if it exists.
108        //
109        // Since most Gradle projects use the wrapper (gradlew) and build_command
110        // already handles that fallback, we only warn for Maven projects where
111        // system-wide mvn is genuinely required.
112        if which::which("gradle").is_ok()
113            || which::which("mvn").is_ok()
114            || which::which("ant").is_ok()
115        {
116            return None;
117        }
118        // Don't warn — the vast majority of Gradle projects ship a ./gradlew
119        // wrapper, and build_command will find it. Reporting "missing" here
120        // would be a false positive for nearly every Gradle project.
121        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                // Gradle: pass -b <build-file> for subdir projects
174                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                // Maven
185                cmd = Command::new("mvn");
186                cmd.arg("test");
187                cmd.arg("-B"); // batch mode (no interactive)
188                // Maven: pass -f <pom-path> for subdir projects
189                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        // Maven/Gradle both accept -Dtest=pattern
208        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        // Try Maven Surefire parsing first, then Gradle
215        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        // Enrich with failure details from Maven or Gradle output
222        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
237/// Parse Maven Surefire output.
238///
239/// Format:
240/// ```text
241/// [INFO] -------------------------------------------------------
242/// [INFO]  T E S T S
243/// [INFO] -------------------------------------------------------
244/// [INFO] Running com.example.AppTest
245/// [INFO] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s
246/// [INFO]
247/// [INFO] Results:
248/// [INFO]
249/// [INFO] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
250/// ```
251fn 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        // Strip [INFO] / [ERROR] prefix
258        let clean = trimmed
259            .strip_prefix("[INFO] ")
260            .or_else(|| trimmed.strip_prefix("[ERROR] "))
261            .unwrap_or(trimmed)
262            .trim();
263
264        // Suite start: "Running com.example.AppTest"
265        if let Some(rest) = clean.strip_prefix("Running ") {
266            current_suite = rest.trim().to_string();
267            continue;
268        }
269
270        // Result line: "Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s"
271        if clean.starts_with("Tests run:")
272            && !current_suite.is_empty()
273            && let Some(suite) = parse_surefire_result_line(clean, &current_suite)
274        {
275            suites.push(suite);
276        }
277        // Don't reset current_suite — Maven repeats the summary at the end
278    }
279
280    // Deduplicate: Maven prints per-class results AND a final summary.
281    // Keep only the per-class results (which have specific suite names).
282    // If we only got the summary, keep that.
283    if suites.len() > 1 {
284        // Remove any suite that's just a repeat of the totals (has same name as another)
285        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    // "Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s"
311    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
368/// Parse Gradle test output.
369///
370/// Format:
371/// ```text
372/// > Task :test
373///
374/// com.example.AppTest > testAdd PASSED
375/// com.example.AppTest > testDivide FAILED
376///
377/// 3 tests completed, 1 failed
378/// ```
379fn 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        // "com.example.AppTest > testAdd PASSED"
387        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                // Strip status suffix
404                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    // Fallback: parse summary line "X tests completed, Y failed"
427    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        // "3 tests completed, 1 failed"
441        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    // Maven: "Time elapsed: 0.05 s" or "Total time:  1.234 s"
498    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        // Gradle: "BUILD SUCCESSFUL in 2s" or "BUILD FAILED in 1s"
511        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/// A parsed test failure from Maven/Gradle output.
530#[derive(Debug, Clone)]
531struct JavaTestFailure {
532    /// The fully-qualified test name (e.g., "testAdd(com.example.MathTest)")
533    test_name: String,
534    /// Short test method name
535    method_name: String,
536    /// The error message
537    message: String,
538    /// Stack trace snippet (first few lines)
539    stack_trace: Option<String>,
540}
541
542/// Parse Java test failures from Maven Surefire or Gradle output.
543///
544/// Maven format:
545/// ```text
546/// [ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
547/// [ERROR] Failures:
548/// [ERROR]   AppTest.testDivide:42 expected:<4> but was:<3>
549/// ```
550///
551/// Or verbose Maven format:
552/// ```text
553/// Failed tests:
554///   testDivide(com.example.AppTest): expected:<4> but was:<3>
555///
556/// Tests in error:
557///   testBroken(com.example.AppTest): NullPointerException
558/// ```
559///
560/// Gradle format:
561/// ```text
562/// com.example.AppTest > testDivide FAILED
563///     org.opentest4j.AssertionFailedError: expected: <4> but was: <3>
564///         at com.example.AppTest.testDivide(AppTest.java:42)
565/// ```
566fn parse_java_failures(output: &str) -> Vec<JavaTestFailure> {
567    let failures = Vec::new();
568
569    // Try Maven "Failed tests:" section
570    let maven_failures = parse_maven_failed_tests_section(output);
571    if !maven_failures.is_empty() {
572        return maven_failures;
573    }
574
575    // Try Gradle inline failure output
576    let gradle_failures = parse_gradle_failures(output);
577    if !gradle_failures.is_empty() {
578        return gradle_failures;
579    }
580
581    // Try Maven [ERROR] Failures: section
582    let error_failures = parse_maven_error_failures(output);
583    if !error_failures.is_empty() {
584        return error_failures;
585    }
586
587    failures
588}
589
590/// Parse Maven "Failed tests:" and "Tests in error:" sections.
591fn 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            // End of section: empty line or new section header
609            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            // "  testDivide(com.example.AppTest): expected:<4> but was:<3>"
623            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
634/// Parse a single Maven failure line:
635/// "testDivide(com.example.AppTest): expected:<4> but was:<3>"
636fn parse_maven_failure_line(line: &str) -> Option<JavaTestFailure> {
637    // Format: "methodName(ClassName): message"
638    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
667/// Parse Gradle inline failure blocks.
668/// ```text
669/// com.example.AppTest > testDivide FAILED
670///     org.opentest4j.AssertionFailedError: expected: <4> but was: <3>
671///         at com.example.AppTest.testDivide(AppTest.java:42)
672/// ```
673fn 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        // "com.example.AppTest > testDivide FAILED"
682        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            // Collect following indented lines as error details
695            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
741/// Parse Maven [ERROR] section failures.
742/// "[ERROR]   AppTest.testDivide:42 expected:<4> but was:<3>"
743fn 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            // "  AppTest.testDivide:42 expected:<4> but was:<3>"
759            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                    // Extract method name from "AppTest.testDivide:42"
770                    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
795/// Strip Maven log prefix: "[INFO] " or "[ERROR] "
796fn 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
804/// Enrich test cases with failure details.
805fn 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
826/// Find a matching failure for a test in the failures list.
827fn 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        // Direct method name match
834        if test_name == failure.method_name {
835            return Some(failure);
836        }
837        // Full test name match (Gradle style: "com.example.AppTest.testMethod")
838        if failure.test_name.ends_with(&format!(".{}", test_name)) {
839            return Some(failure);
840        }
841        // Check if test_name is contained in the failure's full name
842        if failure.test_name.contains(test_name) {
843            return Some(failure);
844        }
845        // Suite + method match
846        if failure.test_name.contains(suite_name) && failure.method_name == test_name {
847            return Some(failure);
848        }
849    }
850    // Single failure for synthetic name
851    if failures.len() == 1 && test_name.starts_with("failed_test_") {
852        return Some(&failures[0]);
853    }
854    None
855}
856
857/// Parse Surefire XML test report files from standard locations.
858/// Returns parsed test suites from XML report files found at:
859/// - target/surefire-reports/TEST-*.xml (Maven)
860/// - build/test-results/test/TEST-*.xml (Gradle)
861pub 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
892/// Parse a single Surefire/JUnit XML report file.
893///
894/// Format:
895/// ```xml
896/// <?xml version="1.0" encoding="UTF-8"?>
897/// <testsuite name="com.example.AppTest" tests="3" failures="1" errors="0" skipped="0" time="0.05">
898///   <testcase name="testAdd" classname="com.example.AppTest" time="0.01"/>
899///   <testcase name="testSub" classname="com.example.AppTest" time="0.02"/>
900///   <testcase name="testDiv" classname="com.example.AppTest" time="0.02">
901///     <failure message="expected:&lt;4&gt; but was:&lt;3&gt;" type="AssertionError">
902///       stack trace here
903///     </failure>
904///   </testcase>
905/// </testsuite>
906/// ```
907fn parse_single_surefire_xml(content: &str) -> Option<TestSuite> {
908    // Extract suite name
909    let suite_name = extract_xml_attr(content, "testsuite", "name")?;
910
911    let mut tests = Vec::new();
912
913    // Find all <testcase> elements
914    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        // Find the end of this testcase element
919        let tc_content_start = absolute_start + 9; // skip "<testcase"
920        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            // Self-closing means passed (no failure/error/skipped child)
941            (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
990/// Find the end of a self-closing XML tag (/>).
991/// Returns the position after the /> if the tag closes itself (no children).
992/// Only checks the opening tag itself — not child elements.
993fn find_self_closing_end(content: &str, from: usize) -> Option<usize> {
994    let remaining = &content[from..];
995    // Find the first '>' character — this ends the opening tag
996    let first_close = remaining.find('>')?;
997    // Check if it's "/>" (self-closing)
998    if first_close > 0 && remaining.as_bytes()[first_close - 1] == b'/' {
999        Some(from + first_close + 1)
1000    } else {
1001        None
1002    }
1003}
1004
1005/// Extract an XML attribute value from a tag.
1006/// Simple string-based extraction - not a full XML parser.
1007fn 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
1026/// Unescape basic XML entities.
1027fn xml_unescape(s: &str) -> String {
1028    s.replace("&lt;", "<")
1029        .replace("&gt;", ">")
1030        .replace("&amp;", "&")
1031        .replace("&quot;", "\"")
1032        .replace("&apos;", "'")
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:&lt;4&gt; but was:&lt;3&gt;" 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:&lt;4&gt; but was:&lt;3&gt;"),
1443            "expected:<4> but was:<3>"
1444        );
1445        assert_eq!(xml_unescape("&amp;&quot;&apos;"), "&\"'");
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}