Skip to main content

rpi_tools/
in_memory.rs

1//! `InMemoryExecutionEnv` — an in-memory `ExecutionEnv` for tests. NOT a direct
2//! mirror of a single TS file (the TS harness has no in-memory env; it tests
3//! against `NodeExecutionEnv` + tempdirs). This is the Rust port's primary test
4//! double, modeled to satisfy the same `FileSystem` + `Shell` contract so the
5//! conformance suite can run identical assertions against both `InMemory` and
6//! `Os`.
7//!
8//! Storage: `BTreeMap<String, InMemoryFile>` keyed by absolute path string.
9//! `mtime_ms` advances on each mutation (synthetic — there's no real clock, so
10//! we use a monotonic counter so order is stable + determinisitic-ish; the TS
11//! uses real `Date.now()` but tests only check is_newer-than-prev).
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17use async_trait::async_trait;
18use tokio::sync::Mutex;
19use tokio_util::sync::CancellationToken;
20
21use crate::env::{
22    check_cancel_exec, check_cancel_file, ExecutionEnv, FileContent, FileInfo, FileKind,
23    FileSystem, Shell, ShellExecOptions, ShellOutput,
24};
25#[cfg(test)]
26use crate::error::ExecutionErrorCode;
27use crate::error::{io_to_file_code, ExecutionError, FileError, FileErrorCode};
28use crate::file_mutation_queue::{MutatingEnv, MutationQueueRegistry};
29
30/// An in-memory file: bytes + mtime counter.
31#[derive(Clone)]
32struct InMemoryFile {
33    bytes: Vec<u8>,
34    mtime_ms: i64,
35    kind: FileKind,
36}
37
38#[derive(Default)]
39struct Inner {
40    cwd: PathBuf,
41    files: BTreeMap<String, InMemoryFile>,
42    /// Synthetic monotonic clock — every mutation bumps this so `mtime_ms`
43    /// strictly increases within a process.
44    clock: i64,
45    /// Registered shell scripts: command-prefix → canned result. The `exec`
46    /// impl looks up the longest matching prefix.
47    shell_scripts: Vec<(String, ShellScript)>,
48    /// Tracked "child pids" — simulated; `cleanup` clears them. Kept as a set
49    /// of opaque i64 handles.
50    active_pids: Vec<i64>,
51}
52
53/// A canned shell result for `InMemoryExecutionEnv::exec`.
54#[derive(Clone)]
55pub struct ShellScript {
56    pub stdout: String,
57    pub stderr: String,
58    pub exit_code: i32,
59    /// Optional delay before the result resolves (simulates a slow command for
60    /// timeout/abort tests).
61    pub delay_ms: Option<u64>,
62}
63
64impl ShellScript {
65    pub fn success(stdout: impl Into<String>) -> Self {
66        Self {
67            stdout: stdout.into(),
68            stderr: String::new(),
69            exit_code: 0,
70            delay_ms: None,
71        }
72    }
73    pub fn failure(exit_code: i32, stderr: impl Into<String>) -> Self {
74        Self {
75            stdout: String::new(),
76            stderr: stderr.into(),
77            exit_code,
78            delay_ms: None,
79        }
80    }
81}
82
83/// In-memory `ExecutionEnv`. Clone shares the underlying state (cheap — the
84/// `Arc<Mutex<Inner>>` is shared).
85#[derive(Clone)]
86pub struct InMemoryExecutionEnv {
87    inner: Arc<Mutex<Inner>>,
88    registry: Arc<MutationQueueRegistry>,
89    /// Cwd snapshot, set at construction and never mutated. Stored outside the
90    /// mutex so the sync `FileSystem::cwd()` can return `&Path` without locking.
91    cwd_snapshot: Arc<PathBuf>,
92}
93
94impl InMemoryExecutionEnv {
95    pub fn new() -> Self {
96        Self::with_cwd(PathBuf::from("/"))
97    }
98
99    pub fn with_cwd(cwd: PathBuf) -> Self {
100        let snap = Arc::new(cwd.clone());
101        let mut files: BTreeMap<String, InMemoryFile> = BTreeMap::new();
102        // The cwd (and every ancestor) always exists on a real fs — you're in
103        // it. Register them as directories so tools that default their search
104        // path to `.` (ls/find/grep) can `list_dir`/`exists` the cwd without the
105        // test needing to `make_dir` it first. Matches `OsExecutionEnv` where
106        // `cwd()` is guaranteed to resolve + be listable.
107        let cwd_norm = Self::norm(&cwd.to_string_lossy());
108        let mut acc = String::new();
109        for seg in cwd_norm.trim_matches('/').split('/') {
110            acc = if acc.is_empty() {
111                format!("/{seg}")
112            } else {
113                format!("{acc}/{seg}")
114            };
115            files.insert(
116                acc.clone(),
117                InMemoryFile {
118                    bytes: Vec::new(),
119                    mtime_ms: 0,
120                    kind: FileKind::Directory,
121                },
122            );
123        }
124        Self {
125            inner: Arc::new(Mutex::new(Inner {
126                cwd,
127                files,
128                clock: 0,
129                shell_scripts: Vec::new(),
130                active_pids: Vec::new(),
131            })),
132            registry: Arc::new(MutationQueueRegistry::new()),
133            cwd_snapshot: snap,
134        }
135    }
136
137    /// Register a canned shell result for commands whose start matches `prefix`.
138    /// Longest-prefix match wins. Mirrors the faux-provider script pattern.
139    pub async fn register_shell(&self, prefix: impl Into<String>, script: ShellScript) {
140        let mut g = self.inner.lock().await;
141        g.shell_scripts.push((prefix.into(), script));
142        // Stable: longest-prefix-first lookup below.
143        g.shell_scripts.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
144    }
145
146    /// Directly seed a file at an absolute path (test setup). Bumps mtime.
147    pub async fn seed_file(&self, abs_path: &str, bytes: Vec<u8>) {
148        let mut g = self.inner.lock().await;
149        g.clock += 1;
150        let mtime = g.clock;
151        g.files.insert(
152            abs_path.to_string(),
153            InMemoryFile {
154                bytes,
155                mtime_ms: mtime,
156                kind: FileKind::File,
157            },
158        );
159    }
160
161    async fn bump_clock(&self, inner: &mut Inner) -> i64 {
162        inner.clock += 1;
163        inner.clock
164    }
165
166    /// Resolve a path to an absolute `PathBuf` using the env's cwd.
167    ///
168    /// After joining, `.`/`..` components are collapsed component-by-component
169    /// (without a FS round-trip) to match `OsExecutionEnv::normalize_absolute`.
170    /// This matters because `PathBuf::join` preserves `.` as a `CurDir`
171    /// component: `cwd.join(".")` yields `/tmp/work/.`, which would miss the
172    /// `BTreeMap` key `/tmp/work` (and break tools that default their search
173    /// path to `.`). Collapsing here keeps the in-memory env consistent with the
174    /// OS env — every `absolute_path`/`resolve_key` consumer sees the same
175    /// canonical form both in tests (`InMemory`) and in production (`Os`).
176    fn resolve(cwd: &Path, path: &str) -> PathBuf {
177        let pb = PathBuf::from(path);
178        let joined = if pb.is_absolute() { pb } else { cwd.join(pb) };
179        let mut out = PathBuf::new();
180        for comp in joined.components() {
181            match comp {
182                std::path::Component::ParentDir => {
183                    out.pop();
184                }
185                std::path::Component::CurDir => {}
186                other => out.push(other),
187            }
188        }
189        out
190    }
191
192    /// Normalize a path string to forward slashes. The internal `BTreeMap` keys
193    /// are always stored normalized so that path-separator differences across
194    /// platforms (Windows `\` vs POSIX `/`) do not break the prefix-based
195    /// `list_dir` / `remove` lookups against POSIX-style test paths like
196    /// `/sessions` / `/proj`. `PathBuf::push`/`join` insert the OS separator
197    /// (`\` on Windows); without normalization, a file written at
198    /// `/sessions\--proj--\file.jsonl` would never match a `list_dir` prefix of
199    /// `/sessions/--proj--/` — which is exactly the cross-platform bug this
200    /// guards. This is a test double, so canonicalizing to `/` is safe and keeps
201    /// test paths portable.
202    fn norm(s: &str) -> String {
203        s.replace('\\', "/")
204    }
205
206    /// Resolve `path` against `cwd` and normalize to a forward-slash absolute
207    /// string suitable for use as a `BTreeMap` key.
208    fn resolve_key(cwd: &Path, path: &str) -> String {
209        Self::norm(&Self::resolve(cwd, path).to_string_lossy())
210    }
211}
212
213impl Default for InMemoryExecutionEnv {
214    fn default() -> Self {
215        Self::new()
216    }
217}
218
219#[async_trait]
220impl FileSystem for InMemoryExecutionEnv {
221    fn cwd(&self) -> &Path {
222        &self.cwd_snapshot
223    }
224
225    async fn absolute_path(
226        &self,
227        path: &str,
228        cancel: Option<&CancellationToken>,
229    ) -> Result<PathBuf, FileError> {
230        check_cancel_file(cancel, Some(path))?;
231        let g = self.inner.lock().await;
232        let resolved = Self::resolve(&g.cwd, path).to_string_lossy().into_owned();
233        // Normalize to forward slashes so absolute_path() returns a string
234        // consistent with the BTreeMap keys (POSIX-style), keeping the repo's
235        // join_path + write_file + list_dir chain coherent on Windows.
236        Ok(PathBuf::from(Self::norm(&resolved)))
237    }
238
239    async fn join_path(
240        &self,
241        parts: &[&str],
242        cancel: Option<&CancellationToken>,
243    ) -> Result<PathBuf, FileError> {
244        check_cancel_file(cancel, None)?;
245        let mut out = String::new();
246        for p in parts {
247            if out.is_empty() {
248                out = p.to_string();
249            } else if out.ends_with('/') {
250                out.push_str(p);
251            } else {
252                out.push('/');
253                out.push_str(p);
254            }
255        }
256        // Normalize any `\` the parts may carry to `/`.
257        Ok(PathBuf::from(Self::norm(&out)))
258    }
259
260    async fn read_text_file(
261        &self,
262        path: &str,
263        cancel: Option<&CancellationToken>,
264    ) -> Result<String, FileError> {
265        let bytes = self.read_binary_file(path, cancel).await?;
266        String::from_utf8(bytes).map_err(|e| {
267            FileError::new(FileErrorCode::Invalid, format!("invalid utf-8: {e}")).with_path(path)
268        })
269    }
270
271    async fn read_text_lines(
272        &self,
273        path: &str,
274        max_lines: Option<usize>,
275        cancel: Option<&CancellationToken>,
276    ) -> Result<Vec<String>, FileError> {
277        check_cancel_file(cancel, Some(path))?;
278        if max_lines.map(|m| m == 0).unwrap_or(false) {
279            return Ok(Vec::new());
280        }
281        let text = self.read_text_file(path, cancel).await?;
282        let mut lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();
283        if text.ends_with('\n') {
284            if lines.last().map(|s| s.is_empty()).unwrap_or(false) {
285                lines.pop();
286            }
287        }
288        if let Some(m) = max_lines {
289            lines.truncate(m);
290        }
291        Ok(lines)
292    }
293
294    async fn read_binary_file(
295        &self,
296        path: &str,
297        cancel: Option<&CancellationToken>,
298    ) -> Result<Vec<u8>, FileError> {
299        check_cancel_file(cancel, Some(path))?;
300        let g = self.inner.lock().await;
301        let abs = Self::resolve_key(&g.cwd, path);
302        match g.files.get(&abs) {
303            Some(f) if f.kind == FileKind::File => Ok(f.bytes.clone()),
304            Some(f) if f.kind == FileKind::Directory => Err(FileError::new(
305                FileErrorCode::IsDirectory,
306                "path is a directory",
307            )
308            .with_path(&abs)),
309            Some(f) if f.kind == FileKind::Symlink => {
310                // Symlinks aren't really modeled here; treat as not-supported.
311                Err(
312                    FileError::new(FileErrorCode::NotSupported, "symlink read unsupported")
313                        .with_path(&abs),
314                )
315            }
316            _ => Err(FileError::not_found(&abs)),
317        }
318    }
319
320    async fn write_file(
321        &self,
322        path: &str,
323        content: FileContent,
324        cancel: Option<&CancellationToken>,
325    ) -> Result<(), FileError> {
326        check_cancel_file(cancel, Some(path))?;
327        let mut g = self.inner.lock().await;
328        let abs = Self::resolve_key(&g.cwd, path);
329        // Auto-create parent dirs (normalized so the key matches what later
330        // reads/writes look up).
331        let parent = Path::new(&abs)
332            .parent()
333            .map(|p| Self::norm(&p.to_string_lossy()));
334        if let Some(p) = parent {
335            if !p.is_empty() && !g.files.contains_key(&p) {
336                let mtime = self.bump_clock(&mut g).await;
337                g.files.insert(
338                    p,
339                    InMemoryFile {
340                        bytes: Vec::new(),
341                        mtime_ms: mtime,
342                        kind: FileKind::Directory,
343                    },
344                );
345            }
346        }
347        let mtime = self.bump_clock(&mut g).await;
348        g.files.insert(
349            abs.clone(),
350            InMemoryFile {
351                bytes: content.as_bytes().to_vec(),
352                mtime_ms: mtime,
353                kind: FileKind::File,
354            },
355        );
356        Ok(())
357    }
358
359    async fn append_file(
360        &self,
361        path: &str,
362        content: FileContent,
363        cancel: Option<&CancellationToken>,
364    ) -> Result<(), FileError> {
365        check_cancel_file(cancel, Some(path))?;
366        let mut g = self.inner.lock().await;
367        let abs = Self::resolve_key(&g.cwd, path);
368        let mtime = self.bump_clock(&mut g).await;
369        match g.files.get_mut(&abs) {
370            Some(f) if f.kind == FileKind::File => {
371                f.bytes.extend_from_slice(content.as_bytes());
372                f.mtime_ms = mtime;
373                Ok(())
374            }
375            Some(f) if f.kind == FileKind::Directory => Err(FileError::new(
376                FileErrorCode::IsDirectory,
377                "path is a directory",
378            )
379            .with_path(&abs)),
380            _ => {
381                g.files.insert(
382                    abs.clone(),
383                    InMemoryFile {
384                        bytes: content.as_bytes().to_vec(),
385                        mtime_ms: mtime,
386                        kind: FileKind::File,
387                    },
388                );
389                Ok(())
390            }
391        }
392    }
393
394    async fn rename_file(
395        &self,
396        source: &str,
397        dest: &str,
398        cancel: Option<&CancellationToken>,
399    ) -> Result<(), FileError> {
400        check_cancel_file(cancel, Some(source))?;
401        let mut g = self.inner.lock().await;
402        let src_abs = Self::resolve_key(&g.cwd, source);
403        let dst_abs = Self::resolve_key(&g.cwd, dest);
404        match g.files.remove(&src_abs) {
405            Some(f) => {
406                let mtime = self.bump_clock(&mut g).await;
407                g.files.insert(
408                    dst_abs.clone(),
409                    InMemoryFile {
410                        bytes: f.bytes,
411                        mtime_ms: mtime,
412                        kind: f.kind,
413                    },
414                );
415                Ok(())
416            }
417            None => Err(FileError::not_found(&src_abs)),
418        }
419    }
420
421    async fn file_info(
422        &self,
423        path: &str,
424        cancel: Option<&CancellationToken>,
425    ) -> Result<FileInfo, FileError> {
426        check_cancel_file(cancel, Some(path))?;
427        let g = self.inner.lock().await;
428        let abs = Self::resolve_key(&g.cwd, path);
429        match g.files.get(&abs) {
430            Some(f) => Ok(FileInfo {
431                name: Path::new(&abs)
432                    .file_name()
433                    .map(|n| n.to_string_lossy().into_owned())
434                    .unwrap_or_else(|| abs.clone()),
435                path: PathBuf::from(&abs),
436                kind: f.kind,
437                size: f.bytes.len() as u64,
438                mtime_ms: f.mtime_ms,
439            }),
440            None => Err(FileError::not_found(&abs)),
441        }
442    }
443
444    async fn list_dir(
445        &self,
446        path: &str,
447        cancel: Option<&CancellationToken>,
448    ) -> Result<Vec<FileInfo>, FileError> {
449        check_cancel_file(cancel, Some(path))?;
450        let g = self.inner.lock().await;
451        let abs = Self::resolve_key(&g.cwd, path);
452        // Normalize the directory prefix for matching children.
453        let prefix = if abs.ends_with('/') {
454            abs.clone()
455        } else {
456            format!("{abs}/")
457        };
458        // Verify the dir itself exists.
459        let dir_exists = g
460            .files
461            .get(&abs)
462            .map(|f| f.kind == FileKind::Directory)
463            .unwrap_or(abs == "/");
464        if !dir_exists {
465            return Err(if g.files.contains_key(&abs) {
466                FileError::new(FileErrorCode::NotDirectory, "path is not a directory")
467                    .with_path(&abs)
468            } else {
469                FileError::not_found(&abs)
470            });
471        }
472        let mut out = Vec::new();
473        for (k, f) in g.files.iter() {
474            if let Some(rest) = k.strip_prefix(&prefix) {
475                // Direct child: no further '/'.
476                if !rest.is_empty() && !rest.contains('/') {
477                    out.push(FileInfo {
478                        name: rest.to_string(),
479                        path: PathBuf::from(k),
480                        kind: f.kind,
481                        size: f.bytes.len() as u64,
482                        mtime_ms: f.mtime_ms,
483                    });
484                }
485            }
486        }
487        Ok(out)
488    }
489
490    async fn canonical_path(
491        &self,
492        path: &str,
493        cancel: Option<&CancellationToken>,
494    ) -> Result<PathBuf, FileError> {
495        check_cancel_file(cancel, Some(path))?;
496        let g = self.inner.lock().await;
497        let abs = Self::resolve_key(&g.cwd, path);
498        if g.files.contains_key(&abs) || abs == "/" {
499            Ok(PathBuf::from(&abs))
500        } else {
501            Err(FileError::not_found(&abs))
502        }
503    }
504
505    async fn exists(
506        &self,
507        path: &str,
508        cancel: Option<&CancellationToken>,
509    ) -> Result<bool, FileError> {
510        check_cancel_file(cancel, Some(path))?;
511        let g = self.inner.lock().await;
512        let abs = Self::resolve_key(&g.cwd, path);
513        Ok(g.files.contains_key(&abs) || abs == "/")
514    }
515
516    async fn create_dir(
517        &self,
518        path: &str,
519        recursive: bool,
520        cancel: Option<&CancellationToken>,
521    ) -> Result<(), FileError> {
522        check_cancel_file(cancel, Some(path))?;
523        let mut g = self.inner.lock().await;
524        let abs = Self::resolve_key(&g.cwd, path);
525        if recursive {
526            // Create all ancestor dirs.
527            let mut acc = String::new();
528            for seg in abs.trim_matches('/').split('/') {
529                if acc.is_empty() {
530                    acc = format!("/{seg}");
531                } else {
532                    acc = format!("{acc}/{seg}");
533                }
534                if !g.files.contains_key(&acc) {
535                    let mtime = self.bump_clock(&mut g).await;
536                    g.files.insert(
537                        acc.clone(),
538                        InMemoryFile {
539                            bytes: Vec::new(),
540                            mtime_ms: mtime,
541                            kind: FileKind::Directory,
542                        },
543                    );
544                }
545            }
546        } else {
547            if !g.files.contains_key(&abs) {
548                let mtime = self.bump_clock(&mut g).await;
549                g.files.insert(
550                    abs.clone(),
551                    InMemoryFile {
552                        bytes: Vec::new(),
553                        mtime_ms: mtime,
554                        kind: FileKind::Directory,
555                    },
556                );
557            }
558        }
559        Ok(())
560    }
561
562    async fn remove(
563        &self,
564        path: &str,
565        recursive: bool,
566        force: bool,
567        cancel: Option<&CancellationToken>,
568    ) -> Result<(), FileError> {
569        check_cancel_file(cancel, Some(path))?;
570        let mut g = self.inner.lock().await;
571        let abs = Self::resolve_key(&g.cwd, path);
572        if !g.files.contains_key(&abs) {
573            if force {
574                return Ok(());
575            }
576            return Err(FileError::not_found(&abs));
577        }
578        if recursive {
579            // Remove this + all descendants.
580            let prefix = if abs.ends_with('/') {
581                abs.clone()
582            } else {
583                format!("{abs}/")
584            };
585            let to_remove: Vec<String> = g
586                .files
587                .keys()
588                .filter(|k| *k == &abs || k.starts_with(&prefix))
589                .cloned()
590                .collect();
591            for k in to_remove {
592                g.files.remove(&k);
593            }
594        } else {
595            // Non-recursive: error if dir has children.
596            let prefix = if abs.ends_with('/') {
597                abs.clone()
598            } else {
599                format!("{abs}/")
600            };
601            let has_children = g.files.keys().any(|k| k.starts_with(&prefix));
602            if has_children {
603                return Err(FileError::new(
604                    FileErrorCode::NotSupported,
605                    "directory not empty (use recursive)",
606                )
607                .with_path(&abs));
608            }
609            g.files.remove(&abs);
610        }
611        Ok(())
612    }
613
614    async fn create_temp_dir(
615        &self,
616        prefix: Option<&str>,
617        cancel: Option<&CancellationToken>,
618    ) -> Result<PathBuf, FileError> {
619        check_cancel_file(cancel, None)?;
620        let mut g = self.inner.lock().await;
621        let prefix = prefix.unwrap_or("tmp-");
622        // Use the synthetic clock for a unique suffix.
623        let n = g.clock + 1;
624        let name = format!("{}{}", prefix, unique_suffix(n));
625        let abs = format!("/tmp/{name}");
626        let mtime = self.bump_clock(&mut g).await;
627        g.files.insert(
628            abs.clone(),
629            InMemoryFile {
630                bytes: Vec::new(),
631                mtime_ms: mtime,
632                kind: FileKind::Directory,
633            },
634        );
635        Ok(PathBuf::from(abs))
636    }
637
638    async fn create_temp_file(
639        &self,
640        prefix: &str,
641        suffix: &str,
642        cancel: Option<&CancellationToken>,
643    ) -> Result<PathBuf, FileError> {
644        check_cancel_file(cancel, None)?;
645        let mut g = self.inner.lock().await;
646        let n = g.clock + 1;
647        let name = format!("{}{}{}", prefix, unique_suffix(n), suffix);
648        let abs = format!("/tmp/{name}");
649        let mtime = self.bump_clock(&mut g).await;
650        g.files.insert(
651            abs.clone(),
652            InMemoryFile {
653                bytes: Vec::new(),
654                mtime_ms: mtime,
655                kind: FileKind::File,
656            },
657        );
658        Ok(PathBuf::from(abs))
659    }
660
661    async fn cleanup(&self) {
662        let mut g = self.inner.lock().await;
663        g.active_pids.clear();
664    }
665}
666
667#[async_trait]
668impl Shell for InMemoryExecutionEnv {
669    async fn exec<'a>(
670        &'a self,
671        command: &str,
672        mut options: ShellExecOptions<'a>,
673    ) -> Result<ShellOutput, ExecutionError> {
674        check_cancel_exec(options.cancel)?;
675        let scripts = {
676            let g = self.inner.lock().await;
677            g.shell_scripts.clone()
678        };
679        // Longest-prefix match.
680        let matched = scripts.iter().find(|(p, _)| command.starts_with(p));
681        let script = match matched {
682            Some((_, s)) => s.clone(),
683            None => ShellScript {
684                stdout: String::new(),
685                stderr: format!("no shell script registered for: {command}"),
686                exit_code: 127,
687                delay_ms: None,
688            },
689        };
690
691        // Simulate a child pid tracking.
692        {
693            let mut g = self.inner.lock().await;
694            let pid = g.clock;
695            g.active_pids.push(pid);
696        }
697
698        // Optional delay — honor cancellation during it.
699        if let Some(ms) = script.delay_ms {
700            let dur = std::time::Duration::from_millis(ms);
701            if let Some(token) = options.cancel {
702                tokio::select! {
703                    _ = tokio::time::sleep(dur) => {}
704                    _ = token.cancelled() => {
705                        return Err(ExecutionError::aborted());
706                    }
707                }
708            } else if let Some(secs) = options.timeout {
709                let to = std::time::Duration::from_secs_f64(secs);
710                let sleep = tokio::time::sleep(dur.min(to));
711                tokio::pin!(sleep);
712                let deadline = tokio::time::sleep(to);
713                tokio::pin!(deadline);
714                tokio::select! {
715                    _ = &mut sleep => {}
716                    _ = &mut deadline => {
717                        return Err(ExecutionError::timeout(secs as u64));
718                    }
719                }
720            } else {
721                tokio::time::sleep(dur).await;
722            }
723        } else if let Some(secs) = options.timeout {
724            // No delay but a timeout — simulate instant completion; the timeout
725            // only matters if `delay_ms` exceeds it (handled above). If a test
726            // wants timeout-without-delay it should register a script with
727            // delay_ms > timeout.
728            let _ = secs;
729        }
730
731        // Stream stdout/stderr to the callbacks (single chunk).
732        if let Some(cb) = options.on_stdout.as_mut() {
733            if !script.stdout.is_empty() {
734                cb(&script.stdout);
735            }
736        }
737        if let Some(cb) = options.on_stderr.as_mut() {
738            if !script.stderr.is_empty() {
739                cb(&script.stderr);
740            }
741        }
742
743        Ok(ShellOutput {
744            stdout: script.stdout,
745            stderr: script.stderr,
746            exit_code: script.exit_code,
747        })
748    }
749
750    async fn cleanup(&self) {
751        let mut g = self.inner.lock().await;
752        g.active_pids.clear();
753    }
754}
755
756#[async_trait]
757impl ExecutionEnv for InMemoryExecutionEnv {}
758
759#[async_trait]
760impl MutatingEnv for InMemoryExecutionEnv {
761    fn as_env(&self) -> &dyn ExecutionEnv {
762        self
763    }
764    fn mutation_registry(&self) -> &MutationQueueRegistry {
765        &self.registry
766    }
767}
768
769/// Generate a deterministic-ish unique suffix from a counter. Mirrors
770/// `mkdtemp`'s 6-char suffix shape loosely (test-only).
771fn unique_suffix(n: i64) -> String {
772    // Base36 of the counter, zero-padded to 6 chars.
773    let mut s = String::new();
774    let mut v = n.max(0) as u64;
775    if v == 0 {
776        return "000000".to_string();
777    }
778    while v > 0 {
779        let d = (v % 36) as u8;
780        let c = if d < 10 { b'0' + d } else { b'a' + (d - 10) };
781        s.insert(0, c as char);
782        v /= 36;
783    }
784    while s.len() < 6 {
785        s.insert(0, '0');
786    }
787    s
788}
789
790/// io classification helper re-exported for env consumers (e.g. `OsExecutionEnv`
791/// test doubles). Kept here so the in-memory env can classify synthetic errors
792/// without duplicating the table.
793#[allow(dead_code)]
794fn unused_io_reexport(_: std::io::Error) -> FileErrorCode {
795    io_to_file_code(&std::io::Error::from(std::io::ErrorKind::Other))
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    #[tokio::test]
803    async fn write_read_roundtrip() {
804        let env = InMemoryExecutionEnv::new();
805        env.write_file("/a/b.txt", "hello".into(), None)
806            .await
807            .unwrap();
808        let text = env.read_text_file("/a/b.txt", None).await.unwrap();
809        assert_eq!(text, "hello");
810        // Parent dir auto-created.
811        assert!(env.exists("/a", None).await.unwrap());
812    }
813
814    #[tokio::test]
815    async fn rename_moves_bytes() {
816        let env = InMemoryExecutionEnv::new();
817        env.write_file("/x.txt", "data".into(), None).await.unwrap();
818        env.rename_file("/x.txt", "/y.txt", None).await.unwrap();
819        assert!(!env.exists("/x.txt", None).await.unwrap());
820        assert_eq!(env.read_text_file("/y.txt", None).await.unwrap(), "data");
821    }
822
823    #[tokio::test]
824    async fn list_dir_children() {
825        let env = InMemoryExecutionEnv::new();
826        env.create_dir("/d", true, None).await.unwrap();
827        env.write_file("/d/a.txt", "1".into(), None).await.unwrap();
828        env.write_file("/d/b.txt", "2".into(), None).await.unwrap();
829        let entries = env.list_dir("/d", None).await.unwrap();
830        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
831        assert!(names.contains(&"a.txt"));
832        assert!(names.contains(&"b.txt"));
833    }
834
835    #[tokio::test]
836    async fn remove_recursive() {
837        let env = InMemoryExecutionEnv::new();
838        env.create_dir("/r", true, None).await.unwrap();
839        env.write_file("/r/a.txt", "1".into(), None).await.unwrap();
840        env.remove("/r", true, true, None).await.unwrap();
841        assert!(!env.exists("/r", None).await.unwrap());
842        assert!(!env.exists("/r/a.txt", None).await.unwrap());
843    }
844
845    #[tokio::test]
846    async fn temp_dir_and_file_are_unique() {
847        let env = InMemoryExecutionEnv::new();
848        let d1 = env.create_temp_dir(Some("prefix-"), None).await.unwrap();
849        let d2 = env.create_temp_dir(Some("prefix-"), None).await.unwrap();
850        assert_ne!(d1, d2);
851        let f1 = env.create_temp_file("pre-", "-suf", None).await.unwrap();
852        let f2 = env.create_temp_file("pre-", "-suf", None).await.unwrap();
853        assert_ne!(f1, f2);
854    }
855
856    #[tokio::test]
857    async fn shell_script_longest_prefix_match() {
858        let env = InMemoryExecutionEnv::new();
859        env.register_shell("git", ShellScript::success("git-out"))
860            .await;
861        env.register_shell("git status", ShellScript::success("clean"))
862            .await;
863        let out = env
864            .exec("git status", ShellExecOptions::default())
865            .await
866            .unwrap();
867        assert_eq!(out.stdout, "clean");
868        let out2 = env
869            .exec("git log", ShellExecOptions::default())
870            .await
871            .unwrap();
872        assert_eq!(out2.stdout, "git-out");
873    }
874
875    #[tokio::test]
876    async fn cancel_returns_aborted() {
877        let env = InMemoryExecutionEnv::new();
878        let token = CancellationToken::new();
879        token.cancel();
880        let r = env
881            .exec(
882                "anything",
883                ShellExecOptions {
884                    cancel: Some(&token),
885                    ..Default::default()
886                },
887            )
888            .await;
889        assert!(matches!(
890            r,
891            Err(ExecutionError {
892                code: ExecutionErrorCode::Aborted,
893                ..
894            })
895        ));
896    }
897}