Skip to main content

rpi_tools/
env.rs

1//! Mirrors `packages/agent/src/harness/types.ts::ExecutionEnv` — the trait every
2//! tool and test-double implements. `ExecutionEnv = FileSystem + Shell`.
3//!
4//! Contract (plan §5.13): **every method must never panic.** All failures are
5//! encoded in `Result<_, FileError>` / `Result<_, ExecutionError>`. Paths may be
6//! absolute or relative to [`FileSystem::cwd`]; returned paths are "addressed"
7//! (symlink-canonicalized only by [`FileSystem::canonical_path`]).
8//!
9//! The TS `AbortSignal` becomes `&tokio_util::sync::CancellationToken` on every
10//! method. Backends SHOULD check `is_cancelled()` at entry and honor cooperative
11//! cancellation during blocking ops via `tokio::select!`.
12
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16use async_trait::async_trait;
17use tokio_util::sync::CancellationToken;
18
19use crate::error::{ExecutionError, FileError};
20
21/// `file` | `directory` | `symlink`. Mirrors TS `FileKind`.
22///
23/// Backends detect via `symlink_metadata` (lstat) — symlinks are NOT followed
24/// automatically by any `FileSystem` method except `canonical_path`.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum FileKind {
27    File,
28    Directory,
29    Symlink,
30}
31
32/// Mirrors TS `FileInfo`. `path` is the absolute addressed (resolved) path —
33/// NOT symlink-canonicalized. `mtime_ms` is ms since Unix epoch.
34#[derive(Debug, Clone)]
35pub struct FileInfo {
36    pub name: String,
37    pub path: PathBuf,
38    pub kind: FileKind,
39    pub size: u64,
40    pub mtime_ms: i64,
41}
42
43/// Write/append payload. Mirrors the TS `string | Uint8Array` union.
44#[derive(Debug, Clone)]
45pub enum FileContent {
46    Text(String),
47    Bytes(Vec<u8>),
48}
49
50impl FileContent {
51    pub fn as_bytes(&self) -> &[u8] {
52        match self {
53            FileContent::Text(s) => s.as_bytes(),
54            FileContent::Bytes(b) => b,
55        }
56    }
57}
58
59impl From<String> for FileContent {
60    fn from(s: String) -> Self {
61        FileContent::Text(s)
62    }
63}
64
65impl From<&str> for FileContent {
66    fn from(s: &str) -> Self {
67        FileContent::Text(s.to_string())
68    }
69}
70
71impl From<Vec<u8>> for FileContent {
72    fn from(b: Vec<u8>) -> Self {
73        FileContent::Bytes(b)
74    }
75}
76
77/// Result of `Shell::exec`. Mirrors the TS inline return type
78/// `{ stdout, stderr, exitCode }`. Signal kills collapse to `exit_code: 0`
79/// (matching the TS `code ?? 0` rule).
80#[derive(Debug, Clone)]
81pub struct ShellOutput {
82    pub stdout: String,
83    pub stderr: String,
84    pub exit_code: i32,
85}
86
87/// Mirrors TS `ShellExecOptions` (note: `timeout` is in **seconds**, matching
88/// the TS contract). `on_stdout` / `on_stderr` fire per chunk, interleaved in
89/// arrival order; the bash-tool wraps these with throttling + capture.
90#[derive(Clone, Debug)]
91pub struct ShellOverride {
92    pub program: PathBuf,
93    pub args: Vec<String>,
94}
95
96pub struct ShellExecOptions<'a> {
97    /// Optional executable and fixed arguments. `None` keeps the environment's
98    /// default shell (Bash on POSIX/Windows), while tools such as PowerShell
99    /// can select their native interpreter without going through Bash.
100    pub shell: Option<ShellOverride>,
101    pub cwd: Option<PathBuf>,
102    pub env: Option<HashMap<String, String>>,
103    /// Default `true`. When `false`, ONLY `env` is used (no process env, no
104    /// shell base env).
105    pub inherit_env: bool,
106    /// Seconds. `None` = no timeout.
107    pub timeout: Option<f64>,
108    pub cancel: Option<&'a CancellationToken>,
109    pub on_stdout: Option<Box<dyn FnMut(&str) + Send + 'a>>,
110    pub on_stderr: Option<Box<dyn FnMut(&str) + Send + 'a>>,
111}
112
113impl<'a> Default for ShellExecOptions<'a> {
114    fn default() -> Self {
115        Self {
116            shell: None,
117            cwd: None,
118            env: None,
119            inherit_env: true,
120            timeout: None,
121            cancel: None,
122            on_stdout: None,
123            on_stderr: None,
124        }
125    }
126}
127
128impl<'a> ShellExecOptions<'a> {
129    pub fn new() -> Self {
130        Self::default()
131    }
132}
133
134/// Mirrors TS `FileSystem`. All methods async, never panic.
135///
136/// `cancel: Option<&CancellationToken>` is the Rust analogue of the optional
137/// `abortSignal`; backends SHOULD check it at entry (returning
138/// `FileError::aborted()`) and honor cooperative cancel during long ops.
139#[async_trait]
140pub trait FileSystem: Send + Sync {
141    /// The environment's working directory (absolute).
142    fn cwd(&self) -> &Path;
143
144    /// Resolve `path` (relative-to-cwd, `~`, `file://`, absolute) to an absolute
145    /// addressed path. Pure — no FS access, no abort. Mirrors TS `absolutePath`.
146    async fn absolute_path(
147        &self,
148        path: &str,
149        cancel: Option<&CancellationToken>,
150    ) -> Result<PathBuf, FileError>;
151
152    /// Join path components. Pure. Mirrors TS `joinPath`.
153    async fn join_path(
154        &self,
155        parts: &[&str],
156        cancel: Option<&CancellationToken>,
157    ) -> Result<PathBuf, FileError>;
158
159    async fn read_text_file(
160        &self,
161        path: &str,
162        cancel: Option<&CancellationToken>,
163    ) -> Result<String, FileError>;
164
165    /// Stream-read up to `max_lines` lines (split on `\n`; `max_lines <= 0`
166    /// → empty). Mirrors TS `readTextLines{maxLines}`.
167    async fn read_text_lines(
168        &self,
169        path: &str,
170        max_lines: Option<usize>,
171        cancel: Option<&CancellationToken>,
172    ) -> Result<Vec<String>, FileError>;
173
174    async fn read_binary_file(
175        &self,
176        path: &str,
177        cancel: Option<&CancellationToken>,
178    ) -> Result<Vec<u8>, FileError>;
179
180    /// Creates parent directories (recursive) automatically. Mirrors TS
181    /// `writeFile`.
182    async fn write_file(
183        &self,
184        path: &str,
185        content: FileContent,
186        cancel: Option<&CancellationToken>,
187    ) -> Result<(), FileError>;
188
189    async fn append_file(
190        &self,
191        path: &str,
192        content: FileContent,
193        cancel: Option<&CancellationToken>,
194    ) -> Result<(), FileError>;
195
196    async fn rename_file(
197        &self,
198        source: &str,
199        dest: &str,
200        cancel: Option<&CancellationToken>,
201    ) -> Result<(), FileError>;
202
203    /// Uses `symlink_metadata` (lstat — symlinks not followed). Mirrors TS
204    /// `fileInfo`.
205    async fn file_info(
206        &self,
207        path: &str,
208        cancel: Option<&CancellationToken>,
209    ) -> Result<FileInfo, FileError>;
210
211    async fn list_dir(
212        &self,
213        path: &str,
214        cancel: Option<&CancellationToken>,
215    ) -> Result<Vec<FileInfo>, FileError>;
216
217    /// Follows symlinks (`canonicalize`). Mirrors TS `canonicalPath`. Backends
218    /// may return `not_found` or `not_supported` for missing/unsupported paths;
219    /// callers (mutation queue) fall back to absolute path on those codes.
220    async fn canonical_path(
221        &self,
222        path: &str,
223        cancel: Option<&CancellationToken>,
224    ) -> Result<PathBuf, FileError>;
225
226    /// `false` ONLY for `not_found`; other errors propagate. Mirrors TS
227    /// `exists` (which delegates to `fileInfo`, NOT `access(F_OK)`).
228    async fn exists(
229        &self,
230        path: &str,
231        cancel: Option<&CancellationToken>,
232    ) -> Result<bool, FileError>;
233
234    /// Default `recursive=true`. Mirrors TS `createDir{recursive}`.
235    async fn create_dir(
236        &self,
237        path: &str,
238        recursive: bool,
239        cancel: Option<&CancellationToken>,
240    ) -> Result<(), FileError>;
241
242    /// Defaults `recursive=false`, `force=false`. Mirrors TS `remove`.
243    async fn remove(
244        &self,
245        path: &str,
246        recursive: bool,
247        force: bool,
248        cancel: Option<&CancellationToken>,
249    ) -> Result<(), FileError>;
250
251    /// Default prefix `"tmp-"`. Mirrors TS `createTempDir{prefix}`.
252    async fn create_temp_dir(
253        &self,
254        prefix: Option<&str>,
255        cancel: Option<&CancellationToken>,
256    ) -> Result<PathBuf, FileError>;
257
258    /// Mirrors TS `createTempFile{prefix,suffix}` (defaults empty). Note: the
259    /// TS impl creates an isolated temp **dir** per file; backends may simplify
260    /// to a single file in the OS temp dir (document any divergence).
261    async fn create_temp_file(
262        &self,
263        prefix: &str,
264        suffix: &str,
265        cancel: Option<&CancellationToken>,
266    ) -> Result<PathBuf, FileError>;
267
268    /// Best-effort, never returns `Err`. Mirrors TS `cleanup` (kills tracked
269    /// child processes, etc.).
270    async fn cleanup(&self);
271}
272
273/// Mirrors TS `Shell`.
274#[async_trait]
275pub trait Shell: Send + Sync {
276    /// Execute `command` in a bash shell. The TS env resolves a bash binary
277    /// (git-bash on Windows, `/bin/bash` on POSIX, `/bin/sh` fallback on POSIX).
278    /// There is NO cmd.exe fallback on Windows — failure to find bash is
279    /// `ExecutionError::ShellUnavailable`.
280    async fn exec<'a>(
281        &'a self,
282        command: &str,
283        options: ShellExecOptions<'a>,
284    ) -> Result<ShellOutput, ExecutionError>;
285
286    /// Best-effort, never returns `Err`. Mirrors TS `Shell.cleanup`.
287    async fn cleanup(&self);
288}
289
290/// Mirrors TS `ExecutionEnv extends FileSystem, Shell`. A marker combining both
291/// capabilities — tools receive `Arc<dyn ExecutionEnv>` (or a concrete env) and
292/// use `FileSystem` + `Shell` methods interchangeably.
293#[async_trait]
294pub trait ExecutionEnv: FileSystem + Shell {}
295
296/// Check `cancel` at a method entry point, returning `FileError::aborted()`
297/// when set. Mirrors the TS `abortResult<T>(signal, path?)` helper used at the
298/// top of every `FileSystem` method.
299pub fn check_cancel_file(
300    cancel: Option<&CancellationToken>,
301    path: Option<&str>,
302) -> Result<(), FileError> {
303    if let Some(t) = cancel {
304        if t.is_cancelled() {
305            let mut err = FileError::aborted();
306            if let Some(p) = path {
307                err = err.with_path(p);
308            }
309            return Err(err);
310        }
311    }
312    Ok(())
313}
314
315/// Same as [`check_cancel_file`] but for `Shell`-side (returns
316/// `ExecutionError::aborted()`).
317pub fn check_cancel_exec(cancel: Option<&CancellationToken>) -> Result<(), ExecutionError> {
318    if let Some(t) = cancel {
319        if t.is_cancelled() {
320            return Err(ExecutionError::aborted());
321        }
322    }
323    Ok(())
324}