Skip to main content

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}