Skip to main content

ralph_workflow/
executor.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 AppEffectHandler 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
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
19use crate::agents::JsonParserType;
20use std::collections::HashMap;
21use std::io;
22use std::path::Path;
23use std::process::ExitStatus;
24
25#[cfg(any(test, feature = "test-utils"))]
26use std::sync::Mutex;
27
28#[cfg(any(test, feature = "test-utils"))]
29use std::io::Cursor;
30
31/// Output from an executed process.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ProcessOutput {
34    /// The exit status of process.
35    pub status: ExitStatus,
36    /// The captured stdout as a UTF-8 string.
37    pub stdout: String,
38    /// The captured stderr as a UTF-8 string.
39    pub stderr: String,
40}
41
42/// Configuration for spawning an agent process with streaming support.
43///
44/// This struct contains all the parameters needed to spawn an agent subprocess,
45/// including the command, arguments, environment variables, prompt, and parser type.
46#[derive(Debug, Clone)]
47pub struct AgentSpawnConfig {
48    /// The command to execute (e.g., "claude", "codex").
49    pub command: String,
50    /// Arguments to pass to the command.
51    pub args: Vec<String>,
52    /// Environment variables to set for the process.
53    pub env: HashMap<String, String>,
54    /// The prompt to pass to the agent.
55    pub prompt: String,
56    /// Path to the log file for output.
57    pub logfile: String,
58    /// The JSON parser type to use for output.
59    pub parser_type: JsonParserType,
60}
61
62/// Result of spawning an agent process.
63///
64/// This wraps the spawned child process with handles to stdout and stderr
65/// for streaming output in real-time.
66pub struct AgentChildHandle {
67    /// The stdout stream for reading agent output.
68    pub stdout: Box<dyn io::Read + Send>,
69    /// The stderr stream for reading error output.
70    pub stderr: Box<dyn io::Read + Send>,
71    /// The inner child process handle.
72    pub inner: Box<dyn AgentChild>,
73}
74
75/// Trait for interacting with a spawned agent child process.
76///
77/// This trait abstracts the `std::process::Child` operations needed for
78/// agent monitoring and output collection. It allows mocking in tests.
79pub trait AgentChild: Send + std::fmt::Debug {
80    /// Get the process ID.
81    fn id(&self) -> u32;
82
83    /// Wait for the process to complete and return the exit status.
84    fn wait(&mut self) -> io::Result<std::process::ExitStatus>;
85
86    /// Try to wait without blocking.
87    fn try_wait(&mut self) -> io::Result<Option<std::process::ExitStatus>>;
88}
89
90/// Wrapper for real `std::process::Child`.
91pub struct RealAgentChild(pub std::process::Child);
92
93impl std::fmt::Debug for RealAgentChild {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.debug_struct("RealAgentChild")
96            .field("id", &self.0.id())
97            .finish()
98    }
99}
100
101impl AgentChild for RealAgentChild {
102    fn id(&self) -> u32 {
103        self.0.id()
104    }
105
106    fn wait(&mut self) -> io::Result<std::process::ExitStatus> {
107        self.0.wait()
108    }
109
110    fn try_wait(&mut self) -> io::Result<Option<std::process::ExitStatus>> {
111        self.0.try_wait()
112    }
113}
114
115/// Result of an agent command execution (for testing).
116///
117/// This is used by MockProcessExecutor to return mock results without
118/// actually spawning processes.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct AgentCommandResult {
121    /// Exit code from the command (0 = success).
122    pub exit_code: i32,
123    /// Standard error from the command.
124    pub stderr: String,
125}
126
127impl AgentCommandResult {
128    /// Create a successful result.
129    pub fn success() -> Self {
130        Self {
131            exit_code: 0,
132            stderr: String::new(),
133        }
134    }
135
136    /// Create a failed result with the given exit code and stderr.
137    pub fn failure(exit_code: i32, stderr: impl Into<String>) -> Self {
138        Self {
139            exit_code,
140            stderr: stderr.into(),
141        }
142    }
143}
144
145/// Real process executor that uses `std::process::Command`.
146///
147/// This is the production implementation that spawns actual processes.
148#[derive(Debug, Clone, Default)]
149pub struct RealProcessExecutor;
150
151impl RealProcessExecutor {
152    /// Create a new RealProcessExecutor.
153    pub fn new() -> Self {
154        Self
155    }
156}
157
158impl ProcessExecutor for RealProcessExecutor {
159    fn execute(
160        &self,
161        command: &str,
162        args: &[&str],
163        env: &[(String, String)],
164        workdir: Option<&Path>,
165    ) -> io::Result<ProcessOutput> {
166        let mut cmd = std::process::Command::new(command);
167        cmd.args(args);
168
169        for (key, value) in env {
170            cmd.env(key, value);
171        }
172
173        if let Some(dir) = workdir {
174            cmd.current_dir(dir);
175        }
176
177        let output = cmd.output()?;
178
179        Ok(ProcessOutput {
180            status: output.status,
181            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
182            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
183        })
184    }
185
186    fn spawn(
187        &self,
188        command: &str,
189        args: &[&str],
190        env: &[(String, String)],
191        workdir: Option<&Path>,
192    ) -> io::Result<std::process::Child> {
193        let mut cmd = std::process::Command::new(command);
194        cmd.args(args);
195
196        for (key, value) in env {
197            cmd.env(key, value);
198        }
199
200        if let Some(dir) = workdir {
201            cmd.current_dir(dir);
202        }
203
204        cmd.stdin(std::process::Stdio::piped())
205            .stdout(std::process::Stdio::piped())
206            .stderr(std::process::Stdio::piped())
207            .spawn()
208    }
209}
210
211/// Trait for executing external processes.
212///
213/// This trait abstracts process execution to allow dependency injection.
214/// Production code uses `RealProcessExecutor` which calls actual commands.
215/// Test code can use `MockProcessExecutor` to control process behavior.
216///
217/// Only external process execution is abstracted. Internal code logic is never mocked.
218pub trait ProcessExecutor: Send + Sync + std::fmt::Debug {
219    /// Execute a command with given arguments and return its output.
220    ///
221    /// # Arguments
222    ///
223    /// * `command` - The program to execute
224    /// * `args` - Command-line arguments to pass to the program
225    /// * `env` - Environment variables to set for the process (optional)
226    /// * `workdir` - Working directory for the process (optional)
227    ///
228    /// # Returns
229    ///
230    /// Returns a `ProcessOutput` containing exit status, stdout, and stderr.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if command cannot be spawned or if output capture fails.
235    fn execute(
236        &self,
237        command: &str,
238        args: &[&str],
239        env: &[(String, String)],
240        workdir: Option<&Path>,
241    ) -> io::Result<ProcessOutput>;
242
243    /// Spawn a process with stdin input and return the child handle.
244    ///
245    /// This method is used when you need to write to the process's stdin
246    /// or stream its output in real-time. Unlike `execute()`, this returns
247    /// a `Child` handle for direct interaction.
248    ///
249    /// # Arguments
250    ///
251    /// * `command` - The program to execute
252    /// * `args` - Command-line arguments to pass to the program
253    /// * `env` - Environment variables to set for the process (optional)
254    /// * `workdir` - Working directory for the process (optional)
255    ///
256    /// # Returns
257    ///
258    /// Returns a `Child` handle that can be used to interact with the process.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if command cannot be spawned.
263    fn spawn(
264        &self,
265        command: &str,
266        args: &[&str],
267        env: &[(String, String)],
268        workdir: Option<&Path>,
269    ) -> io::Result<std::process::Child> {
270        let mut cmd = std::process::Command::new(command);
271        cmd.args(args);
272
273        for (key, value) in env {
274            cmd.env(key, value);
275        }
276
277        if let Some(dir) = workdir {
278            cmd.current_dir(dir);
279        }
280
281        cmd.stdin(std::process::Stdio::piped())
282            .stdout(std::process::Stdio::piped())
283            .stderr(std::process::Stdio::piped())
284            .spawn()
285    }
286
287    /// Spawn an agent process with streaming output support.
288    ///
289    /// This method is specifically designed for spawning AI agent subprocesses
290    /// that need to output streaming JSON in real-time. Unlike `spawn()`, this
291    /// returns a handle with boxed stdout for trait object compatibility.
292    ///
293    /// # Arguments
294    ///
295    /// * `config` - Agent spawn configuration including command, args, env, prompt, etc.
296    ///
297    /// # Returns
298    ///
299    /// Returns an `AgentChildHandle` with stdout, stderr, and the child process.
300    ///
301    /// # Errors
302    ///
303    /// Returns an error if the agent cannot be spawned.
304    ///
305    /// # Default Implementation
306    ///
307    /// The default implementation uses the `spawn()` method with additional
308    /// configuration for agent-specific needs. Mock implementations should
309    /// override this to return mock results without spawning real processes.
310    fn spawn_agent(&self, config: &AgentSpawnConfig) -> io::Result<AgentChildHandle> {
311        let mut cmd = std::process::Command::new(&config.command);
312        cmd.args(&config.args);
313
314        // Set environment variables
315        for (key, value) in &config.env {
316            cmd.env(key, value);
317        }
318
319        // Add the prompt as the final argument
320        cmd.arg(&config.prompt);
321
322        // Set buffering variables for real-time streaming
323        cmd.env("PYTHONUNBUFFERED", "1");
324        cmd.env("NODE_ENV", "production");
325
326        // Spawn the process with piped stdout/stderr
327        let mut child = cmd
328            .stdin(std::process::Stdio::null())
329            .stdout(std::process::Stdio::piped())
330            .stderr(std::process::Stdio::piped())
331            .spawn()?;
332
333        let stdout = child
334            .stdout
335            .take()
336            .ok_or_else(|| io::Error::other("Failed to capture stdout"))?;
337        let stderr = child
338            .stderr
339            .take()
340            .ok_or_else(|| io::Error::other("Failed to capture stderr"))?;
341
342        Ok(AgentChildHandle {
343            stdout: Box::new(stdout),
344            stderr: Box::new(stderr),
345            inner: Box::new(RealAgentChild(child)),
346        })
347    }
348
349    /// Check if a command exists and can be executed.
350    ///
351    /// This is a convenience method that executes a command with a
352    /// `--version` or similar flag to check if it's available.
353    ///
354    /// # Arguments
355    ///
356    /// * `command` - The program to check
357    ///
358    /// # Returns
359    ///
360    /// Returns `true` if command exists, `false` otherwise.
361    fn command_exists(&self, command: &str) -> bool {
362        match self.execute(command, &[], &[], None) {
363            Ok(output) => output.status.success(),
364            Err(_) => false,
365        }
366    }
367}
368
369/// Clonable representation of an io::Result.
370///
371/// Since io::Error doesn't implement Clone, we store error info as strings
372/// and reconstructs the error on demand.
373#[cfg(any(test, feature = "test-utils"))]
374#[derive(Debug, Clone)]
375enum MockResult<T: Clone> {
376    Ok(T),
377    Err {
378        kind: io::ErrorKind,
379        message: String,
380    },
381}
382
383#[cfg(any(test, feature = "test-utils"))]
384impl<T: Clone> MockResult<T> {
385    fn to_io_result(&self) -> io::Result<T> {
386        match self {
387            MockResult::Ok(v) => Ok(v.clone()),
388            MockResult::Err { kind, message } => Err(io::Error::new(*kind, message.clone())),
389        }
390    }
391
392    fn from_io_result(result: io::Result<T>) -> Self {
393        match result {
394            Ok(v) => MockResult::Ok(v),
395            Err(e) => MockResult::Err {
396                kind: e.kind(),
397                message: e.to_string(),
398            },
399        }
400    }
401}
402
403#[cfg(any(test, feature = "test-utils"))]
404impl<T: Clone + Default> Default for MockResult<T> {
405    fn default() -> Self {
406        MockResult::Ok(T::default())
407    }
408}
409
410/// Type alias for captured execute calls.
411///
412/// Each call is a tuple of (command, args, env, workdir).
413#[cfg(any(test, feature = "test-utils"))]
414type ExecuteCall = (String, Vec<String>, Vec<(String, String)>, Option<String>);
415
416/// Mock process executor for testing.
417///
418/// This implementation captures all calls and allows tests to control
419/// what each execution returns.
420#[cfg(any(test, feature = "test-utils"))]
421#[derive(Debug)]
422pub struct MockProcessExecutor {
423    /// Captured execute calls: (command, args, env, workdir).
424    execute_calls: Mutex<Vec<ExecuteCall>>,
425    /// Mock results indexed by command.
426    results: Mutex<HashMap<String, MockResult<ProcessOutput>>>,
427    /// Default result for commands not explicitly set.
428    default_result: Mutex<MockResult<ProcessOutput>>,
429    /// Captured agent spawn calls.
430    agent_calls: Mutex<Vec<AgentSpawnConfig>>,
431    /// Mock agent results indexed by command pattern.
432    agent_results: Mutex<HashMap<String, MockResult<AgentCommandResult>>>,
433    /// Default agent result.
434    default_agent_result: Mutex<MockResult<AgentCommandResult>>,
435}
436
437#[cfg(any(test, feature = "test-utils"))]
438impl Default for MockProcessExecutor {
439    fn default() -> Self {
440        #[cfg(unix)]
441        use std::os::unix::process::ExitStatusExt;
442
443        Self {
444            execute_calls: Mutex::new(Vec::new()),
445            results: Mutex::new(HashMap::new()),
446            #[cfg(unix)]
447            default_result: Mutex::new(MockResult::Ok(ProcessOutput {
448                status: ExitStatus::from_raw(0),
449                stdout: String::new(),
450                stderr: String::new(),
451            })),
452            #[cfg(not(unix))]
453            default_result: Mutex::new(MockResult::Ok(ProcessOutput {
454                status: std::process::ExitStatus::default(),
455                stdout: String::new(),
456                stderr: String::new(),
457            })),
458            agent_calls: Mutex::new(Vec::new()),
459            agent_results: Mutex::new(HashMap::new()),
460            default_agent_result: Mutex::new(MockResult::Ok(AgentCommandResult::success())),
461        }
462    }
463}
464
465#[cfg(any(test, feature = "test-utils"))]
466impl MockProcessExecutor {
467    /// Create a new MockProcessExecutor with default successful responses.
468    pub fn new() -> Self {
469        Self::default()
470    }
471
472    /// Create a new MockProcessExecutor that returns errors for all commands.
473    pub fn new_error() -> Self {
474        fn err_result<T: Clone>(msg: &str) -> MockResult<T> {
475            MockResult::Err {
476                kind: io::ErrorKind::Other,
477                message: msg.to_string(),
478            }
479        }
480
481        Self {
482            execute_calls: Mutex::new(Vec::new()),
483            results: Mutex::new(HashMap::new()),
484            default_result: Mutex::new(err_result("mock process error")),
485            agent_calls: Mutex::new(Vec::new()),
486            agent_results: Mutex::new(HashMap::new()),
487            default_agent_result: Mutex::new(err_result("mock agent error")),
488        }
489    }
490
491    /// Set the mock result for a specific command.
492    ///
493    /// # Arguments
494    ///
495    /// * `command` - The command name
496    /// * `result` - The mock result to return
497    pub fn with_result(self, command: &str, result: io::Result<ProcessOutput>) -> Self {
498        self.results
499            .lock()
500            .unwrap()
501            .insert(command.to_string(), MockResult::from_io_result(result));
502        self
503    }
504
505    /// Set a default successful output for a command.
506    ///
507    /// # Arguments
508    ///
509    /// * `command` - The command name
510    /// * `stdout` - The stdout to return
511    pub fn with_output(self, command: &str, stdout: &str) -> Self {
512        #[cfg(unix)]
513        use std::os::unix::process::ExitStatusExt;
514
515        #[cfg(unix)]
516        let result = Ok(ProcessOutput {
517            status: ExitStatus::from_raw(0),
518            stdout: stdout.to_string(),
519            stderr: String::new(),
520        });
521        #[cfg(not(unix))]
522        let result = Ok(ProcessOutput {
523            status: std::process::ExitStatus::default(),
524            stdout: stdout.to_string(),
525            stderr: String::new(),
526        });
527        self.with_result(command, result)
528    }
529
530    /// Set a default failed output for a command.
531    ///
532    /// # Arguments
533    ///
534    /// * `command` - The command name
535    /// * `stderr` - The stderr to return
536    pub fn with_error(self, command: &str, stderr: &str) -> Self {
537        #[cfg(unix)]
538        use std::os::unix::process::ExitStatusExt;
539
540        #[cfg(unix)]
541        let result = Ok(ProcessOutput {
542            status: ExitStatus::from_raw(1),
543            stdout: String::new(),
544            stderr: stderr.to_string(),
545        });
546        #[cfg(not(unix))]
547        let result = Ok(ProcessOutput {
548            status: std::process::ExitStatus::default(),
549            stdout: String::new(),
550            stderr: stderr.to_string(),
551        });
552        self.with_result(command, result)
553    }
554
555    /// Set a mock error result for a specific command.
556    ///
557    /// # Arguments
558    ///
559    /// * `command` - The command name
560    /// * `kind` - The error kind
561    /// * `message` - The error message
562    pub fn with_io_error(self, command: &str, kind: io::ErrorKind, message: &str) -> Self {
563        self.with_result(command, Err(io::Error::new(kind, message)))
564    }
565
566    /// Get the number of times execute was called.
567    pub fn execute_count(&self) -> usize {
568        self.execute_calls.lock().unwrap().len()
569    }
570
571    /// Get all execute calls.
572    ///
573    /// Each call is a tuple of (command, args, env, workdir).
574    pub fn execute_calls(&self) -> Vec<ExecuteCall> {
575        self.execute_calls.lock().unwrap().clone()
576    }
577
578    /// Get all execute calls for a specific command.
579    pub fn execute_calls_for(&self, command: &str) -> Vec<ExecuteCall> {
580        self.execute_calls
581            .lock()
582            .unwrap()
583            .iter()
584            .filter(|(cmd, _, _, _)| cmd == command)
585            .cloned()
586            .collect()
587    }
588
589    /// Reset all captured calls.
590    pub fn reset_calls(&self) {
591        self.execute_calls.lock().unwrap().clear();
592        self.agent_calls.lock().unwrap().clear();
593    }
594
595    /// Set a mock result for agent spawning.
596    ///
597    /// # Arguments
598    ///
599    /// * `command_pattern` - Pattern to match against the agent command
600    /// * `result` - The mock result to return when the pattern matches
601    pub fn with_agent_result(
602        self,
603        command_pattern: &str,
604        result: io::Result<AgentCommandResult>,
605    ) -> Self {
606        self.agent_results.lock().unwrap().insert(
607            command_pattern.to_string(),
608            MockResult::from_io_result(result),
609        );
610        self
611    }
612
613    /// Get all agent spawn calls.
614    pub fn agent_calls(&self) -> Vec<AgentSpawnConfig> {
615        self.agent_calls.lock().unwrap().clone()
616    }
617
618    /// Get agent spawn calls for a specific command pattern.
619    pub fn agent_calls_for(&self, command_pattern: &str) -> Vec<AgentSpawnConfig> {
620        self.agent_calls
621            .lock()
622            .unwrap()
623            .iter()
624            .filter(|config| config.command.contains(command_pattern))
625            .cloned()
626            .collect()
627    }
628}
629
630/// Generate minimal valid agent output for mock testing.
631///
632/// This function creates a minimal valid NDJSON output that the streaming
633/// parser can successfully parse without hanging. The output format depends
634/// on the parser type being used.
635///
636/// # Arguments
637///
638/// * `parser_type` - The JSON parser type (Claude, Codex, Gemini, OpenCode, Generic)
639/// * `_command` - The agent command name (reserved for future logging/debugging)
640///
641/// # Returns
642///
643/// A string containing valid NDJSON output for the given parser type.
644#[cfg(any(test, feature = "test-utils"))]
645fn generate_mock_agent_output(parser_type: JsonParserType, _command: &str) -> String {
646    // Valid commit message in XML format for commit generation tests
647    let commit_message = r#"<ralph-commit>
648<ralph-subject>test: commit message</ralph-subject>
649<ralph-body>Test commit message for integration tests.</ralph-body>
650</ralph-commit>"#;
651
652    match parser_type {
653        JsonParserType::Claude => {
654            // Claude expects events with "type" field
655            // Include session_id in init event (for session continuation tests)
656            // and the commit message in the result
657            format!(
658                r#"{{"type":"system","subtype":"init","session_id":"ses_mock_session_12345"}}
659{{"type":"result","result":"{}"}}
660"#,
661                commit_message.replace('\n', "\\n").replace('"', "\\\"")
662            )
663        }
664        JsonParserType::Codex => {
665            // Codex expects completion events with the actual content
666            // We need to provide events that will be written to the log file
667            // and then extracted as the commit message
668            format!(
669                r#"{{"type":"turn_started","turn_id":"test_turn"}}
670{{"type":"item_started","item":{{"type":"agent_message","text":"{}"}}}}
671{{"type":"item_completed","item":{{"type":"agent_message","text":"{}"}}}}
672{{"type":"turn_completed"}}
673{{"type":"completion","reason":"stop"}}
674"#,
675                commit_message, commit_message
676            )
677        }
678        JsonParserType::Gemini => {
679            // Gemini expects message events with content
680            format!(
681                r#"{{"type":"message","role":"assistant","content":"{}"}}
682{{"type":"result","status":"success"}}
683"#,
684                commit_message.replace('\n', "\\n")
685            )
686        }
687        JsonParserType::OpenCode => {
688            // OpenCode expects text events
689            format!(
690                r#"{{"type":"text","content":"{}"}}
691{{"type":"end","success":true}}
692"#,
693                commit_message.replace('\n', "\\n")
694            )
695        }
696        JsonParserType::Generic => {
697            // Generic parser treats all output as plain text
698            // Return the commit message directly
699            format!("{}\n", commit_message)
700        }
701    }
702}
703
704/// Mock agent child process for testing.
705///
706/// This simulates a real Child process but returns a predetermined exit code.
707#[cfg(any(test, feature = "test-utils"))]
708#[derive(Debug)]
709pub struct MockAgentChild {
710    exit_code: i32,
711}
712
713#[cfg(any(test, feature = "test-utils"))]
714impl MockAgentChild {
715    fn new(exit_code: i32) -> Self {
716        Self { exit_code }
717    }
718}
719
720#[cfg(any(test, feature = "test-utils"))]
721impl AgentChild for MockAgentChild {
722    fn id(&self) -> u32 {
723        0 // Mock PID
724    }
725
726    fn wait(&mut self) -> io::Result<std::process::ExitStatus> {
727        #[cfg(unix)]
728        use std::os::unix::process::ExitStatusExt;
729
730        // On Unix, wait status encoding: exit code is in bits 8-15, so shift left by 8
731        #[cfg(unix)]
732        return Ok(ExitStatus::from_raw(self.exit_code << 8));
733        #[cfg(not(unix))]
734        return Ok(std::process::ExitStatus::default());
735    }
736
737    fn try_wait(&mut self) -> io::Result<Option<std::process::ExitStatus>> {
738        #[cfg(unix)]
739        use std::os::unix::process::ExitStatusExt;
740
741        // On Unix, wait status encoding: exit code is in bits 8-15, so shift left by 8
742        #[cfg(unix)]
743        return Ok(Some(ExitStatus::from_raw(self.exit_code << 8)));
744        #[cfg(not(unix))]
745        return Ok(Some(std::process::ExitStatus::default()));
746    }
747}
748
749#[cfg(any(test, feature = "test-utils"))]
750impl ProcessExecutor for MockProcessExecutor {
751    fn spawn(
752        &self,
753        _command: &str,
754        _args: &[&str],
755        _env: &[(String, String)],
756        _workdir: Option<&Path>,
757    ) -> io::Result<std::process::Child> {
758        // Mock executor doesn't support real spawning
759        // This is only used in production code (clipboard, etc.)
760        // Tests use execute() instead which is properly mocked
761        Err(io::Error::other(
762            "MockProcessExecutor doesn't support spawn() - use execute() instead",
763        ))
764    }
765
766    fn spawn_agent(&self, config: &AgentSpawnConfig) -> io::Result<AgentChildHandle> {
767        // Record the call for assertions
768        self.agent_calls.lock().unwrap().push(config.clone());
769
770        // Find the appropriate mock result
771        let result = self.find_agent_result(&config.command);
772
773        // Generate minimal valid JSON output based on parser type
774        // This prevents the streaming parser from hanging on empty input
775        let mock_output = generate_mock_agent_output(config.parser_type, &config.command);
776
777        // Return a mock handle with valid stdout that provides complete JSON
778        Ok(AgentChildHandle {
779            stdout: Box::new(Cursor::new(mock_output)),
780            stderr: Box::new(io::empty()),
781            inner: Box::new(MockAgentChild::new(result.exit_code)),
782        })
783    }
784
785    fn execute(
786        &self,
787        command: &str,
788        args: &[&str],
789        env: &[(String, String)],
790        workdir: Option<&Path>,
791    ) -> io::Result<ProcessOutput> {
792        // Capture the call
793        let workdir_str = workdir.map(|p| p.display().to_string());
794        self.execute_calls.lock().unwrap().push((
795            command.to_string(),
796            args.iter().map(|s| s.to_string()).collect(),
797            env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
798            workdir_str,
799        ));
800
801        // Return the mock result
802        if let Some(result) = self.results.lock().unwrap().get(command) {
803            result.to_io_result()
804        } else {
805            self.default_result.lock().unwrap().to_io_result()
806        }
807    }
808}
809
810#[cfg(any(test, feature = "test-utils"))]
811impl MockProcessExecutor {
812    /// Find the agent result for a given command pattern.
813    fn find_agent_result(&self, command: &str) -> AgentCommandResult {
814        // Look for an exact match first
815        if let Some(result) = self.agent_results.lock().unwrap().get(command) {
816            return result
817                .clone()
818                .to_io_result()
819                .unwrap_or_else(|_| AgentCommandResult::success());
820        }
821
822        // Look for a partial pattern match
823        for (pattern, result) in self.agent_results.lock().unwrap().iter() {
824            if command.contains(pattern) {
825                return result
826                    .clone()
827                    .to_io_result()
828                    .unwrap_or_else(|_| AgentCommandResult::success());
829            }
830        }
831
832        // Use default result
833        self.default_agent_result
834            .lock()
835            .unwrap()
836            .clone()
837            .to_io_result()
838            .unwrap_or_else(|_| AgentCommandResult::success())
839    }
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845
846    #[test]
847    fn test_real_executor_can_be_created() {
848        let executor = RealProcessExecutor::new();
849        // Can't test actual execution without real commands
850        let _ = executor;
851    }
852
853    #[test]
854    fn test_real_executor_execute_basic() {
855        let executor = RealProcessExecutor::new();
856        // Use 'echo' command which should exist on all Unix systems
857        let result = executor.execute("echo", &["hello"], &[], None);
858        // Should succeed
859        assert!(result.is_ok());
860        if let Ok(output) = result {
861            assert!(output.status.success());
862            assert_eq!(output.stdout.trim(), "hello");
863        }
864    }
865}
866
867#[cfg(all(test, feature = "test-utils"))]
868mod mock_tests {
869    use super::*;
870
871    #[test]
872    fn test_mock_executor_captures_calls() {
873        let mock = MockProcessExecutor::new();
874        let _ = mock.execute("echo", &["hello"], &[], None);
875
876        assert_eq!(mock.execute_count(), 1);
877        let calls = mock.execute_calls();
878        assert_eq!(calls.len(), 1);
879        assert_eq!(calls[0].0, "echo");
880        assert_eq!(calls[0].1, vec!["hello"]);
881    }
882
883    #[test]
884    fn test_mock_executor_returns_output() {
885        let mock = MockProcessExecutor::new().with_output("git", "git version 2.40.0");
886
887        let result = mock.execute("git", &["--version"], &[], None).unwrap();
888        assert_eq!(result.stdout, "git version 2.40.0");
889        assert!(result.status.success());
890    }
891
892    #[test]
893    fn test_mock_executor_returns_error() {
894        let mock = MockProcessExecutor::new().with_io_error(
895            "git",
896            io::ErrorKind::NotFound,
897            "git not found",
898        );
899
900        let result = mock.execute("git", &["--version"], &[], None);
901        assert!(result.is_err());
902        let err = result.unwrap_err();
903        assert_eq!(err.kind(), io::ErrorKind::NotFound);
904        assert_eq!(err.to_string(), "git not found");
905    }
906
907    #[test]
908    fn test_mock_executor_can_be_reset() {
909        let mock = MockProcessExecutor::new();
910        let _ = mock.execute("echo", &["test"], &[], None);
911
912        assert_eq!(mock.execute_count(), 1);
913        mock.reset_calls();
914        assert_eq!(mock.execute_count(), 0);
915    }
916
917    #[test]
918    fn test_mock_executor_command_exists() {
919        let mock = MockProcessExecutor::new().with_output("which", "/usr/bin/git");
920
921        assert!(mock.command_exists("which"));
922    }
923
924    #[test]
925    fn test_mock_executor_command_not_exists() {
926        let mock = MockProcessExecutor::new_error();
927        assert!(!mock.command_exists("nonexistent"));
928    }
929}