commandeer-test 0.1.1

Takes over your commands for testing with record and replay support.
Documentation
use anyhow::{Result, anyhow};
use escargot::CargoBuild;
use serde::{Deserialize, Serialize};
use std::{
    collections::HashMap,
    env, fmt, fs,
    path::{Path, PathBuf},
};
use tempfile::TempDir;
use tokio::{
    fs::{DirBuilder, try_exists},
    io::AsyncReadExt as _,
    process::Command,
};

pub use commandeer_macros::commandeer;

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CommandInvocation {
    pub binary_name: String,
    pub args: Vec<String>,
    pub stdout: String,
    pub stderr: String,
    pub exit_code: i32,
}

#[derive(Serialize, Deserialize, Debug, Default)]
pub struct RecordedCommands {
    commands: HashMap<String, Vec<CommandInvocation>>,
}

impl RecordedCommands {
    fn generate_key(binary_name: &str, args: &[String]) -> String {
        format!("{binary_name}:{}", args.join(" "))
    }

    pub fn add_invocation(&mut self, invocation: CommandInvocation) {
        let key = Self::generate_key(&invocation.binary_name, &invocation.args);

        self.commands.entry(key).or_default().push(invocation);
    }

    pub fn find_invocation(
        &self,
        binary_name: &str,
        args: &[String],
    ) -> Option<&CommandInvocation> {
        let key = Self::generate_key(binary_name, args);

        self.commands.get(&key)?.first()
    }
}

pub async fn load_recordings(file_path: &PathBuf) -> Result<RecordedCommands> {
    let mut f = tokio::fs::File::options();

    let mut contents = String::new();
    f.create(true)
        .write(true)
        .read(true)
        .open(file_path)
        .await?
        .read_to_string(&mut contents)
        .await?;

    if contents.trim().is_empty() {
        return Ok(RecordedCommands::default());
    }

    let recordings: RecordedCommands = serde_json::from_str(&contents)?;

    Ok(recordings)
}

pub async fn save_recordings(file_path: &PathBuf, recordings: &RecordedCommands) -> Result<()> {
    let json = serde_json::to_string_pretty(recordings)?;

    tokio::fs::write(file_path, json.as_bytes()).await?;

    Ok(())
}

pub async fn record_command(
    truncate: bool,
    file_path: PathBuf,
    command: String,
    args: Vec<String>,
) -> Result<CommandInvocation> {
    let recording_dir = file_path
        .parent()
        .ok_or_else(|| anyhow!("Couldn't get parent of recording {}", file_path.display()))?;

    DirBuilder::new()
        .recursive(true)
        .create(recording_dir)
        .await?;

    let mut recordings = if truncate {
        if try_exists(&file_path).await? {
            tokio::fs::remove_file(&file_path).await?;
        }

        RecordedCommands::default()
    } else {
        load_recordings(&file_path).await?
    };

    let output = Command::new(&command).args(&args).output().await?;

    let invocation = CommandInvocation {
        binary_name: command,
        args,
        stdout: String::from_utf8_lossy(&output.stdout).to_string(),
        stderr: String::from_utf8_lossy(&output.stderr).to_string(),
        exit_code: output.status.code().unwrap_or(-1),
    };

    recordings.add_invocation(invocation.clone());
    save_recordings(&file_path, &recordings).await?;

    Ok(invocation)
}

pub async fn replay_command(
    file_path: PathBuf,
    command: String,
    args: Vec<String>,
) -> Result<Option<CommandInvocation>> {
    let recordings = load_recordings(&file_path).await?;

    Ok(recordings.find_invocation(&command, &args).cloned())
}

pub fn output_invocation(invocation: &CommandInvocation) {
    print!("{}", invocation.stdout);
    eprint!("{}", invocation.stderr);
}

pub fn exit_with_code(code: i32) -> ! {
    std::process::exit(code);
}

#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum Mode {
    Record,
    Replay,
}

pub struct Commandeer {
    mock_runner: escargot::CargoRun,
    temp_dir: TempDir,
    fixture: PathBuf,
    mode: Mode,
    original_path: String,
}

impl fmt::Display for Mode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Mode::Record => write!(f, "record"),
            Mode::Replay => write!(f, "replay"),
        }
    }
}

impl Commandeer {
    pub fn new(test_name: impl AsRef<Path>, mode: Mode) -> Self {
        let dir = PathBuf::from(
            std::env::var("CARGO_MANIFEST_DIR").expect("Failed to get crate directory."),
        );

        std::fs::DirBuilder::new()
            .recursive(true)
            .create(&dir)
            .expect("Failed to create testcmds dir");

        let fixture = dir.join("testcmds").join(test_name);

        if fixture.exists() && mode == Mode::Record {
            std::fs::remove_file(&fixture).expect("Failed to remove existing fixture file");
        }

        let mock_runner = CargoBuild::new()
            .manifest_path(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"))
            .package("commandeer-test")
            .bin("commandeer")
            .run()
            .expect("Failed to build mock binary");

        let temp_dir = TempDir::new().expect("Failed to create temp dir");

        let original_path = std::env::var("PATH").unwrap_or_default();
        let new_path = format!("{}:{original_path}", temp_dir.path().display());

        unsafe {
            std::env::set_var("PATH", new_path);
        }

        Self {
            mock_runner,
            temp_dir,
            fixture,
            mode,
            original_path,
        }
    }
    pub fn mock_command(&self, command_name: &str) -> PathBuf {
        let mock_path = self.temp_dir.path().join(command_name);

        let wrapper = format!(
            r#"#!/usr/bin/env bash
exec env PATH="{}" {} {} --file {} --command {command_name} "$@"
"#,
            self.original_path,
            self.mock_runner.path().display(),
            self.mode,
            self.fixture.display(),
        );

        fs::write(&mock_path, wrapper).expect("Failed to write mock wrapper script");

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;

            let mut perms = fs::metadata(&mock_path)
                .expect("Could not get permissions")
                .permissions();

            perms.set_mode(0o755);

            fs::set_permissions(&mock_path, perms).expect("Could not set permissions");
        }

        mock_path
    }
}

impl Drop for Commandeer {
    fn drop(&mut self) {
        unsafe {
            std::env::set_var("PATH", &self.original_path);
        }
    }
}

#[cfg(test)]
mod tests {
    use crate as commandeer_test;
    use crate::{Commandeer, Mode, commandeer};

    #[serial_test::serial]
    fn test_mock_cmd() {
        let commandeer = Commandeer::new("test_recordings.json", Mode::Replay);
        let mock_path = commandeer.mock_command("echo");

        let status = std::process::Command::new("echo")
            .arg("foo")
            .status()
            .unwrap();

        assert!(status.success());

        assert!(mock_path.exists());
    }

    #[commandeer(Replay, "echo")]
    #[serial_test::serial]
    fn my_test() {
        let output = std::process::Command::new("echo")
            .arg("hello")
            .output()
            .unwrap();

        assert!(output.status.success());
    }

    #[commandeer(Replay, "date")]
    #[tokio::test]
    #[serial_test::serial]
    async fn async_replay() {
        let output = std::process::Command::new("date").output().unwrap();

        insta::assert_debug_snapshot!(output, @r#"
        Output {
            status: ExitStatus(
                unix_wait_status(
                    0,
                ),
            ),
            stdout: "Wed Aug 20 12:46:19 EDT 2025\n",
            stderr: "",
        }
        "#);
    }

    #[commandeer(Replay, "git", "date")]
    #[test]
    #[serial_test::serial]
    fn test_flag_args() {
        let output = std::process::Command::new("git")
            .arg("--version")
            .output()
            .unwrap();

        insta::assert_debug_snapshot!(output, @r#"
        Output {
            status: ExitStatus(
                unix_wait_status(
                    0,
                ),
            ),
            stdout: "git version 2.51.0\n",
            stderr: "",
        }
        "#);

        let output = std::process::Command::new("date").output().unwrap();

        insta::assert_debug_snapshot!(output, @r#"
        Output {
            status: ExitStatus(
                unix_wait_status(
                    0,
                ),
            ),
            stdout: "Thu Aug 21 14:54:45 EDT 2025\n",
            stderr: "",
        }
        "#);
    }
}