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::{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    /// Detect test framework: rspec or minitest
27    fn detect_framework(project_dir: &Path) -> Option<&'static str> {
28        // RSpec
29        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        // Check Gemfile for test framework
37        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            // Has Gemfile but no specific test framework detected
48            return Some("minitest"); // Ruby's default
49        }
50
51        // Rakefile with test task
52        let rakefile = project_dir.join("Rakefile");
53        if rakefile.exists() {
54            return Some("minitest");
55        }
56
57        // test/ directory exists with .rb files inside (not just any test/ dir)
58        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                // minitest
128                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        // RSpec uses -e for example filter
150        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        // Try verbose parsing first (--format documentation / --verbose)
157        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        // Enrich failed tests with error details from failure blocks
174        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
189/// Parse RSpec output.
190///
191/// Format:
192/// ```text
193/// ..F.*
194///
195/// Failures:
196///
197///   1) Calculator adds two numbers
198///      Failure/Error: expect(sum).to eq(5)
199///        expected: 5
200///             got: 4
201///
202/// Finished in 0.012 seconds (files took 0.1 seconds to load)
203/// 5 examples, 1 failure, 1 pending
204/// ```
205fn parse_rspec_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
206    let mut tests = Vec::new();
207
208    // Parse the summary line: "5 examples, 1 failure, 1 pending"
209    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
282/// Parse Minitest output.
283///
284/// Format:
285/// ```text
286/// Run options: --seed 12345
287///
288/// # Running:
289///
290/// ..F.
291///
292/// Finished in 0.001234s, 3000.0 runs/s, 3000.0 assertions/s.
293///
294/// 4 runs, 4 assertions, 1 failures, 0 errors, 0 skips
295/// ```
296fn 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        // "4 runs, 4 assertions, 1 failures, 0 errors, 0 skips"
302        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        // RSpec: "Finished in 0.012 seconds"
378        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        // Minitest: "Finished in 0.001234s,"
393        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
411// ─── Verbose RSpec Parser (--format documentation) ──────────────────────────
412
413/// Parse RSpec verbose/documentation format output.
414///
415/// ```text
416/// User authentication
417///   with valid credentials
418///     allows login (0.02s)
419///   with invalid credentials
420///     shows error message (0.01s)
421///     increments attempt counter (FAILED - 1)
422/// ```
423fn 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        // Skip empty lines and non-test lines
433        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        // Detect indentation level
444        let indent = line.len() - line.trim_start().len();
445
446        // Test result line: ends with time or FAILED or PENDING
447        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            // Context/describe line: adjust context stack based on indent
472            let level = indent / 2;
473            while current_context.len() > level {
474                current_context.pop();
475            }
476
477            // If we're at top level and have tests, store the previous suite
478            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    // Store remaining tests
500    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
514/// Check if a line looks like an RSpec test result.
515fn is_rspec_test_line(line: &str) -> bool {
516    // Matches patterns like:
517    //   "allows login (0.02s)"
518    //   "shows error message (FAILED - 1)"
519    //   "is pending (PENDING: Not yet implemented)"
520    line.contains("(FAILED")
521        || line.contains("(PENDING")
522        || (line.ends_with(')') && line.contains('(') && line.contains("s)"))
523}
524
525/// Parse a single RSpec test result line.
526fn 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    // Try to extract duration: "test name (0.02s)" or "test name (0.02 seconds)"
548    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
562/// Parse inline duration from "0.02s)" or "0.02 seconds)".
563fn 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
575// ─── Verbose Minitest Parser (--verbose) ────────────────────────────────────
576
577/// Parse Minitest verbose output.
578///
579/// ```text
580/// TestUser#test_name_returns_full_name = 0.01 s = .
581/// TestUser#test_email_validation = 0.00 s = F
582/// TestUser#test_age_is_positive = 0.00 s = S
583/// ```
584fn 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        // Format: "ClassName#test_name = TIME s = STATUS"
592        if let Some((class_test, rest)) = trimmed.split_once(" = ")
593            && let Some((class, test)) = class_test.split_once('#')
594        {
595            // Extract duration and status
596            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
619/// Parse "0.01 s = ." or "0.00 s = F" from minitest verbose.
620fn 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// ─── RSpec Failure Block Parser ─────────────────────────────────────────────
654
655/// A parsed failure from RSpec output.
656#[derive(Debug, Clone)]
657struct RspecFailure {
658    /// Full test name, e.g. "User authentication with invalid credentials increments attempt counter"
659    name: String,
660    /// Error message, e.g. "expected: 1\n     got: 0"
661    message: String,
662    /// Location, e.g. "./spec/auth_spec.rb:25:in `block (3 levels)'"
663    location: Option<String>,
664}
665
666/// Parse RSpec failure blocks.
667///
668/// ```text
669/// Failures:
670///
671///   1) User authentication with invalid credentials increments attempt counter
672///      Failure/Error: expect(counter).to eq(1)
673///
674///        expected: 1
675///             got: 0
676///
677///      # ./spec/auth_spec.rb:25:in `block (3 levels)'
678/// ```
679fn 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        // End of failures section
699        if trimmed.starts_with("Finished in") || trimmed.starts_with("Pending:") {
700            // Save last failure
701            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        // Numbered failure: "1) Description here"
712        if let Some(rest) = strip_failure_number(trimmed) {
713            // Save previous failure
714            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            // Location line: "# ./spec/file.rb:25"
729            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    // Save last failure if section ended without "Finished in"
743    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
754/// Strip failure number prefix like "1) ", "12) ", etc.
755fn 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// ─── Minitest Failure Block Parser ──────────────────────────────────────────
772
773/// A parsed failure from Minitest output.
774#[derive(Debug, Clone)]
775struct MinitestFailure {
776    /// Test name, e.g. "test_email_validation"
777    name: String,
778    /// Error message
779    message: String,
780    /// Location
781    location: Option<String>,
782}
783
784/// Parse Minitest failure blocks.
785///
786/// ```text
787///   1) Failure:
788/// TestUser#test_email_validation [test/user_test.rb:15]:
789/// Expected: true
790///   Actual: false
791///
792///   2) Error:
793/// TestCalc#test_divide [test/calc_test.rb:8]:
794/// ZeroDivisionError: divided by 0
795///     test/calc_test.rb:9:in `test_divide'
796/// ```
797fn 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        // Detect failure/error header
808        if (trimmed.ends_with("Failure:") || trimmed.ends_with("Error:"))
809            && trimmed.chars().next().is_some_and(|c| c.is_ascii_digit())
810        {
811            // Save previous
812            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            // Next line after "Failure:" should be "ClassName#test_name [location]:"
827            if trimmed.contains('#') && trimmed.contains('[') {
828                if let Some(bracket_idx) = trimmed.find('[') {
829                    let name_part = trimmed[..bracket_idx].trim();
830                    // Extract just the test method name
831                    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                    // Extract location from [path:line]
839                    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                // End of this failure block
853                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    // Save last
869    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
880// ─── Error Enrichment ───────────────────────────────────────────────────────
881
882/// Enrich test cases with error details from parsed failure blocks.
883fn 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                        // Try to find matching RSpec failure
897                        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                        // Try to find matching Minitest failure
907                        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    // ─── Verbose RSpec Tests ────────────────────────────────────────────
1060
1061    #[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    // ─── Verbose Minitest Tests ─────────────────────────────────────────
1129
1130    #[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    // ─── RSpec Failure Extraction Tests ──────────────────────────────────
1178
1179    #[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    // ─── Minitest Failure Extraction Tests ───────────────────────────────
1226
1227    #[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    // ─── Error Enrichment Tests ─────────────────────────────────────────
1261
1262    #[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    // ─── Integration Tests ──────────────────────────────────────────────
1354
1355    #[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        // Should have parsed verbose tests
1380        let all_tests: Vec<_> = result.suites.iter().flat_map(|s| &s.tests).collect();
1381        assert!(all_tests.len() >= 2);
1382
1383        // Failed test should have error details
1384        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        // A bare test/ directory without .rb files should NOT trigger Ruby detection
1425        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}