1use crate::error::{ExecutionError, ExecutionResult};
7use crate::models::{TestFailure, TestFramework, TestResults};
8use std::path::Path;
9use std::process::Command;
10use tracing::{debug, error, info};
11
12pub struct TestRunner {
14 project_root: std::path::PathBuf,
16}
17
18impl TestRunner {
19 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 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 pub fn detect_framework(&self) -> ExecutionResult<TestFramework> {
36 debug!(project_root = ?self.project_root, "Detecting test framework");
37
38 if self.project_root.join("Cargo.toml").exists() {
40 debug!("Detected Rust project");
41 return Ok(TestFramework::Rust);
42 }
43
44 if self.project_root.join("package.json").exists() {
46 debug!("Detected TypeScript/Node.js project");
47 return Ok(TestFramework::TypeScript);
48 }
49
50 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 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 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 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 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 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 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 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 for line in stdout.lines() {
177 if line.contains("test result:") {
178 if line.contains("ok.") {
179 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 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 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 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 for line in stdout.lines() {
225 if line.contains("passed") && line.contains("failed") {
226 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 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 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 for line in stdout.lines() {
270 if line.contains("passed") || line.contains("failed") {
271 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 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}