only-engine 0.4.0

Execution planning and runtime engine for only tasks.
Documentation
use std::collections::HashMap;
use std::ffi::OsString;
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
use std::process::{Command, ExitStatus, Stdio};
use std::sync::mpsc::Sender;
use std::thread;

use crate::EngineError;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CommandStatus {
    Success,
    Failed(String),
}

impl CommandStatus {
    pub(crate) fn from_code(code: i32) -> Self {
        if code == 0 {
            Self::Success
        } else {
            Self::Failed(platform_exit_reason(code))
        }
    }

    pub(crate) fn failure_reason(&self) -> Option<&str> {
        match self {
            Self::Success => None,
            Self::Failed(reason) => Some(reason),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OutputStream {
    Stdout,
    Stderr,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OutputChunk {
    pub stream: OutputStream,
    pub text: String,
}

pub(crate) fn run_with_system_shell(
    program: &str,
    arg: &str,
    command: &str,
    working_dir: &Path,
    output: Sender<OutputChunk>,
) -> Result<CommandStatus, EngineError> {
    let mut process = Command::new(program);
    process
        .current_dir(working_dir)
        .arg(arg)
        .arg(command)
        .envs(build_command_env())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

    let mut child = process.spawn().map_err(|source| EngineError::Io {
        message: "failed to start shell command",
        path: program.to_string(),
        source,
    })?;

    let stdout = child.stdout.take().ok_or_else(|| {
        EngineError::Runtime(format!("failed to capture stdout for shell '{program}'"))
    })?;
    let stderr = child.stderr.take().ok_or_else(|| {
        EngineError::Runtime(format!("failed to capture stderr for shell '{program}'"))
    })?;

    let stdout_handle = spawn_output_reader(stdout, OutputStream::Stdout, output.clone());
    let stderr_handle = spawn_output_reader(stderr, OutputStream::Stderr, output);
    let status = child.wait().map_err(|source| EngineError::Io {
        message: "failed to wait for shell command",
        path: program.to_string(),
        source,
    })?;
    join_output_reader(stdout_handle)?;
    join_output_reader(stderr_handle)?;

    Ok(command_status(status))
}

pub(crate) fn run_with_system_shell_inherit(
    program: &str,
    arg: &str,
    command: &str,
    working_dir: &Path,
) -> Result<CommandStatus, EngineError> {
    let mut process = Command::new(program);
    process
        .current_dir(working_dir)
        .arg(arg)
        .arg(command)
        .envs(build_command_env())
        .stdin(Stdio::inherit())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit());

    let status = process.status().map_err(|source| EngineError::Io {
        message: "failed to run shell command",
        path: program.to_string(),
        source,
    })?;

    Ok(command_status(status))
}

pub(crate) fn build_command_env() -> HashMap<OsString, OsString> {
    let mut env_vars = std::env::vars_os().collect::<HashMap<_, _>>();
    env_vars
        .entry(OsString::from("INIT_CWD"))
        .or_insert_with(|| std::env::current_dir().unwrap_or_default().into_os_string());
    env_vars
}

pub(crate) fn spawn_output_reader<R>(
    reader: R,
    stream: OutputStream,
    output: Sender<OutputChunk>,
) -> thread::JoinHandle<Result<(), EngineError>>
where
    R: Read + Send + 'static,
{
    thread::spawn(move || {
        let mut reader = BufReader::new(reader);
        let mut buffer = Vec::new();

        loop {
            buffer.clear();
            let bytes_read = reader.read_until(b'\n', &mut buffer).map_err(|error| {
                EngineError::Runtime(format!("failed to read task output: {error}"))
            })?;
            if bytes_read == 0 {
                break;
            }

            output
                .send(OutputChunk {
                    stream,
                    text: String::from_utf8_lossy(&buffer).into_owned(),
                })
                .map_err(|_| EngineError::Runtime("failed to forward task output".to_string()))?;
        }

        Ok(())
    })
}

pub(crate) fn join_output_reader(
    handle: thread::JoinHandle<Result<(), EngineError>>,
) -> Result<(), EngineError> {
    match handle.join() {
        Ok(result) => result,
        Err(_) => Err(EngineError::Runtime(
            "task output reader thread panicked".to_string(),
        )),
    }
}

fn command_status(status: ExitStatus) -> CommandStatus {
    if let Some(code) = status.code() {
        return CommandStatus::from_code(code);
    }

    #[cfg(unix)]
    {
        use std::os::unix::process::ExitStatusExt;
        if let Some(signal) = status.signal() {
            return CommandStatus::Failed(format!("unix_signal({signal})"));
        }
    }

    CommandStatus::Failed("unknown_exit_status".to_string())
}

fn platform_exit_reason(code: i32) -> String {
    if cfg!(windows) {
        format!("windows_exit_code({code})")
    } else if cfg!(unix) {
        format!("unix_exit_code({code})")
    } else {
        format!("exit_code({code})")
    }
}

#[cfg(test)]
mod tests {
    use super::CommandStatus;

    #[test]
    fn formats_exit_status_cleanly() {
        let status = CommandStatus::from_code(1);
        let reason = status
            .failure_reason()
            .expect("a non-zero status should have a failure reason");
        let expected = if cfg!(windows) {
            "windows_exit_code(1)"
        } else if cfg!(unix) {
            "unix_exit_code(1)"
        } else {
            "exit_code(1)"
        };

        assert_eq!(reason, expected);
        assert!(!reason.contains("ExitCode("));
    }
}