Skip to main content

clawft_platform/
process.rs

1//! Process spawning abstraction and native implementation.
2//!
3//! Provides a platform-agnostic [`ProcessSpawner`] trait for running external
4//! commands. The native implementation uses [`tokio::process`]. This capability
5//! is unavailable in WASM environments, which is why [`super::Platform::process`]
6//! returns `Option`.
7
8use async_trait::async_trait;
9use std::path::Path;
10
11/// Result of running an external process.
12#[derive(Debug, Clone)]
13pub struct ProcessOutput {
14    /// Process exit code. 0 typically indicates success.
15    pub exit_code: i32,
16    /// Captured standard output as a UTF-8 string.
17    pub stdout: String,
18    /// Captured standard error as a UTF-8 string.
19    pub stderr: String,
20}
21
22/// Platform-agnostic process spawner.
23///
24/// Implementations run external commands and capture their output.
25/// This is intentionally not part of the [`super::Platform`] trait bundle
26/// because it is unavailable in WASM environments.
27#[cfg_attr(not(feature = "browser"), async_trait)]
28#[cfg_attr(feature = "browser", async_trait(?Send))]
29pub trait ProcessSpawner: Send + Sync {
30    /// Run a command with arguments and capture its output.
31    ///
32    /// # Arguments
33    /// * `command` - The executable to run.
34    /// * `args` - Command-line arguments.
35    /// * `working_dir` - Optional working directory for the process.
36    /// * `timeout_secs` - Optional timeout in seconds. If the process exceeds
37    ///   this duration, it will be killed and an error returned.
38    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/// Native process spawner using [`tokio::process`].
48#[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        // /tmp may be a symlink (e.g., to /private/tmp on macOS)
123        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}