use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tempfile::TempDir;
#[derive(Debug)]
pub struct CliOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub success: bool,
}
impl CliOutput {
pub fn stdout_contains(&self, text: &str) -> bool {
self.stdout.contains(text)
}
pub fn stderr_contains(&self, text: &str) -> bool {
self.stderr.contains(text)
}
}
pub struct CliTest {
temp_dir: TempDir,
command: Command,
config_content: Option<String>,
env_vars: Vec<(String, String)>,
}
impl CliTest {
pub fn new() -> Self {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
Command::new("git")
.arg("init")
.current_dir(temp_dir.path())
.output()
.expect("Failed to initialize git repo");
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp_dir.path())
.output()
.ok();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp_dir.path())
.output()
.ok();
let readme = temp_dir.path().join("README.md");
std::fs::write(&readme, "# Test Project\n").ok();
Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.ok();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp_dir.path())
.output()
.ok();
let binary_path = std::env::current_exe()
.ok()
.and_then(|path| {
path.parent()
.and_then(|p| p.parent())
.map(|p| p.join("prodigy"))
})
.filter(|p| p.exists())
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("debug")
.join("prodigy")
});
let mut command = Command::new(binary_path);
command.current_dir(temp_dir.path());
let prodigy_home = temp_dir.path().join(".prodigy-test-home");
std::fs::create_dir_all(&prodigy_home).expect("Failed to create test PRODIGY_HOME");
command.env("PRODIGY_HOME", &prodigy_home);
Self {
temp_dir,
command,
config_content: None,
env_vars: Vec::new(),
}
}
pub fn arg(mut self, arg: &str) -> Self {
self.command.arg(arg);
self
}
pub fn env(mut self, key: &str, value: &str) -> Self {
self.env_vars.push((key.to_string(), value.to_string()));
self.command.env(key, value);
self
}
pub fn with_config(mut self, content: &str) -> Self {
self.config_content = Some(content.to_string());
self
}
pub fn with_workflow(self, name: &str, content: &str) -> (Self, PathBuf) {
let workflow_path = self.temp_dir.path().join(format!("{}.yaml", name));
std::fs::write(&workflow_path, content).expect("Failed to write workflow");
(self, workflow_path)
}
pub fn temp_path(&self) -> &Path {
self.temp_dir.path()
}
pub fn run(&mut self) -> CliOutput {
if let Some(ref config) = self.config_content {
let config_dir = self.temp_dir.path().join(".prodigy");
std::fs::create_dir_all(&config_dir).ok();
let config_path = config_dir.join("config.yaml");
std::fs::write(&config_path, config).expect("Failed to write config");
}
self.command.env("PRODIGY_AUTOMATION", "true");
let output = self
.command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("Failed to execute command");
CliOutput {
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),
success: output.status.success(),
}
}
pub fn spawn(&mut self) -> std::process::Child {
self.command.env("PRODIGY_AUTOMATION", "true");
self.command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("Failed to spawn command")
}
}
pub fn create_test_workflow(name: &str) -> String {
format!(
r#"
name: {}
commands:
- shell: "echo 'Test workflow {}'"
"#,
name, name
)
}
#[allow(dead_code)]
pub fn create_failing_workflow(name: &str) -> String {
format!(
r#"
name: {}
commands:
- shell: "exit 1"
"#,
name
)
}
pub fn create_long_workflow(name: &str) -> String {
format!(
r#"
name: {}
commands:
- shell: "sleep 30"
"#,
name
)
}
pub fn create_mapreduce_workflow(name: &str) -> String {
format!(
r#"
name: {}
mode: mapreduce
setup:
- shell: "echo '[\"item1\", \"item2\"]' > work-items.json"
map:
input: work-items.json
json_path: "$[*]"
agent_template:
- shell: "echo 'Processing ${{item}}'"
reduce:
- shell: "echo 'Reduce complete'"
"#,
name
)
}
pub mod exit_codes {
pub const SUCCESS: i32 = 0;
pub const GENERAL_ERROR: i32 = 1;
pub const ARGUMENT_ERROR: i32 = 2;
pub const CONFIG_ERROR: i32 = 3;
pub const INTERRUPTED: i32 = 130;
pub const TERMINATED: i32 = 143;
}
pub fn assert_output(
output: &CliOutput,
expected_exit: i32,
stdout_contains: Option<&str>,
stderr_contains: Option<&str>,
) {
assert_eq!(
output.exit_code, expected_exit,
"Expected exit code {}, got {}. Stdout: {}, Stderr: {}",
expected_exit, output.exit_code, output.stdout, output.stderr
);
if let Some(expected) = stdout_contains {
assert!(
output.stdout_contains(expected),
"Expected stdout to contain '{}', got: {}",
expected,
output.stdout
);
}
if let Some(expected) = stderr_contains {
assert!(
output.stderr_contains(expected),
"Expected stderr to contain '{}', got: {}",
expected,
output.stderr
);
}
}
pub fn create_test_worktree(
prodigy_home: &Path,
project_root: &Path,
worktree_name: &str,
) -> anyhow::Result<PathBuf> {
use prodigy::subprocess::SubprocessManager;
let _subprocess = SubprocessManager::production();
let repo_name = project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("prodigy");
let worktree_path = prodigy_home
.join("worktrees")
.join(repo_name)
.join(worktree_name);
std::fs::create_dir_all(worktree_path.parent().unwrap())?;
let branch = format!("prodigy-{}", worktree_name);
let add_command = Command::new("git")
.args(["worktree", "add", "-b", &branch])
.arg(&worktree_path)
.current_dir(project_root)
.output()?;
if !add_command.status.success() {
anyhow::bail!(
"Failed to create worktree: {}",
String::from_utf8_lossy(&add_command.stderr)
);
}
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&worktree_path)
.output()?;
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&worktree_path)
.output()?;
Ok(worktree_path)
}
pub fn create_test_checkpoint_with_worktree(
prodigy_home: &Path,
project_root: &Path,
workflow_id: &str,
commands_executed: usize,
total_commands: usize,
variables: serde_json::Value,
) -> anyhow::Result<PathBuf> {
use serde_json::json;
let worktree_path = create_test_worktree(prodigy_home, project_root, workflow_id)?;
let checkpoint_dir = prodigy_home
.join("state")
.join(workflow_id)
.join("checkpoints");
std::fs::create_dir_all(&checkpoint_dir)?;
let now = chrono::Utc::now();
let checkpoint = json!({
"workflow_id": workflow_id,
"execution_state": {
"current_step_index": commands_executed,
"total_steps": total_commands,
"status": "Interrupted",
"start_time": now.to_rfc3339(),
"last_checkpoint": now.to_rfc3339(),
"current_iteration": null,
"total_iterations": null
},
"completed_steps": (0..commands_executed).map(|i| {
json!({
"step_index": i,
"command": format!("shell: echo 'Command {}'", i + 1),
"success": true,
"output": format!("Command {} output", i + 1),
"captured_variables": {},
"duration": {
"secs": 1,
"nanos": 0
},
"completed_at": now.to_rfc3339(),
"retry_state": null
})
}).collect::<Vec<_>>(),
"variable_state": variables,
"mapreduce_state": null,
"timestamp": now.to_rfc3339(),
"version": 1,
"workflow_hash": "test-hash-12345",
"total_steps": total_commands,
"workflow_name": "test-resume-workflow",
"workflow_path": "test-resume-workflow.yaml"
});
let checkpoint_file = checkpoint_dir.join(format!("{}.checkpoint.json", workflow_id));
std::fs::write(&checkpoint_file, serde_json::to_string_pretty(&checkpoint)?)?;
let unified_session = json!({
"id": workflow_id,
"session_type": "Workflow",
"status": "Paused", "started_at": now.to_rfc3339(),
"updated_at": now.to_rfc3339(),
"completed_at": null,
"metadata": {},
"checkpoints": [],
"timings": {},
"error": null,
"workflow_data": {
"workflow_id": workflow_id,
"workflow_name": "test-resume-workflow",
"current_step": commands_executed,
"total_steps": total_commands,
"completed_steps": (0..commands_executed).collect::<Vec<_>>(),
"variables": {},
"iterations_completed": 0,
"files_changed": 0,
"worktree_name": workflow_id
},
"mapreduce_data": null
});
let sessions_dir = prodigy_home.join("sessions");
std::fs::create_dir_all(&sessions_dir)?;
std::fs::write(
sessions_dir.join(format!("{}.json", workflow_id)),
serde_json::to_string_pretty(&unified_session)?,
)?;
Ok(worktree_path)
}
#[allow(dead_code)]
pub fn cleanup_test_worktree(
prodigy_home: &Path,
project_root: &Path,
worktree_name: &str,
) -> anyhow::Result<()> {
let repo_name = project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("prodigy");
let worktree_path = prodigy_home
.join("worktrees")
.join(repo_name)
.join(worktree_name);
let remove_command = Command::new("git")
.args(["worktree", "remove", "--force"])
.arg(&worktree_path)
.current_dir(project_root)
.output()?;
if !remove_command.status.success() {
eprintln!(
"Warning: Failed to remove worktree: {}",
String::from_utf8_lossy(&remove_command.stderr)
);
}
if worktree_path.exists() {
std::fs::remove_dir_all(&worktree_path).ok();
}
Ok(())
}