1use 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#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ProcessOutput {
34 pub status: ExitStatus,
36 pub stdout: String,
38 pub stderr: String,
40}
41
42#[derive(Debug, Clone)]
47pub struct AgentSpawnConfig {
48 pub command: String,
50 pub args: Vec<String>,
52 pub env: HashMap<String, String>,
54 pub prompt: String,
56 pub logfile: String,
58 pub parser_type: JsonParserType,
60}
61
62pub struct AgentChildHandle {
67 pub stdout: Box<dyn io::Read + Send>,
69 pub stderr: Box<dyn io::Read + Send>,
71 pub inner: Box<dyn AgentChild>,
73}
74
75pub trait AgentChild: Send + std::fmt::Debug {
80 fn id(&self) -> u32;
82
83 fn wait(&mut self) -> io::Result<std::process::ExitStatus>;
85
86 fn try_wait(&mut self) -> io::Result<Option<std::process::ExitStatus>>;
88}
89
90pub 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#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct AgentCommandResult {
121 pub exit_code: i32,
123 pub stderr: String,
125}
126
127impl AgentCommandResult {
128 pub fn success() -> Self {
130 Self {
131 exit_code: 0,
132 stderr: String::new(),
133 }
134 }
135
136 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#[derive(Debug, Clone, Default)]
149pub struct RealProcessExecutor;
150
151impl RealProcessExecutor {
152 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
211pub trait ProcessExecutor: Send + Sync + std::fmt::Debug {
219 fn execute(
236 &self,
237 command: &str,
238 args: &[&str],
239 env: &[(String, String)],
240 workdir: Option<&Path>,
241 ) -> io::Result<ProcessOutput>;
242
243 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 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 for (key, value) in &config.env {
316 cmd.env(key, value);
317 }
318
319 cmd.arg(&config.prompt);
321
322 cmd.env("PYTHONUNBUFFERED", "1");
324 cmd.env("NODE_ENV", "production");
325
326 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 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#[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#[cfg(any(test, feature = "test-utils"))]
414type ExecuteCall = (String, Vec<String>, Vec<(String, String)>, Option<String>);
415
416#[cfg(any(test, feature = "test-utils"))]
421#[derive(Debug)]
422pub struct MockProcessExecutor {
423 execute_calls: Mutex<Vec<ExecuteCall>>,
425 results: Mutex<HashMap<String, MockResult<ProcessOutput>>>,
427 default_result: Mutex<MockResult<ProcessOutput>>,
429 agent_calls: Mutex<Vec<AgentSpawnConfig>>,
431 agent_results: Mutex<HashMap<String, MockResult<AgentCommandResult>>>,
433 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 pub fn new() -> Self {
469 Self::default()
470 }
471
472 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 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 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 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 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 pub fn execute_count(&self) -> usize {
568 self.execute_calls.lock().unwrap().len()
569 }
570
571 pub fn execute_calls(&self) -> Vec<ExecuteCall> {
575 self.execute_calls.lock().unwrap().clone()
576 }
577
578 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 pub fn reset_calls(&self) {
591 self.execute_calls.lock().unwrap().clear();
592 self.agent_calls.lock().unwrap().clear();
593 }
594
595 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 pub fn agent_calls(&self) -> Vec<AgentSpawnConfig> {
615 self.agent_calls.lock().unwrap().clone()
616 }
617
618 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#[cfg(any(test, feature = "test-utils"))]
645fn generate_mock_agent_output(parser_type: JsonParserType, _command: &str) -> String {
646 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 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 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 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 format!(
690 r#"{{"type":"text","content":"{}"}}
691{{"type":"end","success":true}}
692"#,
693 commit_message.replace('\n', "\\n")
694 )
695 }
696 JsonParserType::Generic => {
697 format!("{}\n", commit_message)
700 }
701 }
702}
703
704#[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 }
725
726 fn wait(&mut self) -> io::Result<std::process::ExitStatus> {
727 #[cfg(unix)]
728 use std::os::unix::process::ExitStatusExt;
729
730 #[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 #[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 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 self.agent_calls.lock().unwrap().push(config.clone());
769
770 let result = self.find_agent_result(&config.command);
772
773 let mock_output = generate_mock_agent_output(config.parser_type, &config.command);
776
777 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 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 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 fn find_agent_result(&self, command: &str) -> AgentCommandResult {
814 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 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 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 let _ = executor;
851 }
852
853 #[test]
854 fn test_real_executor_execute_basic() {
855 let executor = RealProcessExecutor::new();
856 let result = executor.execute("echo", &["hello"], &[], None);
858 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}