Skip to main content

prov_store/fs/
memory.rs

1//! An in-memory [`Storage`] backend.
2//!
3//! Available on every target prov 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 prov_graph::fs::{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
289impl Storage for InMemoryFs {
290    async fn write(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
291        let normalized = normalize_path(path);
292        insert_ancestor_dirs(&mut self.directories.write().unwrap(), &normalized);
293
294        // Store as text when the bytes are valid UTF-8, so `read_to_string`
295        // and `export_entries` see a plain string — matching the diaryx
296        // behavior this mirrors, where `write`/`read_to_string` round-tripped
297        // through a text store. Non-UTF-8 content still round-trips through
298        // `read`, just via the binary store instead.
299        match std::str::from_utf8(contents) {
300            Ok(s) => {
301                self.files
302                    .write()
303                    .unwrap()
304                    .insert(normalized.clone(), s.to_string());
305                self.binary_files.write().unwrap().remove(&normalized);
306            }
307            Err(_) => {
308                self.binary_files
309                    .write()
310                    .unwrap()
311                    .insert(normalized.clone(), contents.to_vec());
312                self.files.write().unwrap().remove(&normalized);
313            }
314        }
315        Ok(())
316    }
317
318    async fn create_dir_all(&self, path: &Path) -> io::Result<()> {
319        let normalized = normalize_path(path);
320        let mut dirs = self.directories.write().unwrap();
321        if !normalized.as_os_str().is_empty() {
322            dirs.insert(normalized.clone());
323        }
324        insert_ancestor_dirs(&mut dirs, &normalized);
325        Ok(())
326    }
327
328    async fn remove_file(&self, path: &Path) -> io::Result<()> {
329        let normalized = normalize_path(path);
330        if self.files.write().unwrap().remove(&normalized).is_some() {
331            return Ok(());
332        }
333        if self
334            .binary_files
335            .write()
336            .unwrap()
337            .remove(&normalized)
338            .is_some()
339        {
340            return Ok(());
341        }
342        if self.symlinks.write().unwrap().remove(&normalized).is_some() {
343            return Ok(());
344        }
345        Err(not_found(path))
346    }
347
348    async fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
349        let normalized = normalize_path(path);
350        self.files
351            .write()
352            .unwrap()
353            .retain(|p, _| !p.starts_with(&normalized));
354        self.binary_files
355            .write()
356            .unwrap()
357            .retain(|p, _| !p.starts_with(&normalized));
358        self.symlinks
359            .write()
360            .unwrap()
361            .retain(|p, _| !p.starts_with(&normalized));
362        self.directories
363            .write()
364            .unwrap()
365            .retain(|p| p != &normalized && !p.starts_with(&normalized));
366        Ok(())
367    }
368
369    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
370        let from_norm = normalize_path(from);
371        let to_norm = normalize_path(to);
372        if from_norm == to_norm {
373            return Ok(());
374        }
375
376        let is_dir = self.directories.read().unwrap().contains(&from_norm);
377        if is_dir {
378            self.rename_dir(&from_norm, &to_norm, to)
379        } else {
380            self.rename_file(&from_norm, &to_norm, from, to).await
381        }
382    }
383
384    fn capabilities(&self) -> Capabilities {
385        Capabilities::IN_MEMORY
386    }
387
388    async fn write_atomic(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
389        // The default protocol stages through a temp sibling and a `rename`
390        // because *that* is what makes a plain `write` atomic on a real
391        // filesystem. Here, a single `write` already is the atomic step — it
392        // takes the map's write lock for its entire duration, so no observer
393        // ever sees a splice — so replaying the temp-then-rename dance would
394        // only litter the map with a `.prov-tmp` entry no caller asked for.
395        // This is exactly the "backend with a better native path" case the
396        // default documents overriding wholesale.
397        self.write(path, contents).await
398    }
399}
400
401impl InMemoryFs {
402    fn rename_dir(&self, from_norm: &Path, to_norm: &Path, to: &Path) -> io::Result<()> {
403        {
404            let files = self.files.read().unwrap();
405            let bin = self.binary_files.read().unwrap();
406            let dirs = self.directories.read().unwrap();
407            if files.contains_key(to_norm) || bin.contains_key(to_norm) || dirs.contains(to_norm) {
408                return Err(Error::new(
409                    ErrorKind::AlreadyExists,
410                    format!("destination already exists: {}", to.display()),
411                ));
412            }
413        }
414
415        let files_to_move: Vec<(PathBuf, String)> = self
416            .files
417            .read()
418            .unwrap()
419            .iter()
420            .filter(|(p, _)| p.starts_with(from_norm))
421            .map(|(p, c)| (p.clone(), c.clone()))
422            .collect();
423        let binaries_to_move: Vec<(PathBuf, Vec<u8>)> = self
424            .binary_files
425            .read()
426            .unwrap()
427            .iter()
428            .filter(|(p, _)| p.starts_with(from_norm))
429            .map(|(p, c)| (p.clone(), c.clone()))
430            .collect();
431
432        {
433            let mut files = self.files.write().unwrap();
434            for (old_path, content) in files_to_move {
435                files.remove(&old_path);
436                let relative = old_path.strip_prefix(from_norm).unwrap();
437                files.insert(to_norm.join(relative), content);
438            }
439        }
440        {
441            let mut binary = self.binary_files.write().unwrap();
442            for (old_path, content) in binaries_to_move {
443                binary.remove(&old_path);
444                let relative = old_path.strip_prefix(from_norm).unwrap();
445                binary.insert(to_norm.join(relative), content);
446            }
447        }
448        {
449            let mut dirs = self.directories.write().unwrap();
450            let old_dirs: Vec<PathBuf> = dirs
451                .iter()
452                .filter(|d| d.starts_with(from_norm))
453                .cloned()
454                .collect();
455            for old_dir in old_dirs {
456                dirs.remove(&old_dir);
457                let relative = old_dir.strip_prefix(from_norm).unwrap();
458                dirs.insert(to_norm.join(relative));
459            }
460            insert_ancestor_dirs(&mut dirs, to_norm);
461        }
462
463        Ok(())
464    }
465
466    async fn rename_file(
467        &self,
468        from_norm: &Path,
469        to_norm: &Path,
470        from: &Path,
471        to: &Path,
472    ) -> io::Result<()> {
473        {
474            let files = self.files.read().unwrap();
475            let bin = self.binary_files.read().unwrap();
476            if !files.contains_key(from_norm) && !bin.contains_key(from_norm) {
477                return Err(not_found(from));
478            }
479            if files.contains_key(to_norm) || bin.contains_key(to_norm) {
480                return Err(Error::new(
481                    ErrorKind::AlreadyExists,
482                    format!("destination already exists: {}", to.display()),
483                ));
484            }
485        }
486
487        if let Some(parent) = to_norm.parent() {
488            self.create_dir_all(parent).await?;
489        }
490
491        // Each removal is its own statement, not an `if let`'s scrutinee: an
492        // `if let SCRUTINEE { BODY }` extends the scrutinee's temporaries
493        // across the whole body, so writing `if let Some(c) =
494        // self.files.write().unwrap().remove(..) { self.files.write()... }`
495        // would keep the first write guard alive while the body took a second
496        // one on the same lock — a same-thread self-deadlock on
497        // `std::sync::RwLock`, not a panic. Binding the removal to a plain
498        // `let` first drops that guard before the body ever runs.
499        let removed_text = self.files.write().unwrap().remove(from_norm);
500        if let Some(content) = removed_text {
501            self.files
502                .write()
503                .unwrap()
504                .insert(to_norm.to_path_buf(), content);
505            return Ok(());
506        }
507        let removed_binary = self.binary_files.write().unwrap().remove(from_norm);
508        if let Some(content) = removed_binary {
509            self.binary_files
510                .write()
511                .unwrap()
512                .insert(to_norm.to_path_buf(), content);
513            return Ok(());
514        }
515        Err(not_found(from))
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use prov_graph::exec::block_on;
523
524    #[test]
525    fn read_write_roundtrip() {
526        let fs = InMemoryFs::new();
527        block_on(fs.write(Path::new("test.md"), b"Hello, World!")).unwrap();
528        assert_eq!(
529            block_on(fs.read_to_string(Path::new("test.md"))).unwrap(),
530            "Hello, World!"
531        );
532        assert!(block_on(fs.try_exists(Path::new("test.md"))).unwrap());
533        block_on(fs.remove_file(Path::new("test.md"))).unwrap();
534        assert!(!block_on(fs.try_exists(Path::new("test.md"))).unwrap());
535    }
536
537    #[test]
538    fn binary_content_round_trips_through_read_but_not_read_to_string() {
539        let fs = InMemoryFs::new();
540        let invalid_utf8 = vec![0xff, 0xfe, 0xfd];
541        block_on(fs.write(Path::new("bin.dat"), &invalid_utf8)).unwrap();
542        assert_eq!(
543            block_on(fs.read(Path::new("bin.dat"))).unwrap(),
544            invalid_utf8
545        );
546        let err = block_on(fs.read_to_string(Path::new("bin.dat"))).unwrap_err();
547        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
548    }
549
550    #[test]
551    fn create_dir_all_creates_parents_implicitly_via_write() {
552        let fs = InMemoryFs::new();
553        block_on(fs.write(Path::new("a/b/c/file.md"), b"Content")).unwrap();
554        assert!(block_on(fs.metadata(Path::new("a"))).unwrap().is_dir());
555        assert!(block_on(fs.metadata(Path::new("a/b"))).unwrap().is_dir());
556        assert!(block_on(fs.metadata(Path::new("a/b/c"))).unwrap().is_dir());
557        assert!(block_on(fs.try_exists(Path::new("a/b/c/file.md"))).unwrap());
558    }
559
560    #[test]
561    fn read_dir_returns_immediate_children_only() {
562        let fs = InMemoryFs::new();
563        block_on(fs.write(Path::new("dir/file1.md"), b"1")).unwrap();
564        block_on(fs.write(Path::new("dir/file2.md"), b"2")).unwrap();
565        block_on(fs.write(Path::new("dir/subdir/file3.md"), b"3")).unwrap();
566
567        let entries = block_on(fs.read_dir(Path::new("dir"))).unwrap();
568        let paths: Vec<PathBuf> = entries.iter().map(|e| e.path().to_path_buf()).collect();
569        assert!(paths.contains(&PathBuf::from("dir/file1.md")));
570        assert!(paths.contains(&PathBuf::from("dir/file2.md")));
571        assert!(paths.contains(&PathBuf::from("dir/subdir")));
572        assert!(!paths.contains(&PathBuf::from("dir/subdir/file3.md")));
573    }
574
575    #[test]
576    fn read_dir_of_an_untracked_directory_is_not_found() {
577        // Fidelity to `std::fs::read_dir`'s contract: a path that was never
578        // written to or `create_dir_all`'d is an error, not an empty listing.
579        let fs = InMemoryFs::new();
580        let err = block_on(fs.read_dir(Path::new("never/created"))).unwrap_err();
581        assert_eq!(err.kind(), io::ErrorKind::NotFound);
582    }
583
584    #[test]
585    fn read_dir_of_the_root_never_errors() {
586        // The root is never explicitly inserted into `directories` (it has no
587        // non-empty parent to register it), so it needs its own carve-out
588        // against the untracked-directory check above.
589        let fs = InMemoryFs::new();
590        assert!(block_on(fs.read_dir(Path::new(""))).unwrap().is_empty());
591    }
592
593    #[test]
594    fn export_then_import_roundtrip() {
595        let fs = InMemoryFs::new();
596        block_on(fs.write(Path::new("file1.md"), b"Content 1")).unwrap();
597        block_on(fs.write(Path::new("dir/file2.md"), b"Content 2")).unwrap();
598
599        let entries = fs.export_entries();
600        let fs2 = InMemoryFs::load_from_entries(entries);
601
602        assert_eq!(
603            block_on(fs2.read_to_string(Path::new("file1.md"))).unwrap(),
604            "Content 1"
605        );
606        assert_eq!(
607            block_on(fs2.read_to_string(Path::new("dir/file2.md"))).unwrap(),
608            "Content 2"
609        );
610    }
611
612    #[test]
613    fn path_normalization() {
614        let fs = InMemoryFs::new();
615        block_on(fs.write(Path::new("dir/file.md"), b"Content")).unwrap();
616        assert!(block_on(fs.try_exists(Path::new("dir/file.md"))).unwrap());
617        assert!(block_on(fs.try_exists(Path::new("dir/./file.md"))).unwrap());
618        assert!(block_on(fs.try_exists(Path::new("dir/subdir/../file.md"))).unwrap());
619    }
620
621    #[test]
622    fn rename_moves_a_single_file() {
623        let fs = InMemoryFs::new();
624        block_on(fs.write(Path::new("old.md"), b"content")).unwrap();
625        block_on(fs.rename(Path::new("old.md"), Path::new("new.md"))).unwrap();
626        assert!(!block_on(fs.try_exists(Path::new("old.md"))).unwrap());
627        assert_eq!(
628            block_on(fs.read_to_string(Path::new("new.md"))).unwrap(),
629            "content"
630        );
631    }
632
633    #[test]
634    fn rename_moves_a_directory_and_its_contents() {
635        let fs = InMemoryFs::new();
636        block_on(fs.write(Path::new("dir/a.md"), b"a")).unwrap();
637        block_on(fs.write(Path::new("dir/sub/b.md"), b"b")).unwrap();
638
639        block_on(fs.rename(Path::new("dir"), Path::new("moved"))).unwrap();
640
641        assert!(!block_on(fs.try_exists(Path::new("dir/a.md"))).unwrap());
642        assert_eq!(
643            block_on(fs.read_to_string(Path::new("moved/a.md"))).unwrap(),
644            "a"
645        );
646        assert_eq!(
647            block_on(fs.read_to_string(Path::new("moved/sub/b.md"))).unwrap(),
648            "b"
649        );
650        assert!(
651            block_on(fs.metadata(Path::new("moved/sub")))
652                .unwrap()
653                .is_dir()
654        );
655    }
656
657    #[test]
658    fn rename_refuses_to_clobber_an_existing_destination() {
659        let fs = InMemoryFs::new();
660        block_on(fs.write(Path::new("a.md"), b"a")).unwrap();
661        block_on(fs.write(Path::new("b.md"), b"b")).unwrap();
662        let err = block_on(fs.rename(Path::new("a.md"), Path::new("b.md"))).unwrap_err();
663        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
664    }
665
666    // ---- symlinks: coherence with `ReadStorage::metadata`'s "follows symlinks"
667    // contract, and with `read_dir`'s un-followed listing — the two shapes
668    // diaryx_core's validator actually exercises (skip a symlink named
669    // directly, and skip one discovered by scanning a directory). ----
670
671    #[test]
672    fn metadata_and_read_follow_a_symlink_to_its_target() {
673        let fs = InMemoryFs::new();
674        block_on(fs.write(Path::new("real.md"), b"hello")).unwrap();
675        fs.add_symlink(Path::new("link.md"), Path::new("real.md"));
676
677        let m = block_on(fs.metadata(Path::new("link.md"))).unwrap();
678        assert!(m.is_file());
679        assert!(!m.is_dir());
680
681        assert_eq!(
682            block_on(fs.read_to_string(Path::new("link.md"))).unwrap(),
683            "hello"
684        );
685    }
686
687    #[test]
688    fn read_dir_reports_a_symlink_by_its_own_unfollowed_type() {
689        // This is what a directory scan (diaryx_core's orphan-file pass) uses
690        // to recognize and skip a symlink without ever resolving it.
691        let fs = InMemoryFs::new();
692        block_on(fs.write(Path::new("real.md"), b"hello")).unwrap();
693        fs.add_symlink(Path::new("link.md"), Path::new("real.md"));
694
695        let entries = block_on(fs.read_dir(Path::new(""))).unwrap();
696        let link_entry = entries
697            .iter()
698            .find(|e| e.path() == Path::new("link.md"))
699            .expect("symlink should appear in its parent's listing");
700        assert!(link_entry.file_type().is_symlink());
701
702        let real_entry = entries
703            .iter()
704            .find(|e| e.path() == Path::new("real.md"))
705            .expect("the real file should also be listed");
706        assert!(!real_entry.file_type().is_symlink());
707    }
708
709    #[test]
710    fn a_symlink_to_a_missing_target_is_not_found_by_metadata() {
711        let fs = InMemoryFs::new();
712        fs.add_symlink(Path::new("dangling.md"), Path::new("nowhere.md"));
713        let err = block_on(fs.metadata(Path::new("dangling.md"))).unwrap_err();
714        assert_eq!(err.kind(), io::ErrorKind::NotFound);
715    }
716
717    #[test]
718    fn removing_a_symlink_leaves_its_target_untouched() {
719        let fs = InMemoryFs::new();
720        block_on(fs.write(Path::new("real.md"), b"hello")).unwrap();
721        fs.add_symlink(Path::new("link.md"), Path::new("real.md"));
722
723        block_on(fs.remove_file(Path::new("link.md"))).unwrap();
724
725        assert!(!block_on(fs.try_exists(Path::new("link.md"))).unwrap());
726        assert_eq!(
727            block_on(fs.read_to_string(Path::new("real.md"))).unwrap(),
728            "hello"
729        );
730    }
731
732    // ---- capabilities ----
733
734    #[test]
735    fn in_memory_declares_atomic_replace_but_no_durability_across_a_restart() {
736        let fs = InMemoryFs::new();
737        let caps = fs.capabilities();
738        assert!(
739            caps.atomic_replace,
740            "a single locked write is already atomic"
741        );
742        assert_eq!(
743            caps.sync_guarantee,
744            super::super::SyncGuarantee::None,
745            "nothing here survives the process exiting, so there is not even an \
746             ordering worth promising against a crash"
747        );
748        assert!(
749            !caps.native_transactions,
750            "the lock covers one call, not a batch of several committed together"
751        );
752    }
753
754    #[test]
755    fn write_atomic_lands_the_new_contents_without_a_temp_sibling() {
756        let fs = InMemoryFs::new();
757        block_on(fs.write(Path::new("doc.md"), b"old")).unwrap();
758        block_on(fs.write_atomic(Path::new("doc.md"), b"new")).unwrap();
759
760        assert_eq!(
761            block_on(fs.read_to_string(Path::new("doc.md"))).unwrap(),
762            "new"
763        );
764        // No `.doc.md.prov-tmp` sibling should exist — `write_atomic` was
765        // overridden to skip the default's staging dance.
766        let entries = block_on(fs.read_dir(Path::new(""))).unwrap();
767        assert_eq!(entries.len(), 1, "no stray temp-sibling entry: {entries:?}");
768    }
769
770    // ---- clone-shares-state ----
771
772    #[test]
773    fn clones_share_the_same_backing_store() {
774        let fs = InMemoryFs::new();
775        let clone = fs.clone();
776        block_on(fs.write(Path::new("shared.md"), b"visible everywhere")).unwrap();
777        assert_eq!(
778            block_on(clone.read_to_string(Path::new("shared.md"))).unwrap(),
779            "visible everywhere"
780        );
781    }
782
783    #[test]
784    fn clear_empties_every_store() {
785        let fs = InMemoryFs::new();
786        block_on(fs.write(Path::new("a.md"), b"a")).unwrap();
787        fs.add_symlink(Path::new("link.md"), Path::new("a.md"));
788
789        fs.clear();
790
791        assert!(!block_on(fs.try_exists(Path::new("a.md"))).unwrap());
792        assert!(block_on(fs.metadata(Path::new("link.md"))).is_err());
793    }
794}