ralph_workflow/executor/mod.rs
1//! Process execution abstraction for dependency injection.
2//!
3//! This module provides a trait-based abstraction for executing external processes,
4//! allowing production code to use real processes and test code to use mocks.
5//! This follows the same pattern as [`crate::workspace::Workspace`] for dependency injection.
6//!
7//! # Purpose
8//!
9//! - Production: [`RealProcessExecutor`] executes actual commands using `std::process::Command`
10//! - Tests: `MockProcessExecutor` captures calls and returns controlled results (with `test-utils` feature)
11//!
12//! # Benefits
13//!
14//! - Test isolation: Tests don't spawn real processes
15//! - Determinism: Tests produce consistent results
16//! - Speed: Tests run faster without subprocess overhead
17//! - Mockability: Full control over process behavior in tests
18//!
19//! # Key Types
20//!
21//! - [`ProcessExecutor`] - The trait abstraction for process execution
22//! - [`AgentSpawnConfig`] - Configuration for spawning agent processes
23//! - [`AgentChildHandle`] - Handle to a spawned agent with streaming output
24//! - [`ProcessOutput`] - Captured output from a completed process
25//!
26//! # Testing with MockProcessExecutor
27//!
28//! The `test-utils` feature enables `MockProcessExecutor` for integration tests:
29//!
30//! ```ignore
31//! use ralph_workflow::{MockProcessExecutor, ProcessExecutor};
32//!
33//! // Create a mock that returns success for 'git' commands
34//! let executor = MockProcessExecutor::new()
35//! .with_output("git", "On branch main\nnothing to commit");
36//!
37//! // Execute command (captured, returns mock result)
38//! let result = executor.execute("git", &["status"], &[], None)?;
39//! assert!(result.status.success());
40//!
41//! // Verify the call was captured
42//! assert_eq!(executor.execute_count(), 1);
43//! ```
44//!
45//! # See Also
46//!
47//! - [`crate::workspace::Workspace`] - Similar abstraction for filesystem operations
48
49mod executor_trait;
50#[cfg(any(test, feature = "test-utils"))]
51mod mock;
52mod real;
53mod types;
54
55// Re-export all public types
56pub use executor_trait::ProcessExecutor;
57pub use real::RealProcessExecutor;
58pub use types::{
59 AgentChild, AgentChildHandle, AgentCommandResult, AgentSpawnConfig, ProcessOutput,
60 RealAgentChild,
61};
62
63#[cfg(any(test, feature = "test-utils"))]
64pub use mock::{MockAgentChild, MockProcessExecutor};
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69
70 #[test]
71 fn test_real_executor_can_be_created() {
72 let executor = RealProcessExecutor::new();
73 // Can't test actual execution without real commands
74 let _ = executor;
75 }
76
77 #[test]
78 #[cfg(unix)]
79 fn test_real_executor_execute_basic() {
80 let executor = RealProcessExecutor::new();
81 // Use 'echo' command which should exist on all Unix systems
82 let result = executor.execute("echo", &["hello"], &[], None);
83 // Should succeed
84 assert!(result.is_ok());
85 if let Ok(output) = result {
86 assert!(output.status.success());
87 assert_eq!(output.stdout.trim(), "hello");
88 }
89 }
90
91 /// Unsafe code verification tests module.
92 ///
93 /// This module tests the observable behavior of unsafe code without testing
94 /// the unsafe blocks directly. These tests verify correctness through behavioral
95 /// tests to ensure unsafe operations work correctly.
96 mod safety {
97 use super::*;
98 use crate::agents::JsonParserType;
99 use std::collections::HashMap;
100 use tempfile::tempdir;
101
102 #[test]
103 #[cfg(unix)]
104 fn test_nonblocking_io_setup_succeeds() {
105 // This internally uses unsafe fcntl - verify it works
106 let executor = RealProcessExecutor::new();
107 let tempdir = tempdir().expect("create tempdir for logfile");
108 let logfile_path = tempdir.path().join("opencode_agent.log");
109 let config = AgentSpawnConfig {
110 command: "echo".to_string(),
111 args: vec!["test".to_string()],
112 env: HashMap::new(),
113 prompt: "test prompt".to_string(),
114 logfile: logfile_path.to_string_lossy().to_string(),
115 parser_type: JsonParserType::OpenCode,
116 };
117
118 let result = executor.spawn_agent(&config);
119
120 assert!(
121 result.is_ok(),
122 "Agent spawn with non-blocking I/O should succeed"
123 );
124
125 // Clean up spawned process
126 if let Ok(mut handle) = result {
127 let _ = handle.inner.wait();
128 }
129 }
130
131 #[test]
132 #[cfg(unix)]
133 fn test_process_termination_cleanup_works() {
134 // Test that the unsafe kill() calls work correctly for process cleanup
135 let executor = RealProcessExecutor::new();
136
137 // Spawn a long-running process
138 let result = executor.spawn("sleep", &["10"], &[], None);
139
140 assert!(result.is_ok(), "Process spawn should succeed");
141
142 if let Ok(mut child) = result {
143 // Verify process is running
144 assert!(
145 child.try_wait().unwrap().is_none(),
146 "Process should be running"
147 );
148
149 // Terminate the process (uses unsafe kill internally)
150 let kill_result = child.kill();
151 assert!(kill_result.is_ok(), "Process termination should succeed");
152
153 // Wait for the process to exit
154 let wait_result = child.wait();
155 assert!(
156 wait_result.is_ok(),
157 "Process wait should succeed after kill"
158 );
159 }
160 }
161
162 #[test]
163 #[cfg(unix)]
164 fn test_process_group_creation_succeeds() {
165 // Test that the unsafe setpgid() call in pre_exec works correctly
166 let executor = RealProcessExecutor::new();
167 let tempdir = tempdir().expect("create tempdir for logfile");
168 let logfile_path = tempdir.path().join("opencode_agent.log");
169 let config = AgentSpawnConfig {
170 command: "echo".to_string(),
171 args: vec!["test".to_string()],
172 env: HashMap::new(),
173 prompt: "test prompt".to_string(),
174 logfile: logfile_path.to_string_lossy().to_string(),
175 parser_type: JsonParserType::OpenCode,
176 };
177
178 // This internally uses unsafe setpgid in pre_exec
179 let result = executor.spawn_agent(&config);
180
181 assert!(
182 result.is_ok(),
183 "Agent spawn with process group creation should succeed"
184 );
185
186 // Clean up
187 if let Ok(mut handle) = result {
188 let _ = handle.inner.wait();
189 }
190 }
191
192 #[test]
193 fn test_executor_handles_invalid_command_gracefully() {
194 // Verify error handling when file descriptors are invalid
195 let executor = RealProcessExecutor::new();
196
197 // Try to spawn a command that doesn't exist
198 let result = executor.spawn("nonexistent_command_12345", &[], &[], None);
199
200 assert!(
201 result.is_err(),
202 "Spawning nonexistent command should fail gracefully"
203 );
204 }
205
206 #[test]
207 #[cfg(unix)]
208 fn test_agent_spawn_handles_env_vars_correctly() {
209 // Test that environment variables are passed correctly (no unsafe code,
210 // but important for agent behavior)
211 let executor = RealProcessExecutor::new();
212 let mut env = HashMap::new();
213 env.insert("TEST_VAR_1".to_string(), "value1".to_string());
214 env.insert("TEST_VAR_2".to_string(), "value2".to_string());
215
216 let tempdir = tempdir().expect("create tempdir for logfile");
217 let logfile_path = tempdir.path().join("opencode_agent.log");
218
219 let config = AgentSpawnConfig {
220 command: "env".to_string(),
221 args: vec![],
222 env,
223 prompt: "".to_string(),
224 logfile: logfile_path.to_string_lossy().to_string(),
225 parser_type: JsonParserType::OpenCode,
226 };
227
228 let result = executor.spawn_agent(&config);
229 assert!(
230 result.is_ok(),
231 "Agent spawn with environment variables should succeed"
232 );
233
234 // Clean up
235 if let Ok(mut handle) = result {
236 let _ = handle.inner.wait();
237 }
238 }
239
240 #[test]
241 fn test_process_executor_execute_with_workdir() {
242 // Test workdir setting (no unsafe code, but important for process spawning)
243 let executor = RealProcessExecutor::new();
244
245 // Use 'pwd' command to verify workdir is set (Unix) or 'cd' (cross-platform alternative)
246 #[cfg(unix)]
247 let result = executor.execute("pwd", &[], &[], Some(std::path::Path::new("/")));
248
249 #[cfg(not(unix))]
250 let result = executor.execute(
251 "cmd",
252 &["/c", "cd"],
253 &[],
254 Some(std::path::Path::new("C:\\")),
255 );
256
257 assert!(result.is_ok(), "Execute with workdir should succeed");
258 }
259 }
260}