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
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}