Skip to main content

affected_core/
runner.rs

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
17/// Configuration for creating a Runner.
18pub 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    /// Whether JSON output mode is enabled.
49    pub fn json(&self) -> bool {
50        self.json
51    }
52
53    /// Whether quiet mode is enabled.
54    pub fn quiet(&self) -> bool {
55        self.quiet
56    }
57
58    /// Convenience constructor for simple cases (backwards compatible).
59    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    /// Execute test commands and collect results.
71    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            // Create a simple work-stealing approach: chunk the commands
147            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    // When running in parallel or capturing output, pipe stdout/stderr
192    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                // Spawn a watchdog thread to kill the child if it exceeds the timeout
203                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                            // Process finished before timeout, nothing to do
209                        }
210                        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
211                            // Timeout expired, kill the process
212                            #[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; // suppress unused on non-unix
221                            }
222                        }
223                        Err(_) => {}
224                    }
225                });
226
227                let output = child.wait_with_output();
228                let _ = tx.send(()); // Signal watchdog that process is done
229                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                // No timeout, just wait
261                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
304/// Convert test results to JSON output format.
305pub 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
330/// Convert test results to JUnit XML format.
331pub 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; // used in testsuite attributes via failed count
373    xml
374}
375
376fn escape_xml(s: &str) -> String {
377    s.replace('&', "&amp;")
378        .replace('<', "&lt;")
379        .replace('>', "&gt;")
380        .replace('"', "&quot;")
381        .replace('\'', "&apos;")
382}
383
384/// Print a summary of test results.
385pub fn print_summary(results: &[TestResult]) {
386    print_summary_impl(results, false);
387}
388
389/// Print a summary, respecting quiet mode.
390pub 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}