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
9pub struct TestResult {
10 pub package_id: PackageId,
11 pub success: bool,
12 pub exit_code: Option<i32>,
13 pub duration: Duration,
14 pub output: Option<String>,
15}
16
17pub struct RunnerConfig {
19 pub root: PathBuf,
20 pub dry_run: bool,
21 pub timeout: Option<Duration>,
22 pub jobs: usize,
23 pub json: bool,
24 pub quiet: bool,
25}
26
27pub struct Runner {
28 root: PathBuf,
29 dry_run: bool,
30 timeout: Option<Duration>,
31 jobs: usize,
32 json: bool,
33 quiet: bool,
34}
35
36impl Runner {
37 pub fn new(config: RunnerConfig) -> Self {
38 Self {
39 root: config.root,
40 dry_run: config.dry_run,
41 timeout: config.timeout,
42 jobs: if config.jobs == 0 { 1 } else { config.jobs },
43 json: config.json,
44 quiet: config.quiet,
45 }
46 }
47
48 pub fn json(&self) -> bool {
50 self.json
51 }
52
53 pub fn quiet(&self) -> bool {
55 self.quiet
56 }
57
58 pub fn new_simple(root: &Path, dry_run: bool) -> Self {
60 Self {
61 root: root.to_path_buf(),
62 dry_run,
63 timeout: None,
64 jobs: 1,
65 json: false,
66 quiet: false,
67 }
68 }
69
70 pub fn run_tests(&self, commands: Vec<(PackageId, Vec<String>)>) -> Result<Vec<TestResult>> {
72 if self.jobs > 1 {
73 self.run_tests_parallel(commands)
74 } else {
75 self.run_tests_sequential(commands)
76 }
77 }
78
79 fn run_tests_sequential(
80 &self,
81 commands: Vec<(PackageId, Vec<String>)>,
82 ) -> Result<Vec<TestResult>> {
83 let mut results = Vec::new();
84
85 for (pkg_id, args) in commands {
86 if args.is_empty() {
87 continue;
88 }
89
90 let cmd_str = args.join(" ");
91
92 if self.dry_run {
93 if !self.quiet {
94 println!(" [dry-run] {}: {}", pkg_id, cmd_str);
95 }
96 results.push(TestResult {
97 package_id: pkg_id,
98 success: true,
99 exit_code: Some(0),
100 duration: Duration::ZERO,
101 output: None,
102 });
103 continue;
104 }
105
106 if !self.quiet {
107 println!(" Testing {}...", pkg_id);
108 }
109
110 let result = self.run_single_test(&pkg_id, &args);
111 results.push(result);
112 }
113
114 Ok(results)
115 }
116
117 fn run_tests_parallel(
118 &self,
119 commands: Vec<(PackageId, Vec<String>)>,
120 ) -> Result<Vec<TestResult>> {
121 let results = Mutex::new(Vec::new());
122 let commands: Vec<_> = commands
123 .into_iter()
124 .filter(|(_, args)| !args.is_empty())
125 .collect();
126
127 if self.dry_run {
128 let mut out = Vec::new();
129 for (pkg_id, args) in &commands {
130 if !self.quiet {
131 println!(" [dry-run] {}: {}", pkg_id, args.join(" "));
132 }
133 out.push(TestResult {
134 package_id: pkg_id.clone(),
135 success: true,
136 exit_code: Some(0),
137 duration: Duration::ZERO,
138 output: None,
139 });
140 }
141 return Ok(out);
142 }
143
144 let jobs = self.jobs;
145 std::thread::scope(|s| {
146 let chunks: Vec<Vec<(PackageId, Vec<String>)>> = {
148 let mut chunks: Vec<Vec<(PackageId, Vec<String>)>> =
149 (0..jobs).map(|_| Vec::new()).collect();
150 for (i, cmd) in commands.into_iter().enumerate() {
151 chunks[i % jobs].push(cmd);
152 }
153 chunks
154 };
155
156 for chunk in chunks {
157 let results_ref = &results;
158 let root = &self.root;
159 let timeout = self.timeout;
160 let quiet = self.quiet;
161 s.spawn(move || {
162 for (pkg_id, args) in chunk {
163 if !quiet {
164 println!(" Testing {}...", pkg_id);
165 }
166 let result = run_single_test_impl(root, timeout, &pkg_id, &args);
167 results_ref.lock().unwrap().push(result);
168 }
169 });
170 }
171 });
172
173 let mut out = results.into_inner().unwrap();
174 out.sort_by(|a, b| a.package_id.0.cmp(&b.package_id.0));
175 Ok(out)
176 }
177
178 fn run_single_test(&self, pkg_id: &PackageId, args: &[String]) -> TestResult {
179 run_single_test_impl(&self.root, self.timeout, pkg_id, args)
180 }
181}
182
183fn run_single_test_impl(
184 root: &Path,
185 timeout: Option<Duration>,
186 pkg_id: &PackageId,
187 args: &[String],
188) -> TestResult {
189 let start = Instant::now();
190
191 let child_result = Command::new(&args[0])
193 .args(&args[1..])
194 .current_dir(root)
195 .stdout(Stdio::piped())
196 .stderr(Stdio::piped())
197 .spawn();
198
199 match child_result {
200 Ok(child) => {
201 if let Some(timeout_dur) = timeout {
202 let child_id = child.id();
204 let (tx, rx) = std::sync::mpsc::channel();
205 let watchdog = std::thread::spawn(move || {
206 match rx.recv_timeout(timeout_dur) {
207 Ok(()) => {
208 }
210 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
211 #[cfg(unix)]
213 {
214 unsafe {
215 libc::kill(child_id as i32, libc::SIGKILL);
216 }
217 }
218 #[cfg(not(unix))]
219 {
220 let _ = child_id; }
222 }
223 Err(_) => {}
224 }
225 });
226
227 let output = child.wait_with_output();
228 let _ = tx.send(()); let _ = watchdog.join();
230 let duration = start.elapsed();
231
232 match output {
233 Ok(out) => {
234 let captured = format!(
235 "{}{}",
236 String::from_utf8_lossy(&out.stdout),
237 String::from_utf8_lossy(&out.stderr)
238 );
239 let timed_out = duration >= timeout_dur;
240 TestResult {
241 package_id: pkg_id.clone(),
242 success: !timed_out && out.status.success(),
243 exit_code: out.status.code(),
244 duration,
245 output: Some(captured),
246 }
247 }
248 Err(e) => {
249 let duration = start.elapsed();
250 TestResult {
251 package_id: pkg_id.clone(),
252 success: false,
253 exit_code: None,
254 duration,
255 output: Some(format!("Failed to wait for process: {e}")),
256 }
257 }
258 }
259 } else {
260 let output = child.wait_with_output();
262 let duration = start.elapsed();
263
264 match output {
265 Ok(out) => {
266 let captured = format!(
267 "{}{}",
268 String::from_utf8_lossy(&out.stdout),
269 String::from_utf8_lossy(&out.stderr)
270 );
271 TestResult {
272 package_id: pkg_id.clone(),
273 success: out.status.success(),
274 exit_code: out.status.code(),
275 duration,
276 output: Some(captured),
277 }
278 }
279 Err(e) => TestResult {
280 package_id: pkg_id.clone(),
281 success: false,
282 exit_code: None,
283 duration,
284 output: Some(format!("Failed to wait for process: {e}")),
285 },
286 }
287 }
288 }
289 Err(e) => {
290 let cmd_str = args.join(" ");
291 let duration = start.elapsed();
292 eprintln!(" Failed to execute '{}': {}", cmd_str, e);
293 TestResult {
294 package_id: pkg_id.clone(),
295 success: false,
296 exit_code: None,
297 duration,
298 output: Some(format!("Failed to execute: {e}")),
299 }
300 }
301 }
302}
303
304pub fn results_to_json(affected: &[String], results: &[TestResult]) -> TestOutputJson {
306 let total_duration: Duration = results.iter().map(|r| r.duration).sum();
307 let passed = results.iter().filter(|r| r.success).count();
308 let failed = results.len() - passed;
309
310 TestOutputJson {
311 affected: affected.to_vec(),
312 results: results
313 .iter()
314 .map(|r| TestResultJson {
315 package: r.package_id.0.clone(),
316 success: r.success,
317 duration_ms: r.duration.as_millis() as u64,
318 exit_code: r.exit_code,
319 })
320 .collect(),
321 summary: TestSummaryJson {
322 passed,
323 failed,
324 total: results.len(),
325 duration_ms: total_duration.as_millis() as u64,
326 },
327 }
328}
329
330pub fn results_to_junit(results: &[TestResult]) -> String {
332 let total_duration: Duration = results.iter().map(|r| r.duration).sum();
333 let passed = results.iter().filter(|r| r.success).count();
334 let failed = results.len() - passed;
335
336 let mut xml = String::new();
337 xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
338 xml.push_str(&format!(
339 "<testsuite name=\"affected\" tests=\"{}\" failures=\"{}\" time=\"{:.3}\">\n",
340 results.len(),
341 failed,
342 total_duration.as_secs_f64(),
343 ));
344
345 for r in results {
346 let time = r.duration.as_secs_f64();
347 xml.push_str(&format!(
348 " <testcase name=\"{}\" classname=\"affected\" time=\"{:.3}\"",
349 escape_xml(&r.package_id.0),
350 time,
351 ));
352
353 if r.success {
354 xml.push_str(" />\n");
355 } else {
356 xml.push_str(">\n");
357 let msg = match r.exit_code {
358 Some(code) => format!("Exit code: {}", code),
359 None => "Process failed to execute".to_string(),
360 };
361 xml.push_str(&format!(
362 " <failure message=\"{}\">{}</failure>\n",
363 escape_xml(&msg),
364 escape_xml(r.output.as_deref().unwrap_or("")),
365 ));
366 xml.push_str(" </testcase>\n");
367 }
368 }
369
370 xml.push_str("</testsuite>\n");
371
372 let _ = passed; xml
374}
375
376fn escape_xml(s: &str) -> String {
377 s.replace('&', "&")
378 .replace('<', "<")
379 .replace('>', ">")
380 .replace('"', """)
381 .replace('\'', "'")
382}
383
384pub fn print_summary(results: &[TestResult]) {
386 print_summary_impl(results, false);
387}
388
389pub fn print_summary_impl(results: &[TestResult], quiet: bool) {
391 if quiet {
392 return;
393 }
394
395 let total = results.len();
396 let passed = results.iter().filter(|r| r.success).count();
397 let failed = total - passed;
398 let total_duration: Duration = results.iter().map(|r| r.duration).sum();
399
400 println!();
401 println!(
402 " Results: {} passed, {} failed, {} total ({:.1}s)",
403 passed,
404 failed,
405 total,
406 total_duration.as_secs_f64()
407 );
408
409 if failed > 0 {
410 println!();
411 println!(" Failed:");
412 for r in results.iter().filter(|r| !r.success) {
413 println!(" - {}", r.package_id);
414 }
415 }
416}