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}