cargo-test-changed 0.1.1

A Cargo subcommand to run tests for changed crates and their dependents.
use anyhow::Result;
use std::io::{Read, Write};
use std::process::Stdio;
use std::time::Instant;

use super::plan::TestPlan;
use super::result::{TestResult, TestResults};
use crate::error::AppError;
use crate::reporting::Reporter;
use crate::test_runner::TestRunner;

pub struct TestExecutor<'a> {
    test_plan: &'a TestPlan,
    runner: &'a dyn TestRunner,
    reporter: &'a mut dyn Reporter,
}

impl<'a> TestExecutor<'a> {
    pub fn new(
        plan: &'a TestPlan,
        runner: &'a dyn TestRunner,
        reporter: &'a mut dyn Reporter,
    ) -> Self {
        TestExecutor {
            test_plan: plan,
            runner,
            reporter,
        }
    }

    pub fn execute(&mut self) -> Result<TestResults, AppError> {
        let mut results = TestResults::new();
        let start_time = Instant::now();

        if !self.runner.is_installed() {
            return Err(AppError::TestRunnerNotInstalled {
                runner_name: self.runner.name().to_string(),
                installation_tip: self.runner.installation_instructions(),
            });
        }

        let crates_to_test = &self.test_plan.get_crates_to_test();
        for (index, test_crate) in crates_to_test.iter().enumerate() {
            let result = self.execute_single_test(test_crate, index + 1, crates_to_test.len())?;

            let should_stop = !result.success && self.test_plan.fail_fast;
            results.add_result(result);

            if should_stop {
                break;
            }
        }

        results.duration = start_time.elapsed();
        Ok(results)
    }

    fn execute_single_test(
        &mut self,
        crate_name: &str,
        test_number: usize,
        total_tests: usize,
    ) -> Result<TestResult, AppError> {
        self.reporter
            .test_start(crate_name, test_number, total_tests);

        let _ = std::io::stdout().flush();

        let crate_start = Instant::now();
        let mut cmd = self.runner.command(crate_name);
        cmd.args(&self.test_plan.test_runner_args);

        cmd.stdout(Stdio::piped());
        cmd.stderr(Stdio::piped());

        let mut child = cmd
            .current_dir(&self.test_plan.workspace_root)
            .spawn()
            .map_err(|e| AppError::CommandFailed {
                command: format!("{:?}", cmd),
                reason: e.to_string(),
            })?;

        let mut output_capture = Vec::new();

        let stdout = child.stdout.take();
        let stderr = child.stderr.take();

        if let (Some(stdout), Some(stderr)) = (stdout, stderr) {
            let mut merged_output = std::io::BufReader::new(stdout)
                .bytes()
                .map(|r| (r, false))
                .chain(std::io::BufReader::new(stderr).bytes().map(|r| (r, true)));

            if self.test_plan.verbose {
                for (byte_result, _is_stderr) in merged_output.by_ref() {
                    match byte_result {
                        Ok(byte) => {
                            std::io::stdout().write_all(&[byte]).map_err(|e| {
                                AppError::CommandFailed {
                                    command: format!("{:?}", cmd),
                                    reason: format!("Failed to write to stdout: {}", e),
                                }
                            })?;
                            let _ = std::io::stdout().flush();
                            output_capture.push(byte);
                        }
                        Err(e) => {
                            if e.kind() != std::io::ErrorKind::BrokenPipe {
                                return Err(AppError::CommandFailed {
                                    command: format!("{:?}", cmd),
                                    reason: format!("Failed to read output: {}", e),
                                });
                            }
                            break;
                        }
                    }
                }
            } else {
                for (byte_result, _is_stderr) in merged_output {
                    match byte_result {
                        Ok(byte) => {
                            output_capture.push(byte);
                        }
                        Err(e) => {
                            if e.kind() != std::io::ErrorKind::BrokenPipe {
                                return Err(AppError::CommandFailed {
                                    command: format!("{:?}", cmd),
                                    reason: format!("Failed to read output: {}", e),
                                });
                            }
                            break;
                        }
                    }
                }
            }
        }

        let status = child.wait().map_err(|e| AppError::CommandFailed {
            command: format!("{:?}", cmd),
            reason: e.to_string(),
        })?;

        let success = status.success();
        let duration = crate_start.elapsed();

        self.reporter
            .test_result(crate_name, success, duration.as_millis() as u64);

        Ok(TestResult {
            crate_name: crate_name.to_string(),
            success,
            output: String::from_utf8_lossy(&output_capture).into_owned(),
        })
    }
}