o7 0.1.0

O7 workflow DSL runner
Documentation
use crate::harness::types::{ExecInvocation, ExecResult};
use tokio::process::Command;

/// Execute a command invocation using tokio::process.
/// Captures stdout, stderr, and exit code.
pub async fn execute(invocation: &ExecInvocation) -> Result<ExecResult, String> {
    if invocation.command.is_empty() {
        return Err("Empty command".to_string());
    }

    let mut cmd = Command::new(&invocation.command[0]);
    cmd.args(&invocation.command[1..])
        .current_dir(&invocation.cwd)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped());

    if let Some(ref env) = invocation.env {
        for (key, value) in env {
            cmd.env(key, value);
        }
    }

    let child = cmd
        .spawn()
        .map_err(|e| format!("Failed to spawn process: {}", e))?;

    let output = child
        .wait_with_output()
        .await
        .map_err(|e| format!("Failed to wait for process: {}", e))?;

    Ok(ExecResult {
        exit_code: output.status.code().unwrap_or(-1),
        stdout: String::from_utf8_lossy(&output.stdout).to_string(),
        stderr: String::from_utf8_lossy(&output.stderr).to_string(),
    })
}

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

    #[tokio::test]
    async fn test_execute_echo() {
        let invocation = ExecInvocation {
            command: vec!["echo".to_string(), "hello".to_string()],
            cwd: ".".to_string(),
            env: None,
        };
        let result = execute(&invocation).await.unwrap();
        assert_eq!(result.exit_code, 0);
        assert_eq!(result.stdout.trim(), "hello");
    }

    #[tokio::test]
    async fn test_execute_nonexistent_command() {
        let invocation = ExecInvocation {
            command: vec!["nonexistent_command_xyz".to_string()],
            cwd: ".".to_string(),
            env: None,
        };
        let result = execute(&invocation).await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn test_execute_exit_code() {
        let invocation = ExecInvocation {
            command: vec!["sh".to_string(), "-c".to_string(), "exit 42".to_string()],
            cwd: ".".to_string(),
            env: None,
        };
        let result = execute(&invocation).await.unwrap();
        assert_eq!(result.exit_code, 42);
    }

    #[tokio::test]
    async fn test_execute_empty_command() {
        let invocation = ExecInvocation {
            command: vec![],
            cwd: ".".to_string(),
            env: None,
        };
        let result = execute(&invocation).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Empty command"));
    }

    #[tokio::test]
    async fn test_execute_captures_stderr() {
        let invocation = ExecInvocation {
            command: vec![
                "sh".to_string(),
                "-c".to_string(),
                "echo err >&2".to_string(),
            ],
            cwd: ".".to_string(),
            env: None,
        };
        let result = execute(&invocation).await.unwrap();
        assert_eq!(result.exit_code, 0);
        assert_eq!(result.stderr.trim(), "err");
    }
}