Skip to main content

testx/adapters/
ruby.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 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    /// Detect test framework: rspec or minitest
26    fn detect_framework(project_dir: &Path) -> Option<&'static str> {
27        // RSpec
28        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        // Check Gemfile for test framework
36        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            // Has Gemfile but no specific test framework detected
47            return Some("minitest"); // Ruby's default
48        }
49
50        // Rakefile with test task
51        let rakefile = project_dir.join("Rakefile");
52        if rakefile.exists() {
53            return Some("minitest");
54        }
55
56        // test/ directory exists with .rb files inside (not just any test/ dir)
57        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                // minitest
116                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        // Try verbose parsing first (--format documentation / --verbose)
140        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        // Enrich failed tests with error details from failure blocks
157        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
172/// Parse RSpec output.
173///
174/// Format:
175/// ```text
176/// ..F.*
177///
178/// Failures:
179///
180///   1) Calculator adds two numbers
181///      Failure/Error: expect(sum).to eq(5)
182///        expected: 5
183///             got: 4
184///
185/// Finished in 0.012 seconds (files took 0.1 seconds to load)
186/// 5 examples, 1 failure, 1 pending
187/// ```
188fn parse_rspec_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
189    let mut tests = Vec::new();
190
191    // Parse the summary line: "5 examples, 1 failure, 1 pending"
192    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
265/// Parse Minitest output.
266///
267/// Format:
268/// ```text
269/// Run options: --seed 12345
270///
271/// # Running:
272///
273/// ..F.
274///
275/// Finished in 0.001234s, 3000.0 runs/s, 3000.0 assertions/s.
276///
277/// 4 runs, 4 assertions, 1 failures, 0 errors, 0 skips
278/// ```
279fn 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        // "4 runs, 4 assertions, 1 failures, 0 errors, 0 skips"
285        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        // RSpec: "Finished in 0.012 seconds"
361        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        // Minitest: "Finished in 0.001234s,"
376        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
394// ─── Verbose RSpec Parser (--format documentation) ──────────────────────────
395
396/// Parse RSpec verbose/documentation format output.
397///
398/// ```text
399/// User authentication
400///   with valid credentials
401///     allows login (0.02s)
402///   with invalid credentials
403///     shows error message (0.01s)
404///     increments attempt counter (FAILED - 1)
405/// ```
406fn 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        // Skip empty lines and non-test lines
416        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        // Detect indentation level
427        let indent = line.len() - line.trim_start().len();
428
429        // Test result line: ends with time or FAILED or PENDING
430        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            // Context/describe line: adjust context stack based on indent
455            let level = indent / 2;
456            while current_context.len() > level {
457                current_context.pop();
458            }
459
460            // If we're at top level and have tests, store the previous suite
461            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    // Store remaining tests
483    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
497/// Check if a line looks like an RSpec test result.
498fn is_rspec_test_line(line: &str) -> bool {
499    // Matches patterns like:
500    //   "allows login (0.02s)"
501    //   "shows error message (FAILED - 1)"
502    //   "is pending (PENDING: Not yet implemented)"
503    line.contains("(FAILED")
504        || line.contains("(PENDING")
505        || (line.ends_with(')') && line.contains('(') && line.contains("s)"))
506}
507
508/// Parse a single RSpec test result line.
509fn 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    // Try to extract duration: "test name (0.02s)" or "test name (0.02 seconds)"
531    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
545/// Parse inline duration from "0.02s)" or "0.02 seconds)".
546fn 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
558// ─── Verbose Minitest Parser (--verbose) ────────────────────────────────────
559
560/// Parse Minitest verbose output.
561///
562/// ```text
563/// TestUser#test_name_returns_full_name = 0.01 s = .
564/// TestUser#test_email_validation = 0.00 s = F
565/// TestUser#test_age_is_positive = 0.00 s = S
566/// ```
567fn 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        // Format: "ClassName#test_name = TIME s = STATUS"
575        if let Some((class_test, rest)) = trimmed.split_once(" = ")
576            && let Some((class, test)) = class_test.split_once('#')
577        {
578            // Extract duration and status
579            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
602/// Parse "0.01 s = ." or "0.00 s = F" from minitest verbose.
603fn 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// ─── RSpec Failure Block Parser ─────────────────────────────────────────────
637
638/// A parsed failure from RSpec output.
639#[derive(Debug, Clone)]
640struct RspecFailure {
641    /// Full test name, e.g. "User authentication with invalid credentials increments attempt counter"
642    name: String,
643    /// Error message, e.g. "expected: 1\n     got: 0"
644    message: String,
645    /// Location, e.g. "./spec/auth_spec.rb:25:in `block (3 levels)'"
646    location: Option<String>,
647}
648
649/// Parse RSpec failure blocks.
650///
651/// ```text
652/// Failures:
653///
654///   1) User authentication with invalid credentials increments attempt counter
655///      Failure/Error: expect(counter).to eq(1)
656///
657///        expected: 1
658///             got: 0
659///
660///      # ./spec/auth_spec.rb:25:in `block (3 levels)'
661/// ```
662fn 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        // End of failures section
682        if trimmed.starts_with("Finished in") || trimmed.starts_with("Pending:") {
683            // Save last failure
684            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        // Numbered failure: "1) Description here"
695        if let Some(rest) = strip_failure_number(trimmed) {
696            // Save previous failure
697            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            // Location line: "# ./spec/file.rb:25"
712            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    // Save last failure if section ended without "Finished in"
726    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
737/// Strip failure number prefix like "1) ", "12) ", etc.
738fn 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// ─── Minitest Failure Block Parser ──────────────────────────────────────────
755
756/// A parsed failure from Minitest output.
757#[derive(Debug, Clone)]
758struct MinitestFailure {
759    /// Test name, e.g. "test_email_validation"
760    name: String,
761    /// Error message
762    message: String,
763    /// Location
764    location: Option<String>,
765}
766
767/// Parse Minitest failure blocks.
768///
769/// ```text
770///   1) Failure:
771/// TestUser#test_email_validation [test/user_test.rb:15]:
772/// Expected: true
773///   Actual: false
774///
775///   2) Error:
776/// TestCalc#test_divide [test/calc_test.rb:8]:
777/// ZeroDivisionError: divided by 0
778///     test/calc_test.rb:9:in `test_divide'
779/// ```
780fn 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        // Detect failure/error header
791        if (trimmed.ends_with("Failure:") || trimmed.ends_with("Error:"))
792            && trimmed.chars().next().is_some_and(|c| c.is_ascii_digit())
793        {
794            // Save previous
795            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            // Next line after "Failure:" should be "ClassName#test_name [location]:"
810            if trimmed.contains('#') && trimmed.contains('[') {
811                if let Some(bracket_idx) = trimmed.find('[') {
812                    let name_part = trimmed[..bracket_idx].trim();
813                    // Extract just the test method name
814                    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                    // Extract location from [path:line]
822                    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                // End of this failure block
836                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    // Save last
852    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
863// ─── Error Enrichment ───────────────────────────────────────────────────────
864
865/// Enrich test cases with error details from parsed failure blocks.
866fn 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                        // Try to find matching RSpec failure
880                        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                        // Try to find matching Minitest failure
890                        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
911/// Truncate a message to max_len characters.
912fn 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    // ─── Verbose RSpec Tests ────────────────────────────────────────────
1052
1053    #[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    // ─── Verbose Minitest Tests ─────────────────────────────────────────
1121
1122    #[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    // ─── RSpec Failure Extraction Tests ──────────────────────────────────
1170
1171    #[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    // ─── Minitest Failure Extraction Tests ───────────────────────────────
1218
1219    #[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    // ─── Error Enrichment Tests ─────────────────────────────────────────
1253
1254    #[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); // 500 + "..."
1321        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    // ─── Integration Tests ──────────────────────────────────────────────
1346
1347    #[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        // Should have parsed verbose tests
1372        let all_tests: Vec<_> = result.suites.iter().flat_map(|s| &s.tests).collect();
1373        assert!(all_tests.len() >= 2);
1374
1375        // Failed test should have error details
1376        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        // A bare test/ directory without .rb files should NOT trigger Ruby detection
1417        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}