Skip to main content

fs_transaction/fs/
memory.rs

1//! An in-memory [`Storage`] backend.
2//!
3//! Available on every target this crate compiles for — including
4//! `wasm32-unknown-unknown`, where it needs no browser API at all. Useful for
5//! tests and sandboxes, and for clients (a WASM frontend with no direct disk
6//! access) that load a workspace into memory up front and persist it
7//! out-of-band (export/import, a network round-trip, OPFS as a bulk blob).
8
9use std::collections::{HashMap, HashSet};
10use std::io::{self, Error, ErrorKind};
11use std::path::{Component, Path, PathBuf};
12use std::sync::{Arc, RwLock};
13
14use super::{DirEntry, FileType, Metadata, ReadStorage};
15
16use super::{Capabilities, Storage};
17
18/// An in-memory, clone-shared [`Storage`] backend.
19///
20/// Content lives behind `Arc<RwLock<_>>`, so cloning an `InMemoryFs` is cheap
21/// and every clone sees the same files — the same relationship an `Arc<StdFs>`
22/// has to the one real filesystem it names, but without needing the `Arc`
23/// wrapper, since it's built into the type. `std::sync::RwLock` (not a
24/// runtime's async lock) is deliberate: every method here runs to completion
25/// without ever awaiting *inside* the critical section, so there is nothing
26/// for an async lock to buy, and a plain `std::sync` primitive is the one
27/// that's guaranteed to exist — and to compile — on `wasm32-unknown-unknown`,
28/// which has no threads and no async-runtime assumption to lean on.
29///
30/// Text and binary content are stored separately (a write picks one store
31/// based on whether the bytes are valid UTF-8) so that a round-trip through
32/// [`export_entries`](Self::export_entries) — text only — stays plain
33/// strings, the shape a JS/WASM caller wants. Directories are tracked
34/// explicitly (in a `HashSet`) rather than inferred from file paths, so an
35/// empty directory `create_dir_all` created still shows up in
36/// [`read_dir`](ReadStorage::read_dir).
37///
38/// Symlinks may be added with [`add_symlink`](Self::add_symlink): reading or
39/// getting [`metadata`](ReadStorage::metadata) of the link resolves to the
40/// target's content, matching [`ReadStorage::metadata`]'s documented
41/// "follows symlinks" contract. Resolution is a single hop, not a followed
42/// chain — a symlink to a symlink is not resolved further — which is all the
43/// coherence a test double needs; a real filesystem's chain-following and
44/// cycle detection isn't reproduced here.
45#[derive(Debug, Clone, Default)]
46pub struct InMemoryFs {
47    /// Text files, stored as path -> content.
48    files: Arc<RwLock<HashMap<PathBuf, String>>>,
49    /// Binary (non-UTF-8) files, stored as path -> bytes.
50    binary_files: Arc<RwLock<HashMap<PathBuf, Vec<u8>>>>,
51    /// Directories known to exist — implicitly populated by every write's
52    /// parent chain, and by an explicit `create_dir_all`.
53    directories: Arc<RwLock<HashSet<PathBuf>>>,
54    /// Symlinks: link path -> target path. Reading the link path resolves to
55    /// the target's content; the parent's `read_dir` reports the link itself
56    /// as [`FileType::SYMLINK`].
57    symlinks: Arc<RwLock<HashMap<PathBuf, PathBuf>>>,
58}
59
60impl InMemoryFs {
61    /// An empty in-memory filesystem.
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// A filesystem pre-populated with text files (and the directories that
67    /// contain them).
68    pub fn with_files(entries: Vec<(PathBuf, String)>) -> Self {
69        let fs = Self::new();
70        {
71            let mut files = fs.files.write().unwrap();
72            let mut dirs = fs.directories.write().unwrap();
73            for (path, content) in entries {
74                insert_ancestor_dirs(&mut dirs, &path);
75                files.insert(path, content);
76            }
77        }
78        fs
79    }
80
81    /// Load files from `(path_string, content)` pairs — convenience for a
82    /// caller (JS/WASM interop) that only has strings, not `PathBuf`s.
83    pub fn load_from_entries(entries: Vec<(String, String)>) -> Self {
84        Self::with_files(
85            entries
86                .into_iter()
87                .map(|(path, content)| (PathBuf::from(path), content))
88                .collect(),
89        )
90    }
91
92    /// Every text file, as `(path_string, content)` pairs — the counterpart to
93    /// [`load_from_entries`](Self::load_from_entries), for persisting a
94    /// session's edits back out.
95    pub fn export_entries(&self) -> Vec<(String, String)> {
96        self.files
97            .read()
98            .unwrap()
99            .iter()
100            .map(|(path, content)| (path.to_string_lossy().into_owned(), content.clone()))
101            .collect()
102    }
103
104    /// Every binary file, as `(path_string, content_bytes)` pairs.
105    pub fn export_binary_entries(&self) -> Vec<(String, Vec<u8>)> {
106        self.binary_files
107            .read()
108            .unwrap()
109            .iter()
110            .map(|(path, content)| (path.to_string_lossy().into_owned(), content.clone()))
111            .collect()
112    }
113
114    /// Load binary files from `(path_string, content_bytes)` pairs.
115    pub fn load_binary_entries(&self, entries: Vec<(String, Vec<u8>)>) {
116        let mut binary_files = self.binary_files.write().unwrap();
117        let mut dirs = self.directories.write().unwrap();
118        for (path_str, content) in entries {
119            let path = PathBuf::from(path_str);
120            insert_ancestor_dirs(&mut dirs, &path);
121            binary_files.insert(path, content);
122        }
123    }
124
125    /// Every text-file path currently stored.
126    pub fn list_all_files(&self) -> Vec<PathBuf> {
127        self.files.read().unwrap().keys().cloned().collect()
128    }
129
130    /// Remove every file, directory, and symlink — resetting the filesystem to
131    /// empty without needing a fresh `InMemoryFs` (and its own, separately
132    /// shared, clones).
133    pub fn clear(&self) {
134        self.files.write().unwrap().clear();
135        self.binary_files.write().unwrap().clear();
136        self.directories.write().unwrap().clear();
137        self.symlinks.write().unwrap().clear();
138    }
139
140    /// Add a symlink from `link` to `target`. Reading `link` (or its
141    /// [`metadata`](ReadStorage::metadata)) resolves to `target`'s content;
142    /// `link`'s entry in its parent's [`read_dir`](ReadStorage::read_dir) reports
143    /// [`FileType::SYMLINK`] — the un-followed type a caller needs in order to
144    /// recognize and skip it, since `metadata` itself only ever reports the
145    /// followed, resolved type.
146    pub fn add_symlink(&self, link: &Path, target: &Path) {
147        let link = normalize_path(link);
148        let target = normalize_path(target);
149        insert_ancestor_dirs(&mut self.directories.write().unwrap(), &link);
150        self.symlinks.write().unwrap().insert(link, target);
151    }
152
153    /// The single-hop resolution [`ReadStorage::read`], [`ReadStorage::read_to_string`],
154    /// and [`ReadStorage::metadata`] all use: a symlinked path resolves to its
155    /// target; anything else resolves to itself.
156    fn resolve(&self, normalized: &Path) -> PathBuf {
157        self.symlinks
158            .read()
159            .unwrap()
160            .get(normalized)
161            .cloned()
162            .unwrap_or_else(|| normalized.to_path_buf())
163    }
164}
165
166/// Strip `.` and resolve `..` lexically — the backend has no real parent
167/// directories to walk, so this is the closest available analog of
168/// `std::fs`'s implicit path resolution, and it's what keeps
169/// `"dir/file.md"` and `"dir/sub/../file.md"` naming the same entry.
170fn normalize_path(path: &Path) -> PathBuf {
171    let mut components: Vec<Component> = Vec::new();
172    for component in path.components() {
173        match component {
174            Component::CurDir => {}
175            Component::ParentDir => {
176                if !matches!(components.last(), None | Some(Component::RootDir)) {
177                    components.pop();
178                }
179            }
180            c => components.push(c),
181        }
182    }
183    components.iter().collect()
184}
185
186/// Register every non-empty ancestor of `path` as an existing directory —
187/// the implicit parent-creation a real `write` to a nested path performs via
188/// `create_dir_all`.
189fn insert_ancestor_dirs(dirs: &mut HashSet<PathBuf>, path: &Path) {
190    let mut current = path;
191    while let Some(parent) = current.parent() {
192        if parent.as_os_str().is_empty() {
193            break;
194        }
195        dirs.insert(parent.to_path_buf());
196        current = parent;
197    }
198}
199
200fn not_found(path: &Path) -> Error {
201    Error::new(
202        ErrorKind::NotFound,
203        format!("not found: {}", path.display()),
204    )
205}
206
207impl ReadStorage for InMemoryFs {
208    async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
209        let normalized = normalize_path(path);
210        let resolved = self.resolve(&normalized);
211        if let Some(data) = self.binary_files.read().unwrap().get(&resolved) {
212            return Ok(data.clone());
213        }
214        if let Some(text) = self.files.read().unwrap().get(&resolved) {
215            return Ok(text.as_bytes().to_vec());
216        }
217        Err(not_found(path))
218    }
219
220    async fn read_to_string(&self, path: &Path) -> io::Result<String> {
221        // Built on `read` rather than duplicating its lookup: this is the one
222        // point of divergence from the crossfs reference, and it's a
223        // correctness fix, not just a dedup — reusing `read` means a binary
224        // file correctly reports `InvalidData` (mirroring
225        // `std::fs::read_to_string`) instead of a misleading `NotFound`.
226        let bytes = self.read(path).await?;
227        String::from_utf8(bytes).map_err(|e| Error::new(ErrorKind::InvalidData, e))
228    }
229
230    async fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
231        let normalized = normalize_path(path);
232        if !normalized.as_os_str().is_empty()
233            && !self.directories.read().unwrap().contains(&normalized)
234        {
235            return Err(not_found(path));
236        }
237
238        let mut result = Vec::new();
239        for entry in self.files.read().unwrap().keys() {
240            if entry.parent() == Some(normalized.as_path()) {
241                result.push(DirEntry::new(entry.clone(), FileType::FILE));
242            }
243        }
244        for entry in self.binary_files.read().unwrap().keys() {
245            if entry.parent() == Some(normalized.as_path()) {
246                result.push(DirEntry::new(entry.clone(), FileType::FILE));
247            }
248        }
249        // Listed by un-followed type — a caller that wants to skip symlinks
250        // (rather than transparently read through them) needs exactly this,
251        // since `metadata` itself only ever reports the resolved type.
252        for entry in self.symlinks.read().unwrap().keys() {
253            if entry.parent() == Some(normalized.as_path()) {
254                result.push(DirEntry::new(entry.clone(), FileType::SYMLINK));
255            }
256        }
257        for entry in self.directories.read().unwrap().iter() {
258            if entry.parent() == Some(normalized.as_path()) && entry != &normalized {
259                result.push(DirEntry::new(entry.clone(), FileType::DIR));
260            }
261        }
262        Ok(result)
263    }
264
265    async fn metadata(&self, path: &Path) -> io::Result<Metadata> {
266        let normalized = normalize_path(path);
267        let resolved = self.resolve(&normalized);
268
269        if let Some(data) = self.binary_files.read().unwrap().get(&resolved) {
270            return Ok(Metadata::new(FileType::FILE, data.len() as u64, None));
271        }
272        if let Some(text) = self.files.read().unwrap().get(&resolved) {
273            return Ok(Metadata::new(FileType::FILE, text.len() as u64, None));
274        }
275        if self.directories.read().unwrap().contains(&resolved) {
276            return Ok(Metadata::new(FileType::DIR, 0, None));
277        }
278        Err(not_found(path))
279    }
280
281    // No modification-time tracking: unlike a real filesystem there is no
282    // clock backing these bytes, and a fabricated timestamp (e.g. "now" on
283    // every write) would claim a precision this backend cannot honor across
284    // a clone or an export/import round-trip. `Metadata::modified` reports
285    // `Unsupported` accordingly — an honest "this backend doesn't know",
286    // exactly as it would for a real backend that genuinely lacks the field.
287
288    // `executable` is deliberately left at the trait's default — the decline.
289    // There is no bit behind these bytes, and answering `false` would claim
290    // one; `None` is the honest "no such thing here".
291
292    async fn read_link(&self, path: &Path) -> io::Result<Option<PathBuf>> {
293        // This backend *does* model links, so `Ok(None)` — reserved for the
294        // backend-wide decline — is never its answer: a path holding a link
295        // yields the target, and anything else is an error, exactly as
296        // `readlink` behaves.
297        let normalized = normalize_path(path);
298        if let Some(target) = self.symlinks.read().unwrap().get(&normalized) {
299            return Ok(Some(target.clone()));
300        }
301        let occupied = self.files.read().unwrap().contains_key(&normalized)
302            || self.binary_files.read().unwrap().contains_key(&normalized)
303            || self.directories.read().unwrap().contains(&normalized);
304        if occupied {
305            return Err(Error::new(
306                ErrorKind::InvalidInput,
307                format!("not a symbolic link: {}", path.display()),
308            ));
309        }
310        Err(not_found(path))
311    }
312}
313
314impl Storage for InMemoryFs {
315    async fn write(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
316        let normalized = normalize_path(path);
317        // Through the link where the path holds one, single hop — `std::fs::write`
318        // opens the path and therefore follows, and this backend's reads
319        // already resolve; a write that instead stored bytes *under the link's
320        // own name* would leave them permanently shadowed, readable by nobody,
321        // with the write reporting success. The double must not invent that.
322        let resolved = self.resolve(&normalized);
323        insert_ancestor_dirs(&mut self.directories.write().unwrap(), &resolved);
324
325        // Store as text when the bytes are valid UTF-8, so `read_to_string`
326        // and `export_entries` see a plain string — matching the diaryx
327        // behavior this mirrors, where `write`/`read_to_string` round-tripped
328        // through a text store. Non-UTF-8 content still round-trips through
329        // `read`, just via the binary store instead.
330        match std::str::from_utf8(contents) {
331            Ok(s) => {
332                self.files
333                    .write()
334                    .unwrap()
335                    .insert(resolved.clone(), s.to_string());
336                self.binary_files.write().unwrap().remove(&resolved);
337            }
338            Err(_) => {
339                self.binary_files
340                    .write()
341                    .unwrap()
342                    .insert(resolved.clone(), contents.to_vec());
343                self.files.write().unwrap().remove(&resolved);
344            }
345        }
346        Ok(())
347    }
348
349    async fn create_new(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
350        let normalized = normalize_path(path);
351        // Anything already answering to the name occupies it — a file of either
352        // store, a symlink, or a directory, exactly the set `std::fs`'s
353        // `O_CREAT|O_EXCL` refuses. The checks and the insert are not under one
354        // lock, but nothing interleaves them on the targets this backend
355        // exists for: wasm has no threads, and a multithreaded test that
356        // races two `create_new` calls is testing the double, not the code
357        // under test.
358        let occupied = self.files.read().unwrap().contains_key(&normalized)
359            || self.binary_files.read().unwrap().contains_key(&normalized)
360            || self.symlinks.read().unwrap().contains_key(&normalized)
361            || self.directories.read().unwrap().contains(&normalized);
362        if occupied {
363            return Err(Error::new(
364                ErrorKind::AlreadyExists,
365                format!("already exists: {}", path.display()),
366            ));
367        }
368        self.write(path, contents).await
369    }
370
371    async fn create_dir_all(&self, path: &Path) -> io::Result<()> {
372        let normalized = normalize_path(path);
373        let mut dirs = self.directories.write().unwrap();
374        if !normalized.as_os_str().is_empty() {
375            dirs.insert(normalized.clone());
376        }
377        insert_ancestor_dirs(&mut dirs, &normalized);
378        Ok(())
379    }
380
381    async fn remove_file(&self, path: &Path) -> io::Result<()> {
382        let normalized = normalize_path(path);
383        if self.files.write().unwrap().remove(&normalized).is_some() {
384            return Ok(());
385        }
386        if self
387            .binary_files
388            .write()
389            .unwrap()
390            .remove(&normalized)
391            .is_some()
392        {
393            return Ok(());
394        }
395        if self.symlinks.write().unwrap().remove(&normalized).is_some() {
396            return Ok(());
397        }
398        Err(not_found(path))
399    }
400
401    async fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
402        let normalized = normalize_path(path);
403        self.files
404            .write()
405            .unwrap()
406            .retain(|p, _| !p.starts_with(&normalized));
407        self.binary_files
408            .write()
409            .unwrap()
410            .retain(|p, _| !p.starts_with(&normalized));
411        self.symlinks
412            .write()
413            .unwrap()
414            .retain(|p, _| !p.starts_with(&normalized));
415        self.directories
416            .write()
417            .unwrap()
418            .retain(|p| p != &normalized && !p.starts_with(&normalized));
419        Ok(())
420    }
421
422    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
423        let from_norm = normalize_path(from);
424        let to_norm = normalize_path(to);
425        if from_norm == to_norm {
426            return Ok(());
427        }
428
429        let is_dir = self.directories.read().unwrap().contains(&from_norm);
430        if is_dir {
431            self.rename_dir(&from_norm, &to_norm, to)
432        } else {
433            self.rename_file(&from_norm, &to_norm, from, to).await
434        }
435    }
436
437    async fn set_link(&self, path: &Path, target: &Path) -> io::Result<()> {
438        // Replaces whatever is at the path, per the trait's contract: a plain
439        // file gives way to the link, and an existing link is repointed. The
440        // target is recorded as given, never resolved or required to exist —
441        // a dangling link is an honest link.
442        let normalized = normalize_path(path);
443        insert_ancestor_dirs(&mut self.directories.write().unwrap(), &normalized);
444        self.files.write().unwrap().remove(&normalized);
445        self.binary_files.write().unwrap().remove(&normalized);
446        self.symlinks
447            .write()
448            .unwrap()
449            .insert(normalized, normalize_path(target));
450        Ok(())
451    }
452
453    fn capabilities(&self) -> Capabilities {
454        Capabilities::IN_MEMORY
455    }
456
457    async fn replace(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
458        // The default protocol stages through a temp sibling and a `rename`
459        // because *that* is what makes a plain `write` atomic on a real
460        // filesystem. Here, a single `write` already is the atomic step — it
461        // takes the map's write lock for its entire duration, so no observer
462        // ever sees a splice — so replaying the temp-then-rename dance would
463        // only litter the map with a `.fstx-tmp` entry no caller asked for.
464        // This is exactly the "backend whose atomic replacement is native"
465        // case the default documents overriding — and overriding *here*, not
466        // `write_atomic`, is what lets every protocol built on `replace`
467        // (`write_atomic`'s composed default included) pick the override up.
468        //
469        // One faithful difference from `write`: the rename that realizes the
470        // default protocol replaces the *entry* at the path, so a link there
471        // gives way to the file rather than forwarding to its target — the
472        // same replacement `set_link` performs in the other direction.
473        self.symlinks.write().unwrap().remove(&normalize_path(path));
474        self.write(path, contents).await
475    }
476}
477
478impl InMemoryFs {
479    fn rename_dir(&self, from_norm: &Path, to_norm: &Path, to: &Path) -> io::Result<()> {
480        {
481            // A *directory* rename keeps the refusal a file rename gave up:
482            // renaming onto a non-directory is an error on every platform,
483            // and onto a directory `std::fs::rename` is platform-divergent
484            // (unix replaces only an empty one, Windows refuses outright) —
485            // so the portable double refuses the lot, and nothing in this
486            // crate renames a directory onto an occupied name.
487            let files = self.files.read().unwrap();
488            let bin = self.binary_files.read().unwrap();
489            let dirs = self.directories.read().unwrap();
490            let links = self.symlinks.read().unwrap();
491            if files.contains_key(to_norm)
492                || bin.contains_key(to_norm)
493                || dirs.contains(to_norm)
494                || links.contains_key(to_norm)
495            {
496                return Err(Error::new(
497                    ErrorKind::AlreadyExists,
498                    format!("destination already exists: {}", to.display()),
499                ));
500            }
501        }
502
503        let files_to_move: Vec<(PathBuf, String)> = self
504            .files
505            .read()
506            .unwrap()
507            .iter()
508            .filter(|(p, _)| p.starts_with(from_norm))
509            .map(|(p, c)| (p.clone(), c.clone()))
510            .collect();
511        let binaries_to_move: Vec<(PathBuf, Vec<u8>)> = self
512            .binary_files
513            .read()
514            .unwrap()
515            .iter()
516            .filter(|(p, _)| p.starts_with(from_norm))
517            .map(|(p, c)| (p.clone(), c.clone()))
518            .collect();
519
520        {
521            let mut files = self.files.write().unwrap();
522            for (old_path, content) in files_to_move {
523                files.remove(&old_path);
524                let relative = old_path.strip_prefix(from_norm).unwrap();
525                files.insert(to_norm.join(relative), content);
526            }
527        }
528        {
529            let mut binary = self.binary_files.write().unwrap();
530            for (old_path, content) in binaries_to_move {
531                binary.remove(&old_path);
532                let relative = old_path.strip_prefix(from_norm).unwrap();
533                binary.insert(to_norm.join(relative), content);
534            }
535        }
536        {
537            let mut dirs = self.directories.write().unwrap();
538            let old_dirs: Vec<PathBuf> = dirs
539                .iter()
540                .filter(|d| d.starts_with(from_norm))
541                .cloned()
542                .collect();
543            for old_dir in old_dirs {
544                dirs.remove(&old_dir);
545                let relative = old_dir.strip_prefix(from_norm).unwrap();
546                dirs.insert(to_norm.join(relative));
547            }
548            insert_ancestor_dirs(&mut dirs, to_norm);
549        }
550
551        Ok(())
552    }
553
554    async fn rename_file(
555        &self,
556        from_norm: &Path,
557        to_norm: &Path,
558        from: &Path,
559        to: &Path,
560    ) -> io::Result<()> {
561        {
562            let files = self.files.read().unwrap();
563            let bin = self.binary_files.read().unwrap();
564            let links = self.symlinks.read().unwrap();
565            if !files.contains_key(from_norm)
566                && !bin.contains_key(from_norm)
567                && !links.contains_key(from_norm)
568            {
569                return Err(not_found(from));
570            }
571            // A directory is the one occupant a file's rename never replaces —
572            // `std::fs::rename` refuses that on every platform. Any other
573            // occupant gives way below, which is the mirror's whole point:
574            // the default `write_atomic` publishes by renaming a staged
575            // sibling *over* the target, and a rename that refused an
576            // occupied destination would fail the commonest replace there is.
577            if self.directories.read().unwrap().contains(to_norm) {
578                return Err(Error::new(
579                    ErrorKind::AlreadyExists,
580                    format!("destination is a directory: {}", to.display()),
581                ));
582            }
583        }
584
585        if let Some(parent) = to_norm.parent() {
586            self.create_dir_all(parent).await?;
587        }
588
589        // The non-directory occupant, if any, is replaced — file, binary, or
590        // link alike, exactly the entry-level replacement `rename(2)` performs.
591        self.files.write().unwrap().remove(to_norm);
592        self.binary_files.write().unwrap().remove(to_norm);
593        self.symlinks.write().unwrap().remove(to_norm);
594
595        // A link moves as a link — the entry relocates, the target string
596        // rides along unresolved, exactly as `rename(2)` treats one.
597        let moved_link = self.symlinks.write().unwrap().remove(from_norm);
598        if let Some(target) = moved_link {
599            self.symlinks
600                .write()
601                .unwrap()
602                .insert(to_norm.to_path_buf(), target);
603            return Ok(());
604        }
605
606        // Each removal is its own statement, not an `if let`'s scrutinee: an
607        // `if let SCRUTINEE { BODY }` extends the scrutinee's temporaries
608        // across the whole body, so writing `if let Some(c) =
609        // self.files.write().unwrap().remove(..) { self.files.write()... }`
610        // would keep the first write guard alive while the body took a second
611        // one on the same lock — a same-thread self-deadlock on
612        // `std::sync::RwLock`, not a panic. Binding the removal to a plain
613        // `let` first drops that guard before the body ever runs.
614        let removed_text = self.files.write().unwrap().remove(from_norm);
615        if let Some(content) = removed_text {
616            self.files
617                .write()
618                .unwrap()
619                .insert(to_norm.to_path_buf(), content);
620            return Ok(());
621        }
622        let removed_binary = self.binary_files.write().unwrap().remove(from_norm);
623        if let Some(content) = removed_binary {
624            self.binary_files
625                .write()
626                .unwrap()
627                .insert(to_norm.to_path_buf(), content);
628            return Ok(());
629        }
630        Err(not_found(from))
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use crate::exec::block_on;
638
639    #[test]
640    fn read_write_roundtrip() {
641        let fs = InMemoryFs::new();
642        block_on(fs.write(Path::new("test.md"), b"Hello, World!")).unwrap();
643        assert_eq!(
644            block_on(fs.read_to_string(Path::new("test.md"))).unwrap(),
645            "Hello, World!"
646        );
647        assert!(block_on(fs.try_exists(Path::new("test.md"))).unwrap());
648        block_on(fs.remove_file(Path::new("test.md"))).unwrap();
649        assert!(!block_on(fs.try_exists(Path::new("test.md"))).unwrap());
650    }
651
652    #[test]
653    fn binary_content_round_trips_through_read_but_not_read_to_string() {
654        let fs = InMemoryFs::new();
655        let invalid_utf8 = vec![0xff, 0xfe, 0xfd];
656        block_on(fs.write(Path::new("bin.dat"), &invalid_utf8)).unwrap();
657        assert_eq!(
658            block_on(fs.read(Path::new("bin.dat"))).unwrap(),
659            invalid_utf8
660        );
661        let err = block_on(fs.read_to_string(Path::new("bin.dat"))).unwrap_err();
662        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
663    }
664
665    #[test]
666    fn create_dir_all_creates_parents_implicitly_via_write() {
667        let fs = InMemoryFs::new();
668        block_on(fs.write(Path::new("a/b/c/file.md"), b"Content")).unwrap();
669        assert!(block_on(fs.metadata(Path::new("a"))).unwrap().is_dir());
670        assert!(block_on(fs.metadata(Path::new("a/b"))).unwrap().is_dir());
671        assert!(block_on(fs.metadata(Path::new("a/b/c"))).unwrap().is_dir());
672        assert!(block_on(fs.try_exists(Path::new("a/b/c/file.md"))).unwrap());
673    }
674
675    #[test]
676    fn read_dir_returns_immediate_children_only() {
677        let fs = InMemoryFs::new();
678        block_on(fs.write(Path::new("dir/file1.md"), b"1")).unwrap();
679        block_on(fs.write(Path::new("dir/file2.md"), b"2")).unwrap();
680        block_on(fs.write(Path::new("dir/subdir/file3.md"), b"3")).unwrap();
681
682        let entries = block_on(fs.read_dir(Path::new("dir"))).unwrap();
683        let paths: Vec<PathBuf> = entries.iter().map(|e| e.path().to_path_buf()).collect();
684        assert!(paths.contains(&PathBuf::from("dir/file1.md")));
685        assert!(paths.contains(&PathBuf::from("dir/file2.md")));
686        assert!(paths.contains(&PathBuf::from("dir/subdir")));
687        assert!(!paths.contains(&PathBuf::from("dir/subdir/file3.md")));
688    }
689
690    #[test]
691    fn read_dir_of_an_untracked_directory_is_not_found() {
692        // Fidelity to `std::fs::read_dir`'s contract: a path that was never
693        // written to or `create_dir_all`'d is an error, not an empty listing.
694        let fs = InMemoryFs::new();
695        let err = block_on(fs.read_dir(Path::new("never/created"))).unwrap_err();
696        assert_eq!(err.kind(), io::ErrorKind::NotFound);
697    }
698
699    #[test]
700    fn read_dir_of_the_root_never_errors() {
701        // The root is never explicitly inserted into `directories` (it has no
702        // non-empty parent to register it), so it needs its own carve-out
703        // against the untracked-directory check above.
704        let fs = InMemoryFs::new();
705        assert!(block_on(fs.read_dir(Path::new(""))).unwrap().is_empty());
706    }
707
708    #[test]
709    fn export_then_import_roundtrip() {
710        let fs = InMemoryFs::new();
711        block_on(fs.write(Path::new("file1.md"), b"Content 1")).unwrap();
712        block_on(fs.write(Path::new("dir/file2.md"), b"Content 2")).unwrap();
713
714        let entries = fs.export_entries();
715        let fs2 = InMemoryFs::load_from_entries(entries);
716
717        assert_eq!(
718            block_on(fs2.read_to_string(Path::new("file1.md"))).unwrap(),
719            "Content 1"
720        );
721        assert_eq!(
722            block_on(fs2.read_to_string(Path::new("dir/file2.md"))).unwrap(),
723            "Content 2"
724        );
725    }
726
727    #[test]
728    fn path_normalization() {
729        let fs = InMemoryFs::new();
730        block_on(fs.write(Path::new("dir/file.md"), b"Content")).unwrap();
731        assert!(block_on(fs.try_exists(Path::new("dir/file.md"))).unwrap());
732        assert!(block_on(fs.try_exists(Path::new("dir/./file.md"))).unwrap());
733        assert!(block_on(fs.try_exists(Path::new("dir/subdir/../file.md"))).unwrap());
734    }
735
736    #[test]
737    fn rename_moves_a_single_file() {
738        let fs = InMemoryFs::new();
739        block_on(fs.write(Path::new("old.md"), b"content")).unwrap();
740        block_on(fs.rename(Path::new("old.md"), Path::new("new.md"))).unwrap();
741        assert!(!block_on(fs.try_exists(Path::new("old.md"))).unwrap());
742        assert_eq!(
743            block_on(fs.read_to_string(Path::new("new.md"))).unwrap(),
744            "content"
745        );
746    }
747
748    #[test]
749    fn rename_moves_a_directory_and_its_contents() {
750        let fs = InMemoryFs::new();
751        block_on(fs.write(Path::new("dir/a.md"), b"a")).unwrap();
752        block_on(fs.write(Path::new("dir/sub/b.md"), b"b")).unwrap();
753
754        block_on(fs.rename(Path::new("dir"), Path::new("moved"))).unwrap();
755
756        assert!(!block_on(fs.try_exists(Path::new("dir/a.md"))).unwrap());
757        assert_eq!(
758            block_on(fs.read_to_string(Path::new("moved/a.md"))).unwrap(),
759            "a"
760        );
761        assert_eq!(
762            block_on(fs.read_to_string(Path::new("moved/sub/b.md"))).unwrap(),
763            "b"
764        );
765        assert!(
766            block_on(fs.metadata(Path::new("moved/sub")))
767                .unwrap()
768                .is_dir()
769        );
770    }
771
772    #[test]
773    fn rename_replaces_an_occupied_file_destination() {
774        // The port doc is the contract: `rename` mirrors `std::fs::rename`,
775        // which replaces an existing destination file on every platform —
776        // and the default `write_atomic` publishes by renaming a staged
777        // sibling over the target, so a double that refused would fail the
778        // commonest replace there is.
779        let fs = InMemoryFs::new();
780        block_on(fs.write(Path::new("a.md"), b"a")).unwrap();
781        block_on(fs.write(Path::new("b.md"), b"b")).unwrap();
782        block_on(fs.rename(Path::new("a.md"), Path::new("b.md"))).unwrap();
783        assert!(!block_on(fs.try_exists(Path::new("a.md"))).unwrap());
784        assert_eq!(block_on(fs.read_to_string(Path::new("b.md"))).unwrap(), "a");
785    }
786
787    #[test]
788    fn rename_refuses_a_directory_destination() {
789        // The one occupant a file's rename never replaces, on any platform.
790        let fs = InMemoryFs::new();
791        block_on(fs.write(Path::new("a.md"), b"a")).unwrap();
792        block_on(fs.create_dir_all(Path::new("dir"))).unwrap();
793        let err = block_on(fs.rename(Path::new("a.md"), Path::new("dir"))).unwrap_err();
794        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
795        assert_eq!(block_on(fs.read_to_string(Path::new("a.md"))).unwrap(), "a");
796    }
797
798    #[test]
799    fn a_directory_rename_still_refuses_any_occupied_destination() {
800        // Directory-onto-directory is platform-divergent in std, so the
801        // portable double keeps the refusal for directories — and nothing in
802        // this crate renames a directory onto an occupied name.
803        let fs = InMemoryFs::new();
804        block_on(fs.write(Path::new("dir/a.md"), b"a")).unwrap();
805        block_on(fs.write(Path::new("other/b.md"), b"b")).unwrap();
806        block_on(fs.write(Path::new("file.md"), b"f")).unwrap();
807        for taken in ["other", "file.md"] {
808            let err = block_on(fs.rename(Path::new("dir"), Path::new(taken))).unwrap_err();
809            assert_eq!(err.kind(), io::ErrorKind::AlreadyExists, "{taken}");
810        }
811    }
812
813    // ---- symlinks: coherence with `ReadStorage::metadata`'s "follows symlinks"
814    // contract, and with `read_dir`'s un-followed listing — the two shapes
815    // diaryx_core's validator actually exercises (skip a symlink named
816    // directly, and skip one discovered by scanning a directory). ----
817
818    #[test]
819    fn metadata_and_read_follow_a_symlink_to_its_target() {
820        let fs = InMemoryFs::new();
821        block_on(fs.write(Path::new("real.md"), b"hello")).unwrap();
822        fs.add_symlink(Path::new("link.md"), Path::new("real.md"));
823
824        let m = block_on(fs.metadata(Path::new("link.md"))).unwrap();
825        assert!(m.is_file());
826        assert!(!m.is_dir());
827
828        assert_eq!(
829            block_on(fs.read_to_string(Path::new("link.md"))).unwrap(),
830            "hello"
831        );
832    }
833
834    #[test]
835    fn read_dir_reports_a_symlink_by_its_own_unfollowed_type() {
836        // This is what a directory scan (diaryx_core's orphan-file pass) uses
837        // to recognize and skip a symlink without ever resolving it.
838        let fs = InMemoryFs::new();
839        block_on(fs.write(Path::new("real.md"), b"hello")).unwrap();
840        fs.add_symlink(Path::new("link.md"), Path::new("real.md"));
841
842        let entries = block_on(fs.read_dir(Path::new(""))).unwrap();
843        let link_entry = entries
844            .iter()
845            .find(|e| e.path() == Path::new("link.md"))
846            .expect("symlink should appear in its parent's listing");
847        assert!(link_entry.file_type().is_symlink());
848
849        let real_entry = entries
850            .iter()
851            .find(|e| e.path() == Path::new("real.md"))
852            .expect("the real file should also be listed");
853        assert!(!real_entry.file_type().is_symlink());
854    }
855
856    #[test]
857    fn a_symlink_to_a_missing_target_is_not_found_by_metadata() {
858        let fs = InMemoryFs::new();
859        fs.add_symlink(Path::new("dangling.md"), Path::new("nowhere.md"));
860        let err = block_on(fs.metadata(Path::new("dangling.md"))).unwrap_err();
861        assert_eq!(err.kind(), io::ErrorKind::NotFound);
862    }
863
864    #[test]
865    fn write_follows_a_link_to_its_target() {
866        // `std::fs::write` opens and therefore follows; a double that stored
867        // the bytes under the link's own name would shadow them forever
868        // behind `resolve`, with the write reporting success.
869        let fs = InMemoryFs::new();
870        block_on(fs.write(Path::new("real.md"), b"old")).unwrap();
871        fs.add_symlink(Path::new("link.md"), Path::new("real.md"));
872
873        block_on(fs.write(Path::new("link.md"), b"new")).unwrap();
874
875        assert_eq!(
876            block_on(fs.read_to_string(Path::new("real.md"))).unwrap(),
877            "new",
878            "the bytes must land in the target"
879        );
880        assert_eq!(
881            block_on(fs.read_to_string(Path::new("link.md"))).unwrap(),
882            "new"
883        );
884        assert_eq!(
885            block_on(fs.read_link(Path::new("link.md"))).unwrap(),
886            Some(PathBuf::from("real.md")),
887            "the link itself must still stand"
888        );
889    }
890
891    #[test]
892    fn write_atomic_replaces_a_link_rather_than_writing_through_it() {
893        // The default protocol's rename replaces the entry at the path; the
894        // native override must keep that half of the contract too.
895        let fs = InMemoryFs::new();
896        block_on(fs.write(Path::new("real.md"), b"target bytes")).unwrap();
897        fs.add_symlink(Path::new("link.md"), Path::new("real.md"));
898
899        block_on(fs.write_atomic(Path::new("link.md"), b"a file now")).unwrap();
900
901        assert_eq!(
902            block_on(fs.read_link(Path::new("link.md")))
903                .unwrap_err()
904                .kind(),
905            io::ErrorKind::InvalidInput,
906            "the link must be gone, replaced by a regular file"
907        );
908        assert_eq!(
909            block_on(fs.read_to_string(Path::new("link.md"))).unwrap(),
910            "a file now"
911        );
912        assert_eq!(
913            block_on(fs.read_to_string(Path::new("real.md"))).unwrap(),
914            "target bytes",
915            "nothing may be written through the link"
916        );
917    }
918
919    #[test]
920    fn rename_moves_a_link_as_a_link_and_replaces_one_at_the_destination() {
921        let fs = InMemoryFs::new();
922        block_on(fs.write(Path::new("real.md"), b"content")).unwrap();
923        fs.add_symlink(Path::new("link.md"), Path::new("real.md"));
924
925        block_on(fs.rename(Path::new("link.md"), Path::new("moved.md"))).unwrap();
926        assert_eq!(
927            block_on(fs.read_link(Path::new("moved.md"))).unwrap(),
928            Some(PathBuf::from("real.md"))
929        );
930        assert!(!block_on(fs.try_exists(Path::new("link.md"))).unwrap());
931
932        // A link at the destination is replaced like any non-directory
933        // occupant — `rename(2)` removes the entry, never follows it.
934        block_on(fs.write(Path::new("other.md"), b"other")).unwrap();
935        block_on(fs.rename(Path::new("other.md"), Path::new("moved.md"))).unwrap();
936        assert_eq!(
937            block_on(fs.read_link(Path::new("moved.md")))
938                .unwrap_err()
939                .kind(),
940            ErrorKind::InvalidInput,
941            "the link must be gone, replaced by the renamed file"
942        );
943        assert_eq!(
944            block_on(fs.read_to_string(Path::new("moved.md"))).unwrap(),
945            "other"
946        );
947        assert_eq!(
948            block_on(fs.read_to_string(Path::new("real.md"))).unwrap(),
949            "content",
950            "nothing may be renamed through the link"
951        );
952    }
953
954    #[test]
955    fn removing_a_symlink_leaves_its_target_untouched() {
956        let fs = InMemoryFs::new();
957        block_on(fs.write(Path::new("real.md"), b"hello")).unwrap();
958        fs.add_symlink(Path::new("link.md"), Path::new("real.md"));
959
960        block_on(fs.remove_file(Path::new("link.md"))).unwrap();
961
962        assert!(!block_on(fs.try_exists(Path::new("link.md"))).unwrap());
963        assert_eq!(
964            block_on(fs.read_to_string(Path::new("real.md"))).unwrap(),
965            "hello"
966        );
967    }
968
969    // ---- exclusive create ----
970
971    #[test]
972    fn create_new_writes_a_fresh_file_and_refuses_a_second() {
973        let fs = InMemoryFs::new();
974        block_on(fs.create_new(Path::new("once.md"), b"first")).unwrap();
975        assert_eq!(
976            block_on(fs.read_to_string(Path::new("once.md"))).unwrap(),
977            "first"
978        );
979        let err = block_on(fs.create_new(Path::new("once.md"), b"second")).unwrap_err();
980        assert_eq!(err.kind(), ErrorKind::AlreadyExists);
981        assert_eq!(
982            block_on(fs.read_to_string(Path::new("once.md"))).unwrap(),
983            "first",
984            "the loser must have changed nothing"
985        );
986    }
987
988    #[test]
989    fn create_new_counts_every_kind_of_occupant() {
990        // A directory, a symlink, and a binary file all answer to their names;
991        // `create_new` must refuse each exactly as `O_CREAT|O_EXCL` would.
992        let fs = InMemoryFs::new();
993        block_on(fs.create_dir_all(Path::new("dir"))).unwrap();
994        block_on(fs.write(Path::new("bin.dat"), &[0xff, 0xfe])).unwrap();
995        block_on(fs.write(Path::new("real.md"), b"hello")).unwrap();
996        fs.add_symlink(Path::new("link.md"), Path::new("real.md"));
997
998        for taken in ["dir", "bin.dat", "link.md"] {
999            let err = block_on(fs.create_new(Path::new(taken), b"x")).unwrap_err();
1000            assert_eq!(err.kind(), ErrorKind::AlreadyExists, "{taken}");
1001        }
1002    }
1003
1004    #[test]
1005    fn in_memory_declares_exclusive_create() {
1006        assert!(InMemoryFs::new().capabilities().exclusive_create);
1007    }
1008
1009    // ---- capabilities ----
1010
1011    #[test]
1012    fn in_memory_declares_atomic_replace_but_no_durability_across_a_restart() {
1013        let fs = InMemoryFs::new();
1014        let caps = fs.capabilities();
1015        assert!(
1016            caps.atomic_replace,
1017            "a single locked write is already atomic"
1018        );
1019        assert_eq!(
1020            caps.sync_guarantee,
1021            super::super::SyncGuarantee::None,
1022            "nothing here survives the process exiting, so there is not even an \
1023             ordering worth promising against a crash"
1024        );
1025        assert!(
1026            !caps.native_transactions,
1027            "the lock covers one call, not a batch of several committed together"
1028        );
1029    }
1030
1031    #[test]
1032    fn write_atomic_lands_the_new_contents_without_a_temp_sibling() {
1033        let fs = InMemoryFs::new();
1034        block_on(fs.write(Path::new("doc.md"), b"old")).unwrap();
1035        block_on(fs.write_atomic(Path::new("doc.md"), b"new")).unwrap();
1036
1037        assert_eq!(
1038            block_on(fs.read_to_string(Path::new("doc.md"))).unwrap(),
1039            "new"
1040        );
1041        // No `.doc.md.fstx-tmp` sibling should exist — `replace` was
1042        // overridden to skip the default's staging dance, and `write_atomic`
1043        // composes on the override.
1044        let entries = block_on(fs.read_dir(Path::new(""))).unwrap();
1045        assert_eq!(entries.len(), 1, "no stray temp-sibling entry: {entries:?}");
1046    }
1047
1048    // ---- clone-shares-state ----
1049
1050    #[test]
1051    fn clones_share_the_same_backing_store() {
1052        let fs = InMemoryFs::new();
1053        let clone = fs.clone();
1054        block_on(fs.write(Path::new("shared.md"), b"visible everywhere")).unwrap();
1055        assert_eq!(
1056            block_on(clone.read_to_string(Path::new("shared.md"))).unwrap(),
1057            "visible everywhere"
1058        );
1059    }
1060
1061    #[test]
1062    fn clear_empties_every_store() {
1063        let fs = InMemoryFs::new();
1064        block_on(fs.write(Path::new("a.md"), b"a")).unwrap();
1065        fs.add_symlink(Path::new("link.md"), Path::new("a.md"));
1066
1067        fs.clear();
1068
1069        assert!(!block_on(fs.try_exists(Path::new("a.md"))).unwrap());
1070        assert!(block_on(fs.metadata(Path::new("link.md"))).is_err());
1071    }
1072}