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
49pub mod bfs;
50pub mod command;
51mod executor_trait;
52pub mod macos;
53#[cfg(any(test, feature = "test-utils"))]
54mod mock;
55pub mod ps;
56mod real;
57mod types;
58
59// Re-export all public types
60pub use executor_trait::ProcessExecutor;
61pub use real::RealProcessExecutor;
62pub use types::{
63 AgentChild, AgentChildHandle, AgentCommandResult, AgentSpawnConfig, ChildProcessInfo,
64 ProcessOutput, RealAgentChild, SpawnedProcess,
65};
66
67#[cfg(any(test, feature = "test-utils"))]
68pub use mock::{MockAgentChild, MockProcessExecutor};
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73
74 #[test]
75 fn test_real_executor_can_be_created() {
76 let executor = RealProcessExecutor::new();
77 // Can't test actual execution without real commands
78 let _ = executor;
79 }
80
81 #[test]
82 #[cfg(unix)]
83 fn test_real_executor_execute_basic() {
84 let executor = RealProcessExecutor::new();
85 // Use 'echo' command which should exist on all Unix systems
86 let result = executor.execute("echo", &["hello"], &[], None);
87 // Should succeed
88 assert!(result.is_ok());
89 if let Ok(output) = result {
90 assert!(output.status.success());
91 assert_eq!(output.stdout.trim(), "hello");
92 }
93 }
94
95 /// Unsafe code verification tests module.
96 ///
97 /// This module tests the observable behavior of unsafe code without testing
98 /// the unsafe blocks directly. These tests verify correctness through behavioral
99 /// tests to ensure unsafe operations work correctly.
100 mod safety {
101 use super::*;
102 use crate::agents::JsonParserType;
103 use std::collections::HashMap;
104 use tempfile::tempdir;
105
106 #[test]
107 #[cfg(unix)]
108 fn test_nonblocking_io_setup_succeeds() {
109 // This internally uses unsafe fcntl - verify it works
110 let executor = RealProcessExecutor::new();
111 let tempdir = tempdir().expect("create tempdir for logfile");
112 let logfile_path = tempdir.path().join("opencode_agent.log");
113 let config = AgentSpawnConfig {
114 command: "echo".to_string(),
115 args: vec!["test".to_string()],
116 env: HashMap::new(),
117 prompt: "test prompt".to_string(),
118 logfile: logfile_path.to_string_lossy().to_string(),
119 parser_type: JsonParserType::OpenCode,
120 };
121
122 let result = executor.spawn_agent(&config);
123
124 assert!(
125 result.is_ok(),
126 "Agent spawn with non-blocking I/O should succeed"
127 );
128
129 // Clean up spawned process
130 if let Ok(mut handle) = result {
131 let _ = handle.inner.wait();
132 }
133 }
134
135 #[test]
136 #[cfg(unix)]
137 fn test_process_termination_cleanup_works() {
138 // Test that the unsafe kill() calls work correctly for process cleanup
139 let executor = RealProcessExecutor::new();
140
141 // Spawn a long-running process
142 let result = executor.spawn("sleep", &["10"], &[], None);
143
144 assert!(result.is_ok(), "Process spawn should succeed");
145
146 if let Ok(mut child) = result {
147 // Verify process is running
148 assert!(
149 child.try_wait().unwrap().is_none(),
150 "Process should be running"
151 );
152
153 // Terminate the process (uses unsafe kill internally)
154 let kill_result = child.kill();
155 assert!(kill_result.is_ok(), "Process termination should succeed");
156
157 // Wait for the process to exit
158 let wait_result = child.wait();
159 assert!(
160 wait_result.is_ok(),
161 "Process wait should succeed after kill"
162 );
163 }
164 }
165
166 #[test]
167 #[cfg(unix)]
168 fn test_process_group_creation_succeeds() {
169 // Test that the unsafe setpgid() call in pre_exec works correctly
170 let executor = RealProcessExecutor::new();
171 let tempdir = tempdir().expect("create tempdir for logfile");
172 let logfile_path = tempdir.path().join("opencode_agent.log");
173 let config = AgentSpawnConfig {
174 command: "echo".to_string(),
175 args: vec!["test".to_string()],
176 env: HashMap::new(),
177 prompt: "test prompt".to_string(),
178 logfile: logfile_path.to_string_lossy().to_string(),
179 parser_type: JsonParserType::OpenCode,
180 };
181
182 // This internally uses unsafe setpgid in pre_exec
183 let result = executor.spawn_agent(&config);
184
185 assert!(
186 result.is_ok(),
187 "Agent spawn with process group creation should succeed"
188 );
189
190 // Clean up
191 if let Ok(mut handle) = result {
192 let _ = handle.inner.wait();
193 }
194 }
195
196 #[test]
197 fn test_executor_handles_invalid_command_gracefully() {
198 // Verify error handling when file descriptors are invalid
199 let executor = RealProcessExecutor::new();
200
201 // Try to spawn a command that doesn't exist
202 let result = executor.spawn("nonexistent_command_12345", &[], &[], None);
203
204 assert!(
205 result.is_err(),
206 "Spawning nonexistent command should fail gracefully"
207 );
208 }
209
210 #[test]
211 #[cfg(unix)]
212 fn test_agent_spawn_handles_env_vars_correctly() {
213 // Test that environment variables are passed correctly (no unsafe code,
214 // but important for agent behavior)
215 let executor = RealProcessExecutor::new();
216 let mut env = HashMap::new();
217 env.insert("TEST_VAR_1".to_string(), "value1".to_string());
218 env.insert("TEST_VAR_2".to_string(), "value2".to_string());
219
220 let tempdir = tempdir().expect("create tempdir for logfile");
221 let logfile_path = tempdir.path().join("opencode_agent.log");
222
223 let config = AgentSpawnConfig {
224 command: "env".to_string(),
225 args: vec![],
226 env,
227 prompt: String::new(),
228 logfile: logfile_path.to_string_lossy().to_string(),
229 parser_type: JsonParserType::OpenCode,
230 };
231
232 let result = executor.spawn_agent(&config);
233 assert!(
234 result.is_ok(),
235 "Agent spawn with environment variables should succeed"
236 );
237
238 // Clean up
239 if let Ok(mut handle) = result {
240 let _ = handle.inner.wait();
241 }
242 }
243
244 #[test]
245 fn test_process_executor_execute_with_workdir() {
246 // Test workdir setting (no unsafe code, but important for process spawning)
247 let executor = RealProcessExecutor::new();
248
249 // Use 'pwd' command to verify workdir is set (Unix) or 'cd' (cross-platform alternative)
250 #[cfg(unix)]
251 let result = executor.execute("pwd", &[], &[], Some(std::path::Path::new("/")));
252
253 #[cfg(not(unix))]
254 let result = executor.execute(
255 "cmd",
256 &["/c", "cd"],
257 &[],
258 Some(std::path::Path::new("C:\\")),
259 );
260
261 assert!(result.is_ok(), "Execute with workdir should succeed");
262 }
263 }
264}