ricecoder_execution/
test_runner.rs

1//! Test runner for executing tests and parsing results
2//!
3//! Provides test framework detection, test execution, and result parsing.
4//! Supports Rust (cargo test), TypeScript (npm test), and Python (pytest).
5
6use crate::error::{ExecutionError, ExecutionResult};
7use crate::models::{TestFailure, TestFramework, TestResults};
8use std::path::Path;
9use std::process::Command;
10use tracing::{debug, error, info};
11
12/// Test runner for executing tests with framework detection
13pub struct TestRunner {
14    /// Project root directory
15    project_root: std::path::PathBuf,
16}
17
18impl TestRunner {
19    /// Create a new test runner for the given project root
20    pub fn new(project_root: impl AsRef<Path>) -> Self {
21        Self {
22            project_root: project_root.as_ref().to_path_buf(),
23        }
24    }
25
26    /// Create a test runner for the current directory
27    pub fn current_dir() -> ExecutionResult<Self> {
28        let project_root = std::env::current_dir().map_err(|e| {
29            ExecutionError::ValidationError(format!("Failed to get current dir: {}", e))
30        })?;
31        Ok(Self { project_root })
32    }
33
34    /// Detect the test framework based on project structure
35    pub fn detect_framework(&self) -> ExecutionResult<TestFramework> {
36        debug!(project_root = ?self.project_root, "Detecting test framework");
37
38        // Check for Rust (Cargo.toml)
39        if self.project_root.join("Cargo.toml").exists() {
40            debug!("Detected Rust project");
41            return Ok(TestFramework::Rust);
42        }
43
44        // Check for TypeScript/Node.js (package.json)
45        if self.project_root.join("package.json").exists() {
46            debug!("Detected TypeScript/Node.js project");
47            return Ok(TestFramework::TypeScript);
48        }
49
50        // Check for Python (pytest.ini or setup.py)
51        if self.project_root.join("pytest.ini").exists()
52            || self.project_root.join("setup.py").exists()
53        {
54            debug!("Detected Python project");
55            return Ok(TestFramework::Python);
56        }
57
58        Err(ExecutionError::ValidationError(
59            "Could not detect test framework".to_string(),
60        ))
61    }
62
63    /// Run tests with optional pattern filtering
64    ///
65    /// # Arguments
66    /// * `pattern` - Optional test pattern to filter tests
67    ///
68    /// # Returns
69    /// Test results including pass/fail counts and failure details
70    pub fn run_tests(&self, pattern: Option<&str>) -> ExecutionResult<TestResults> {
71        let framework = self.detect_framework()?;
72        info!(framework = ?framework, pattern = ?pattern, "Running tests");
73
74        let (command, args) = self.build_test_command(&framework, pattern)?;
75
76        // Execute the test command
77        let output = Command::new(&command)
78            .args(&args)
79            .current_dir(&self.project_root)
80            .output()
81            .map_err(|e| {
82                ExecutionError::StepFailed(format!(
83                    "Failed to execute test command {}: {}",
84                    command, e
85                ))
86            })?;
87
88        // Parse test results
89        let test_output = String::from_utf8_lossy(&output.stdout);
90        let test_stderr = String::from_utf8_lossy(&output.stderr);
91
92        let mut results = TestResults {
93            passed: 0,
94            failed: 0,
95            skipped: 0,
96            failures: Vec::new(),
97            framework,
98        };
99
100        // Parse results based on framework
101        match framework {
102            TestFramework::Rust => {
103                self.parse_rust_output(&test_output, &test_stderr, &mut results)?;
104            }
105            TestFramework::TypeScript => {
106                self.parse_typescript_output(&test_output, &test_stderr, &mut results)?;
107            }
108            TestFramework::Python => {
109                self.parse_python_output(&test_output, &test_stderr, &mut results)?;
110            }
111            TestFramework::Other => {
112                debug!("Unknown test framework, skipping output parsing");
113            }
114        }
115
116        // Check if tests failed
117        if results.failed > 0 {
118            error!(failed = results.failed, "Tests failed");
119            return Err(ExecutionError::TestsFailed(results.failed));
120        }
121
122        info!(passed = results.passed, "Tests passed");
123        Ok(results)
124    }
125
126    /// Build test command for the detected framework
127    pub fn build_test_command(
128        &self,
129        framework: &TestFramework,
130        pattern: Option<&str>,
131    ) -> ExecutionResult<(String, Vec<String>)> {
132        match framework {
133            TestFramework::Rust => {
134                let mut args = vec![
135                    "test".to_string(),
136                    "--".to_string(),
137                    "--nocapture".to_string(),
138                ];
139                if let Some(p) = pattern {
140                    args.push(p.to_string());
141                }
142                Ok(("cargo".to_string(), args))
143            }
144            TestFramework::TypeScript => {
145                let mut args = vec!["test".to_string()];
146                if let Some(p) = pattern {
147                    args.push("--".to_string());
148                    args.push(p.to_string());
149                }
150                Ok(("npm".to_string(), args))
151            }
152            TestFramework::Python => {
153                let mut args = vec![];
154                if let Some(p) = pattern {
155                    args.push(p.to_string());
156                }
157                Ok(("pytest".to_string(), args))
158            }
159            TestFramework::Other => Err(ExecutionError::ValidationError(
160                "Cannot build test command for unknown framework".to_string(),
161            )),
162        }
163    }
164
165    /// Parse Rust test output
166    fn parse_rust_output(
167        &self,
168        stdout: &str,
169        _stderr: &str,
170        results: &mut TestResults,
171    ) -> ExecutionResult<()> {
172        debug!("Parsing Rust test output");
173
174        // Parse test results from cargo test output
175        // Format: "test result: ok. X passed; Y failed; Z ignored"
176        for line in stdout.lines() {
177            if line.contains("test result:") {
178                if line.contains("ok.") {
179                    // Extract counts from the line
180                    if let Some(passed_str) = line.split("passed;").next() {
181                        if let Some(num_str) = passed_str.split_whitespace().last() {
182                            if let Ok(num) = num_str.parse::<usize>() {
183                                results.passed = num;
184                            }
185                        }
186                    }
187                } else if line.contains("FAILED") {
188                    // Extract failure counts
189                    if let Some(failed_str) = line.split("failed;").next() {
190                        if let Some(num_str) = failed_str.split_whitespace().last() {
191                            if let Ok(num) = num_str.parse::<usize>() {
192                                results.failed = num;
193                            }
194                        }
195                    }
196                }
197            }
198
199            // Parse individual test failures
200            if line.contains("FAILED") && line.contains("::") {
201                let test_name = line.split("FAILED").nth(1).unwrap_or("").trim().to_string();
202                results.failures.push(TestFailure {
203                    name: test_name,
204                    message: "Test failed".to_string(),
205                    location: None,
206                });
207            }
208        }
209
210        Ok(())
211    }
212
213    /// Parse TypeScript test output
214    fn parse_typescript_output(
215        &self,
216        stdout: &str,
217        _stderr: &str,
218        results: &mut TestResults,
219    ) -> ExecutionResult<()> {
220        debug!("Parsing TypeScript test output");
221
222        // Parse Jest/npm test output
223        // Look for patterns like "Tests: X passed, Y failed"
224        for line in stdout.lines() {
225            if line.contains("passed") && line.contains("failed") {
226                // Try to extract pass/fail counts
227                if let Some(passed_part) = line.split("passed").next() {
228                    if let Some(num_str) = passed_part.split_whitespace().last() {
229                        if let Ok(num) = num_str.parse::<usize>() {
230                            results.passed = num;
231                        }
232                    }
233                }
234
235                if let Some(failed_part) = line.split("failed").next() {
236                    if let Some(num_str) = failed_part.split_whitespace().last() {
237                        if let Ok(num) = num_str.parse::<usize>() {
238                            results.failed = num;
239                        }
240                    }
241                }
242            }
243
244            // Parse individual test failures
245            if line.contains("✕") || line.contains("FAIL") {
246                let test_name = line.trim().to_string();
247                results.failures.push(TestFailure {
248                    name: test_name,
249                    message: "Test failed".to_string(),
250                    location: None,
251                });
252            }
253        }
254
255        Ok(())
256    }
257
258    /// Parse Python test output
259    fn parse_python_output(
260        &self,
261        stdout: &str,
262        _stderr: &str,
263        results: &mut TestResults,
264    ) -> ExecutionResult<()> {
265        debug!("Parsing Python test output");
266
267        // Parse pytest output
268        // Look for patterns like "X passed, Y failed"
269        for line in stdout.lines() {
270            if line.contains("passed") || line.contains("failed") {
271                // Try to extract pass/fail counts
272                if let Some(passed_part) = line.split("passed").next() {
273                    if let Some(num_str) = passed_part.split_whitespace().last() {
274                        if let Ok(num) = num_str.parse::<usize>() {
275                            results.passed = num;
276                        }
277                    }
278                }
279
280                if let Some(failed_part) = line.split("failed").next() {
281                    if let Some(num_str) = failed_part.split_whitespace().last() {
282                        if let Ok(num) = num_str.parse::<usize>() {
283                            results.failed = num;
284                        }
285                    }
286                }
287            }
288
289            // Parse individual test failures
290            if line.contains("FAILED") {
291                let test_name = line.trim().to_string();
292                results.failures.push(TestFailure {
293                    name: test_name,
294                    message: "Test failed".to_string(),
295                    location: None,
296                });
297            }
298        }
299
300        Ok(())
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use tempfile::TempDir;
308
309    #[test]
310    fn test_detect_rust_framework() {
311        let temp_dir = TempDir::new().unwrap();
312        std::fs::write(temp_dir.path().join("Cargo.toml"), "").unwrap();
313
314        let runner = TestRunner::new(temp_dir.path());
315        let framework = runner.detect_framework().unwrap();
316        assert_eq!(framework, TestFramework::Rust);
317    }
318
319    #[test]
320    fn test_detect_typescript_framework() {
321        let temp_dir = TempDir::new().unwrap();
322        std::fs::write(temp_dir.path().join("package.json"), "").unwrap();
323
324        let runner = TestRunner::new(temp_dir.path());
325        let framework = runner.detect_framework().unwrap();
326        assert_eq!(framework, TestFramework::TypeScript);
327    }
328
329    #[test]
330    fn test_detect_python_framework_pytest() {
331        let temp_dir = TempDir::new().unwrap();
332        std::fs::write(temp_dir.path().join("pytest.ini"), "").unwrap();
333
334        let runner = TestRunner::new(temp_dir.path());
335        let framework = runner.detect_framework().unwrap();
336        assert_eq!(framework, TestFramework::Python);
337    }
338
339    #[test]
340    fn test_detect_python_framework_setup() {
341        let temp_dir = TempDir::new().unwrap();
342        std::fs::write(temp_dir.path().join("setup.py"), "").unwrap();
343
344        let runner = TestRunner::new(temp_dir.path());
345        let framework = runner.detect_framework().unwrap();
346        assert_eq!(framework, TestFramework::Python);
347    }
348
349    #[test]
350    fn test_detect_no_framework() {
351        let temp_dir = TempDir::new().unwrap();
352        let runner = TestRunner::new(temp_dir.path());
353        let result = runner.detect_framework();
354        assert!(result.is_err());
355    }
356
357    #[test]
358    fn test_build_rust_test_command() {
359        let temp_dir = TempDir::new().unwrap();
360        let runner = TestRunner::new(temp_dir.path());
361
362        let (cmd, args) = runner
363            .build_test_command(&TestFramework::Rust, None)
364            .unwrap();
365        assert_eq!(cmd, "cargo");
366        assert!(args.contains(&"test".to_string()));
367    }
368
369    #[test]
370    fn test_build_rust_test_command_with_pattern() {
371        let temp_dir = TempDir::new().unwrap();
372        let runner = TestRunner::new(temp_dir.path());
373
374        let (cmd, args) = runner
375            .build_test_command(&TestFramework::Rust, Some("my_test"))
376            .unwrap();
377        assert_eq!(cmd, "cargo");
378        assert!(args.contains(&"my_test".to_string()));
379    }
380
381    #[test]
382    fn test_build_typescript_test_command() {
383        let temp_dir = TempDir::new().unwrap();
384        let runner = TestRunner::new(temp_dir.path());
385
386        let (cmd, args) = runner
387            .build_test_command(&TestFramework::TypeScript, None)
388            .unwrap();
389        assert_eq!(cmd, "npm");
390        assert!(args.contains(&"test".to_string()));
391    }
392
393    #[test]
394    fn test_build_python_test_command() {
395        let temp_dir = TempDir::new().unwrap();
396        let runner = TestRunner::new(temp_dir.path());
397
398        let (cmd, _args) = runner
399            .build_test_command(&TestFramework::Python, None)
400            .unwrap();
401        assert_eq!(cmd, "pytest");
402    }
403
404    #[test]
405    fn test_parse_rust_output() {
406        let temp_dir = TempDir::new().unwrap();
407        let runner = TestRunner::new(temp_dir.path());
408
409        let stdout = "test result: ok. 5 passed; 0 failed; 1 ignored";
410        let mut results = TestResults {
411            passed: 0,
412            failed: 0,
413            skipped: 0,
414            failures: Vec::new(),
415            framework: TestFramework::Rust,
416        };
417
418        runner.parse_rust_output(stdout, "", &mut results).unwrap();
419        assert_eq!(results.passed, 5);
420        assert_eq!(results.failed, 0);
421    }
422
423    #[test]
424    fn test_parse_typescript_output() {
425        let temp_dir = TempDir::new().unwrap();
426        let runner = TestRunner::new(temp_dir.path());
427
428        let stdout = "Tests: 3 passed, 0 failed";
429        let mut results = TestResults {
430            passed: 0,
431            failed: 0,
432            skipped: 0,
433            failures: Vec::new(),
434            framework: TestFramework::TypeScript,
435        };
436
437        runner
438            .parse_typescript_output(stdout, "", &mut results)
439            .unwrap();
440        assert_eq!(results.passed, 3);
441        assert_eq!(results.failed, 0);
442    }
443
444    #[test]
445    fn test_parse_python_output() {
446        let temp_dir = TempDir::new().unwrap();
447        let runner = TestRunner::new(temp_dir.path());
448
449        let stdout = "4 passed, 0 failed";
450        let mut results = TestResults {
451            passed: 0,
452            failed: 0,
453            skipped: 0,
454            failures: Vec::new(),
455            framework: TestFramework::Python,
456        };
457
458        runner
459            .parse_python_output(stdout, "", &mut results)
460            .unwrap();
461        assert_eq!(results.passed, 4);
462        assert_eq!(results.failed, 0);
463    }
464}