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