use super::environment::WorkflowEnv;
use super::{CommandError, CommandOutput};
use crate::cook::workflow::pure::{build_command, parse_output_variables};
use std::collections::HashMap;
use stillwater::{from_async, Effect};
pub fn execute_shell_command_effect(
template: &str,
variables: &HashMap<String, String>,
timeout: Option<u64>,
) -> impl Effect<Output = CommandOutput, Error = CommandError, Env = WorkflowEnv> {
let template = template.to_string();
let variables = variables.clone();
from_async(move |env: &WorkflowEnv| {
let template = template.clone();
let variables = variables.clone();
let working_dir = env.working_dir.clone();
let shell_runner = env.shell_runner.clone();
let output_patterns = env.output_patterns.clone();
let env_vars = env.env_vars.clone();
async move {
let command = build_command(&template, &variables);
let output = shell_runner
.run(&command, &working_dir, env_vars, timeout)
.await
.map_err(|e| CommandError::ExecutionFailed {
message: e.to_string(),
exit_code: None,
})?;
if !output.success {
return Err(CommandError::ExecutionFailed {
message: output.stderr.clone(),
exit_code: output.exit_code,
});
}
let extracted_vars = parse_output_variables(&output.stdout, &output_patterns);
Ok(CommandOutput {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.exit_code,
success: output.success,
variables: extracted_vars,
json_log_location: None,
})
}
})
}
pub fn execute_shell_command_effect_fallible(
template: &str,
variables: &HashMap<String, String>,
timeout: Option<u64>,
) -> impl Effect<Output = CommandOutput, Error = CommandError, Env = WorkflowEnv> {
let template = template.to_string();
let variables = variables.clone();
from_async(move |env: &WorkflowEnv| {
let template = template.clone();
let variables = variables.clone();
let working_dir = env.working_dir.clone();
let shell_runner = env.shell_runner.clone();
let output_patterns = env.output_patterns.clone();
let env_vars = env.env_vars.clone();
async move {
let command = build_command(&template, &variables);
let output = shell_runner
.run(&command, &working_dir, env_vars, timeout)
.await
.map_err(|e| CommandError::ExecutionFailed {
message: e.to_string(),
exit_code: None,
})?;
let extracted_vars = parse_output_variables(&output.stdout, &output_patterns);
Ok(CommandOutput {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.exit_code,
success: output.success,
variables: extracted_vars,
json_log_location: None,
})
}
})
}
pub fn execute_shell_command_effect_with_timeout_error(
template: &str,
variables: &HashMap<String, String>,
timeout_secs: u64,
) -> impl Effect<Output = CommandOutput, Error = CommandError, Env = WorkflowEnv> {
let template = template.to_string();
let variables = variables.clone();
from_async(move |env: &WorkflowEnv| {
let template = template.clone();
let variables = variables.clone();
let working_dir = env.working_dir.clone();
let shell_runner = env.shell_runner.clone();
let output_patterns = env.output_patterns.clone();
let env_vars = env.env_vars.clone();
async move {
let command = build_command(&template, &variables);
let output = shell_runner
.run(&command, &working_dir, env_vars, Some(timeout_secs))
.await
.map_err(|e| CommandError::ExecutionFailed {
message: e.to_string(),
exit_code: None,
})?;
if output.exit_code == Some(-1) && output.stderr.contains("timed out") {
return Err(CommandError::Timeout {
seconds: timeout_secs,
});
}
if !output.success {
return Err(CommandError::ExecutionFailed {
message: output.stderr.clone(),
exit_code: output.exit_code,
});
}
let extracted_vars = parse_output_variables(&output.stdout, &output_patterns);
Ok(CommandOutput {
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.exit_code,
success: output.success,
variables: extracted_vars,
json_log_location: None,
})
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cook::workflow::effects::environment::{ClaudeRunner, RunnerOutput, ShellRunner};
use async_trait::async_trait;
use std::path::{Path, PathBuf};
use std::sync::Arc;
type ShellCallRecord = (String, Option<u64>);
struct MockShellRunner {
responses: Arc<std::sync::Mutex<Vec<RunnerOutput>>>,
calls: Arc<std::sync::Mutex<Vec<ShellCallRecord>>>,
}
impl MockShellRunner {
fn new() -> Self {
Self {
responses: Arc::new(std::sync::Mutex::new(Vec::new())),
calls: Arc::new(std::sync::Mutex::new(Vec::new())),
}
}
fn add_response(&self, response: RunnerOutput) {
self.responses.lock().unwrap().push(response);
}
fn get_calls(&self) -> Vec<(String, Option<u64>)> {
self.calls.lock().unwrap().clone()
}
}
#[async_trait]
impl ShellRunner for MockShellRunner {
async fn run(
&self,
command: &str,
_working_dir: &Path,
_env_vars: HashMap<String, String>,
timeout: Option<u64>,
) -> anyhow::Result<RunnerOutput> {
self.calls
.lock()
.unwrap()
.push((command.to_string(), timeout));
self.responses
.lock()
.unwrap()
.pop()
.ok_or_else(|| anyhow::anyhow!("No mock response configured"))
}
}
struct MockClaudeRunner;
#[async_trait]
impl ClaudeRunner for MockClaudeRunner {
async fn run(
&self,
_command: &str,
_working_dir: &Path,
_env_vars: HashMap<String, String>,
) -> anyhow::Result<RunnerOutput> {
Ok(RunnerOutput::success("claude output".to_string()))
}
}
fn create_test_env(shell_runner: Arc<dyn ShellRunner>) -> WorkflowEnv {
WorkflowEnv {
claude_runner: Arc::new(MockClaudeRunner),
shell_runner,
output_patterns: Vec::new(),
working_dir: PathBuf::from("/tmp"),
env_vars: HashMap::new(),
}
}
#[tokio::test]
async fn test_execute_shell_command_effect_success() {
let mock_runner = Arc::new(MockShellRunner::new());
mock_runner.add_response(RunnerOutput::success("file1\nfile2\n".to_string()));
let env = create_test_env(mock_runner.clone());
let mut vars = HashMap::new();
vars.insert("dir".to_string(), "/home/user".to_string());
let effect = execute_shell_command_effect("ls ${dir}", &vars, None);
let result = effect.run(&env).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.success);
assert!(output.stdout.contains("file1"));
let calls = mock_runner.get_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "ls /home/user");
assert_eq!(calls[0].1, None);
}
#[tokio::test]
async fn test_execute_shell_command_effect_with_timeout() {
let mock_runner = Arc::new(MockShellRunner::new());
mock_runner.add_response(RunnerOutput::success("done".to_string()));
let env = create_test_env(mock_runner.clone());
let effect = execute_shell_command_effect("long-running-cmd", &HashMap::new(), Some(30));
let result = effect.run(&env).await;
assert!(result.is_ok());
let calls = mock_runner.get_calls();
assert_eq!(calls[0].1, Some(30));
}
#[tokio::test]
async fn test_execute_shell_command_effect_failure() {
let mock_runner = Arc::new(MockShellRunner::new());
mock_runner.add_response(RunnerOutput::failure("command not found".to_string(), 127));
let env = create_test_env(mock_runner);
let effect = execute_shell_command_effect("nonexistent-cmd", &HashMap::new(), None);
let result = effect.run(&env).await;
assert!(result.is_err());
let err = result.unwrap_err();
match err {
CommandError::ExecutionFailed {
message, exit_code, ..
} => {
assert!(message.contains("command not found"));
assert_eq!(exit_code, Some(127));
}
_ => panic!("Expected ExecutionFailed error"),
}
}
#[tokio::test]
async fn test_execute_shell_command_effect_fallible_returns_on_failure() {
let mock_runner = Arc::new(MockShellRunner::new());
mock_runner.add_response(RunnerOutput::failure("error output".to_string(), 1));
let env = create_test_env(mock_runner);
let effect = execute_shell_command_effect_fallible("failing-cmd", &HashMap::new(), None);
let result = effect.run(&env).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(!output.success);
assert_eq!(output.exit_code, Some(1));
assert_eq!(output.stderr, "error output");
}
#[tokio::test]
async fn test_execute_shell_command_effect_with_output_patterns() {
use crate::cook::workflow::pure::OutputPattern;
use regex::Regex;
let mock_runner = Arc::new(MockShellRunner::new());
mock_runner.add_response(RunnerOutput::success("Version: 1.2.3".to_string()));
let env = WorkflowEnv {
claude_runner: Arc::new(MockClaudeRunner),
shell_runner: mock_runner,
output_patterns: vec![OutputPattern::Regex {
name: "version".to_string(),
regex: Regex::new(r"Version: (\S+)").unwrap(),
}],
working_dir: PathBuf::from("/tmp"),
env_vars: HashMap::new(),
};
let effect = execute_shell_command_effect("get-version", &HashMap::new(), None);
let result = effect.run(&env).await;
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.variables.get("version"), Some(&"1.2.3".to_string()));
}
#[tokio::test]
async fn test_execute_shell_command_effect_with_timeout_error() {
let mock_runner = Arc::new(MockShellRunner::new());
let timeout_response = RunnerOutput {
stdout: String::new(),
stderr: "Command timed out after 5 seconds".to_string(),
exit_code: Some(-1),
success: false,
json_log_location: None,
};
mock_runner.add_response(timeout_response);
let env = create_test_env(mock_runner);
let effect =
execute_shell_command_effect_with_timeout_error("sleep 100", &HashMap::new(), 5);
let result = effect.run(&env).await;
assert!(result.is_err());
let err = result.unwrap_err();
match err {
CommandError::Timeout { seconds } => {
assert_eq!(seconds, 5);
}
_ => panic!("Expected Timeout error, got {:?}", err),
}
}
#[tokio::test]
async fn test_execute_shell_command_effect_multiple_variables() {
let mock_runner = Arc::new(MockShellRunner::new());
mock_runner.add_response(RunnerOutput::success("copied".to_string()));
let env = create_test_env(mock_runner.clone());
let mut vars = HashMap::new();
vars.insert("src".to_string(), "/source/file".to_string());
vars.insert("dest".to_string(), "/destination/".to_string());
let effect = execute_shell_command_effect("cp ${src} ${dest}", &vars, None);
let result = effect.run(&env).await;
assert!(result.is_ok());
let calls = mock_runner.get_calls();
assert_eq!(calls[0].0, "cp /source/file /destination/");
}
#[tokio::test]
async fn test_execute_shell_command_effect_runner_error() {
struct FailingRunner;
#[async_trait]
impl ShellRunner for FailingRunner {
async fn run(
&self,
_command: &str,
_working_dir: &Path,
_env_vars: HashMap<String, String>,
_timeout: Option<u64>,
) -> anyhow::Result<RunnerOutput> {
Err(anyhow::anyhow!("Failed to spawn process"))
}
}
let env = WorkflowEnv {
claude_runner: Arc::new(MockClaudeRunner),
shell_runner: Arc::new(FailingRunner),
output_patterns: Vec::new(),
working_dir: PathBuf::from("/tmp"),
env_vars: HashMap::new(),
};
let effect = execute_shell_command_effect("cmd", &HashMap::new(), None);
let result = effect.run(&env).await;
assert!(result.is_err());
let err = result.unwrap_err();
match err {
CommandError::ExecutionFailed { message, .. } => {
assert!(message.contains("Failed to spawn process"));
}
_ => panic!("Expected ExecutionFailed error"),
}
}
#[tokio::test]
async fn test_execute_shell_command_effect_empty_output() {
let mock_runner = Arc::new(MockShellRunner::new());
mock_runner.add_response(RunnerOutput::success(String::new()));
let env = create_test_env(mock_runner);
let effect = execute_shell_command_effect("true", &HashMap::new(), None);
let result = effect.run(&env).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.success);
assert!(output.stdout.is_empty());
}
}