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, truncate};
8use super::{
9 ConfidenceScore, DetectionResult, TestAdapter, TestCase, TestError, TestRunResult, TestStatus,
10 TestSuite,
11};
12
13pub struct RubyAdapter;
14
15impl Default for RubyAdapter {
16 fn default() -> Self {
17 Self::new()
18 }
19}
20
21impl RubyAdapter {
22 pub fn new() -> Self {
23 Self
24 }
25
26 fn detect_framework(project_dir: &Path) -> Option<&'static str> {
28 if project_dir.join(".rspec").exists() {
30 return Some("rspec");
31 }
32 if project_dir.join("spec").is_dir() {
33 return Some("rspec");
34 }
35
36 let gemfile = project_dir.join("Gemfile");
38 if gemfile.exists() {
39 if let Ok(content) = std::fs::read_to_string(&gemfile) {
40 if content.contains("rspec") {
41 return Some("rspec");
42 }
43 if content.contains("minitest") {
44 return Some("minitest");
45 }
46 }
47 return Some("minitest"); }
50
51 let rakefile = project_dir.join("Rakefile");
53 if rakefile.exists() {
54 return Some("minitest");
55 }
56
57 if project_dir.join("test").is_dir()
59 && let Ok(entries) = std::fs::read_dir(project_dir.join("test"))
60 {
61 let has_ruby_files = entries
62 .filter_map(|e| e.ok())
63 .any(|e| e.path().extension().is_some_and(|ext| ext == "rb"));
64 if has_ruby_files {
65 return Some("minitest");
66 }
67 }
68
69 None
70 }
71
72 fn has_bundler(project_dir: &Path) -> bool {
73 project_dir.join("Gemfile").exists()
74 }
75}
76
77impl TestAdapter for RubyAdapter {
78 fn name(&self) -> &str {
79 "Ruby"
80 }
81
82 fn check_runner(&self) -> Option<String> {
83 if which::which("ruby").is_err() {
84 return Some("ruby not found. Install Ruby.".into());
85 }
86 None
87 }
88
89 fn detect(&self, project_dir: &Path) -> Option<DetectionResult> {
90 let framework = Self::detect_framework(project_dir)?;
91
92 let has_spec_or_test =
93 project_dir.join("spec").is_dir() || project_dir.join("test").is_dir();
94 let has_lock = project_dir.join("Gemfile.lock").exists();
95 let has_runner = which::which("ruby").is_ok();
96
97 let confidence = ConfidenceScore::base(0.50)
98 .signal(0.15, has_spec_or_test)
99 .signal(0.15, has_lock)
100 .signal(0.10, has_runner)
101 .finish();
102
103 Some(DetectionResult {
104 language: "Ruby".into(),
105 framework: framework.into(),
106 confidence,
107 })
108 }
109
110 fn build_command(&self, project_dir: &Path, extra_args: &[String]) -> Result<Command> {
111 let framework = Self::detect_framework(project_dir).unwrap_or("rspec");
112 let use_bundler = Self::has_bundler(project_dir);
113
114 let mut cmd;
115
116 match framework {
117 "rspec" => {
118 if use_bundler {
119 cmd = Command::new("bundle");
120 cmd.arg("exec");
121 cmd.arg("rspec");
122 } else {
123 cmd = Command::new("rspec");
124 }
125 }
126 _ => {
127 if use_bundler {
129 cmd = Command::new("bundle");
130 cmd.arg("exec");
131 cmd.arg("rake");
132 cmd.arg("test");
133 } else {
134 cmd = Command::new("rake");
135 cmd.arg("test");
136 }
137 }
138 }
139
140 for arg in extra_args {
141 cmd.arg(arg);
142 }
143
144 cmd.current_dir(project_dir);
145 Ok(cmd)
146 }
147
148 fn filter_args(&self, pattern: &str) -> Vec<String> {
149 vec!["-e".to_string(), pattern.to_string()]
151 }
152
153 fn parse_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> TestRunResult {
154 let combined = combined_output(stdout, stderr);
155
156 let suites = if combined.contains("example") || combined.contains("Example") {
158 let verbose = parse_rspec_verbose(&combined);
159 if verbose.iter().any(|s| !s.tests.is_empty()) {
160 verbose
161 } else {
162 parse_rspec_output(&combined, exit_code)
163 }
164 } else {
165 let verbose = parse_minitest_verbose(&combined);
166 if verbose.iter().any(|s| !s.tests.is_empty()) {
167 verbose
168 } else {
169 parse_minitest_output(&combined, exit_code)
170 }
171 };
172
173 let failures = parse_rspec_failures(&combined);
175 let minitest_failures = parse_minitest_failures(&combined);
176
177 let suites = enrich_with_errors(suites, &failures, &minitest_failures);
178
179 let duration = parse_ruby_duration(&combined).unwrap_or(Duration::from_secs(0));
180
181 TestRunResult {
182 suites,
183 duration,
184 raw_exit_code: exit_code,
185 }
186 }
187}
188
189fn parse_rspec_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
206 let mut tests = Vec::new();
207
208 for line in output.lines() {
210 let trimmed = line.trim();
211 if trimmed.contains("example")
212 && (trimmed.contains("failure") || trimmed.contains("pending"))
213 {
214 let parts: Vec<&str> = trimmed.split(',').collect();
215 let mut examples = 0usize;
216 let mut failures = 0usize;
217 let mut pending = 0usize;
218
219 for part in &parts {
220 let part = part.trim();
221 let words: Vec<&str> = part.split_whitespace().collect();
222 if words.len() >= 2 {
223 let count: usize = words[0].parse().unwrap_or(0);
224 if words[1].starts_with("example") {
225 examples = count;
226 } else if words[1].starts_with("failure") {
227 failures = count;
228 } else if words[1].starts_with("pending") {
229 pending = count;
230 }
231 }
232 }
233
234 let passed = examples.saturating_sub(failures + pending);
235 for i in 0..passed {
236 tests.push(TestCase {
237 name: format!("example_{}", i + 1),
238 status: TestStatus::Passed,
239 duration: Duration::from_millis(0),
240 error: None,
241 });
242 }
243 for i in 0..failures {
244 tests.push(TestCase {
245 name: format!("failed_example_{}", i + 1),
246 status: TestStatus::Failed,
247 duration: Duration::from_millis(0),
248 error: None,
249 });
250 }
251 for i in 0..pending {
252 tests.push(TestCase {
253 name: format!("pending_example_{}", i + 1),
254 status: TestStatus::Skipped,
255 duration: Duration::from_millis(0),
256 error: None,
257 });
258 }
259 break;
260 }
261 }
262
263 if tests.is_empty() {
264 tests.push(TestCase {
265 name: "test_suite".into(),
266 status: if exit_code == 0 {
267 TestStatus::Passed
268 } else {
269 TestStatus::Failed
270 },
271 duration: Duration::from_millis(0),
272 error: None,
273 });
274 }
275
276 vec![TestSuite {
277 name: "spec".into(),
278 tests,
279 }]
280}
281
282fn parse_minitest_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
297 let mut tests = Vec::new();
298
299 for line in output.lines() {
300 let trimmed = line.trim();
301 if trimmed.contains("runs,") && trimmed.contains("assertions,") {
303 let mut runs = 0usize;
304 let mut failures = 0usize;
305 let mut errors = 0usize;
306 let mut skips = 0usize;
307
308 for part in trimmed.split(',') {
309 let part = part.trim();
310 let words: Vec<&str> = part.split_whitespace().collect();
311 if words.len() >= 2 {
312 let count: usize = words[0].parse().unwrap_or(0);
313 if words[1].starts_with("run") {
314 runs = count;
315 } else if words[1].starts_with("failure") {
316 failures = count;
317 } else if words[1].starts_with("error") {
318 errors = count;
319 } else if words[1].starts_with("skip") {
320 skips = count;
321 }
322 }
323 }
324
325 let failed = failures + errors;
326 let passed = runs.saturating_sub(failed + skips);
327
328 for i in 0..passed {
329 tests.push(TestCase {
330 name: format!("test_{}", i + 1),
331 status: TestStatus::Passed,
332 duration: Duration::from_millis(0),
333 error: None,
334 });
335 }
336 for i in 0..failed {
337 tests.push(TestCase {
338 name: format!("failed_test_{}", i + 1),
339 status: TestStatus::Failed,
340 duration: Duration::from_millis(0),
341 error: None,
342 });
343 }
344 for i in 0..skips {
345 tests.push(TestCase {
346 name: format!("skipped_test_{}", i + 1),
347 status: TestStatus::Skipped,
348 duration: Duration::from_millis(0),
349 error: None,
350 });
351 }
352 break;
353 }
354 }
355
356 if tests.is_empty() {
357 tests.push(TestCase {
358 name: "test_suite".into(),
359 status: if exit_code == 0 {
360 TestStatus::Passed
361 } else {
362 TestStatus::Failed
363 },
364 duration: Duration::from_millis(0),
365 error: None,
366 });
367 }
368
369 vec![TestSuite {
370 name: "tests".into(),
371 tests,
372 }]
373}
374
375fn parse_ruby_duration(output: &str) -> Option<Duration> {
376 for line in output.lines() {
377 if line.contains("Finished in")
379 && line.contains("second")
380 && let Some(idx) = line.find("Finished in")
381 {
382 let after = &line[idx + 12..];
383 let num_str: String = after
384 .trim()
385 .chars()
386 .take_while(|c| c.is_ascii_digit() || *c == '.')
387 .collect();
388 if let Ok(secs) = num_str.parse::<f64>() {
389 return Some(duration_from_secs_safe(secs));
390 }
391 }
392 if line.contains("Finished in")
394 && line.contains("runs/s")
395 && let Some(idx) = line.find("Finished in")
396 {
397 let after = &line[idx + 12..];
398 let num_str: String = after
399 .trim()
400 .chars()
401 .take_while(|c| c.is_ascii_digit() || *c == '.')
402 .collect();
403 if let Ok(secs) = num_str.parse::<f64>() {
404 return Some(duration_from_secs_safe(secs));
405 }
406 }
407 }
408 None
409}
410
411fn parse_rspec_verbose(output: &str) -> Vec<TestSuite> {
424 let mut suites: Vec<TestSuite> = Vec::new();
425 let mut current_context: Vec<String> = Vec::new();
426 let mut current_tests: Vec<TestCase> = Vec::new();
427 let mut current_suite_name = String::new();
428
429 for line in output.lines() {
430 let trimmed = line.trim();
431
432 if trimmed.is_empty()
434 || trimmed.starts_with("Finished in")
435 || trimmed.starts_with("Failures:")
436 || trimmed.starts_with("Pending:")
437 || trimmed.contains("example")
438 && (trimmed.contains("failure") || trimmed.contains("pending"))
439 {
440 continue;
441 }
442
443 let indent = line.len() - line.trim_start().len();
445
446 if is_rspec_test_line(trimmed) {
448 let (name, status, duration) = parse_rspec_test_line(trimmed);
449
450 let full_name = if current_context.is_empty() {
451 name.clone()
452 } else {
453 format!("{} {}", current_context.join(" "), name)
454 };
455
456 current_tests.push(TestCase {
457 name: full_name,
458 status,
459 duration,
460 error: None,
461 });
462 } else if !trimmed.starts_with('#')
463 && !trimmed.starts_with("1)")
464 && !trimmed.starts_with("2)")
465 && !trimmed.starts_with("3)")
466 && !trimmed.contains("Failure/Error")
467 && !trimmed.contains("expected:")
468 && !trimmed.contains("got:")
469 && !trimmed.starts_with("./")
470 {
471 let level = indent / 2;
473 while current_context.len() > level {
474 current_context.pop();
475 }
476
477 if level == 0 && !current_tests.is_empty() {
479 suites.push(TestSuite {
480 name: if current_suite_name.is_empty() {
481 "spec".to_string()
482 } else {
483 current_suite_name.clone()
484 },
485 tests: std::mem::take(&mut current_tests),
486 });
487 }
488
489 if level == 0 {
490 current_suite_name = trimmed.to_string();
491 }
492
493 if current_context.len() == level {
494 current_context.push(trimmed.to_string());
495 }
496 }
497 }
498
499 if !current_tests.is_empty() {
501 suites.push(TestSuite {
502 name: if current_suite_name.is_empty() {
503 "spec".to_string()
504 } else {
505 current_suite_name
506 },
507 tests: current_tests,
508 });
509 }
510
511 suites
512}
513
514fn is_rspec_test_line(line: &str) -> bool {
516 line.contains("(FAILED")
521 || line.contains("(PENDING")
522 || (line.ends_with(')') && line.contains('(') && line.contains("s)"))
523}
524
525fn parse_rspec_test_line(line: &str) -> (String, TestStatus, Duration) {
527 if line.contains("(FAILED") {
528 let name = line
529 .split("(FAILED")
530 .next()
531 .unwrap_or(line)
532 .trim()
533 .to_string();
534 return (name, TestStatus::Failed, Duration::from_millis(0));
535 }
536
537 if line.contains("(PENDING") {
538 let name = line
539 .split("(PENDING")
540 .next()
541 .unwrap_or(line)
542 .trim()
543 .to_string();
544 return (name, TestStatus::Skipped, Duration::from_millis(0));
545 }
546
547 if let Some(paren_idx) = line.rfind('(') {
549 let name = line[..paren_idx].trim().to_string();
550 let time_part = &line[paren_idx + 1..];
551 let duration = parse_rspec_inline_duration(time_part);
552 return (name, TestStatus::Passed, duration);
553 }
554
555 (
556 line.trim().to_string(),
557 TestStatus::Passed,
558 Duration::from_millis(0),
559 )
560}
561
562fn parse_rspec_inline_duration(s: &str) -> Duration {
564 let num_str: String = s
565 .chars()
566 .take_while(|c| c.is_ascii_digit() || *c == '.')
567 .collect();
568 if let Ok(secs) = num_str.parse::<f64>() {
569 duration_from_secs_safe(secs)
570 } else {
571 Duration::from_millis(0)
572 }
573}
574
575fn parse_minitest_verbose(output: &str) -> Vec<TestSuite> {
585 let mut suites_map: std::collections::HashMap<String, Vec<TestCase>> =
586 std::collections::HashMap::new();
587
588 for line in output.lines() {
589 let trimmed = line.trim();
590
591 if let Some((class_test, rest)) = trimmed.split_once(" = ")
593 && let Some((class, test)) = class_test.split_once('#')
594 {
595 let (duration, status) = parse_minitest_verbose_result(rest);
597
598 suites_map
599 .entry(class.to_string())
600 .or_default()
601 .push(TestCase {
602 name: test.to_string(),
603 status,
604 duration,
605 error: None,
606 });
607 }
608 }
609
610 let mut suites: Vec<TestSuite> = suites_map
611 .into_iter()
612 .map(|(name, tests)| TestSuite { name, tests })
613 .collect();
614 suites.sort_by(|a, b| a.name.cmp(&b.name));
615
616 suites
617}
618
619fn parse_minitest_verbose_result(s: &str) -> (Duration, TestStatus) {
621 let parts: Vec<&str> = s.split('=').collect();
622
623 let duration = if let Some(time_part) = parts.first() {
624 let num_str: String = time_part
625 .trim()
626 .chars()
627 .take_while(|c| c.is_ascii_digit() || *c == '.')
628 .collect();
629 num_str
630 .parse::<f64>()
631 .map(duration_from_secs_safe)
632 .unwrap_or(Duration::from_millis(0))
633 } else {
634 Duration::from_millis(0)
635 };
636
637 let status = if let Some(status_part) = parts.get(1) {
638 let status_char = status_part.trim();
639 match status_char {
640 "." => TestStatus::Passed,
641 "F" => TestStatus::Failed,
642 "E" => TestStatus::Failed,
643 "S" => TestStatus::Skipped,
644 _ => TestStatus::Passed,
645 }
646 } else {
647 TestStatus::Passed
648 };
649
650 (duration, status)
651}
652
653#[derive(Debug, Clone)]
657struct RspecFailure {
658 name: String,
660 message: String,
662 location: Option<String>,
664}
665
666fn parse_rspec_failures(output: &str) -> Vec<RspecFailure> {
680 let mut failures = Vec::new();
681 let mut in_failures_section = false;
682 let mut current_name: Option<String> = None;
683 let mut current_message = Vec::new();
684 let mut current_location: Option<String> = None;
685
686 for line in output.lines() {
687 let trimmed = line.trim();
688
689 if trimmed == "Failures:" {
690 in_failures_section = true;
691 continue;
692 }
693
694 if !in_failures_section {
695 continue;
696 }
697
698 if trimmed.starts_with("Finished in") || trimmed.starts_with("Pending:") {
700 if let Some(name) = current_name.take() {
702 failures.push(RspecFailure {
703 name,
704 message: current_message.join("\n").trim().to_string(),
705 location: current_location.take(),
706 });
707 }
708 break;
709 }
710
711 if let Some(rest) = strip_failure_number(trimmed) {
713 if let Some(name) = current_name.take() {
715 failures.push(RspecFailure {
716 name,
717 message: current_message.join("\n").trim().to_string(),
718 location: current_location.take(),
719 });
720 }
721 current_name = Some(rest.to_string());
722 current_message.clear();
723 current_location = None;
724 continue;
725 }
726
727 if current_name.is_some() {
728 if trimmed.starts_with("# ./") || trimmed.starts_with("# /") {
730 current_location = Some(trimmed.trim_start_matches("# ").to_string());
731 } else if trimmed.starts_with("Failure/Error:") {
732 let msg = trimmed.strip_prefix("Failure/Error:").unwrap_or("").trim();
733 if !msg.is_empty() {
734 current_message.push(msg.to_string());
735 }
736 } else if !trimmed.is_empty() {
737 current_message.push(trimmed.to_string());
738 }
739 }
740 }
741
742 if let Some(name) = current_name {
744 failures.push(RspecFailure {
745 name,
746 message: current_message.join("\n").trim().to_string(),
747 location: current_location,
748 });
749 }
750
751 failures
752}
753
754fn strip_failure_number(s: &str) -> Option<&str> {
756 let mut chars = s.chars();
757 let first = chars.next()?;
758 if !first.is_ascii_digit() {
759 return None;
760 }
761 let rest: String = chars.collect();
762 if let Some(idx) = rest.find(") ") {
763 let before = &rest[..idx];
764 if before.chars().all(|c| c.is_ascii_digit()) {
765 return Some(s[idx + 2 + 1..].trim_start());
766 }
767 }
768 None
769}
770
771#[derive(Debug, Clone)]
775struct MinitestFailure {
776 name: String,
778 message: String,
780 location: Option<String>,
782}
783
784fn parse_minitest_failures(output: &str) -> Vec<MinitestFailure> {
798 let mut failures = Vec::new();
799 let mut in_failure = false;
800 let mut current_name: Option<String> = None;
801 let mut current_message = Vec::new();
802 let mut current_location: Option<String> = None;
803
804 for line in output.lines() {
805 let trimmed = line.trim();
806
807 if (trimmed.ends_with("Failure:") || trimmed.ends_with("Error:"))
809 && trimmed.chars().next().is_some_and(|c| c.is_ascii_digit())
810 {
811 if let Some(name) = current_name.take() {
813 failures.push(MinitestFailure {
814 name,
815 message: current_message.join("\n").trim().to_string(),
816 location: current_location.take(),
817 });
818 }
819 in_failure = true;
820 current_message.clear();
821 current_location = None;
822 continue;
823 }
824
825 if in_failure && current_name.is_none() {
826 if trimmed.contains('#') && trimmed.contains('[') {
828 if let Some(bracket_idx) = trimmed.find('[') {
829 let name_part = trimmed[..bracket_idx].trim();
830 let test_name = if let Some(hash_idx) = name_part.find('#') {
832 &name_part[hash_idx + 1..]
833 } else {
834 name_part
835 };
836 current_name = Some(test_name.to_string());
837
838 if let Some(close_bracket) = trimmed.find(']') {
840 let loc = &trimmed[bracket_idx + 1..close_bracket];
841 current_location = Some(loc.to_string());
842 }
843 }
844 } else if !trimmed.is_empty() {
845 current_name = Some(trimmed.to_string());
846 }
847 continue;
848 }
849
850 if in_failure && current_name.is_some() {
851 if trimmed.is_empty() {
852 if let Some(name) = current_name.take() {
854 failures.push(MinitestFailure {
855 name,
856 message: current_message.join("\n").trim().to_string(),
857 location: current_location.take(),
858 });
859 }
860 in_failure = false;
861 current_message.clear();
862 } else {
863 current_message.push(trimmed.to_string());
864 }
865 }
866 }
867
868 if let Some(name) = current_name {
870 failures.push(MinitestFailure {
871 name,
872 message: current_message.join("\n").trim().to_string(),
873 location: current_location,
874 });
875 }
876
877 failures
878}
879
880fn enrich_with_errors(
884 suites: Vec<TestSuite>,
885 rspec_failures: &[RspecFailure],
886 minitest_failures: &[MinitestFailure],
887) -> Vec<TestSuite> {
888 suites
889 .into_iter()
890 .map(|suite| {
891 let tests = suite
892 .tests
893 .into_iter()
894 .map(|mut test| {
895 if test.status == TestStatus::Failed && test.error.is_none() {
896 if let Some(failure) = rspec_failures
898 .iter()
899 .find(|f| f.name.contains(&test.name) || test.name.contains(&f.name))
900 {
901 test.error = Some(TestError {
902 message: truncate(&failure.message, 500),
903 location: failure.location.clone(),
904 });
905 }
906 else if let Some(failure) = minitest_failures
908 .iter()
909 .find(|f| f.name == test.name || test.name.contains(&f.name))
910 {
911 test.error = Some(TestError {
912 message: truncate(&failure.message, 500),
913 location: failure.location.clone(),
914 });
915 }
916 }
917 test
918 })
919 .collect();
920 TestSuite {
921 name: suite.name,
922 tests,
923 }
924 })
925 .collect()
926}
927
928#[cfg(test)]
929mod tests {
930 use super::*;
931
932 #[test]
933 fn detect_rspec_project() {
934 let dir = tempfile::tempdir().unwrap();
935 std::fs::write(dir.path().join(".rspec"), "--format documentation\n").unwrap();
936 let adapter = RubyAdapter::new();
937 let det = adapter.detect(dir.path()).unwrap();
938 assert_eq!(det.language, "Ruby");
939 assert_eq!(det.framework, "rspec");
940 }
941
942 #[test]
943 fn detect_rspec_via_gemfile() {
944 let dir = tempfile::tempdir().unwrap();
945 std::fs::write(
946 dir.path().join("Gemfile"),
947 "source 'https://rubygems.org'\ngem 'rspec'\n",
948 )
949 .unwrap();
950 let adapter = RubyAdapter::new();
951 let det = adapter.detect(dir.path()).unwrap();
952 assert_eq!(det.framework, "rspec");
953 }
954
955 #[test]
956 fn detect_minitest_via_gemfile() {
957 let dir = tempfile::tempdir().unwrap();
958 std::fs::write(
959 dir.path().join("Gemfile"),
960 "source 'https://rubygems.org'\ngem 'minitest'\n",
961 )
962 .unwrap();
963 let adapter = RubyAdapter::new();
964 let det = adapter.detect(dir.path()).unwrap();
965 assert_eq!(det.framework, "minitest");
966 }
967
968 #[test]
969 fn detect_no_ruby() {
970 let dir = tempfile::tempdir().unwrap();
971 let adapter = RubyAdapter::new();
972 assert!(adapter.detect(dir.path()).is_none());
973 }
974
975 #[test]
976 fn parse_rspec_output_test() {
977 let stdout = r#"
978..F.*
979
980Failures:
981
982 1) Calculator adds two numbers
983 Failure/Error: expect(sum).to eq(5)
984 expected: 5
985 got: 4
986
987Finished in 0.012 seconds (files took 0.1 seconds to load)
9885 examples, 1 failure, 1 pending
989"#;
990 let adapter = RubyAdapter::new();
991 let result = adapter.parse_output(stdout, "", 1);
992
993 assert_eq!(result.total_tests(), 5);
994 assert_eq!(result.total_passed(), 3);
995 assert_eq!(result.total_failed(), 1);
996 assert_eq!(result.total_skipped(), 1);
997 }
998
999 #[test]
1000 fn parse_rspec_all_pass() {
1001 let stdout = "Finished in 0.005 seconds\n3 examples, 0 failures\n";
1002 let adapter = RubyAdapter::new();
1003 let result = adapter.parse_output(stdout, "", 0);
1004
1005 assert_eq!(result.total_tests(), 3);
1006 assert_eq!(result.total_passed(), 3);
1007 assert!(result.is_success());
1008 }
1009
1010 #[test]
1011 fn parse_minitest_output_test() {
1012 let stdout = r#"
1013Run options: --seed 12345
1014
1015# Running:
1016
1017..F.
1018
1019Finished in 0.001234s, 3000.0 runs/s, 3000.0 assertions/s.
1020
10214 runs, 4 assertions, 1 failures, 0 errors, 0 skips
1022"#;
1023 let adapter = RubyAdapter::new();
1024 let result = adapter.parse_output(stdout, "", 1);
1025
1026 assert_eq!(result.total_tests(), 4);
1027 assert_eq!(result.total_passed(), 3);
1028 assert_eq!(result.total_failed(), 1);
1029 }
1030
1031 #[test]
1032 fn parse_minitest_all_pass() {
1033 let stdout = "4 runs, 4 assertions, 0 failures, 0 errors, 0 skips\n";
1034 let adapter = RubyAdapter::new();
1035 let result = adapter.parse_output(stdout, "", 0);
1036
1037 assert_eq!(result.total_tests(), 4);
1038 assert_eq!(result.total_passed(), 4);
1039 assert!(result.is_success());
1040 }
1041
1042 #[test]
1043 fn parse_ruby_empty_output() {
1044 let adapter = RubyAdapter::new();
1045 let result = adapter.parse_output("", "", 0);
1046
1047 assert_eq!(result.total_tests(), 1);
1048 assert!(result.is_success());
1049 }
1050
1051 #[test]
1052 fn parse_rspec_duration_test() {
1053 assert_eq!(
1054 parse_ruby_duration("Finished in 0.012 seconds (files took 0.1 seconds to load)"),
1055 Some(Duration::from_millis(12))
1056 );
1057 }
1058
1059 #[test]
1062 fn parse_rspec_verbose_documentation_format() {
1063 let output = r#"
1064User authentication
1065 with valid credentials
1066 allows login (0.02s)
1067 redirects to dashboard (0.01s)
1068 with invalid credentials
1069 shows error message (0.01s)
1070 increments attempt counter (FAILED - 1)
1071
1072Finished in 0.04 seconds
10734 examples, 1 failure
1074"#;
1075 let suites = parse_rspec_verbose(output);
1076 assert!(!suites.is_empty());
1077
1078 let all_tests: Vec<_> = suites.iter().flat_map(|s| &s.tests).collect();
1079 assert!(all_tests.len() >= 4);
1080
1081 let failed: Vec<_> = all_tests
1082 .iter()
1083 .filter(|t| t.status == TestStatus::Failed)
1084 .collect();
1085 assert_eq!(failed.len(), 1);
1086 assert!(failed[0].name.contains("increments attempt counter"));
1087 }
1088
1089 #[test]
1090 fn parse_rspec_verbose_with_pending() {
1091 let output = r#"
1092Calculator
1093 adds numbers (0.01s)
1094 subtracts (PENDING: Not yet implemented)
1095 multiplies (0.00s)
1096"#;
1097 let suites = parse_rspec_verbose(output);
1098 let all_tests: Vec<_> = suites.iter().flat_map(|s| &s.tests).collect();
1099
1100 let pending: Vec<_> = all_tests
1101 .iter()
1102 .filter(|t| t.status == TestStatus::Skipped)
1103 .collect();
1104 assert_eq!(pending.len(), 1);
1105 }
1106
1107 #[test]
1108 fn parse_rspec_inline_duration_parsing() {
1109 assert_eq!(
1110 parse_rspec_inline_duration("0.02s)"),
1111 Duration::from_millis(20)
1112 );
1113 assert_eq!(
1114 parse_rspec_inline_duration("1.5 seconds)"),
1115 Duration::from_millis(1500)
1116 );
1117 }
1118
1119 #[test]
1120 fn is_rspec_test_line_detection() {
1121 assert!(is_rspec_test_line("allows login (0.02s)"));
1122 assert!(is_rspec_test_line("fails (FAILED - 1)"));
1123 assert!(is_rspec_test_line("is pending (PENDING: reason)"));
1124 assert!(!is_rspec_test_line("User authentication"));
1125 assert!(!is_rspec_test_line("with valid credentials"));
1126 }
1127
1128 #[test]
1131 fn parse_minitest_verbose_output() {
1132 let output = r#"
1133TestUser#test_name_returns_full_name = 0.01 s = .
1134TestUser#test_email_validation = 0.00 s = F
1135TestUser#test_age_is_positive = 0.00 s = S
1136TestCalc#test_add = 0.01 s = .
1137TestCalc#test_divide = 0.00 s = E
1138"#;
1139 let suites = parse_minitest_verbose(output);
1140 assert_eq!(suites.len(), 2);
1141
1142 let user_suite = suites.iter().find(|s| s.name == "TestUser").unwrap();
1143 assert_eq!(user_suite.tests.len(), 3);
1144 assert_eq!(user_suite.tests[0].status, TestStatus::Passed);
1145 assert_eq!(user_suite.tests[1].status, TestStatus::Failed);
1146 assert_eq!(user_suite.tests[2].status, TestStatus::Skipped);
1147
1148 let calc_suite = suites.iter().find(|s| s.name == "TestCalc").unwrap();
1149 assert_eq!(calc_suite.tests.len(), 2);
1150 }
1151
1152 #[test]
1153 fn parse_minitest_verbose_result_dot() {
1154 let (dur, status) = parse_minitest_verbose_result("0.01 s = .");
1155 assert_eq!(status, TestStatus::Passed);
1156 assert!(dur.as_millis() >= 10);
1157 }
1158
1159 #[test]
1160 fn parse_minitest_verbose_result_fail() {
1161 let (_, status) = parse_minitest_verbose_result("0.00 s = F");
1162 assert_eq!(status, TestStatus::Failed);
1163 }
1164
1165 #[test]
1166 fn parse_minitest_verbose_result_error() {
1167 let (_, status) = parse_minitest_verbose_result("0.00 s = E");
1168 assert_eq!(status, TestStatus::Failed);
1169 }
1170
1171 #[test]
1172 fn parse_minitest_verbose_result_skip() {
1173 let (_, status) = parse_minitest_verbose_result("0.00 s = S");
1174 assert_eq!(status, TestStatus::Skipped);
1175 }
1176
1177 #[test]
1180 fn parse_rspec_failure_blocks() {
1181 let output = r#"
1182Failures:
1183
1184 1) Calculator adds two numbers
1185 Failure/Error: expect(sum).to eq(5)
1186
1187 expected: 5
1188 got: 4
1189
1190 # ./spec/calculator_spec.rb:25:in `block (3 levels)'
1191
1192 2) User validates email
1193 Failure/Error: expect(user).to be_valid
1194
1195 expected valid? to return true, got false
1196
1197 # ./spec/user_spec.rb:12:in `block (2 levels)'
1198
1199Finished in 0.05 seconds
1200"#;
1201 let failures = parse_rspec_failures(output);
1202 assert_eq!(failures.len(), 2);
1203
1204 assert_eq!(failures[0].name, "Calculator adds two numbers");
1205 assert!(failures[0].message.contains("expected: 5"));
1206 assert!(
1207 failures[0]
1208 .location
1209 .as_ref()
1210 .unwrap()
1211 .contains("calculator_spec.rb:25")
1212 );
1213
1214 assert_eq!(failures[1].name, "User validates email");
1215 assert!(failures[1].message.contains("expected valid?"));
1216 }
1217
1218 #[test]
1219 fn parse_rspec_failures_empty() {
1220 let output = "Finished in 0.01 seconds\n3 examples, 0 failures\n";
1221 let failures = parse_rspec_failures(output);
1222 assert!(failures.is_empty());
1223 }
1224
1225 #[test]
1228 fn parse_minitest_failure_blocks() {
1229 let output = r#"
1230 1) Failure:
1231TestUser#test_email_validation [test/user_test.rb:15]:
1232Expected: true
1233 Actual: false
1234
1235 2) Error:
1236TestCalc#test_divide [test/calc_test.rb:8]:
1237ZeroDivisionError: divided by 0
1238"#;
1239 let failures = parse_minitest_failures(output);
1240 assert_eq!(failures.len(), 2);
1241
1242 assert_eq!(failures[0].name, "test_email_validation");
1243 assert!(failures[0].message.contains("Expected: true"));
1244 assert_eq!(
1245 failures[0].location.as_ref().unwrap(),
1246 "test/user_test.rb:15"
1247 );
1248
1249 assert_eq!(failures[1].name, "test_divide");
1250 assert!(failures[1].message.contains("ZeroDivisionError"));
1251 }
1252
1253 #[test]
1254 fn parse_minitest_failures_empty() {
1255 let output = "4 runs, 4 assertions, 0 failures, 0 errors, 0 skips\n";
1256 let failures = parse_minitest_failures(output);
1257 assert!(failures.is_empty());
1258 }
1259
1260 #[test]
1263 fn enrich_tests_with_rspec_errors() {
1264 let suites = vec![TestSuite {
1265 name: "spec".into(),
1266 tests: vec![
1267 TestCase {
1268 name: "adds two numbers".into(),
1269 status: TestStatus::Failed,
1270 duration: Duration::from_millis(0),
1271 error: None,
1272 },
1273 TestCase {
1274 name: "subtracts".into(),
1275 status: TestStatus::Passed,
1276 duration: Duration::from_millis(10),
1277 error: None,
1278 },
1279 ],
1280 }];
1281
1282 let rspec_failures = vec![RspecFailure {
1283 name: "Calculator adds two numbers".to_string(),
1284 message: "expected: 5\n got: 4".to_string(),
1285 location: Some("./spec/calc_spec.rb:10".to_string()),
1286 }];
1287
1288 let enriched = enrich_with_errors(suites, &rspec_failures, &[]);
1289 let failed = &enriched[0].tests[0];
1290 assert!(failed.error.is_some());
1291 let err = failed.error.as_ref().unwrap();
1292 assert!(err.message.contains("expected: 5"));
1293 assert!(err.location.as_ref().unwrap().contains("calc_spec.rb"));
1294 }
1295
1296 #[test]
1297 fn enrich_tests_with_minitest_errors() {
1298 let suites = vec![TestSuite {
1299 name: "tests".into(),
1300 tests: vec![TestCase {
1301 name: "test_email_validation".into(),
1302 status: TestStatus::Failed,
1303 duration: Duration::from_millis(0),
1304 error: None,
1305 }],
1306 }];
1307
1308 let minitest_failures = vec![MinitestFailure {
1309 name: "test_email_validation".to_string(),
1310 message: "Expected: true\n Actual: false".to_string(),
1311 location: Some("test/user_test.rb:15".to_string()),
1312 }];
1313
1314 let enriched = enrich_with_errors(suites, &[], &minitest_failures);
1315 let failed = &enriched[0].tests[0];
1316 assert!(failed.error.is_some());
1317 }
1318
1319 #[test]
1320 fn truncate_short() {
1321 assert_eq!(truncate("hello", 10), "hello");
1322 }
1323
1324 #[test]
1325 fn truncate_long() {
1326 let long = "a".repeat(600);
1327 let result = truncate(&long, 500);
1328 assert_eq!(result.len(), 500);
1329 assert!(result.ends_with("..."));
1330 }
1331
1332 #[test]
1333 fn strip_failure_number_valid() {
1334 assert_eq!(
1335 strip_failure_number("1) Calculator adds two numbers"),
1336 Some("Calculator adds two numbers")
1337 );
1338 }
1339
1340 #[test]
1341 fn strip_failure_number_double_digit() {
1342 assert_eq!(
1343 strip_failure_number("12) Some test name"),
1344 Some("Some test name")
1345 );
1346 }
1347
1348 #[test]
1349 fn strip_failure_number_invalid() {
1350 assert_eq!(strip_failure_number("not a number"), None);
1351 }
1352
1353 #[test]
1356 fn full_rspec_verbose_with_failures() {
1357 let stdout = r#"
1358User authentication
1359 with valid credentials
1360 allows login (0.02s)
1361 with invalid credentials
1362 shows error message (FAILED - 1)
1363
1364Failures:
1365
1366 1) User authentication with invalid credentials shows error message
1367 Failure/Error: expect(page).to have_content("Error")
1368
1369 expected to find text "Error" in "Welcome"
1370
1371 # ./spec/auth_spec.rb:25:in `block (3 levels)'
1372
1373Finished in 0.03 seconds (files took 0.1 seconds to load)
13742 examples, 1 failure
1375"#;
1376 let adapter = RubyAdapter::new();
1377 let result = adapter.parse_output(stdout, "", 1);
1378
1379 let all_tests: Vec<_> = result.suites.iter().flat_map(|s| &s.tests).collect();
1381 assert!(all_tests.len() >= 2);
1382
1383 let failed: Vec<_> = all_tests
1385 .iter()
1386 .filter(|t| t.status == TestStatus::Failed)
1387 .collect();
1388 assert!(!failed.is_empty());
1389 }
1390
1391 #[test]
1392 fn full_minitest_verbose_with_failures() {
1393 let stdout = r#"
1394TestUser#test_name_returns_full_name = 0.01 s = .
1395TestUser#test_email_validation = 0.00 s = F
1396
1397 1) Failure:
1398TestUser#test_email_validation [test/user_test.rb:15]:
1399Expected: true
1400 Actual: false
1401
14024 runs, 4 assertions, 1 failures, 0 errors, 0 skips
1403"#;
1404 let adapter = RubyAdapter::new();
1405 let result = adapter.parse_output(stdout, "", 1);
1406
1407 let all_tests: Vec<_> = result.suites.iter().flat_map(|s| &s.tests).collect();
1408 assert!(!all_tests.is_empty());
1409 }
1410
1411 #[test]
1412 fn detect_minitest_via_test_dir() {
1413 let dir = tempfile::tempdir().unwrap();
1414 let test_dir = dir.path().join("test");
1415 std::fs::create_dir(&test_dir).unwrap();
1416 std::fs::write(test_dir.join("test_example.rb"), "# test").unwrap();
1417 let adapter = RubyAdapter::new();
1418 let det = adapter.detect(dir.path()).unwrap();
1419 assert_eq!(det.framework, "minitest");
1420 }
1421
1422 #[test]
1423 fn detect_no_ruby_from_bare_test_dir() {
1424 let dir = tempfile::tempdir().unwrap();
1426 std::fs::create_dir(dir.path().join("test")).unwrap();
1427 let adapter = RubyAdapter::new();
1428 assert!(adapter.detect(dir.path()).is_none());
1429 }
1430
1431 #[test]
1432 fn detect_minitest_via_rakefile() {
1433 let dir = tempfile::tempdir().unwrap();
1434 std::fs::write(dir.path().join("Rakefile"), "require 'rake/testtask'\n").unwrap();
1435 let adapter = RubyAdapter::new();
1436 let det = adapter.detect(dir.path()).unwrap();
1437 assert_eq!(det.framework, "minitest");
1438 }
1439
1440 #[test]
1441 fn detect_rspec_via_spec_dir() {
1442 let dir = tempfile::tempdir().unwrap();
1443 std::fs::create_dir(dir.path().join("spec")).unwrap();
1444 let adapter = RubyAdapter::new();
1445 let det = adapter.detect(dir.path()).unwrap();
1446 assert_eq!(det.framework, "rspec");
1447 }
1448}