1use anyhow::Result;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::sync::Mutex;
5use std::time::{Duration, Instant};
6
7use crate::types::{PackageId, TestOutputJson, TestResultJson, TestSummaryJson};
8
9#[non_exhaustive]
10pub struct TestResult {
11 pub package_id: PackageId,
12 pub success: bool,
13 pub exit_code: Option<i32>,
14 pub duration: Duration,
15 pub output: Option<String>,
16}
17
18#[non_exhaustive]
20pub struct RunnerConfig {
21 pub root: PathBuf,
22 pub dry_run: bool,
23 pub timeout: Option<Duration>,
24 pub jobs: usize,
25 pub json: bool,
26 pub quiet: bool,
27}
28
29impl RunnerConfig {
30 pub fn new(
32 root: PathBuf,
33 dry_run: bool,
34 timeout: Option<Duration>,
35 jobs: usize,
36 json: bool,
37 quiet: bool,
38 ) -> Self {
39 Self {
40 root,
41 dry_run,
42 timeout,
43 jobs,
44 json,
45 quiet,
46 }
47 }
48}
49
50#[non_exhaustive]
51pub struct Runner {
52 root: PathBuf,
53 dry_run: bool,
54 timeout: Option<Duration>,
55 jobs: usize,
56 json: bool,
57 quiet: bool,
58}
59
60impl Runner {
61 pub fn new(config: RunnerConfig) -> Self {
62 Self {
63 root: config.root,
64 dry_run: config.dry_run,
65 timeout: config.timeout,
66 jobs: if config.jobs == 0 { 1 } else { config.jobs },
67 json: config.json,
68 quiet: config.quiet,
69 }
70 }
71
72 pub fn json(&self) -> bool {
74 self.json
75 }
76
77 pub fn quiet(&self) -> bool {
79 self.quiet
80 }
81
82 pub fn new_simple(root: &Path, dry_run: bool) -> Self {
84 Self {
85 root: root.to_path_buf(),
86 dry_run,
87 timeout: None,
88 jobs: 1,
89 json: false,
90 quiet: false,
91 }
92 }
93
94 pub fn run_tests(&self, commands: Vec<(PackageId, Vec<String>)>) -> Result<Vec<TestResult>> {
96 if self.jobs > 1 {
97 self.run_tests_parallel(commands)
98 } else {
99 self.run_tests_sequential(commands)
100 }
101 }
102
103 fn run_tests_sequential(
104 &self,
105 commands: Vec<(PackageId, Vec<String>)>,
106 ) -> Result<Vec<TestResult>> {
107 let mut results = Vec::new();
108
109 for (pkg_id, args) in commands {
110 if args.is_empty() {
111 continue;
112 }
113
114 let cmd_str = args.join(" ");
115
116 if self.dry_run {
117 if !self.quiet {
118 println!(" [dry-run] {}: {}", pkg_id, cmd_str);
119 }
120 results.push(TestResult {
121 package_id: pkg_id,
122 success: true,
123 exit_code: Some(0),
124 duration: Duration::ZERO,
125 output: None,
126 });
127 continue;
128 }
129
130 if !self.quiet {
131 println!(" Testing {}...", pkg_id);
132 }
133
134 let result = self.run_single_test(&pkg_id, &args);
135 results.push(result);
136 }
137
138 Ok(results)
139 }
140
141 fn run_tests_parallel(
142 &self,
143 commands: Vec<(PackageId, Vec<String>)>,
144 ) -> Result<Vec<TestResult>> {
145 let results = Mutex::new(Vec::new());
146 let commands: Vec<_> = commands
147 .into_iter()
148 .filter(|(_, args)| !args.is_empty())
149 .collect();
150
151 if self.dry_run {
152 let mut out = Vec::new();
153 for (pkg_id, args) in &commands {
154 if !self.quiet {
155 println!(" [dry-run] {}: {}", pkg_id, args.join(" "));
156 }
157 out.push(TestResult {
158 package_id: pkg_id.clone(),
159 success: true,
160 exit_code: Some(0),
161 duration: Duration::ZERO,
162 output: None,
163 });
164 }
165 return Ok(out);
166 }
167
168 let jobs = self.jobs;
169 std::thread::scope(|s| {
170 let chunks: Vec<Vec<(PackageId, Vec<String>)>> = {
172 let mut chunks: Vec<Vec<(PackageId, Vec<String>)>> =
173 (0..jobs).map(|_| Vec::new()).collect();
174 for (i, cmd) in commands.into_iter().enumerate() {
175 chunks[i % jobs].push(cmd);
176 }
177 chunks
178 };
179
180 for chunk in chunks {
181 let results_ref = &results;
182 let root = &self.root;
183 let timeout = self.timeout;
184 let quiet = self.quiet;
185 s.spawn(move || {
186 for (pkg_id, args) in chunk {
187 if !quiet {
188 println!(" Testing {}...", pkg_id);
189 }
190 let result = run_single_test_impl(root, timeout, &pkg_id, &args);
191 results_ref
192 .lock()
193 .unwrap_or_else(|e| e.into_inner())
194 .push(result);
195 }
196 });
197 }
198 });
199
200 let mut out = results.into_inner().unwrap_or_else(|e| e.into_inner());
201 out.sort_by(|a, b| a.package_id.0.cmp(&b.package_id.0));
202 Ok(out)
203 }
204
205 fn run_single_test(&self, pkg_id: &PackageId, args: &[String]) -> TestResult {
206 run_single_test_impl(&self.root, self.timeout, pkg_id, args)
207 }
208}
209
210fn run_single_test_impl(
211 root: &Path,
212 timeout: Option<Duration>,
213 pkg_id: &PackageId,
214 args: &[String],
215) -> TestResult {
216 let start = Instant::now();
217
218 let child_result = Command::new(&args[0])
220 .args(&args[1..])
221 .current_dir(root)
222 .stdout(Stdio::piped())
223 .stderr(Stdio::piped())
224 .spawn();
225
226 match child_result {
227 Ok(child) => {
228 if let Some(timeout_dur) = timeout {
229 let child_id = child.id();
231 let (tx, rx) = std::sync::mpsc::channel();
232 let watchdog = std::thread::spawn(move || {
233 match rx.recv_timeout(timeout_dur) {
234 Ok(()) => {
235 }
237 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
238 #[cfg(unix)]
240 {
241 unsafe {
242 libc::kill(child_id as i32, libc::SIGKILL);
243 }
244 }
245 #[cfg(not(unix))]
246 {
247 let _ = child_id; }
249 }
250 Err(_) => {}
251 }
252 });
253
254 let output = child.wait_with_output();
255 let _ = tx.send(()); let _ = watchdog.join();
257 let duration = start.elapsed();
258
259 match output {
260 Ok(out) => {
261 let captured = format!(
262 "{}{}",
263 String::from_utf8_lossy(&out.stdout),
264 String::from_utf8_lossy(&out.stderr)
265 );
266 let timed_out = duration >= timeout_dur;
267 TestResult {
268 package_id: pkg_id.clone(),
269 success: !timed_out && out.status.success(),
270 exit_code: out.status.code(),
271 duration,
272 output: Some(captured),
273 }
274 }
275 Err(e) => {
276 let duration = start.elapsed();
277 TestResult {
278 package_id: pkg_id.clone(),
279 success: false,
280 exit_code: None,
281 duration,
282 output: Some(format!("Failed to wait for process: {e}")),
283 }
284 }
285 }
286 } else {
287 let output = child.wait_with_output();
289 let duration = start.elapsed();
290
291 match output {
292 Ok(out) => {
293 let captured = format!(
294 "{}{}",
295 String::from_utf8_lossy(&out.stdout),
296 String::from_utf8_lossy(&out.stderr)
297 );
298 TestResult {
299 package_id: pkg_id.clone(),
300 success: out.status.success(),
301 exit_code: out.status.code(),
302 duration,
303 output: Some(captured),
304 }
305 }
306 Err(e) => TestResult {
307 package_id: pkg_id.clone(),
308 success: false,
309 exit_code: None,
310 duration,
311 output: Some(format!("Failed to wait for process: {e}")),
312 },
313 }
314 }
315 }
316 Err(e) => {
317 let cmd_str = args.join(" ");
318 let duration = start.elapsed();
319 eprintln!(" Failed to execute '{}': {}", cmd_str, e);
320 TestResult {
321 package_id: pkg_id.clone(),
322 success: false,
323 exit_code: None,
324 duration,
325 output: Some(format!("Failed to execute: {e}")),
326 }
327 }
328 }
329}
330
331pub fn empty_test_output() -> TestOutputJson {
333 TestOutputJson {
334 affected: vec![],
335 results: vec![],
336 summary: TestSummaryJson {
337 passed: 0,
338 failed: 0,
339 total: 0,
340 duration_ms: 0,
341 },
342 }
343}
344
345pub fn results_to_json(affected: &[String], results: &[TestResult]) -> TestOutputJson {
347 let total_duration: Duration = results.iter().map(|r| r.duration).sum();
348 let passed = results.iter().filter(|r| r.success).count();
349 let failed = results.len() - passed;
350
351 TestOutputJson {
352 affected: affected.to_vec(),
353 results: results
354 .iter()
355 .map(|r| TestResultJson {
356 package: r.package_id.0.clone(),
357 success: r.success,
358 duration_ms: r.duration.as_millis() as u64,
359 exit_code: r.exit_code,
360 })
361 .collect(),
362 summary: TestSummaryJson {
363 passed,
364 failed,
365 total: results.len(),
366 duration_ms: total_duration.as_millis() as u64,
367 },
368 }
369}
370
371pub fn results_to_junit(results: &[TestResult]) -> String {
373 let total_duration: Duration = results.iter().map(|r| r.duration).sum();
374 let passed = results.iter().filter(|r| r.success).count();
375 let failed = results.len() - passed;
376
377 let mut xml = String::new();
378 xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
379 xml.push_str(&format!(
380 "<testsuite name=\"affected\" tests=\"{}\" failures=\"{}\" time=\"{:.3}\">\n",
381 results.len(),
382 failed,
383 total_duration.as_secs_f64(),
384 ));
385
386 for r in results {
387 let time = r.duration.as_secs_f64();
388 xml.push_str(&format!(
389 " <testcase name=\"{}\" classname=\"affected\" time=\"{:.3}\"",
390 escape_xml(&r.package_id.0),
391 time,
392 ));
393
394 if r.success {
395 xml.push_str(" />\n");
396 } else {
397 xml.push_str(">\n");
398 let msg = match r.exit_code {
399 Some(code) => format!("Exit code: {}", code),
400 None => "Process failed to execute".to_string(),
401 };
402 xml.push_str(&format!(
403 " <failure message=\"{}\">{}</failure>\n",
404 escape_xml(&msg),
405 escape_xml(r.output.as_deref().unwrap_or("")),
406 ));
407 xml.push_str(" </testcase>\n");
408 }
409 }
410
411 xml.push_str("</testsuite>\n");
412
413 let _ = passed; xml
415}
416
417fn escape_xml(s: &str) -> String {
418 s.replace('&', "&")
419 .replace('<', "<")
420 .replace('>', ">")
421 .replace('"', """)
422 .replace('\'', "'")
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 fn make_runner(root: &Path, dry_run: bool, jobs: usize, timeout: Option<Duration>) -> Runner {
430 Runner::new(RunnerConfig {
431 root: root.to_path_buf(),
432 dry_run,
433 timeout,
434 jobs,
435 json: false,
436 quiet: true,
437 })
438 }
439
440 #[test]
441 fn test_sequential_execution() {
442 let dir = tempfile::tempdir().unwrap();
443 let runner = make_runner(dir.path(), false, 1, None);
444 let commands = vec![(
445 PackageId("pkg-a".into()),
446 vec!["echo".into(), "hello".into()],
447 )];
448 let results = runner.run_tests(commands).unwrap();
449 assert_eq!(results.len(), 1);
450 assert!(results[0].success);
451 }
452
453 #[test]
454 fn test_parallel_execution() {
455 let dir = tempfile::tempdir().unwrap();
456 let runner = make_runner(dir.path(), false, 2, None);
457 let commands = vec![
458 (PackageId("pkg-a".into()), vec!["echo".into(), "a".into()]),
459 (PackageId("pkg-b".into()), vec!["echo".into(), "b".into()]),
460 (PackageId("pkg-c".into()), vec!["echo".into(), "c".into()]),
461 ];
462 let results = runner.run_tests(commands).unwrap();
463 assert_eq!(results.len(), 3);
464 assert!(results.iter().all(|r| r.success));
465 }
466
467 #[test]
468 fn test_dry_run() {
469 let dir = tempfile::tempdir().unwrap();
470 let runner = make_runner(dir.path(), true, 1, None);
471 let commands = vec![(PackageId("pkg-a".into()), vec!["false".into()])];
472 let results = runner.run_tests(commands).unwrap();
473 assert_eq!(results.len(), 1);
474 assert!(results[0].success); assert_eq!(results[0].duration, Duration::ZERO);
476 }
477
478 #[test]
479 fn test_dry_run_parallel() {
480 let dir = tempfile::tempdir().unwrap();
481 let runner = make_runner(dir.path(), true, 2, None);
482 let commands = vec![
483 (PackageId("pkg-a".into()), vec!["false".into()]),
484 (PackageId("pkg-b".into()), vec!["false".into()]),
485 ];
486 let results = runner.run_tests(commands).unwrap();
487 assert_eq!(results.len(), 2);
488 assert!(results.iter().all(|r| r.success));
489 }
490
491 #[test]
492 fn test_timeout_enforcement() {
493 let dir = tempfile::tempdir().unwrap();
494 let runner = make_runner(dir.path(), false, 1, Some(Duration::from_secs(1)));
495 let commands = vec![(PackageId("slow".into()), vec!["sleep".into(), "60".into()])];
496 let results = runner.run_tests(commands).unwrap();
497 assert_eq!(results.len(), 1);
498 assert!(!results[0].success);
499 assert!(results[0].duration < Duration::from_secs(10));
500 }
501
502 #[test]
503 fn test_empty_commands() {
504 let dir = tempfile::tempdir().unwrap();
505 let runner = make_runner(dir.path(), false, 1, None);
506 let results = runner.run_tests(vec![]).unwrap();
507 assert!(results.is_empty());
508 }
509
510 #[test]
511 fn test_empty_args_skipped() {
512 let dir = tempfile::tempdir().unwrap();
513 let runner = make_runner(dir.path(), false, 1, None);
514 let commands = vec![(PackageId("empty".into()), vec![])];
515 let results = runner.run_tests(commands).unwrap();
516 assert!(results.is_empty());
517 }
518
519 #[test]
520 fn test_all_fail() {
521 let dir = tempfile::tempdir().unwrap();
522 let runner = make_runner(dir.path(), false, 1, None);
523 let commands = vec![
524 (PackageId("pkg-a".into()), vec!["false".into()]),
525 (PackageId("pkg-b".into()), vec!["false".into()]),
526 ];
527 let results = runner.run_tests(commands).unwrap();
528 assert_eq!(results.len(), 2);
529 assert!(results.iter().all(|r| !r.success));
530 }
531
532 #[test]
533 fn test_results_to_json_output() {
534 let results = vec![
535 TestResult {
536 package_id: PackageId("pkg-a".into()),
537 success: true,
538 exit_code: Some(0),
539 duration: Duration::from_millis(100),
540 output: None,
541 },
542 TestResult {
543 package_id: PackageId("pkg-b".into()),
544 success: false,
545 exit_code: Some(1),
546 duration: Duration::from_millis(200),
547 output: Some("error".into()),
548 },
549 ];
550 let json = results_to_json(&["pkg-a".into(), "pkg-b".into()], &results);
551 assert_eq!(json.summary.passed, 1);
552 assert_eq!(json.summary.failed, 1);
553 assert_eq!(json.summary.total, 2);
554 assert_eq!(json.results.len(), 2);
555 assert!(json.results[0].success);
556 assert!(!json.results[1].success);
557 }
558
559 #[test]
560 fn test_results_to_junit_output() {
561 let results = vec![
562 TestResult {
563 package_id: PackageId("pkg-ok".into()),
564 success: true,
565 exit_code: Some(0),
566 duration: Duration::from_millis(50),
567 output: None,
568 },
569 TestResult {
570 package_id: PackageId("pkg-fail".into()),
571 success: false,
572 exit_code: Some(1),
573 duration: Duration::from_millis(100),
574 output: Some("test failed".into()),
575 },
576 ];
577 let xml = results_to_junit(&results);
578 assert!(xml.contains("<?xml version=\"1.0\""));
579 assert!(xml.contains("tests=\"2\""));
580 assert!(xml.contains("failures=\"1\""));
581 assert!(xml.contains("name=\"pkg-ok\""));
582 assert!(xml.contains("name=\"pkg-fail\""));
583 assert!(xml.contains("<failure"));
584 assert!(xml.contains("test failed"));
585 }
586}
587
588pub fn print_summary(results: &[TestResult]) {
590 print_summary_impl(results, false);
591}
592
593pub fn print_summary_impl(results: &[TestResult], quiet: bool) {
595 if quiet {
596 return;
597 }
598
599 let total = results.len();
600 let passed = results.iter().filter(|r| r.success).count();
601 let failed = total - passed;
602 let total_duration: Duration = results.iter().map(|r| r.duration).sum();
603
604 println!();
605 println!(
606 " Results: {} passed, {} failed, {} total ({:.1}s)",
607 passed,
608 failed,
609 total,
610 total_duration.as_secs_f64()
611 );
612
613 if failed > 0 {
614 println!();
615 println!(" Failed:");
616 for r in results.iter().filter(|r| !r.success) {
617 println!(" - {}", r.package_id);
618 }
619 }
620}