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 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 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 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 let failures = parse_ctest_failures(&combined);
122 if !failures.is_empty() {
123 enrich_with_errors(&mut suites, &failures);
124 }
125
126 let gtest_suites = parse_gtest_output(&combined);
128 if !gtest_suites.is_empty() {
129 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
143fn 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 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 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 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 let name = if let Some(idx) = line.find(": ") {
232 let after = &line[idx + 2..];
233 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 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 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 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 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#[derive(Debug, Clone)]
307struct CTestFailure {
308 test_name: String,
310 output: String,
312 error_line: Option<String>,
314}
315
316fn 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 if trimmed.contains("Test #")
338 && (trimmed.contains("***Failed") || trimmed.contains("***Exception"))
339 {
340 let test_name = extract_ctest_name(trimmed);
341
342 let mut output_lines: Vec<String> = Vec::new();
344 let mut error_line = None;
345 i += 1;
346
347 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 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 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
393fn 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
407fn 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
422fn 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
431fn 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
452fn 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 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
472fn 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
483fn extract_cpp_location(output: &str) -> Option<String> {
485 for line in output.lines() {
486 let trimmed = line.trim();
487 if let Some(loc) = extract_file_line_location(trimmed) {
490 return Some(loc);
491 }
492 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
503fn extract_file_line_location(line: &str) -> Option<String> {
505 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 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 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
531fn 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 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 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
636fn 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
648fn 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 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}