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