clawft_platform/
process.rs1use async_trait::async_trait;
9use std::path::Path;
10
11#[derive(Debug, Clone)]
13pub struct ProcessOutput {
14 pub exit_code: i32,
16 pub stdout: String,
18 pub stderr: String,
20}
21
22#[cfg_attr(not(feature = "browser"), async_trait)]
28#[cfg_attr(feature = "browser", async_trait(?Send))]
29pub trait ProcessSpawner: Send + Sync {
30 async fn run(
39 &self,
40 command: &str,
41 args: &[&str],
42 working_dir: Option<&Path>,
43 timeout_secs: Option<u64>,
44 ) -> Result<ProcessOutput, Box<dyn std::error::Error + Send + Sync>>;
45}
46
47#[cfg(feature = "native")]
49pub struct NativeProcessSpawner;
50
51#[cfg(feature = "native")]
52#[async_trait]
53impl ProcessSpawner for NativeProcessSpawner {
54 async fn run(
55 &self,
56 command: &str,
57 args: &[&str],
58 working_dir: Option<&Path>,
59 timeout_secs: Option<u64>,
60 ) -> Result<ProcessOutput, Box<dyn std::error::Error + Send + Sync>> {
61 let mut cmd = tokio::process::Command::new(command);
62 cmd.args(args)
63 .stdout(std::process::Stdio::piped())
64 .stderr(std::process::Stdio::piped());
65
66 if let Some(dir) = working_dir {
67 cmd.current_dir(dir);
68 }
69
70 let child = cmd.spawn()?;
71
72 let output = if let Some(secs) = timeout_secs {
73 let timeout = std::time::Duration::from_secs(secs);
74 match tokio::time::timeout(timeout, child.wait_with_output()).await {
75 Ok(result) => result?,
76 Err(_) => {
77 return Err(format!("process '{}' timed out after {}s", command, secs).into());
78 }
79 }
80 } else {
81 child.wait_with_output().await?
82 };
83
84 let exit_code = output.status.code().unwrap_or(-1);
85 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
86 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
87
88 Ok(ProcessOutput {
89 exit_code,
90 stdout,
91 stderr,
92 })
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[tokio::test]
101 async fn test_run_echo() {
102 let spawner = NativeProcessSpawner;
103 let output = spawner
104 .run("echo", &["hello", "world"], None, Some(10))
105 .await
106 .unwrap();
107
108 assert_eq!(output.exit_code, 0);
109 assert_eq!(output.stdout.trim(), "hello world");
110 assert!(output.stderr.is_empty());
111 }
112
113 #[tokio::test]
114 async fn test_run_with_working_dir() {
115 let spawner = NativeProcessSpawner;
116 let output = spawner
117 .run("pwd", &[], Some(Path::new("/tmp")), Some(10))
118 .await
119 .unwrap();
120
121 assert_eq!(output.exit_code, 0);
122 assert!(output.stdout.trim().ends_with("tmp"));
124 }
125
126 #[tokio::test]
127 async fn test_run_nonexistent_command() {
128 let spawner = NativeProcessSpawner;
129 let result = spawner
130 .run("clawft_nonexistent_command_xyz", &[], None, Some(5))
131 .await;
132
133 assert!(result.is_err());
134 }
135
136 #[tokio::test]
137 async fn test_run_captures_stderr() {
138 let spawner = NativeProcessSpawner;
139 let output = spawner
140 .run("sh", &["-c", "echo error >&2"], None, Some(10))
141 .await
142 .unwrap();
143
144 assert_eq!(output.exit_code, 0);
145 assert_eq!(output.stderr.trim(), "error");
146 }
147
148 #[tokio::test]
149 async fn test_run_nonzero_exit_code() {
150 let spawner = NativeProcessSpawner;
151 let output = spawner
152 .run("sh", &["-c", "exit 42"], None, Some(10))
153 .await
154 .unwrap();
155
156 assert_eq!(output.exit_code, 42);
157 }
158
159 #[tokio::test]
160 async fn test_run_timeout() {
161 let spawner = NativeProcessSpawner;
162 let result = spawner.run("sleep", &["60"], None, Some(1)).await;
163
164 assert!(result.is_err());
165 let err_msg = result.unwrap_err().to_string();
166 assert!(
167 err_msg.contains("timed out"),
168 "expected timeout error, got: {err_msg}"
169 );
170 }
171}