greentic-dev 0.5.0

Developer CLI and local tooling for Greentic flows, packs, and components
Documentation
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::{Command, ExitStatus, Stdio};

use anyhow::{Context, Result};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum StreamMode {
    Inherit,
    Capture,
}

pub struct CommandSpec {
    pub program: OsString,
    pub args: Vec<OsString>,
    pub env: Vec<(OsString, OsString)>,
    pub current_dir: Option<PathBuf>,
    pub stdout: StreamMode,
    pub stderr: StreamMode,
}

impl CommandSpec {
    pub fn new(program: impl Into<OsString>) -> Self {
        Self {
            program: program.into(),
            args: Vec::new(),
            env: Vec::new(),
            current_dir: None,
            stdout: StreamMode::Inherit,
            stderr: StreamMode::Inherit,
        }
    }
}

pub struct CommandOutput {
    pub status: ExitStatus,
    #[allow(dead_code)]
    pub stdout: Option<Vec<u8>>,
    pub stderr: Option<Vec<u8>>,
}

pub fn run(spec: CommandSpec) -> Result<CommandOutput> {
    let mut command = Command::new(&spec.program);
    command.args(&spec.args);
    if let Some(dir) = &spec.current_dir {
        command.current_dir(dir);
    }
    for (key, value) in &spec.env {
        command.env(key, value);
    }

    match (spec.stdout, spec.stderr) {
        (StreamMode::Inherit, StreamMode::Inherit) => {
            command.stdout(Stdio::inherit());
            command.stderr(Stdio::inherit());
            let status = command
                .status()
                .with_context(|| format!("failed to spawn `{}`", spec.program.to_string_lossy()))?;
            Ok(CommandOutput {
                status,
                stdout: None,
                stderr: None,
            })
        }
        (StreamMode::Capture, StreamMode::Capture) => {
            command.stdout(Stdio::piped());
            command.stderr(Stdio::piped());
            let output = command
                .output()
                .with_context(|| format!("failed to spawn `{}`", spec.program.to_string_lossy()))?;
            Ok(CommandOutput {
                status: output.status,
                stdout: Some(output.stdout),
                stderr: Some(output.stderr),
            })
        }
        _ => anyhow::bail!("mixed capture/inherit mode is not supported yet"),
    }
}

#[cfg(test)]
mod tests {
    use super::{CommandSpec, StreamMode, run};
    use std::ffi::OsString;

    #[test]
    fn capture_mode_collects_stdout_and_stderr() {
        let mut spec = CommandSpec::new("sh");
        spec.args = vec![
            OsString::from("-c"),
            OsString::from("printf hello; printf world >&2"),
        ];
        spec.stdout = StreamMode::Capture;
        spec.stderr = StreamMode::Capture;

        let output = run(spec).unwrap();
        assert!(output.status.success());
        assert_eq!(output.stdout.unwrap(), b"hello");
        assert_eq!(output.stderr.unwrap(), b"world");
    }

    #[test]
    fn inherit_mode_returns_status_without_buffers() {
        let mut spec = CommandSpec::new("sh");
        spec.args = vec![OsString::from("-c"), OsString::from("exit 0")];

        let output = run(spec).unwrap();
        assert!(output.status.success());
        assert!(output.stdout.is_none());
        assert!(output.stderr.is_none());
    }

    #[test]
    fn mixed_modes_are_rejected() {
        let mut spec = CommandSpec::new("sh");
        spec.args = vec![OsString::from("-c"), OsString::from("exit 0")];
        spec.stdout = StreamMode::Capture;
        spec.stderr = StreamMode::Inherit;

        let err = run(spec).err().expect("expected mixed-mode failure");
        assert!(err.to_string().contains("mixed capture/inherit mode"));
    }
}