Skip to main content

testx/adapters/
cpp.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 CppAdapter;
14
15impl Default for CppAdapter {
16    fn default() -> Self {
17        Self::new()
18    }
19}
20
21impl CppAdapter {
22    pub fn new() -> Self {
23        Self
24    }
25
26    /// Detect build system: CMake (ctest) or Meson
27    fn detect_build_system(project_dir: &Path) -> Option<&'static str> {
28        let cmake = project_dir.join("CMakeLists.txt");
29        if cmake.exists() {
30            return Some("cmake");
31        }
32        if project_dir.join("meson.build").exists() {
33            return Some("meson");
34        }
35        // Fallback: check subdirectories for build files
36        if has_marker_in_subdirs(project_dir, 1, |name| name == "CMakeLists.txt") {
37            return Some("cmake");
38        }
39        if has_marker_in_subdirs(project_dir, 1, |name| name == "meson.build") {
40            return Some("meson");
41        }
42        None
43    }
44
45    /// Check if a CMake build directory exists
46    fn find_build_dir(project_dir: &Path) -> Option<std::path::PathBuf> {
47        for name in &[
48            "build",
49            "cmake-build-debug",
50            "cmake-build-release",
51            "out/build",
52        ] {
53            let p = project_dir.join(name);
54            if p.is_dir() {
55                return Some(p);
56            }
57        }
58        None
59    }
60}
61
62impl TestAdapter for CppAdapter {
63    fn name(&self) -> &str {
64        "C/C++"
65    }
66
67    fn check_runner(&self) -> Option<String> {
68        if which::which("ctest").is_err() && which::which("meson").is_err() {
69            return Some("ctest or meson not found. Install CMake or Meson.".into());
70        }
71        None
72    }
73
74    fn detect(&self, project_dir: &Path) -> Option<DetectionResult> {
75        let build_system = Self::detect_build_system(project_dir)?;
76
77        let framework = match build_system {
78            "cmake" => "ctest",
79            "meson" => "meson test",
80            _ => "unknown",
81        };
82
83        let has_build_dir = Self::find_build_dir(project_dir).is_some();
84        let has_test_dir = project_dir.join("test").is_dir() || project_dir.join("tests").is_dir();
85        let has_runner = which::which("ctest").is_ok() || which::which("meson").is_ok();
86
87        let confidence = ConfidenceScore::base(0.50)
88            .signal(0.15, has_build_dir)
89            .signal(0.15, has_test_dir)
90            .signal(0.10, has_runner)
91            .finish();
92
93        Some(DetectionResult {
94            language: "C/C++".into(),
95            framework: framework.into(),
96            confidence,
97        })
98    }
99
100    fn build_command(&self, project_dir: &Path, extra_args: &[String]) -> Result<Command> {
101        let build_system = Self::detect_build_system(project_dir).unwrap_or("cmake");
102
103        let mut cmd;
104
105        match build_system {
106            "meson" => {
107                cmd = Command::new("meson");
108                cmd.arg("test");
109                cmd.arg("-C");
110                let build_dir = Self::find_build_dir(project_dir)
111                    .unwrap_or_else(|| project_dir.join("builddir"));
112                cmd.arg(build_dir);
113            }
114            _ => {
115                // CMake / ctest
116                cmd = Command::new("ctest");
117                cmd.arg("--output-on-failure");
118                cmd.arg("--test-dir");
119                let build_dir =
120                    Self::find_build_dir(project_dir).unwrap_or_else(|| project_dir.join("build"));
121                cmd.arg(build_dir);
122            }
123        }
124
125        for arg in extra_args {
126            cmd.arg(arg);
127        }
128
129        cmd.current_dir(project_dir);
130        Ok(cmd)
131    }
132
133    fn filter_args(&self, pattern: &str) -> Vec<String> {
134        // CTest uses -R for regex filter
135        vec!["-R".to_string(), pattern.to_string()]
136    }
137
138    fn parse_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> TestRunResult {
139        let combined = combined_output(stdout, stderr);
140
141        let mut suites = parse_ctest_output(&combined, exit_code);
142
143        // Try to enrich failed tests with error details from --output-on-failure
144        let failures = parse_ctest_failures(&combined);
145        if !failures.is_empty() {
146            enrich_with_errors(&mut suites, &failures);
147        }
148
149        // Also try parsing Google Test output if present
150        let gtest_suites = parse_gtest_output(&combined);
151        if !gtest_suites.is_empty() {
152            // Google Test output is more detailed, prefer it
153            suites = gtest_suites;
154        }
155
156        let duration = parse_ctest_duration(&combined).unwrap_or(Duration::from_secs(0));
157
158        TestRunResult {
159            suites,
160            duration,
161            raw_exit_code: exit_code,
162        }
163    }
164}
165
166/// Parse CTest output.
167///
168/// Format:
169/// ```text
170/// Test project /path/to/build
171///     Start 1: test_basic
172/// 1/3 Test #1: test_basic ...................   Passed    0.01 sec
173///     Start 2: test_advanced
174/// 2/3 Test #2: test_advanced ................   Passed    0.02 sec
175///     Start 3: test_edge
176/// 3/3 Test #3: test_edge ....................***Failed    0.01 sec
177///
178/// 67% tests passed, 1 tests failed out of 3
179/// ```
180fn parse_ctest_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
181    let mut tests = Vec::new();
182
183    for line in output.lines() {
184        let trimmed = line.trim();
185
186        // "1/3 Test #1: test_basic ...................   Passed    0.01 sec"
187        if trimmed.contains("Test #")
188            && (trimmed.contains("Passed")
189                || trimmed.contains("Failed")
190                || trimmed.contains("Not Run"))
191        {
192            let (name, status, duration) = parse_ctest_line(trimmed);
193            tests.push(TestCase {
194                name,
195                status,
196                duration,
197                error: None,
198            });
199        }
200    }
201
202    // Fallback: parse summary line
203    if tests.is_empty()
204        && let Some((passed, failed)) = parse_ctest_summary(output)
205    {
206        for i in 0..passed {
207            tests.push(TestCase {
208                name: format!("test_{}", i + 1),
209                status: TestStatus::Passed,
210                duration: Duration::from_millis(0),
211                error: None,
212            });
213        }
214        for i in 0..failed {
215            tests.push(TestCase {
216                name: format!("failed_test_{}", i + 1),
217                status: TestStatus::Failed,
218                duration: Duration::from_millis(0),
219                error: None,
220            });
221        }
222    }
223
224    if tests.is_empty() {
225        tests.push(TestCase {
226            name: "test_suite".into(),
227            status: if exit_code == 0 {
228                TestStatus::Passed
229            } else {
230                TestStatus::Failed
231            },
232            duration: Duration::from_millis(0),
233            error: None,
234        });
235    }
236
237    vec![TestSuite {
238        name: "tests".into(),
239        tests,
240    }]
241}
242
243fn parse_ctest_line(line: &str) -> (String, TestStatus, Duration) {
244    // "1/3 Test #1: test_basic ...................   Passed    0.01 sec"
245    let status = if line.contains("Passed") {
246        TestStatus::Passed
247    } else if line.contains("Not Run") {
248        TestStatus::Skipped
249    } else {
250        TestStatus::Failed
251    };
252
253    // Extract test name: between "Test #N: " and the dots/spaces
254    let name = if let Some(idx) = line.find(": ") {
255        let after = &line[idx + 2..];
256        // Name ends at first run of dots or multiple spaces
257        let end = after
258            .find(" .")
259            .or_else(|| after.find("  "))
260            .unwrap_or(after.len());
261        after[..end].trim().to_string()
262    } else {
263        "unknown".into()
264    };
265
266    // Extract duration: "0.01 sec"
267    let duration = if let Some(idx) = line.rfind("    ") {
268        let after = line[idx..].trim();
269        let num_str: String = after
270            .chars()
271            .take_while(|c| c.is_ascii_digit() || *c == '.')
272            .collect();
273        num_str
274            .parse::<f64>()
275            .map(duration_from_secs_safe)
276            .unwrap_or(Duration::from_millis(0))
277    } else {
278        Duration::from_millis(0)
279    };
280
281    (name, status, duration)
282}
283
284fn parse_ctest_summary(output: &str) -> Option<(usize, usize)> {
285    // "67% tests passed, 1 tests failed out of 3"
286    for line in output.lines() {
287        let trimmed = line.trim();
288        if trimmed.contains("tests passed") && trimmed.contains("out of") {
289            let parts: Vec<&str> = trimmed.split_whitespace().collect();
290            let mut failed = 0usize;
291            let mut total = 0usize;
292            for (i, part) in parts.iter().enumerate() {
293                // Pattern: "N tests failed" — number is 2 before "failed"
294                if *part == "failed" && i >= 2 {
295                    failed = parts[i - 2].parse().unwrap_or(0);
296                }
297                if *part == "of" && i + 1 < parts.len() {
298                    total = parts[i + 1].parse().unwrap_or(0);
299                }
300            }
301            if total > 0 {
302                return Some((total.saturating_sub(failed), failed));
303            }
304        }
305    }
306    None
307}
308
309fn parse_ctest_duration(output: &str) -> Option<Duration> {
310    // "Total Test time (real) =   0.05 sec"
311    for line in output.lines() {
312        if line.contains("Total Test time")
313            && let Some(idx) = line.find('=')
314        {
315            let after = line[idx + 1..].trim();
316            let num_str: String = after
317                .chars()
318                .take_while(|c| c.is_ascii_digit() || *c == '.')
319                .collect();
320            if let Ok(secs) = num_str.parse::<f64>() {
321                return Some(duration_from_secs_safe(secs));
322            }
323        }
324    }
325    None
326}
327
328/// A parsed CTest failure with output-on-failure details.
329#[derive(Debug, Clone)]
330struct CTestFailure {
331    /// Test name from the CTest output
332    test_name: String,
333    /// Captured output from the failing test
334    output: String,
335    /// First error/assertion line if detected
336    error_line: Option<String>,
337}
338
339/// Parse CTest --output-on-failure blocks.
340///
341/// When a test fails with `--output-on-failure`, CTest prints the test's
342/// stdout/stderr between markers:
343/// ```text
344/// 3/3 Test #3: test_edge ....................***Failed    0.01 sec
345/// Output:
346/// -------
347/// ASSERTION FAILED: expected 4 but got 3
348///   at test_edge.cpp:42
349/// -------
350/// ```
351fn parse_ctest_failures(output: &str) -> Vec<CTestFailure> {
352    let mut failures = Vec::new();
353    let lines: Vec<&str> = output.lines().collect();
354    let mut i = 0;
355
356    while i < lines.len() {
357        let trimmed = lines[i].trim();
358
359        // Find failed test lines
360        if trimmed.contains("Test #")
361            && (trimmed.contains("***Failed") || trimmed.contains("***Exception"))
362        {
363            let test_name = extract_ctest_name(trimmed);
364
365            // Look for output block following the failure
366            let mut output_lines: Vec<String> = Vec::new();
367            let mut error_line = None;
368            i += 1;
369
370            // Skip to "Output:" or collect indented output
371            while i < lines.len() {
372                let line = lines[i].trim();
373
374                if line == "Output:" || line.starts_with("---") {
375                    i += 1;
376                    continue;
377                }
378
379                // Stop at next test start or summary line
380                if line.contains("Test #")
381                    || line.contains("tests passed")
382                    || line.contains("Total Test time")
383                    || (line.starts_with("Start ") && line.contains(':'))
384                {
385                    break;
386                }
387
388                if !line.is_empty() {
389                    output_lines.push(line.to_string());
390
391                    // Detect assertion/error lines
392                    if error_line.is_none() && is_cpp_error_line(line) {
393                        error_line = Some(line.to_string());
394                    }
395                }
396
397                i += 1;
398            }
399
400            if !output_lines.is_empty() {
401                failures.push(CTestFailure {
402                    test_name: test_name.clone(),
403                    output: truncate(&output_lines.join("\n"), 800),
404                    error_line,
405                });
406            }
407            continue;
408        }
409
410        i += 1;
411    }
412
413    failures
414}
415
416/// Extract test name from a CTest line.
417fn extract_ctest_name(line: &str) -> String {
418    if let Some(idx) = line.find(": ") {
419        let after = &line[idx + 2..];
420        let end = after
421            .find(" .")
422            .or_else(|| after.find("  "))
423            .unwrap_or(after.len());
424        after[..end].trim().to_string()
425    } else {
426        "unknown".into()
427    }
428}
429
430/// Check if a line looks like a C/C++ error or assertion.
431fn is_cpp_error_line(line: &str) -> bool {
432    let lower = line.to_lowercase();
433    lower.contains("assert")
434        || lower.contains("error:")
435        || lower.contains("failure")
436        || lower.contains("expected")
437        || lower.contains("actual")
438        || lower.contains("fatal")
439        || lower.contains("segfault")
440        || lower.contains("sigsegv")
441        || lower.contains("sigabrt")
442        || lower.contains("abort")
443}
444
445/// Enrich test cases with CTest failure details.
446fn enrich_with_errors(suites: &mut [TestSuite], failures: &[CTestFailure]) {
447    for suite in suites.iter_mut() {
448        for test in suite.tests.iter_mut() {
449            if test.status != TestStatus::Failed || test.error.is_some() {
450                continue;
451            }
452            if let Some(failure) = find_matching_ctest_failure(&test.name, failures) {
453                let message = failure
454                    .error_line
455                    .clone()
456                    .unwrap_or_else(|| first_meaningful_line(&failure.output));
457                test.error = Some(TestError {
458                    message,
459                    location: extract_cpp_location(&failure.output),
460                });
461            }
462        }
463    }
464}
465
466/// Find a matching CTest failure.
467fn find_matching_ctest_failure<'a>(
468    test_name: &str,
469    failures: &'a [CTestFailure],
470) -> Option<&'a CTestFailure> {
471    for failure in failures {
472        if failure.test_name == test_name {
473            return Some(failure);
474        }
475        // Partial match
476        if test_name.contains(&failure.test_name) || failure.test_name.contains(test_name) {
477            return Some(failure);
478        }
479    }
480    if failures.len() == 1 {
481        return Some(&failures[0]);
482    }
483    None
484}
485
486/// Get the first meaningful (non-empty, non-separator) line.
487fn first_meaningful_line(s: &str) -> String {
488    for line in s.lines() {
489        let trimmed = line.trim();
490        if !trimmed.is_empty() && !trimmed.starts_with("---") && !trimmed.starts_with("===") {
491            return trimmed.to_string();
492        }
493    }
494    s.lines().next().unwrap_or("unknown error").to_string()
495}
496
497/// Extract a file:line location from C++ output.
498fn extract_cpp_location(output: &str) -> Option<String> {
499    for line in output.lines() {
500        let trimmed = line.trim();
501        // GCC/Clang style: "file.cpp:42: error: ..."
502        // GTest style: "test.cpp:42: Failure"
503        if let Some(loc) = extract_file_line_location(trimmed) {
504            return Some(loc);
505        }
506        // "at file.cpp:42" style
507        if let Some(rest) = trimmed.strip_prefix("at ")
508            && rest.contains(':')
509            && (rest.contains(".cpp") || rest.contains(".c") || rest.contains(".h"))
510        {
511            return Some(rest.to_string());
512        }
513    }
514    None
515}
516
517/// Extract file:line from a C-style error message.
518fn extract_file_line_location(line: &str) -> Option<String> {
519    // Pattern: "filename.ext:NUMBER:" or "filename.ext(NUMBER)"
520    let extensions = [".cpp", ".cc", ".cxx", ".c", ".h", ".hpp"];
521    for ext in &extensions {
522        if let Some(ext_pos) = line.find(ext) {
523            let after_ext = &line[ext_pos + ext.len()..];
524            if let Some(colon_after) = after_ext.strip_prefix(':') {
525                // "file.cpp:42: ..."
526                let num_end = colon_after
527                    .find(|c: char| !c.is_ascii_digit())
528                    .unwrap_or(colon_after.len());
529                if num_end > 0 {
530                    let end = ext_pos + ext.len() + 1 + num_end;
531                    return Some(line[..end].trim().to_string());
532                }
533            } else if after_ext.starts_with('(') {
534                // "file.cpp(42)"
535                if let Some(paren_close) = after_ext.find(')') {
536                    let end = ext_pos + ext.len() + paren_close + 1;
537                    return Some(line[..end].trim().to_string());
538                }
539            }
540        }
541    }
542    None
543}
544
545/// Parse Google Test output.
546///
547/// Format:
548/// ```text
549/// [==========] Running 3 tests from 1 test suite.
550/// [----------] 3 tests from MathTest
551/// [ RUN      ] MathTest.TestAdd
552/// [       OK ] MathTest.TestAdd (0 ms)
553/// [ RUN      ] MathTest.TestSub
554/// [       OK ] MathTest.TestSub (0 ms)
555/// [ RUN      ] MathTest.TestDiv
556/// test_math.cpp:42: Failure
557/// Expected equality of these values:
558///   divide(10, 3)
559///     Which is: 3
560///   4
561/// [  FAILED  ] MathTest.TestDiv (0 ms)
562/// [----------] 3 tests from MathTest (0 ms total)
563/// [==========] 3 tests from 1 test suite ran. (0 ms total)
564/// [  PASSED  ] 2 tests.
565/// [  FAILED  ] 1 test, listed below:
566/// [  FAILED  ] MathTest.TestDiv
567/// ```
568fn parse_gtest_output(output: &str) -> Vec<TestSuite> {
569    let mut suites_map: std::collections::HashMap<String, Vec<TestCase>> =
570        std::collections::HashMap::new();
571
572    let lines: Vec<&str> = output.lines().collect();
573    let mut i = 0;
574
575    while i < lines.len() {
576        let trimmed = lines[i].trim();
577
578        // "[ RUN      ] MathTest.TestAdd"
579        if trimmed.starts_with("[ RUN") {
580            let test_full = trimmed
581                .strip_prefix("[ RUN")
582                .unwrap_or("")
583                .trim()
584                .trim_start_matches(']')
585                .trim();
586
587            let (suite_name, test_name) = split_gtest_name(test_full);
588
589            // Collect lines until we find OK/FAILED
590            let mut output_lines = Vec::new();
591            i += 1;
592
593            let mut status = TestStatus::Passed;
594            let mut duration = Duration::from_millis(0);
595
596            while i < lines.len() {
597                let line = lines[i].trim();
598
599                if line.starts_with("[       OK ]") || line.starts_with("[  FAILED  ]") {
600                    status = if line.starts_with("[       OK ]") {
601                        TestStatus::Passed
602                    } else {
603                        TestStatus::Failed
604                    };
605                    duration = parse_gtest_duration(line);
606                    break;
607                }
608
609                if !line.is_empty() && !line.starts_with("[") {
610                    output_lines.push(line.to_string());
611                }
612
613                i += 1;
614            }
615
616            let error = if status == TestStatus::Failed && !output_lines.is_empty() {
617                let message = output_lines
618                    .iter()
619                    .find(|l| is_cpp_error_line(l))
620                    .cloned()
621                    .unwrap_or_else(|| output_lines[0].clone());
622                let location = output_lines
623                    .iter()
624                    .find_map(|l| extract_file_line_location(l));
625                Some(TestError { message, location })
626            } else {
627                None
628            };
629
630            suites_map.entry(suite_name).or_default().push(TestCase {
631                name: test_name,
632                status,
633                duration,
634                error,
635            });
636        }
637
638        i += 1;
639    }
640
641    let mut suites: Vec<TestSuite> = suites_map
642        .into_iter()
643        .map(|(name, tests)| TestSuite { name, tests })
644        .collect();
645    suites.sort_by(|a, b| a.name.cmp(&b.name));
646
647    suites
648}
649
650/// Split a Google Test full name "SuiteName.TestName" into parts.
651fn split_gtest_name(full_name: &str) -> (String, String) {
652    if let Some(dot) = full_name.find('.') {
653        (
654            full_name[..dot].to_string(),
655            full_name[dot + 1..].to_string(),
656        )
657    } else {
658        ("tests".into(), full_name.to_string())
659    }
660}
661
662/// Parse duration from a GTest OK/FAILED line: "[       OK ] Test (123 ms)"
663fn parse_gtest_duration(line: &str) -> Duration {
664    if let Some(paren_start) = line.rfind('(') {
665        let inside = &line[paren_start + 1..line.len().saturating_sub(1)];
666        let inside = inside.trim();
667        if inside.ends_with("ms") {
668            let num_str = inside.strip_suffix("ms").unwrap_or("").trim();
669            if let Ok(ms) = num_str.parse::<u64>() {
670                return Duration::from_millis(ms);
671            }
672        }
673    }
674    Duration::from_millis(0)
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680
681    #[test]
682    fn detect_cmake_project() {
683        let dir = tempfile::tempdir().unwrap();
684        std::fs::write(
685            dir.path().join("CMakeLists.txt"),
686            "cmake_minimum_required(VERSION 3.14)\nenable_testing()\n",
687        )
688        .unwrap();
689        let adapter = CppAdapter::new();
690        let det = adapter.detect(dir.path()).unwrap();
691        assert_eq!(det.language, "C/C++");
692        assert_eq!(det.framework, "ctest");
693        assert!(det.confidence > 0.4 && det.confidence < 1.0);
694    }
695
696    #[test]
697    fn detect_meson_project() {
698        let dir = tempfile::tempdir().unwrap();
699        std::fs::write(dir.path().join("meson.build"), "project('test', 'c')\n").unwrap();
700        let adapter = CppAdapter::new();
701        let det = adapter.detect(dir.path()).unwrap();
702        assert_eq!(det.framework, "meson test");
703    }
704
705    #[test]
706    fn detect_no_cpp() {
707        let dir = tempfile::tempdir().unwrap();
708        let adapter = CppAdapter::new();
709        assert!(adapter.detect(dir.path()).is_none());
710    }
711
712    #[test]
713    fn parse_ctest_detailed_output() {
714        let stdout = r#"
715Test project /home/user/project/build
716    Start 1: test_basic
7171/3 Test #1: test_basic ...................   Passed    0.01 sec
718    Start 2: test_advanced
7192/3 Test #2: test_advanced ................   Passed    0.02 sec
720    Start 3: test_edge
7213/3 Test #3: test_edge ....................***Failed    0.01 sec
722
72367% tests passed, 1 tests failed out of 3
724
725Total Test time (real) =   0.04 sec
726"#;
727        let adapter = CppAdapter::new();
728        let result = adapter.parse_output(stdout, "", 1);
729
730        assert_eq!(result.total_tests(), 3);
731        assert_eq!(result.total_passed(), 2);
732        assert_eq!(result.total_failed(), 1);
733        assert!(!result.is_success());
734    }
735
736    #[test]
737    fn parse_ctest_all_pass() {
738        let stdout = r#"
739Test project /home/user/project/build
740    Start 1: test_one
7411/2 Test #1: test_one .....................   Passed    0.01 sec
742    Start 2: test_two
7432/2 Test #2: test_two .....................   Passed    0.01 sec
744
745100% tests passed, 0 tests failed out of 2
746
747Total Test time (real) =   0.02 sec
748"#;
749        let adapter = CppAdapter::new();
750        let result = adapter.parse_output(stdout, "", 0);
751
752        assert_eq!(result.total_tests(), 2);
753        assert_eq!(result.total_passed(), 2);
754        assert!(result.is_success());
755    }
756
757    #[test]
758    fn parse_ctest_summary_only() {
759        let stdout = "67% tests passed, 1 tests failed out of 3\n";
760        let adapter = CppAdapter::new();
761        let result = adapter.parse_output(stdout, "", 1);
762
763        assert_eq!(result.total_tests(), 3);
764        assert_eq!(result.total_passed(), 2);
765        assert_eq!(result.total_failed(), 1);
766    }
767
768    #[test]
769    fn parse_ctest_empty_output() {
770        let adapter = CppAdapter::new();
771        let result = adapter.parse_output("", "", 0);
772
773        assert_eq!(result.total_tests(), 1);
774        assert!(result.is_success());
775    }
776
777    #[test]
778    fn parse_ctest_duration_value() {
779        assert_eq!(
780            parse_ctest_duration("Total Test time (real) =   0.05 sec"),
781            Some(Duration::from_millis(50))
782        );
783    }
784
785    #[test]
786    fn find_build_dir_exists() {
787        let dir = tempfile::tempdir().unwrap();
788        std::fs::create_dir(dir.path().join("build")).unwrap();
789        assert!(CppAdapter::find_build_dir(dir.path()).is_some());
790    }
791
792    #[test]
793    fn find_build_dir_missing() {
794        let dir = tempfile::tempdir().unwrap();
795        assert!(CppAdapter::find_build_dir(dir.path()).is_none());
796    }
797
798    #[test]
799    fn parse_gtest_detailed_output() {
800        let stdout = r#"
801[==========] Running 3 tests from 1 test suite.
802[----------] 3 tests from MathTest
803[ RUN      ] MathTest.TestAdd
804[       OK ] MathTest.TestAdd (0 ms)
805[ RUN      ] MathTest.TestSub
806[       OK ] MathTest.TestSub (1 ms)
807[ RUN      ] MathTest.TestDiv
808test_math.cpp:42: Failure
809Expected equality of these values:
810  divide(10, 3)
811    Which is: 3
812  4
813[  FAILED  ] MathTest.TestDiv (0 ms)
814[----------] 3 tests from MathTest (1 ms total)
815[==========] 3 tests from 1 test suite ran. (1 ms total)
816[  PASSED  ] 2 tests.
817[  FAILED  ] 1 test, listed below:
818[  FAILED  ] MathTest.TestDiv
819"#;
820        let suites = parse_gtest_output(stdout);
821        assert_eq!(suites.len(), 1);
822        assert_eq!(suites[0].name, "MathTest");
823        assert_eq!(suites[0].tests.len(), 3);
824        assert_eq!(suites[0].tests[0].name, "TestAdd");
825        assert_eq!(suites[0].tests[0].status, TestStatus::Passed);
826        assert_eq!(suites[0].tests[2].name, "TestDiv");
827        assert_eq!(suites[0].tests[2].status, TestStatus::Failed);
828        assert!(suites[0].tests[2].error.is_some());
829    }
830
831    #[test]
832    fn parse_gtest_all_pass() {
833        let stdout = r#"
834[==========] Running 2 tests from 1 test suite.
835[ RUN      ] MathTest.TestAdd
836[       OK ] MathTest.TestAdd (0 ms)
837[ RUN      ] MathTest.TestSub
838[       OK ] MathTest.TestSub (0 ms)
839[==========] 2 tests from 1 test suite ran. (0 ms total)
840[  PASSED  ] 2 tests.
841"#;
842        let suites = parse_gtest_output(stdout);
843        assert_eq!(suites.len(), 1);
844        assert_eq!(suites[0].tests.len(), 2);
845        assert!(
846            suites[0]
847                .tests
848                .iter()
849                .all(|t| t.status == TestStatus::Passed)
850        );
851    }
852
853    #[test]
854    fn parse_gtest_multiple_suites() {
855        let stdout = r#"
856[ RUN      ] MathTest.TestAdd
857[       OK ] MathTest.TestAdd (0 ms)
858[ RUN      ] StringTest.TestUpper
859[       OK ] StringTest.TestUpper (0 ms)
860"#;
861        let suites = parse_gtest_output(stdout);
862        assert_eq!(suites.len(), 2);
863    }
864
865    #[test]
866    fn parse_gtest_failure_with_error_details() {
867        let stdout = r#"
868[ RUN      ] MathTest.TestDiv
869test_math.cpp:42: Failure
870Expected: 4
871  Actual: 3
872[  FAILED  ] MathTest.TestDiv (0 ms)
873"#;
874        let suites = parse_gtest_output(stdout);
875        let err = suites[0].tests[0].error.as_ref().unwrap();
876        assert!(err.location.is_some());
877        assert!(err.location.as_ref().unwrap().contains("test_math.cpp:42"));
878    }
879
880    #[test]
881    fn parse_gtest_duration_test() {
882        assert_eq!(
883            parse_gtest_duration("[       OK ] MathTest.TestAdd (123 ms)"),
884            Duration::from_millis(123)
885        );
886        assert_eq!(
887            parse_gtest_duration("[  FAILED  ] MathTest.TestDiv (0 ms)"),
888            Duration::from_millis(0)
889        );
890    }
891
892    #[test]
893    fn split_gtest_name_test() {
894        assert_eq!(
895            split_gtest_name("MathTest.TestAdd"),
896            ("MathTest".into(), "TestAdd".into())
897        );
898        assert_eq!(
899            split_gtest_name("SimpleTest"),
900            ("tests".into(), "SimpleTest".into())
901        );
902    }
903
904    #[test]
905    fn is_cpp_error_line_test() {
906        assert!(is_cpp_error_line("ASSERT_EQ failed"));
907        assert!(is_cpp_error_line("error: expected 4"));
908        assert!(is_cpp_error_line("Failure"));
909        assert!(is_cpp_error_line("Segfault at 0x0"));
910        assert!(!is_cpp_error_line("Running tests..."));
911    }
912
913    #[test]
914    fn extract_file_line_location_test() {
915        assert_eq!(
916            extract_file_line_location("test.cpp:42: Failure"),
917            Some("test.cpp:42".into())
918        );
919        assert_eq!(
920            extract_file_line_location("main.c:10: error: boom"),
921            Some("main.c:10".into())
922        );
923        assert_eq!(
924            extract_file_line_location("test.hpp(15): fatal"),
925            Some("test.hpp(15)".into())
926        );
927        assert!(extract_file_line_location("no file here").is_none());
928    }
929
930    #[test]
931    fn extract_ctest_name_test() {
932        assert_eq!(
933            extract_ctest_name("1/3 Test #1: test_basic ...................   Passed    0.01 sec"),
934            "test_basic"
935        );
936    }
937
938    #[test]
939    fn parse_ctest_failures_with_output() {
940        let output = r#"
9411/2 Test #1: test_pass ...................   Passed    0.01 sec
9422/2 Test #2: test_edge ....................***Failed    0.01 sec
943ASSERT_EQ(4, result) failed
944  Expected: 4
945  Actual: 3
946test_edge.cpp:42: Failure
947
94867% tests passed, 1 tests failed out of 2
949"#;
950        let failures = parse_ctest_failures(output);
951        assert_eq!(failures.len(), 1);
952        assert_eq!(failures[0].test_name, "test_edge");
953        assert!(failures[0].error_line.is_some());
954    }
955
956    #[test]
957    fn truncate_test() {
958        assert_eq!(truncate("short", 100), "short");
959        let long = "x".repeat(1000);
960        let truncated = truncate(&long, 800);
961        assert!(truncated.ends_with("..."));
962    }
963
964    #[test]
965    fn first_meaningful_line_test() {
966        assert_eq!(
967            first_meaningful_line("---\n\nHello world\nmore"),
968            "Hello world"
969        );
970        assert_eq!(first_meaningful_line("first"), "first");
971    }
972
973    #[test]
974    fn parse_ctest_with_gtest_output() {
975        let stdout = r#"
976Test project /home/user/project/build
977    Start 1: gtest_math
9781/1 Test #1: gtest_math .....................***Failed    0.01 sec
979[==========] Running 2 tests from 1 test suite.
980[ RUN      ] MathTest.TestAdd
981[       OK ] MathTest.TestAdd (0 ms)
982[ RUN      ] MathTest.TestDiv
983test.cpp:10: Failure
984Expected: 4
985  Actual: 3
986[  FAILED  ] MathTest.TestDiv (0 ms)
987[  PASSED  ] 1 test.
988[  FAILED  ] 1 test, listed below:
989[  FAILED  ] MathTest.TestDiv
990
99167% tests passed, 1 tests failed out of 1
992
993Total Test time (real) =   0.01 sec
994"#;
995        let adapter = CppAdapter::new();
996        let result = adapter.parse_output(stdout, "", 1);
997
998        // GTest output should be preferred
999        assert_eq!(result.suites.len(), 1);
1000        assert_eq!(result.suites[0].name, "MathTest");
1001        assert_eq!(result.total_tests(), 2);
1002        assert_eq!(result.total_passed(), 1);
1003        assert_eq!(result.total_failed(), 1);
1004    }
1005}