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