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.
90pub struct ShellExecOptions<'a> {
91    pub cwd: Option<PathBuf>,
92    pub env: Option<HashMap<String, String>>,
93    /// Default `true`. When `false`, ONLY `env` is used (no process env, no
94    /// shell base env).
95    pub inherit_env: bool,
96    /// Seconds. `None` = no timeout.
97    pub timeout: Option<f64>,
98    pub cancel: Option<&'a CancellationToken>,
99    pub on_stdout: Option<Box<dyn FnMut(&str) + Send + 'a>>,
100    pub on_stderr: Option<Box<dyn FnMut(&str) + Send + 'a>>,
101}
102
103impl<'a> Default for ShellExecOptions<'a> {
104    fn default() -> Self {
105        Self {
106            cwd: None,
107            env: None,
108            inherit_env: true,
109            timeout: None,
110            cancel: None,
111            on_stdout: None,
112            on_stderr: None,
113        }
114    }
115}
116
117impl<'a> ShellExecOptions<'a> {
118    pub fn new() -> Self {
119        Self::default()
120    }
121}
122
123/// Mirrors TS `FileSystem`. All methods async, never panic.
124///
125/// `cancel: Option<&CancellationToken>` is the Rust analogue of the optional
126/// `abortSignal`; backends SHOULD check it at entry (returning
127/// `FileError::aborted()`) and honor cooperative cancel during long ops.
128#[async_trait]
129pub trait FileSystem: Send + Sync {
130    /// The environment's working directory (absolute).
131    fn cwd(&self) -> &Path;
132
133    /// Resolve `path` (relative-to-cwd, `~`, `file://`, absolute) to an absolute
134    /// addressed path. Pure — no FS access, no abort. Mirrors TS `absolutePath`.
135    async fn absolute_path(
136        &self,
137        path: &str,
138        cancel: Option<&CancellationToken>,
139    ) -> Result<PathBuf, FileError>;
140
141    /// Join path components. Pure. Mirrors TS `joinPath`.
142    async fn join_path(
143        &self,
144        parts: &[&str],
145        cancel: Option<&CancellationToken>,
146    ) -> Result<PathBuf, FileError>;
147
148    async fn read_text_file(
149        &self,
150        path: &str,
151        cancel: Option<&CancellationToken>,
152    ) -> Result<String, FileError>;
153
154    /// Stream-read up to `max_lines` lines (split on `\n`; `max_lines <= 0`
155    /// → empty). Mirrors TS `readTextLines{maxLines}`.
156    async fn read_text_lines(
157        &self,
158        path: &str,
159        max_lines: Option<usize>,
160        cancel: Option<&CancellationToken>,
161    ) -> Result<Vec<String>, FileError>;
162
163    async fn read_binary_file(
164        &self,
165        path: &str,
166        cancel: Option<&CancellationToken>,
167    ) -> Result<Vec<u8>, FileError>;
168
169    /// Creates parent directories (recursive) automatically. Mirrors TS
170    /// `writeFile`.
171    async fn write_file(
172        &self,
173        path: &str,
174        content: FileContent,
175        cancel: Option<&CancellationToken>,
176    ) -> Result<(), FileError>;
177
178    async fn append_file(
179        &self,
180        path: &str,
181        content: FileContent,
182        cancel: Option<&CancellationToken>,
183    ) -> Result<(), FileError>;
184
185    async fn rename_file(
186        &self,
187        source: &str,
188        dest: &str,
189        cancel: Option<&CancellationToken>,
190    ) -> Result<(), FileError>;
191
192    /// Uses `symlink_metadata` (lstat — symlinks not followed). Mirrors TS
193    /// `fileInfo`.
194    async fn file_info(
195        &self,
196        path: &str,
197        cancel: Option<&CancellationToken>,
198    ) -> Result<FileInfo, FileError>;
199
200    async fn list_dir(
201        &self,
202        path: &str,
203        cancel: Option<&CancellationToken>,
204    ) -> Result<Vec<FileInfo>, FileError>;
205
206    /// Follows symlinks (`canonicalize`). Mirrors TS `canonicalPath`. Backends
207    /// may return `not_found` or `not_supported` for missing/unsupported paths;
208    /// callers (mutation queue) fall back to absolute path on those codes.
209    async fn canonical_path(
210        &self,
211        path: &str,
212        cancel: Option<&CancellationToken>,
213    ) -> Result<PathBuf, FileError>;
214
215    /// `false` ONLY for `not_found`; other errors propagate. Mirrors TS
216    /// `exists` (which delegates to `fileInfo`, NOT `access(F_OK)`).
217    async fn exists(
218        &self,
219        path: &str,
220        cancel: Option<&CancellationToken>,
221    ) -> Result<bool, FileError>;
222
223    /// Default `recursive=true`. Mirrors TS `createDir{recursive}`.
224    async fn create_dir(
225        &self,
226        path: &str,
227        recursive: bool,
228        cancel: Option<&CancellationToken>,
229    ) -> Result<(), FileError>;
230
231    /// Defaults `recursive=false`, `force=false`. Mirrors TS `remove`.
232    async fn remove(
233        &self,
234        path: &str,
235        recursive: bool,
236        force: bool,
237        cancel: Option<&CancellationToken>,
238    ) -> Result<(), FileError>;
239
240    /// Default prefix `"tmp-"`. Mirrors TS `createTempDir{prefix}`.
241    async fn create_temp_dir(
242        &self,
243        prefix: Option<&str>,
244        cancel: Option<&CancellationToken>,
245    ) -> Result<PathBuf, FileError>;
246
247    /// Mirrors TS `createTempFile{prefix,suffix}` (defaults empty). Note: the
248    /// TS impl creates an isolated temp **dir** per file; backends may simplify
249    /// to a single file in the OS temp dir (document any divergence).
250    async fn create_temp_file(
251        &self,
252        prefix: &str,
253        suffix: &str,
254        cancel: Option<&CancellationToken>,
255    ) -> Result<PathBuf, FileError>;
256
257    /// Best-effort, never returns `Err`. Mirrors TS `cleanup` (kills tracked
258    /// child processes, etc.).
259    async fn cleanup(&self);
260}
261
262/// Mirrors TS `Shell`.
263#[async_trait]
264pub trait Shell: Send + Sync {
265    /// Execute `command` in a bash shell. The TS env resolves a bash binary
266    /// (git-bash on Windows, `/bin/bash` on POSIX, `/bin/sh` fallback on POSIX).
267    /// There is NO cmd.exe fallback on Windows — failure to find bash is
268    /// `ExecutionError::ShellUnavailable`.
269    async fn exec<'a>(
270        &'a self,
271        command: &str,
272        options: ShellExecOptions<'a>,
273    ) -> Result<ShellOutput, ExecutionError>;
274
275    /// Best-effort, never returns `Err`. Mirrors TS `Shell.cleanup`.
276    async fn cleanup(&self);
277}
278
279/// Mirrors TS `ExecutionEnv extends FileSystem, Shell`. A marker combining both
280/// capabilities — tools receive `Arc<dyn ExecutionEnv>` (or a concrete env) and
281/// use `FileSystem` + `Shell` methods interchangeably.
282#[async_trait]
283pub trait ExecutionEnv: FileSystem + Shell {}
284
285/// Check `cancel` at a method entry point, returning `FileError::aborted()`
286/// when set. Mirrors the TS `abortResult<T>(signal, path?)` helper used at the
287/// top of every `FileSystem` method.
288pub fn check_cancel_file(
289    cancel: Option<&CancellationToken>,
290    path: Option<&str>,
291) -> Result<(), FileError> {
292    if let Some(t) = cancel {
293        if t.is_cancelled() {
294            let mut err = FileError::aborted();
295            if let Some(p) = path {
296                err = err.with_path(p);
297            }
298            return Err(err);
299        }
300    }
301    Ok(())
302}
303
304/// Same as [`check_cancel_file`] but for `Shell`-side (returns
305/// `ExecutionError::aborted()`).
306pub fn check_cancel_exec(cancel: Option<&CancellationToken>) -> Result<(), ExecutionError> {
307    if let Some(t) = cancel {
308        if t.is_cancelled() {
309            return Err(ExecutionError::aborted());
310        }
311    }
312    Ok(())
313}