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::duration_from_secs_safe;
8use super::{
9    DetectionResult, TestAdapter, TestCase, TestError, TestRunResult, TestStatus, TestSuite,
10};
11
12pub struct JavaAdapter;
13
14impl Default for JavaAdapter {
15    fn default() -> Self {
16        Self::new()
17    }
18}
19
20impl JavaAdapter {
21    pub fn new() -> Self {
22        Self
23    }
24
25    /// Detect build tool: Maven or Gradle
26    fn detect_build_tool(project_dir: &Path) -> Option<&'static str> {
27        // Gradle takes priority (more modern)
28        if project_dir.join("build.gradle.kts").exists()
29            || project_dir.join("build.gradle").exists()
30        {
31            return Some("gradle");
32        }
33        if project_dir.join("pom.xml").exists() {
34            return Some("maven");
35        }
36        None
37    }
38
39    /// Check for Gradle wrapper
40    fn has_gradle_wrapper(project_dir: &Path) -> bool {
41        project_dir.join("gradlew").exists()
42    }
43}
44
45impl TestAdapter for JavaAdapter {
46    fn name(&self) -> &str {
47        "Java/Kotlin"
48    }
49
50    fn check_runner(&self) -> Option<String> {
51        // Will check in build_command based on build tool
52        None
53    }
54
55    fn detect(&self, project_dir: &Path) -> Option<DetectionResult> {
56        let build_tool = Self::detect_build_tool(project_dir)?;
57
58        let framework = match build_tool {
59            "gradle" => {
60                if project_dir.join("build.gradle.kts").exists() {
61                    "gradle (kotlin dsl)"
62                } else {
63                    "gradle"
64                }
65            }
66            "maven" => "maven surefire",
67            _ => "unknown",
68        };
69
70        Some(DetectionResult {
71            language: "Java".into(),
72            framework: framework.into(),
73            confidence: 0.95,
74        })
75    }
76
77    fn build_command(&self, project_dir: &Path, extra_args: &[String]) -> Result<Command> {
78        let build_tool = Self::detect_build_tool(project_dir).unwrap_or("maven");
79
80        let mut cmd;
81
82        match build_tool {
83            "gradle" => {
84                if Self::has_gradle_wrapper(project_dir) {
85                    cmd = Command::new("./gradlew");
86                } else {
87                    cmd = Command::new("gradle");
88                }
89                cmd.arg("test");
90            }
91            _ => {
92                // Maven
93                cmd = Command::new("mvn");
94                cmd.arg("test");
95                cmd.arg("-B"); // batch mode (no interactive)
96            }
97        }
98
99        for arg in extra_args {
100            cmd.arg(arg);
101        }
102
103        cmd.current_dir(project_dir);
104        Ok(cmd)
105    }
106
107    fn parse_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> TestRunResult {
108        let combined = format!("{}\n{}", stdout, stderr);
109
110        // Try Maven Surefire parsing first, then Gradle
111        let mut suites = if combined.contains("Tests run:") {
112            parse_maven_output(&combined, exit_code)
113        } else {
114            parse_gradle_output(&combined, exit_code)
115        };
116
117        // Enrich with failure details from Maven or Gradle output
118        let failures = parse_java_failures(&combined);
119        if !failures.is_empty() {
120            enrich_with_errors(&mut suites, &failures);
121        }
122
123        let duration = parse_java_duration(&combined).unwrap_or(Duration::from_secs(0));
124
125        TestRunResult {
126            suites,
127            duration,
128            raw_exit_code: exit_code,
129        }
130    }
131}
132
133/// Parse Maven Surefire output.
134///
135/// Format:
136/// ```text
137/// [INFO] -------------------------------------------------------
138/// [INFO]  T E S T S
139/// [INFO] -------------------------------------------------------
140/// [INFO] Running com.example.AppTest
141/// [INFO] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s
142/// [INFO]
143/// [INFO] Results:
144/// [INFO]
145/// [INFO] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
146/// ```
147fn parse_maven_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
148    let mut suites = Vec::new();
149    let mut current_suite = String::new();
150
151    for line in output.lines() {
152        let trimmed = line.trim();
153        // Strip [INFO] / [ERROR] prefix
154        let clean = trimmed
155            .strip_prefix("[INFO] ")
156            .or_else(|| trimmed.strip_prefix("[ERROR] "))
157            .unwrap_or(trimmed)
158            .trim();
159
160        // Suite start: "Running com.example.AppTest"
161        if let Some(rest) = clean.strip_prefix("Running ") {
162            current_suite = rest.trim().to_string();
163            continue;
164        }
165
166        // Result line: "Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s"
167        if clean.starts_with("Tests run:")
168            && !current_suite.is_empty()
169            && let Some(suite) = parse_surefire_result_line(clean, &current_suite)
170        {
171            suites.push(suite);
172        }
173        // Don't reset current_suite — Maven repeats the summary at the end
174    }
175
176    // Deduplicate: Maven prints per-class results AND a final summary.
177    // Keep only the per-class results (which have specific suite names).
178    // If we only got the summary, keep that.
179    if suites.len() > 1 {
180        // Remove any suite that's just a repeat of the totals (has same name as another)
181        let mut seen = std::collections::HashSet::new();
182        suites.retain(|s| seen.insert(s.name.clone()));
183    }
184
185    if suites.is_empty() {
186        let status = if exit_code == 0 {
187            TestStatus::Passed
188        } else {
189            TestStatus::Failed
190        };
191        suites.push(TestSuite {
192            name: "tests".into(),
193            tests: vec![TestCase {
194                name: "test_suite".into(),
195                status,
196                duration: Duration::from_millis(0),
197                error: None,
198            }],
199        });
200    }
201
202    suites
203}
204
205fn parse_surefire_result_line(line: &str, suite_name: &str) -> Option<TestSuite> {
206    // "Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s"
207    let mut total = 0usize;
208    let mut failures = 0usize;
209    let mut errors = 0usize;
210    let mut skipped = 0usize;
211
212    for part in line.split(',') {
213        let part = part.trim();
214        if let Some(rest) = part.strip_prefix("Tests run:") {
215            total = rest.trim().parse().unwrap_or(0);
216        } else if let Some(rest) = part.strip_prefix("Failures:") {
217            failures = rest.trim().parse().unwrap_or(0);
218        } else if let Some(rest) = part.strip_prefix("Errors:") {
219            errors = rest.trim().parse().unwrap_or(0);
220        } else if let Some(rest) = part.strip_prefix("Skipped:") {
221            skipped = rest.trim().parse().unwrap_or(0);
222        }
223    }
224
225    if total == 0 && failures == 0 {
226        return None;
227    }
228
229    let failed = failures + errors;
230    let passed = total.saturating_sub(failed + skipped);
231
232    let mut tests = Vec::new();
233    for i in 0..passed {
234        tests.push(TestCase {
235            name: format!("test_{}", i + 1),
236            status: TestStatus::Passed,
237            duration: Duration::from_millis(0),
238            error: None,
239        });
240    }
241    for i in 0..failed {
242        tests.push(TestCase {
243            name: format!("failed_test_{}", i + 1),
244            status: TestStatus::Failed,
245            duration: Duration::from_millis(0),
246            error: None,
247        });
248    }
249    for i in 0..skipped {
250        tests.push(TestCase {
251            name: format!("skipped_test_{}", i + 1),
252            status: TestStatus::Skipped,
253            duration: Duration::from_millis(0),
254            error: None,
255        });
256    }
257
258    Some(TestSuite {
259        name: suite_name.to_string(),
260        tests,
261    })
262}
263
264/// Parse Gradle test output.
265///
266/// Format:
267/// ```text
268/// > Task :test
269///
270/// com.example.AppTest > testAdd PASSED
271/// com.example.AppTest > testDivide FAILED
272///
273/// 3 tests completed, 1 failed
274/// ```
275fn parse_gradle_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
276    let mut suites_map: std::collections::HashMap<String, Vec<TestCase>> =
277        std::collections::HashMap::new();
278
279    for line in output.lines() {
280        let trimmed = line.trim();
281
282        // "com.example.AppTest > testAdd PASSED"
283        if trimmed.contains(" > ")
284            && (trimmed.ends_with("PASSED")
285                || trimmed.ends_with("FAILED")
286                || trimmed.ends_with("SKIPPED"))
287        {
288            let status = if trimmed.ends_with("PASSED") {
289                TestStatus::Passed
290            } else if trimmed.ends_with("FAILED") {
291                TestStatus::Failed
292            } else {
293                TestStatus::Skipped
294            };
295
296            if let Some(arrow_idx) = trimmed.find(" > ") {
297                let suite_name = trimmed[..arrow_idx].trim().to_string();
298                let rest = &trimmed[arrow_idx + 3..];
299                // Strip status suffix
300                let test_name = rest
301                    .rsplit_once(' ')
302                    .map(|(name, _)| name.trim())
303                    .unwrap_or(rest)
304                    .to_string();
305
306                suites_map.entry(suite_name).or_default().push(TestCase {
307                    name: test_name,
308                    status,
309                    duration: Duration::from_millis(0),
310                    error: None,
311                });
312            }
313        }
314    }
315
316    let mut suites: Vec<TestSuite> = suites_map
317        .into_iter()
318        .map(|(name, tests)| TestSuite { name, tests })
319        .collect();
320    suites.sort_by(|a, b| a.name.cmp(&b.name));
321
322    // Fallback: parse summary line "X tests completed, Y failed"
323    if suites.is_empty() {
324        suites.push(parse_gradle_summary(output, exit_code));
325    }
326
327    suites
328}
329
330fn parse_gradle_summary(output: &str, exit_code: i32) -> TestSuite {
331    let mut passed = 0usize;
332    let mut failed = 0usize;
333
334    for line in output.lines() {
335        let trimmed = line.trim();
336        // "3 tests completed, 1 failed"
337        if trimmed.contains("tests completed") {
338            for part in trimmed.split(',') {
339                let part = part.trim();
340                if part.contains("completed")
341                    && let Some(n) = part.split_whitespace().next().and_then(|s| s.parse().ok())
342                {
343                    passed = n;
344                }
345                if part.contains("failed")
346                    && let Some(n) = part.split_whitespace().next().and_then(|s| s.parse().ok())
347                {
348                    failed = n;
349                    passed = passed.saturating_sub(failed);
350                }
351            }
352        }
353    }
354
355    let mut tests = Vec::new();
356    for i in 0..passed {
357        tests.push(TestCase {
358            name: format!("test_{}", i + 1),
359            status: TestStatus::Passed,
360            duration: Duration::from_millis(0),
361            error: None,
362        });
363    }
364    for i in 0..failed {
365        tests.push(TestCase {
366            name: format!("failed_test_{}", i + 1),
367            status: TestStatus::Failed,
368            duration: Duration::from_millis(0),
369            error: None,
370        });
371    }
372
373    if tests.is_empty() {
374        tests.push(TestCase {
375            name: "test_suite".into(),
376            status: if exit_code == 0 {
377                TestStatus::Passed
378            } else {
379                TestStatus::Failed
380            },
381            duration: Duration::from_millis(0),
382            error: None,
383        });
384    }
385
386    TestSuite {
387        name: "tests".into(),
388        tests,
389    }
390}
391
392fn parse_java_duration(output: &str) -> Option<Duration> {
393    // Maven: "Time elapsed: 0.05 s" or "Total time:  1.234 s"
394    for line in output.lines() {
395        if let Some(idx) = line.find("Time elapsed:") {
396            let after = &line[idx + 13..];
397            let num_str: String = after
398                .trim()
399                .chars()
400                .take_while(|c| c.is_ascii_digit() || *c == '.')
401                .collect();
402            if let Ok(secs) = num_str.parse::<f64>() {
403                return Some(duration_from_secs_safe(secs));
404            }
405        }
406        // Gradle: "BUILD SUCCESSFUL in 2s" or "BUILD FAILED in 1s"
407        if (line.contains("BUILD SUCCESSFUL") || line.contains("BUILD FAILED"))
408            && line.contains(" in ")
409            && let Some(idx) = line.rfind(" in ")
410        {
411            let after = &line[idx + 4..];
412            let num_str: String = after
413                .trim()
414                .chars()
415                .take_while(|c| c.is_ascii_digit() || *c == '.')
416                .collect();
417            if let Ok(secs) = num_str.parse::<f64>() {
418                return Some(duration_from_secs_safe(secs));
419            }
420        }
421    }
422    None
423}
424
425/// A parsed test failure from Maven/Gradle output.
426#[derive(Debug, Clone)]
427struct JavaTestFailure {
428    /// The fully-qualified test name (e.g., "testAdd(com.example.MathTest)")
429    test_name: String,
430    /// Short test method name
431    method_name: String,
432    /// The error message
433    message: String,
434    /// Stack trace snippet (first few lines)
435    stack_trace: Option<String>,
436}
437
438/// Parse Java test failures from Maven Surefire or Gradle output.
439///
440/// Maven format:
441/// ```text
442/// [ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
443/// [ERROR] Failures:
444/// [ERROR]   AppTest.testDivide:42 expected:<4> but was:<3>
445/// ```
446///
447/// Or verbose Maven format:
448/// ```text
449/// Failed tests:
450///   testDivide(com.example.AppTest): expected:<4> but was:<3>
451///
452/// Tests in error:
453///   testBroken(com.example.AppTest): NullPointerException
454/// ```
455///
456/// Gradle format:
457/// ```text
458/// com.example.AppTest > testDivide FAILED
459///     org.opentest4j.AssertionFailedError: expected: <4> but was: <3>
460///         at com.example.AppTest.testDivide(AppTest.java:42)
461/// ```
462fn parse_java_failures(output: &str) -> Vec<JavaTestFailure> {
463    let failures = Vec::new();
464
465    // Try Maven "Failed tests:" section
466    let maven_failures = parse_maven_failed_tests_section(output);
467    if !maven_failures.is_empty() {
468        return maven_failures;
469    }
470
471    // Try Gradle inline failure output
472    let gradle_failures = parse_gradle_failures(output);
473    if !gradle_failures.is_empty() {
474        return gradle_failures;
475    }
476
477    // Try Maven [ERROR] Failures: section
478    let error_failures = parse_maven_error_failures(output);
479    if !error_failures.is_empty() {
480        return error_failures;
481    }
482
483    failures
484}
485
486/// Parse Maven "Failed tests:" and "Tests in error:" sections.
487fn parse_maven_failed_tests_section(output: &str) -> Vec<JavaTestFailure> {
488    let mut failures = Vec::new();
489    let lines: Vec<&str> = output.lines().collect();
490    let mut i = 0;
491    let mut in_section = false;
492
493    while i < lines.len() {
494        let trimmed = lines[i].trim();
495        let clean = strip_maven_prefix(trimmed);
496
497        if clean == "Failed tests:" || clean == "Tests in error:" {
498            in_section = true;
499            i += 1;
500            continue;
501        }
502
503        if in_section {
504            // End of section: empty line or new section header
505            if clean.is_empty()
506                || clean.starts_with("Tests run:")
507                || clean == "Failed tests:"
508                || clean == "Tests in error:"
509            {
510                if clean == "Failed tests:" || clean == "Tests in error:" {
511                    continue;
512                }
513                in_section = false;
514                i += 1;
515                continue;
516            }
517
518            // "  testDivide(com.example.AppTest): expected:<4> but was:<3>"
519            if let Some(failure) = parse_maven_failure_line(clean) {
520                failures.push(failure);
521            }
522        }
523
524        i += 1;
525    }
526
527    failures
528}
529
530/// Parse a single Maven failure line:
531/// "testDivide(com.example.AppTest): expected:<4> but was:<3>"
532fn parse_maven_failure_line(line: &str) -> Option<JavaTestFailure> {
533    // Format: "methodName(ClassName): message"
534    let paren_open = line.find('(')?;
535    let paren_close = line.find(')')?;
536    if paren_close <= paren_open {
537        return None;
538    }
539
540    let method_name = line[..paren_open].trim().to_string();
541    let _class_name = &line[paren_open + 1..paren_close];
542    let test_name = line[..paren_close + 1].trim().to_string();
543
544    let message = if paren_close + 1 < line.len() {
545        let rest = &line[paren_close + 1..];
546        rest.strip_prefix(':')
547            .or_else(|| rest.strip_prefix(": "))
548            .unwrap_or(rest)
549            .trim()
550            .to_string()
551    } else {
552        String::new()
553    };
554
555    Some(JavaTestFailure {
556        test_name,
557        method_name,
558        message,
559        stack_trace: None,
560    })
561}
562
563/// Parse Gradle inline failure blocks.
564/// ```text
565/// com.example.AppTest > testDivide FAILED
566///     org.opentest4j.AssertionFailedError: expected: <4> but was: <3>
567///         at com.example.AppTest.testDivide(AppTest.java:42)
568/// ```
569fn parse_gradle_failures(output: &str) -> Vec<JavaTestFailure> {
570    let mut failures = Vec::new();
571    let lines: Vec<&str> = output.lines().collect();
572    let mut i = 0;
573
574    while i < lines.len() {
575        let trimmed = lines[i].trim();
576
577        // "com.example.AppTest > testDivide FAILED"
578        if trimmed.contains(" > ") && trimmed.ends_with("FAILED") {
579            let arrow_idx = trimmed.find(" > ").unwrap();
580            let class_name = &trimmed[..arrow_idx];
581            let rest = &trimmed[arrow_idx + 3..];
582            let method_name = rest
583                .strip_suffix(" FAILED")
584                .unwrap_or(rest)
585                .trim()
586                .to_string();
587
588            let test_name = format!("{}.{}", class_name, method_name);
589
590            // Collect following indented lines as error details
591            let mut message_lines = Vec::new();
592            let mut stack_lines = Vec::new();
593            i += 1;
594
595            while i < lines.len() {
596                let line = lines[i];
597                if !line.starts_with("    ") && !line.starts_with('\t') {
598                    break;
599                }
600                let content = line.trim();
601                if content.starts_with("at ") {
602                    stack_lines.push(content.to_string());
603                } else if !content.is_empty() {
604                    message_lines.push(content.to_string());
605                }
606                i += 1;
607            }
608
609            let message = message_lines.join("\n");
610            let stack_trace = if stack_lines.is_empty() {
611                None
612            } else {
613                Some(
614                    stack_lines
615                        .into_iter()
616                        .take(5)
617                        .collect::<Vec<_>>()
618                        .join("\n"),
619                )
620            };
621
622            failures.push(JavaTestFailure {
623                test_name,
624                method_name,
625                message: truncate_java_message(&message, 500),
626                stack_trace,
627            });
628            continue;
629        }
630
631        i += 1;
632    }
633
634    failures
635}
636
637/// Parse Maven [ERROR] section failures.
638/// "[ERROR]   AppTest.testDivide:42 expected:<4> but was:<3>"
639fn parse_maven_error_failures(output: &str) -> Vec<JavaTestFailure> {
640    let mut failures = Vec::new();
641    let lines: Vec<&str> = output.lines().collect();
642    let mut in_failures = false;
643
644    for line in &lines {
645        let trimmed = line.trim();
646        let clean = strip_maven_prefix(trimmed);
647
648        if clean == "Failures:" || clean == "Errors:" {
649            in_failures = true;
650            continue;
651        }
652
653        if in_failures && !clean.is_empty() {
654            // "  AppTest.testDivide:42 expected:<4> but was:<3>"
655            if clean.contains('.') && (clean.contains(':') || clean.contains(' ')) {
656                let parts: Vec<&str> = clean.splitn(2, ' ').collect();
657                if !parts.is_empty() {
658                    let test_ref = parts[0];
659                    let message = if parts.len() > 1 {
660                        parts[1].to_string()
661                    } else {
662                        String::new()
663                    };
664
665                    // Extract method name from "AppTest.testDivide:42"
666                    let method_name = test_ref
667                        .split('.')
668                        .next_back()
669                        .unwrap_or(test_ref)
670                        .split(':')
671                        .next()
672                        .unwrap_or(test_ref)
673                        .to_string();
674
675                    failures.push(JavaTestFailure {
676                        test_name: test_ref.to_string(),
677                        method_name,
678                        message: truncate_java_message(&message, 500),
679                        stack_trace: None,
680                    });
681                }
682            } else if clean.starts_with("Tests run:") || clean.starts_with("[") {
683                in_failures = false;
684            }
685        }
686    }
687
688    failures
689}
690
691/// Strip Maven log prefix: "[INFO] " or "[ERROR] "
692fn strip_maven_prefix(line: &str) -> &str {
693    line.strip_prefix("[INFO] ")
694        .or_else(|| line.strip_prefix("[ERROR] "))
695        .or_else(|| line.strip_prefix("[WARNING] "))
696        .unwrap_or(line)
697        .trim()
698}
699
700/// Enrich test cases with failure details.
701fn enrich_with_errors(suites: &mut [TestSuite], failures: &[JavaTestFailure]) {
702    for suite in suites.iter_mut() {
703        for test in suite.tests.iter_mut() {
704            if test.status != TestStatus::Failed || test.error.is_some() {
705                continue;
706            }
707            if let Some(failure) = find_matching_java_failure(&test.name, &suite.name, failures) {
708                let location = failure
709                    .stack_trace
710                    .as_ref()
711                    .and_then(|st| st.lines().next())
712                    .map(|s| s.to_string());
713                test.error = Some(TestError {
714                    message: failure.message.clone(),
715                    location,
716                });
717            }
718        }
719    }
720}
721
722/// Find a matching failure for a test in the failures list.
723fn find_matching_java_failure<'a>(
724    test_name: &str,
725    suite_name: &str,
726    failures: &'a [JavaTestFailure],
727) -> Option<&'a JavaTestFailure> {
728    for failure in failures {
729        // Direct method name match
730        if test_name == failure.method_name {
731            return Some(failure);
732        }
733        // Full test name match (Gradle style: "com.example.AppTest.testMethod")
734        if failure.test_name.ends_with(&format!(".{}", test_name)) {
735            return Some(failure);
736        }
737        // Check if test_name is contained in the failure's full name
738        if failure.test_name.contains(test_name) {
739            return Some(failure);
740        }
741        // Suite + method match
742        if failure.test_name.contains(suite_name) && failure.method_name == test_name {
743            return Some(failure);
744        }
745    }
746    // Single failure for synthetic name
747    if failures.len() == 1 && test_name.starts_with("failed_test_") {
748        return Some(&failures[0]);
749    }
750    None
751}
752
753/// Truncate a message to max length.
754fn truncate_java_message(msg: &str, max_len: usize) -> String {
755    if msg.len() <= max_len {
756        msg.to_string()
757    } else {
758        format!("{}...", &msg[..max_len])
759    }
760}
761
762/// Parse Surefire XML test report files from standard locations.
763/// Returns parsed test suites from XML report files found at:
764/// - target/surefire-reports/TEST-*.xml (Maven)
765/// - build/test-results/test/TEST-*.xml (Gradle)
766pub fn parse_surefire_xml(project_dir: &Path) -> Vec<TestSuite> {
767    let report_dirs = [
768        project_dir.join("target/surefire-reports"),
769        project_dir.join("build/test-results/test"),
770        project_dir.join("build/test-results"),
771    ];
772
773    let mut suites = Vec::new();
774
775    for dir in &report_dirs {
776        if !dir.is_dir() {
777            continue;
778        }
779        if let Ok(entries) = std::fs::read_dir(dir) {
780            for entry in entries.flatten() {
781                let name = entry.file_name();
782                let name = name.to_string_lossy();
783                if name.starts_with("TEST-")
784                    && name.ends_with(".xml")
785                    && let Ok(content) = std::fs::read_to_string(entry.path())
786                    && let Some(suite) = parse_single_surefire_xml(&content)
787                {
788                    suites.push(suite);
789                }
790            }
791        }
792    }
793
794    suites
795}
796
797/// Parse a single Surefire/JUnit XML report file.
798///
799/// Format:
800/// ```xml
801/// <?xml version="1.0" encoding="UTF-8"?>
802/// <testsuite name="com.example.AppTest" tests="3" failures="1" errors="0" skipped="0" time="0.05">
803///   <testcase name="testAdd" classname="com.example.AppTest" time="0.01"/>
804///   <testcase name="testSub" classname="com.example.AppTest" time="0.02"/>
805///   <testcase name="testDiv" classname="com.example.AppTest" time="0.02">
806///     <failure message="expected:&lt;4&gt; but was:&lt;3&gt;" type="AssertionError">
807///       stack trace here
808///     </failure>
809///   </testcase>
810/// </testsuite>
811/// ```
812fn parse_single_surefire_xml(content: &str) -> Option<TestSuite> {
813    // Extract suite name
814    let suite_name = extract_xml_attr(content, "testsuite", "name")?;
815
816    let mut tests = Vec::new();
817
818    // Find all <testcase> elements
819    let mut search_from = 0;
820    while let Some(tc_start) = content[search_from..].find("<testcase") {
821        let absolute_start = search_from + tc_start;
822
823        // Find the end of this testcase element
824        let tc_content_start = absolute_start + 9; // skip "<testcase"
825        let (tc_end, is_self_closing) =
826            if let Some(self_close) = find_self_closing_end(content, tc_content_start) {
827                (self_close, true)
828            } else if let Some(close) = content[tc_content_start..].find("</testcase>") {
829                (tc_content_start + close + 11, false)
830            } else {
831                break;
832            };
833
834        let tc_text = &content[absolute_start..tc_end];
835
836        let name =
837            extract_xml_attr(tc_text, "testcase", "name").unwrap_or_else(|| "unknown".into());
838        let time_str = extract_xml_attr(tc_text, "testcase", "time").unwrap_or_default();
839        let duration = time_str
840            .parse::<f64>()
841            .map(duration_from_secs_safe)
842            .unwrap_or(Duration::from_millis(0));
843
844        let (status, error) = if is_self_closing {
845            // Self-closing means passed (no failure/error/skipped child)
846            (TestStatus::Passed, None)
847        } else if tc_text.contains("<failure") {
848            let msg = extract_xml_attr(tc_text, "failure", "message")
849                .unwrap_or_else(|| "Test failed".into());
850            let error_type = extract_xml_attr(tc_text, "failure", "type");
851            let location = error_type.map(|t| format!("type: {}", t));
852            (
853                TestStatus::Failed,
854                Some(TestError {
855                    message: xml_unescape(&msg),
856                    location,
857                }),
858            )
859        } else if tc_text.contains("<error") {
860            let msg = extract_xml_attr(tc_text, "error", "message")
861                .unwrap_or_else(|| "Test error".into());
862            (
863                TestStatus::Failed,
864                Some(TestError {
865                    message: xml_unescape(&msg),
866                    location: None,
867                }),
868            )
869        } else if tc_text.contains("<skipped") {
870            (TestStatus::Skipped, None)
871        } else {
872            (TestStatus::Passed, None)
873        };
874
875        tests.push(TestCase {
876            name,
877            status,
878            duration,
879            error,
880        });
881
882        search_from = tc_end;
883    }
884
885    if tests.is_empty() {
886        return None;
887    }
888
889    Some(TestSuite {
890        name: suite_name,
891        tests,
892    })
893}
894
895/// Find the end of a self-closing XML tag (/>).
896/// Returns the position after the /> if the tag closes itself (no children).
897/// Only checks the opening tag itself — not child elements.
898fn find_self_closing_end(content: &str, from: usize) -> Option<usize> {
899    let remaining = &content[from..];
900    // Find the first '>' character — this ends the opening tag
901    let first_close = remaining.find('>')?;
902    // Check if it's "/>" (self-closing)
903    if first_close > 0 && remaining.as_bytes()[first_close - 1] == b'/' {
904        Some(from + first_close + 1)
905    } else {
906        None
907    }
908}
909
910/// Extract an XML attribute value from a tag.
911/// Simple string-based extraction - not a full XML parser.
912fn extract_xml_attr(content: &str, tag: &str, attr: &str) -> Option<String> {
913    let tag_start = content.find(&format!("<{}", tag))?;
914    let tag_content = &content[tag_start..];
915    let tag_end = tag_content.find('>')?.min(tag_content.len());
916    let tag_text = &tag_content[..tag_end];
917
918    let attr_pattern = format!("{}=\"", attr);
919    let attr_start = tag_text.find(&attr_pattern)?;
920    let value_start = attr_start + attr_pattern.len();
921    let value_end = tag_text[value_start..].find('"')?;
922    Some(tag_text[value_start..value_start + value_end].to_string())
923}
924
925/// Unescape basic XML entities.
926fn xml_unescape(s: &str) -> String {
927    s.replace("&lt;", "<")
928        .replace("&gt;", ">")
929        .replace("&amp;", "&")
930        .replace("&quot;", "\"")
931        .replace("&apos;", "'")
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937
938    #[test]
939    fn detect_maven_project() {
940        let dir = tempfile::tempdir().unwrap();
941        std::fs::write(
942            dir.path().join("pom.xml"),
943            "<project><modelVersion>4.0.0</modelVersion></project>",
944        )
945        .unwrap();
946        let adapter = JavaAdapter::new();
947        let det = adapter.detect(dir.path()).unwrap();
948        assert_eq!(det.language, "Java");
949        assert_eq!(det.framework, "maven surefire");
950    }
951
952    #[test]
953    fn detect_gradle_project() {
954        let dir = tempfile::tempdir().unwrap();
955        std::fs::write(dir.path().join("build.gradle"), "apply plugin: 'java'\n").unwrap();
956        let adapter = JavaAdapter::new();
957        let det = adapter.detect(dir.path()).unwrap();
958        assert_eq!(det.language, "Java");
959        assert_eq!(det.framework, "gradle");
960    }
961
962    #[test]
963    fn detect_gradle_kts_project() {
964        let dir = tempfile::tempdir().unwrap();
965        std::fs::write(dir.path().join("build.gradle.kts"), "plugins { java }\n").unwrap();
966        let adapter = JavaAdapter::new();
967        let det = adapter.detect(dir.path()).unwrap();
968        assert_eq!(det.framework, "gradle (kotlin dsl)");
969    }
970
971    #[test]
972    fn detect_no_java() {
973        let dir = tempfile::tempdir().unwrap();
974        let adapter = JavaAdapter::new();
975        assert!(adapter.detect(dir.path()).is_none());
976    }
977
978    #[test]
979    fn parse_maven_surefire_output() {
980        let stdout = r#"
981[INFO] -------------------------------------------------------
982[INFO]  T E S T S
983[INFO] -------------------------------------------------------
984[INFO] Running com.example.AppTest
985[INFO] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s
986[INFO]
987[INFO] Results:
988[INFO]
989[INFO] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
990[INFO]
991[INFO] BUILD FAILURE
992"#;
993        let adapter = JavaAdapter::new();
994        let result = adapter.parse_output(stdout, "", 1);
995
996        assert_eq!(result.total_tests(), 3);
997        assert_eq!(result.total_passed(), 2);
998        assert_eq!(result.total_failed(), 1);
999        assert!(!result.is_success());
1000    }
1001
1002    #[test]
1003    fn parse_maven_all_pass() {
1004        let stdout = r#"
1005[INFO] Running com.example.MathTest
1006[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.12 s
1007[INFO] BUILD SUCCESS
1008"#;
1009        let adapter = JavaAdapter::new();
1010        let result = adapter.parse_output(stdout, "", 0);
1011
1012        assert_eq!(result.total_tests(), 5);
1013        assert_eq!(result.total_passed(), 5);
1014        assert!(result.is_success());
1015    }
1016
1017    #[test]
1018    fn parse_maven_with_skipped() {
1019        let stdout = r#"
1020[INFO] Running com.example.AppTest
1021[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 0.03 s
1022"#;
1023        let adapter = JavaAdapter::new();
1024        let result = adapter.parse_output(stdout, "", 0);
1025
1026        assert_eq!(result.total_passed(), 2);
1027        assert_eq!(result.total_skipped(), 2);
1028        assert!(result.is_success());
1029    }
1030
1031    #[test]
1032    fn parse_maven_with_errors() {
1033        let stdout = r#"
1034[INFO] Running com.example.AppTest
1035[INFO] Tests run: 3, Failures: 0, Errors: 2, Skipped: 0, Time elapsed: 0.01 s
1036"#;
1037        let adapter = JavaAdapter::new();
1038        let result = adapter.parse_output(stdout, "", 1);
1039
1040        assert_eq!(result.total_failed(), 2);
1041        assert!(!result.is_success());
1042    }
1043
1044    #[test]
1045    fn parse_gradle_test_output() {
1046        let stdout = r#"
1047> Task :test
1048
1049com.example.AppTest > testAdd PASSED
1050com.example.AppTest > testSubtract PASSED
1051com.example.AppTest > testDivide FAILED
1052
10533 tests completed, 1 failed
1054
1055BUILD FAILED in 2s
1056"#;
1057        let adapter = JavaAdapter::new();
1058        let result = adapter.parse_output(stdout, "", 1);
1059
1060        assert_eq!(result.total_tests(), 3);
1061        assert_eq!(result.total_passed(), 2);
1062        assert_eq!(result.total_failed(), 1);
1063        assert!(!result.is_success());
1064    }
1065
1066    #[test]
1067    fn parse_gradle_all_pass() {
1068        let stdout = r#"
1069> Task :test
1070
1071com.example.MathTest > testAdd PASSED
1072com.example.MathTest > testMultiply PASSED
1073
1074BUILD SUCCESSFUL in 3s
1075"#;
1076        let adapter = JavaAdapter::new();
1077        let result = adapter.parse_output(stdout, "", 0);
1078
1079        assert_eq!(result.total_tests(), 2);
1080        assert_eq!(result.total_passed(), 2);
1081        assert!(result.is_success());
1082    }
1083
1084    #[test]
1085    fn parse_gradle_multiple_suites() {
1086        let stdout = r#"
1087com.example.MathTest > testAdd PASSED
1088com.example.StringTest > testUpper PASSED
1089com.example.StringTest > testLower FAILED
1090
10913 tests completed, 1 failed
1092"#;
1093        let adapter = JavaAdapter::new();
1094        let result = adapter.parse_output(stdout, "", 1);
1095
1096        assert_eq!(result.suites.len(), 2);
1097        assert_eq!(result.total_tests(), 3);
1098    }
1099
1100    #[test]
1101    fn parse_java_empty_output() {
1102        let adapter = JavaAdapter::new();
1103        let result = adapter.parse_output("", "", 0);
1104
1105        assert_eq!(result.total_tests(), 1);
1106        assert!(result.is_success());
1107    }
1108
1109    #[test]
1110    fn parse_java_duration_maven() {
1111        assert_eq!(
1112            parse_java_duration(
1113                "[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.23 s"
1114            ),
1115            Some(Duration::from_millis(1230))
1116        );
1117    }
1118
1119    #[test]
1120    fn parse_java_duration_gradle() {
1121        assert_eq!(
1122            parse_java_duration("BUILD SUCCESSFUL in 5s"),
1123            Some(Duration::from_secs(5))
1124        );
1125    }
1126
1127    #[test]
1128    fn gradle_wrapper_detection() {
1129        let dir = tempfile::tempdir().unwrap();
1130        std::fs::write(dir.path().join("build.gradle"), "").unwrap();
1131        assert!(!JavaAdapter::has_gradle_wrapper(dir.path()));
1132        std::fs::write(dir.path().join("gradlew"), "#!/bin/bash\n").unwrap();
1133        assert!(JavaAdapter::has_gradle_wrapper(dir.path()));
1134    }
1135
1136    #[test]
1137    fn parse_maven_failed_tests_section_test() {
1138        let output = r#"
1139[ERROR] Failed tests:
1140[ERROR]   testDivide(com.example.MathTest): expected:<4> but was:<3>
1141[ERROR]   testModulo(com.example.MathTest): ArithmeticException
1142[ERROR]
1143[ERROR] Tests run: 5, Failures: 2, Errors: 0, Skipped: 0
1144"#;
1145        let failures = parse_maven_failed_tests_section(output);
1146        assert_eq!(failures.len(), 2);
1147        assert_eq!(failures[0].method_name, "testDivide");
1148        assert!(failures[0].message.contains("expected:<4>"));
1149        assert_eq!(failures[1].method_name, "testModulo");
1150    }
1151
1152    #[test]
1153    fn parse_maven_failure_line_test() {
1154        let failure =
1155            parse_maven_failure_line("testAdd(com.example.MathTest): expected 5 got 4").unwrap();
1156        assert_eq!(failure.method_name, "testAdd");
1157        assert_eq!(failure.test_name, "testAdd(com.example.MathTest)");
1158        assert_eq!(failure.message, "expected 5 got 4");
1159    }
1160
1161    #[test]
1162    fn parse_maven_failure_line_no_message() {
1163        let failure = parse_maven_failure_line("testAdd(com.example.MathTest)").unwrap();
1164        assert_eq!(failure.method_name, "testAdd");
1165        assert!(failure.message.is_empty());
1166    }
1167
1168    #[test]
1169    fn parse_gradle_failure_blocks() {
1170        let output = r#"
1171> Task :test
1172
1173com.example.AppTest > testAdd PASSED
1174com.example.AppTest > testDivide FAILED
1175    org.opentest4j.AssertionFailedError: expected: <4> but was: <3>
1176        at com.example.AppTest.testDivide(AppTest.java:42)
1177
11783 tests completed, 1 failed
1179"#;
1180        let failures = parse_gradle_failures(output);
1181        assert_eq!(failures.len(), 1);
1182        assert_eq!(failures[0].method_name, "testDivide");
1183        assert!(failures[0].message.contains("AssertionFailedError"));
1184        assert!(failures[0].stack_trace.is_some());
1185    }
1186
1187    #[test]
1188    fn parse_gradle_multiple_failures() {
1189        let output = r#"
1190com.example.Test > methodA FAILED
1191    java.lang.RuntimeException: boom
1192com.example.Test > methodB FAILED
1193    java.lang.NullPointerException
1194        at com.example.Test.methodB(Test.java:10)
1195"#;
1196        let failures = parse_gradle_failures(output);
1197        assert_eq!(failures.len(), 2);
1198        assert_eq!(failures[0].method_name, "methodA");
1199        assert_eq!(failures[1].method_name, "methodB");
1200    }
1201
1202    #[test]
1203    fn parse_maven_error_failures_test() {
1204        let output = r#"
1205[ERROR] Failures:
1206[ERROR]   AppTest.testDivide:42 expected:<4> but was:<3>
1207[ERROR]
1208"#;
1209        let failures = parse_maven_error_failures(output);
1210        assert_eq!(failures.len(), 1);
1211        assert_eq!(failures[0].method_name, "testDivide");
1212    }
1213
1214    #[test]
1215    fn strip_maven_prefix_test() {
1216        assert_eq!(strip_maven_prefix("[INFO] Hello"), "Hello");
1217        assert_eq!(strip_maven_prefix("[ERROR] Fail"), "Fail");
1218        assert_eq!(strip_maven_prefix("[WARNING] Warn"), "Warn");
1219        assert_eq!(strip_maven_prefix("No prefix"), "No prefix");
1220    }
1221
1222    #[test]
1223    fn enrich_with_errors_test() {
1224        let mut suites = vec![TestSuite {
1225            name: "com.example.AppTest".into(),
1226            tests: vec![
1227                TestCase {
1228                    name: "testAdd".into(),
1229                    status: TestStatus::Passed,
1230                    duration: Duration::from_millis(0),
1231                    error: None,
1232                },
1233                TestCase {
1234                    name: "testDivide".into(),
1235                    status: TestStatus::Failed,
1236                    duration: Duration::from_millis(0),
1237                    error: None,
1238                },
1239            ],
1240        }];
1241        let failures = vec![JavaTestFailure {
1242            test_name: "com.example.AppTest.testDivide".into(),
1243            method_name: "testDivide".into(),
1244            message: "expected 4 got 3".into(),
1245            stack_trace: Some("at com.example.AppTest.testDivide(AppTest.java:42)".into()),
1246        }];
1247        enrich_with_errors(&mut suites, &failures);
1248        assert!(suites[0].tests[0].error.is_none());
1249        let err = suites[0].tests[1].error.as_ref().unwrap();
1250        assert_eq!(err.message, "expected 4 got 3");
1251        assert!(err.location.is_some());
1252    }
1253
1254    #[test]
1255    fn truncate_java_message_test() {
1256        assert_eq!(truncate_java_message("short", 100), "short");
1257        let long = "x".repeat(600);
1258        let truncated = truncate_java_message(&long, 500);
1259        assert!(truncated.ends_with("..."));
1260        assert_eq!(truncated.len(), 503);
1261    }
1262
1263    #[test]
1264    fn parse_surefire_xml_basic() {
1265        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1266<testsuite name="com.example.MathTest" tests="3" failures="1" errors="0" skipped="0" time="0.05">
1267  <testcase name="testAdd" classname="com.example.MathTest" time="0.01"/>
1268  <testcase name="testSub" classname="com.example.MathTest" time="0.02"/>
1269  <testcase name="testDiv" classname="com.example.MathTest" time="0.02">
1270    <failure message="expected:&lt;4&gt; but was:&lt;3&gt;" type="AssertionError">
1271      stack trace here
1272    </failure>
1273  </testcase>
1274</testsuite>"#;
1275        let suite = parse_single_surefire_xml(xml).unwrap();
1276        assert_eq!(suite.name, "com.example.MathTest");
1277        assert_eq!(suite.tests.len(), 3);
1278        assert_eq!(suite.tests[0].status, TestStatus::Passed);
1279        assert_eq!(suite.tests[0].name, "testAdd");
1280        assert_eq!(suite.tests[2].status, TestStatus::Failed);
1281        let err = suite.tests[2].error.as_ref().unwrap();
1282        assert!(err.message.contains("expected:<4>"));
1283    }
1284
1285    #[test]
1286    fn parse_surefire_xml_with_skipped() {
1287        let xml = r#"<testsuite name="Test" tests="2" failures="0" errors="0" skipped="1" time="0.01">
1288  <testcase name="testA" classname="Test" time="0.005"/>
1289  <testcase name="testB" classname="Test" time="0.005">
1290    <skipped/>
1291  </testcase>
1292</testsuite>"#;
1293        let suite = parse_single_surefire_xml(xml).unwrap();
1294        assert_eq!(suite.tests.len(), 2);
1295        assert_eq!(suite.tests[0].status, TestStatus::Passed);
1296        assert_eq!(suite.tests[1].status, TestStatus::Skipped);
1297    }
1298
1299    #[test]
1300    fn parse_surefire_xml_with_error() {
1301        let xml = r#"<testsuite name="Test" tests="1" failures="0" errors="1" time="0.01">
1302  <testcase name="testBroken" classname="Test" time="0.001">
1303    <error message="NullPointerException" type="java.lang.NullPointerException">
1304      at Test.testBroken(Test.java:5)
1305    </error>
1306  </testcase>
1307</testsuite>"#;
1308        let suite = parse_single_surefire_xml(xml).unwrap();
1309        assert_eq!(suite.tests[0].status, TestStatus::Failed);
1310        assert!(
1311            suite.tests[0]
1312                .error
1313                .as_ref()
1314                .unwrap()
1315                .message
1316                .contains("NullPointerException")
1317        );
1318    }
1319
1320    #[test]
1321    fn parse_surefire_xml_empty() {
1322        assert!(parse_single_surefire_xml("<testsuite name=\"Test\"></testsuite>").is_none());
1323    }
1324
1325    #[test]
1326    fn extract_xml_attr_test() {
1327        assert_eq!(
1328            extract_xml_attr(r#"<tag name="value">"#, "tag", "name"),
1329            Some("value".into())
1330        );
1331        assert_eq!(
1332            extract_xml_attr(r#"<tag foo="bar" baz="qux">"#, "tag", "baz"),
1333            Some("qux".into())
1334        );
1335        assert!(extract_xml_attr(r#"<tag>"#, "tag", "name").is_none());
1336    }
1337
1338    #[test]
1339    fn xml_unescape_test() {
1340        assert_eq!(
1341            xml_unescape("expected:&lt;4&gt; but was:&lt;3&gt;"),
1342            "expected:<4> but was:<3>"
1343        );
1344        assert_eq!(xml_unescape("&amp;&quot;&apos;"), "&\"'");
1345    }
1346
1347    #[test]
1348    fn parse_java_failures_integration() {
1349        let output = r#"
1350[INFO] -------------------------------------------------------
1351[INFO]  T E S T S
1352[INFO] -------------------------------------------------------
1353[INFO] Running com.example.AppTest
1354[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
1355
1356Failed tests:
1357  testDivide(com.example.AppTest): expected:<4> but was:<3>
1358
1359[INFO] BUILD FAILURE
1360"#;
1361        let adapter = JavaAdapter::new();
1362        let result = adapter.parse_output(output, "", 1);
1363        assert_eq!(result.total_tests(), 3);
1364        assert_eq!(result.total_failed(), 1);
1365    }
1366}