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
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/// Configuration for creating a Runner.
19#[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    /// Create a new RunnerConfig with the given settings.
31    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    /// Whether JSON output mode is enabled.
73    pub fn json(&self) -> bool {
74        self.json
75    }
76
77    /// Whether quiet mode is enabled.
78    pub fn quiet(&self) -> bool {
79        self.quiet
80    }
81
82    /// Convenience constructor for simple cases (backwards compatible).
83    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    /// Execute test commands and collect results.
95    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            // Create a simple work-stealing approach: chunk the commands
171            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    // When running in parallel or capturing output, pipe stdout/stderr
219    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                // Spawn a watchdog thread to kill the child if it exceeds the timeout
230                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                            // Process finished before timeout, nothing to do
236                        }
237                        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
238                            // Timeout expired, kill the process
239                            #[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; // suppress unused on non-unix
248                            }
249                        }
250                        Err(_) => {}
251                    }
252                });
253
254                let output = child.wait_with_output();
255                let _ = tx.send(()); // Signal watchdog that process is done
256                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                // No timeout, just wait
288                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
331/// Return an empty TestOutputJson (no packages affected).
332pub 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
345/// Convert test results to JSON output format.
346pub 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
371/// Convert test results to JUnit XML format.
372pub 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; // used in testsuite attributes via failed count
414    xml
415}
416
417fn escape_xml(s: &str) -> String {
418    s.replace('&', "&amp;")
419        .replace('<', "&lt;")
420        .replace('>', "&gt;")
421        .replace('"', "&quot;")
422        .replace('\'', "&apos;")
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); // dry-run always succeeds
475        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
588/// Print a summary of test results.
589pub fn print_summary(results: &[TestResult]) {
590    print_summary_impl(results, false);
591}
592
593/// Print a summary, respecting quiet mode.
594pub 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}