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}