composefs/
generic_tree.rs

1//! A generic metadata-only filesystem tree where regular files can be stored
2//! however the caller wants.
3
4use std::{
5    cell::RefCell,
6    collections::BTreeMap,
7    ffi::OsStr,
8    path::{Component, Path},
9    rc::Rc,
10};
11
12use thiserror::Error;
13
14/// File metadata similar to `struct stat` from POSIX.
15#[derive(Debug)]
16pub struct Stat {
17    /// File mode and permissions bits.
18    pub st_mode: u32,
19    /// User ID of owner.
20    pub st_uid: u32,
21    /// Group ID of owner.
22    pub st_gid: u32,
23    /// Modification time in seconds since Unix epoch.
24    pub st_mtim_sec: i64,
25    /// Extended attributes as key-value pairs.
26    pub xattrs: RefCell<BTreeMap<Box<OsStr>, Box<[u8]>>>,
27}
28
29/// Content types for leaf nodes (non-directory files).
30#[derive(Debug)]
31pub enum LeafContent<T> {
32    /// A regular file with content of type `T`.
33    Regular(T),
34    /// A block device with the given device number.
35    BlockDevice(u64),
36    /// A character device with the given device number.
37    CharacterDevice(u64),
38    /// A named pipe (FIFO).
39    Fifo,
40    /// A Unix domain socket.
41    Socket,
42    /// A symbolic link pointing to the given target path.
43    Symlink(Box<OsStr>),
44}
45
46/// A leaf node representing a non-directory file.
47#[derive(Debug)]
48pub struct Leaf<T> {
49    /// Metadata for this leaf node.
50    pub stat: Stat,
51    /// The content and type of this leaf node.
52    pub content: LeafContent<T>,
53}
54
55/// A directory node containing named entries.
56#[derive(Debug)]
57pub struct Directory<T> {
58    /// Metadata for this directory.
59    pub stat: Stat,
60    /// Map of filenames to inodes within this directory.
61    pub(crate) entries: BTreeMap<Box<OsStr>, Inode<T>>,
62}
63
64/// A filesystem inode representing either a directory or a leaf node.
65#[derive(Debug)]
66pub enum Inode<T> {
67    /// A directory inode.
68    Directory(Box<Directory<T>>),
69    /// A leaf inode (reference-counted to support hardlinks).
70    Leaf(Rc<Leaf<T>>),
71}
72
73/// Errors that can occur when working with filesystem images.
74#[derive(Error, Debug)]
75pub enum ImageError {
76    /// The filename contains invalid components (e.g., "..", ".", or Windows prefixes).
77    #[error("Invalid filename {0:?}")]
78    InvalidFilename(Box<OsStr>),
79    /// The specified directory entry does not exist.
80    #[error("Directory entry {0:?} does not exist")]
81    NotFound(Box<OsStr>),
82    /// The entry exists but is not a directory when a directory was expected.
83    #[error("Directory entry {0:?} is not a subdirectory")]
84    NotADirectory(Box<OsStr>),
85    /// The entry is a directory when a non-directory was expected.
86    #[error("Directory entry {0:?} is a directory")]
87    IsADirectory(Box<OsStr>),
88    /// The entry exists but is not a regular file when a regular file was expected.
89    #[error("Directory entry {0:?} is not a regular file")]
90    IsNotRegular(Box<OsStr>),
91}
92
93impl<T> Inode<T> {
94    /// Returns a reference to the metadata for this inode.
95    pub fn stat(&self) -> &Stat {
96        match self {
97            Inode::Directory(dir) => &dir.stat,
98            Inode::Leaf(leaf) => &leaf.stat,
99        }
100    }
101}
102
103// For some reason #[derive(Default)] doesn't work, so let's DIY
104impl<T> Default for Directory<T> {
105    fn default() -> Self {
106        Self {
107            stat: Stat {
108                st_uid: 0,
109                st_gid: 0,
110                st_mode: 0o555,
111                st_mtim_sec: 0,
112                xattrs: Default::default(),
113            },
114            entries: BTreeMap::default(),
115        }
116    }
117}
118
119impl<T> Directory<T> {
120    /// Creates a new directory with the given metadata.
121    pub fn new(stat: Stat) -> Self {
122        Self {
123            stat,
124            entries: BTreeMap::new(),
125        }
126    }
127
128    /// Iterates over all inodes in the current directory, in no particular order.
129    pub fn inodes(&self) -> impl Iterator<Item = &Inode<T>> + use<'_, T> {
130        self.entries.values()
131    }
132
133    /// Iterates over all entries in the current directory, in no particular order.  The iterator
134    /// returns pairs of `(&OsStr, &Inode)` and is probably used like so:
135    ///
136    /// Currently this is equivalent to `Directory::sorted_entries()` but that might change at some
137    /// point.
138    ///
139    /// ```
140    /// use composefs::{tree::FileSystem, fsverity::Sha256HashValue};
141    /// let fs = FileSystem::<Sha256HashValue>::default();
142    ///
143    /// // populate the fs...
144    ///
145    /// for (name, inode) in fs.root.entries() {
146    ///   // name: &OsStr, inode: &Inode
147    /// }
148    /// ```
149    pub fn entries(&self) -> impl Iterator<Item = (&OsStr, &Inode<T>)> + use<'_, T> {
150        self.entries.iter().map(|(k, v)| (k.as_ref(), v))
151    }
152
153    /// Iterates over all entries in the current directory, in asciibetical order of name.  The
154    /// iterator returns pairs of `(&OsStr, &Inode)`.
155    pub fn sorted_entries(&self) -> impl Iterator<Item = (&OsStr, &Inode<T>)> + use<'_, T> {
156        self.entries.iter().map(|(k, v)| (k.as_ref(), v))
157    }
158
159    /// Gets a reference to a subdirectory of this directory.
160    ///
161    /// The given path may be absolute or relative and it makes no difference.  It may not contain
162    /// any Windows-like prefixes, or "." or ".." components.  It may or may not end in "/" and it
163    /// makes no difference.
164    ///
165    /// See `Directory::get_directory_mut()` for the mutable verison of this function.
166    ///
167    /// # Arguments
168    ///
169    ///  * `pathname`: the full pathname of the directory to fetch, taken as being relative to the
170    ///    current directory even if it starts with '/'
171    ///
172    /// # Return value
173    ///
174    /// On success, this returns a reference to the named directory.
175    ///
176    /// On failure, can return any number of errors from ImageError.
177    pub fn get_directory(&self, pathname: &OsStr) -> Result<&Directory<T>, ImageError> {
178        match self.get_directory_opt(pathname)? {
179            Some(r) => Ok(r),
180            None => Err(ImageError::NotFound(Box::from(pathname))),
181        }
182    }
183
184    /// Like [`Self::get_directory()`] but maps [`ImageError::NotFound`] to [`Option`].
185    pub fn get_directory_opt(&self, pathname: &OsStr) -> Result<Option<&Directory<T>>, ImageError> {
186        let path = Path::new(pathname);
187        let mut dir = self;
188
189        for component in path.components() {
190            dir = match component {
191                Component::RootDir => dir,
192                Component::Prefix(..) | Component::CurDir | Component::ParentDir => {
193                    return Err(ImageError::InvalidFilename(pathname.into()))
194                }
195                Component::Normal(filename) => match dir.entries.get(filename) {
196                    Some(Inode::Directory(subdir)) => subdir,
197                    Some(_) => return Err(ImageError::NotADirectory(filename.into())),
198                    None => return Ok(None),
199                },
200            }
201        }
202
203        Ok(Some(dir))
204    }
205
206    /// Gets a mutable reference to a subdirectory of this directory.
207    ///
208    /// This is the mutable version of `Directory::get_directory()`.
209    pub fn get_directory_mut(&mut self, pathname: &OsStr) -> Result<&mut Directory<T>, ImageError> {
210        let path = Path::new(pathname);
211        let mut dir = self;
212
213        for component in path.components() {
214            dir = match component {
215                Component::RootDir => dir,
216                Component::Prefix(..) | Component::CurDir | Component::ParentDir => {
217                    return Err(ImageError::InvalidFilename(pathname.into()))
218                }
219                Component::Normal(filename) => match dir.entries.get_mut(filename) {
220                    Some(Inode::Directory(subdir)) => subdir,
221                    Some(_) => return Err(ImageError::NotADirectory(filename.into())),
222                    None => return Err(ImageError::NotFound(filename.into())),
223                },
224            };
225        }
226
227        Ok(dir)
228    }
229
230    /// Splits a pathname into a directory and the filename within that directory.  The directory
231    /// must already exist.  The filename within the directory may or may not exist.
232    ///
233    /// This is the main entry point for most operations based on pathname.  The expectation is
234    /// that the returned filename will be used to perform a more concrete operation on the
235    /// returned directory.
236    ///
237    /// See `Directory::get_directory()` for more information about path traversal.  See
238    /// `Directory::split_mut()` for the mutable version of this function.
239    ///
240    /// # Arguments
241    ///
242    ///  * `pathname`: the full pathname to the file of interest
243    ///
244    /// # Return value
245    ///
246    /// On success (the pathname is not invalid and the directory exists), returns a tuple of the
247    /// `Directory` containing the file at the given path, and the basename of that file.
248    ///
249    /// On failure, can return any number of errors from ImageError.
250    pub fn split<'d, 'n>(
251        &'d self,
252        pathname: &'n OsStr,
253    ) -> Result<(&'d Directory<T>, &'n OsStr), ImageError> {
254        let path = Path::new(pathname);
255
256        let Some(filename) = path.file_name() else {
257            return Err(ImageError::InvalidFilename(Box::from(pathname)));
258        };
259
260        let dir = match path.parent() {
261            Some(parent) => self.get_directory(parent.as_os_str())?,
262            None => self,
263        };
264
265        Ok((dir, filename))
266    }
267
268    /// Splits a pathname into a directory and the filename within that directory.  The directory
269    /// must already exist.  The filename within the directory may or may not exist.
270    ///
271    /// This is the `_mut` version of `Directory::split()`.
272    pub fn split_mut<'d, 'n>(
273        &'d mut self,
274        pathname: &'n OsStr,
275    ) -> Result<(&'d mut Directory<T>, &'n OsStr), ImageError> {
276        let path = Path::new(pathname);
277
278        let Some(filename) = path.file_name() else {
279            return Err(ImageError::InvalidFilename(Box::from(pathname)));
280        };
281
282        let dir = match path.parent() {
283            Some(parent) => self.get_directory_mut(parent.as_os_str())?,
284            None => self,
285        };
286
287        Ok((dir, filename))
288    }
289
290    /// Takes a reference to the "leaf" file (not directory) with the given filename directly
291    /// contained in this directory.  This is usually done in preparation for creating a hardlink
292    /// or in order to avoid issues with the borrow checker when mutating the tree.
293    ///
294    /// # Arguments
295    ///
296    ///  * `filename`: the filename in the current directory.  If you need to support full
297    ///    pathnames then you should call `Directory::split()` first.
298    ///
299    /// # Return value
300    ///
301    /// On success (the entry exists and is not a directory) the Rc is cloned and a new reference
302    /// is returned.
303    ///
304    /// On failure, can return any number of errors from ImageError.
305    pub fn ref_leaf(&self, filename: &OsStr) -> Result<Rc<Leaf<T>>, ImageError> {
306        match self.entries.get(filename) {
307            Some(Inode::Leaf(leaf)) => Ok(Rc::clone(leaf)),
308            Some(Inode::Directory(..)) => Err(ImageError::IsADirectory(Box::from(filename))),
309            None => Err(ImageError::NotFound(Box::from(filename))),
310        }
311    }
312
313    /// Obtains information about the regular file with the given filename directly contained in
314    /// this directory.
315    ///
316    /// # Arguments
317    ///
318    ///  * `filename`: the filename in the current directory.  If you need to support full
319    ///    pathnames then you should call `Directory::split()` first.
320    ///
321    /// # Return value
322    ///
323    /// On success (the entry exists and is a regular file) then the return value is either:
324    ///  * the inline data
325    ///  * an external reference, with size information
326    ///
327    /// On failure, can return any number of errors from ImageError.
328    pub fn get_file<'a>(&'a self, filename: &OsStr) -> Result<&'a T, ImageError> {
329        self.get_file_opt(filename)?
330            .ok_or_else(|| ImageError::NotFound(Box::from(filename)))
331    }
332
333    /// Like [`Self::get_file()`] but maps [`ImageError::NotFound`] to [`Option`].
334    pub fn get_file_opt<'a>(&'a self, filename: &OsStr) -> Result<Option<&'a T>, ImageError> {
335        match self.entries.get(filename) {
336            Some(Inode::Leaf(leaf)) => match &leaf.content {
337                LeafContent::Regular(file) => Ok(Some(file)),
338                _ => Err(ImageError::IsNotRegular(filename.into())),
339            },
340            Some(Inode::Directory(..)) => Err(ImageError::IsADirectory(filename.into())),
341            None => Ok(None),
342        }
343    }
344
345    /// Inserts the given inode into the directory with special handling for directories.  In case
346    /// the inode is a directory and there is already a subdirectory with the given filename, the
347    /// `stat` field will be updated with the value from the provided `inode` but the old directory
348    /// entries will be left in place.
349    ///
350    /// In all other cases, this function is equivalent to `Directory::insert()`.
351    ///
352    /// This is something like extracting an archive or an overlay: directories are merged with
353    /// existing directories, but otherwise the new content replaces what was there before.
354    ///
355    /// # Arguments
356    ///
357    ///  * `filename`: the filename in the current directory.  If you need to support full
358    ///    pathnames then you should call `Directory::split()` first.
359    ///  * `inode`: the inode to store under the `filename`
360    pub fn merge(&mut self, filename: &OsStr, inode: Inode<T>) {
361        // If we're putting a directory on top of a directory, then update the stat information but
362        // keep the old entries in place.
363        if let Inode::Directory(new_dir) = inode {
364            if let Some(Inode::Directory(old_dir)) = self.entries.get_mut(filename) {
365                old_dir.stat = new_dir.stat;
366            } else {
367                // Unfortunately we already deconstructed the original inode and we can't get it
368                // back again.  This is necessary because we wanted to move the stat field (above)
369                // without cloning it which can't be done through a reference (mutable or not).
370                self.insert(filename, Inode::Directory(new_dir));
371            }
372        } else {
373            self.insert(filename, inode);
374        }
375    }
376
377    /// Inserts the given inode into the directory.
378    ///
379    /// If the `filename` existed previously, the content is completely overwritten, including the
380    /// case that it was a directory.
381    ///
382    /// # Arguments
383    ///
384    ///  * `filename`: the filename in the current directory.  If you need to support full
385    ///    pathnames then you should call `Directory::split()` first.
386    ///  * `inode`: the inode to store under the `filename`
387    pub fn insert(&mut self, filename: &OsStr, inode: Inode<T>) {
388        self.entries.insert(Box::from(filename), inode);
389    }
390
391    /// Removes the named file from the directory, if it exists.  If it doesn't exist, this is a
392    /// no-op.
393    ///
394    /// # Arguments
395    ///
396    ///  * `filename`: the filename in the current directory.  If you need to support full
397    ///    pathnames then you should call `Directory::split()` first.
398    pub fn remove(&mut self, filename: &OsStr) {
399        self.entries.remove(filename);
400    }
401
402    /// Does a directory lookup on the given filename, returning the Inode if it exists.
403    ///
404    /// # Arguments
405    ///
406    ///  * `filename`: the filename in the current directory.  If you need to support full
407    ///    pathnames then you should call `Directory::split()` first.
408    pub fn lookup(&self, filename: &OsStr) -> Option<&Inode<T>> {
409        self.entries.get(filename)
410    }
411
412    /// Removes an item from the directory, if it exists, returning the Inode value.
413    ///
414    /// # Arguments
415    ///
416    ///  * `filename`: the filename in the current directory.  If you need to support full
417    ///    pathnames then you should call `Directory::split_mut()` first.
418    pub fn pop(&mut self, filename: &OsStr) -> Option<Inode<T>> {
419        self.entries.remove(filename)
420    }
421
422    /// Removes all content from this directory, making the directory empty.  The `stat` data
423    /// remains unmodified.
424    pub fn clear(&mut self) {
425        self.entries.clear();
426    }
427
428    /// Recursively finds the newest modification time in this directory tree.
429    ///
430    /// Returns the maximum modification time among this directory's metadata
431    /// and all files and subdirectories it contains.
432    pub fn newest_file(&self) -> i64 {
433        let mut newest = self.stat.st_mtim_sec;
434        for inode in self.entries.values() {
435            let mtime = match inode {
436                Inode::Leaf(ref leaf) => leaf.stat.st_mtim_sec,
437                Inode::Directory(ref dir) => dir.newest_file(),
438            };
439            if mtime > newest {
440                newest = mtime;
441            }
442        }
443        newest
444    }
445}
446
447/// A complete filesystem tree with a root directory.
448#[derive(Debug)]
449pub struct FileSystem<T> {
450    /// The root directory of the filesystem.
451    pub root: Directory<T>,
452    /// Whether the root directory's metadata has been explicitly set.
453    pub have_root_stat: bool,
454}
455
456impl<T> Default for FileSystem<T> {
457    fn default() -> Self {
458        Self {
459            root: Directory::default(),
460            have_root_stat: false,
461        }
462    }
463}
464
465impl<T> FileSystem<T> {
466    /// Sets the metadata for the root directory.
467    ///
468    /// Marks the root directory's stat as explicitly set.
469    pub fn set_root_stat(&mut self, stat: Stat) {
470        self.have_root_stat = true;
471        self.root.stat = stat;
472    }
473
474    /// Ensures the root directory has valid metadata.
475    ///
476    /// If the root stat hasn't been explicitly set, this computes it by finding
477    /// the newest modification time in the entire filesystem tree.
478    pub fn ensure_root_stat(&mut self) {
479        if !self.have_root_stat {
480            self.root.stat.st_mtim_sec = self.root.newest_file();
481            self.have_root_stat = true;
482        }
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use std::cell::RefCell;
490    use std::collections::BTreeMap;
491    use std::ffi::{OsStr, OsString};
492    use std::rc::Rc;
493
494    // We never store any actual data here
495    #[derive(Debug, Default)]
496    struct FileContents {}
497
498    // Helper to create a Stat with a specific mtime
499    fn stat_with_mtime(mtime: i64) -> Stat {
500        Stat {
501            st_mode: 0o755,
502            st_uid: 1000,
503            st_gid: 1000,
504            st_mtim_sec: mtime,
505            xattrs: RefCell::new(BTreeMap::new()),
506        }
507    }
508
509    // Helper to create a simple Leaf (e.g., an empty inline file)
510    fn new_leaf_file(mtime: i64) -> Rc<Leaf<FileContents>> {
511        Rc::new(Leaf {
512            stat: stat_with_mtime(mtime),
513            content: LeafContent::Regular(FileContents::default()),
514        })
515    }
516
517    // Helper to create a simple Leaf (symlink)
518    fn new_leaf_symlink(target: &str, mtime: i64) -> Rc<Leaf<FileContents>> {
519        Rc::new(Leaf {
520            stat: stat_with_mtime(mtime),
521            content: LeafContent::Symlink(OsString::from(target).into_boxed_os_str()),
522        })
523    }
524
525    // Helper to create an empty Directory Inode with a specific mtime
526    fn new_dir_inode<T>(mtime: i64) -> Inode<T> {
527        Inode::Directory(Box::new(Directory {
528            stat: stat_with_mtime(mtime),
529            entries: BTreeMap::new(),
530        }))
531    }
532
533    // Helper to create a Directory Inode with specific stat
534    fn new_dir_inode_with_stat<T>(stat: Stat) -> Inode<T> {
535        Inode::Directory(Box::new(Directory {
536            stat,
537            entries: BTreeMap::new(),
538        }))
539    }
540
541    #[test]
542    fn test_directory_default() {
543        let dir = Directory::<()>::default();
544        assert_eq!(dir.stat.st_uid, 0);
545        assert_eq!(dir.stat.st_gid, 0);
546        assert_eq!(dir.stat.st_mode, 0o555);
547        assert_eq!(dir.stat.st_mtim_sec, 0);
548        assert!(dir.stat.xattrs.borrow().is_empty());
549        assert!(dir.entries.is_empty());
550    }
551
552    #[test]
553    fn test_directory_new() {
554        let stat = stat_with_mtime(123);
555        let dir = Directory::<()>::new(stat);
556        assert_eq!(dir.stat.st_mtim_sec, 123);
557        assert!(dir.entries.is_empty());
558    }
559
560    #[test]
561    fn test_insert_and_get_leaf() {
562        let mut dir = Directory::<FileContents>::default();
563        let leaf = new_leaf_file(10);
564        dir.insert(OsStr::new("file.txt"), Inode::Leaf(Rc::clone(&leaf)));
565        assert_eq!(dir.entries.len(), 1);
566
567        let retrieved_leaf_rc = dir.ref_leaf(OsStr::new("file.txt")).unwrap();
568        assert!(Rc::ptr_eq(&retrieved_leaf_rc, &leaf));
569
570        let regular_file_content = dir.get_file(OsStr::new("file.txt")).unwrap();
571        assert!(matches!(regular_file_content, FileContents {}));
572    }
573
574    #[test]
575    fn test_insert_and_get_directory() {
576        let mut dir = Directory::<()>::default();
577        let sub_dir_inode = new_dir_inode(20);
578        dir.insert(OsStr::new("subdir"), sub_dir_inode);
579        assert_eq!(dir.entries.len(), 1);
580
581        let retrieved_subdir = dir.get_directory(OsStr::new("subdir")).unwrap();
582        assert_eq!(retrieved_subdir.stat.st_mtim_sec, 20);
583
584        let retrieved_subdir_opt = dir
585            .get_directory_opt(OsStr::new("subdir"))
586            .unwrap()
587            .unwrap();
588        assert_eq!(retrieved_subdir_opt.stat.st_mtim_sec, 20);
589    }
590
591    #[test]
592    fn test_get_directory_errors() {
593        let mut root = Directory::default();
594        root.insert(OsStr::new("dir1"), new_dir_inode(10));
595        root.insert(OsStr::new("file1"), Inode::Leaf(new_leaf_file(30)));
596
597        match root.get_directory(OsStr::new("nonexistent")) {
598            Err(ImageError::NotFound(name)) => assert_eq!(name.to_str().unwrap(), "nonexistent"),
599            _ => panic!("Expected NotFound"),
600        }
601        assert!(root
602            .get_directory_opt(OsStr::new("nonexistent"))
603            .unwrap()
604            .is_none());
605
606        match root.get_directory(OsStr::new("file1")) {
607            Err(ImageError::NotADirectory(name)) => assert_eq!(name.to_str().unwrap(), "file1"),
608            _ => panic!("Expected NotADirectory"),
609        }
610    }
611
612    #[test]
613    fn test_get_file_errors() {
614        let mut dir = Directory::default();
615        dir.insert(OsStr::new("subdir"), new_dir_inode(10));
616        dir.insert(
617            OsStr::new("link.txt"),
618            Inode::Leaf(new_leaf_symlink("target", 20)),
619        );
620
621        match dir.get_file(OsStr::new("nonexistent.txt")) {
622            Err(ImageError::NotFound(name)) => {
623                assert_eq!(name.to_str().unwrap(), "nonexistent.txt")
624            }
625            _ => panic!("Expected NotFound"),
626        }
627        assert!(dir
628            .get_file_opt(OsStr::new("nonexistent.txt"))
629            .unwrap()
630            .is_none());
631
632        match dir.get_file(OsStr::new("subdir")) {
633            Err(ImageError::IsADirectory(name)) => assert_eq!(name.to_str().unwrap(), "subdir"),
634            _ => panic!("Expected IsADirectory"),
635        }
636        match dir.get_file(OsStr::new("link.txt")) {
637            Err(ImageError::IsNotRegular(name)) => assert_eq!(name.to_str().unwrap(), "link.txt"),
638            res => panic!("Expected IsNotRegular, got {res:?}"),
639        }
640    }
641
642    #[test]
643    fn test_remove() {
644        let mut dir = Directory::default();
645        dir.insert(OsStr::new("file1.txt"), Inode::Leaf(new_leaf_file(10)));
646        dir.insert(OsStr::new("subdir"), new_dir_inode(20));
647        assert_eq!(dir.entries.len(), 2);
648
649        dir.remove(OsStr::new("file1.txt"));
650        assert_eq!(dir.entries.len(), 1);
651        assert!(!dir.entries.contains_key(OsStr::new("file1.txt")));
652
653        dir.remove(OsStr::new("nonexistent")); // Should be no-op
654        assert_eq!(dir.entries.len(), 1);
655    }
656
657    #[test]
658    fn test_merge() {
659        let mut dir = Directory::default();
660
661        // Merge Leaf onto empty
662        dir.merge(OsStr::new("item"), Inode::Leaf(new_leaf_file(10)));
663        assert_eq!(
664            dir.entries
665                .get(OsStr::new("item"))
666                .unwrap()
667                .stat()
668                .st_mtim_sec,
669            10
670        );
671
672        // Merge Directory onto existing Directory
673        let mut existing_dir_inode = new_dir_inode_with_stat(stat_with_mtime(80));
674        if let Inode::Directory(ref mut ed_box) = existing_dir_inode {
675            ed_box.insert(OsStr::new("inner_file"), Inode::Leaf(new_leaf_file(85)));
676        }
677        dir.insert(OsStr::new("merged_dir"), existing_dir_inode);
678
679        let new_merging_dir_inode = new_dir_inode_with_stat(stat_with_mtime(90));
680        dir.merge(OsStr::new("merged_dir"), new_merging_dir_inode);
681
682        match dir.entries.get(OsStr::new("merged_dir")) {
683            Some(Inode::Directory(d)) => {
684                assert_eq!(d.stat.st_mtim_sec, 90); // Stat updated
685                assert_eq!(d.entries.len(), 1); // Inner file preserved
686                assert!(d.entries.contains_key(OsStr::new("inner_file")));
687            }
688            _ => panic!("Expected directory after merge"),
689        }
690
691        // Merge Leaf onto Directory (replaces)
692        dir.merge(OsStr::new("merged_dir"), Inode::Leaf(new_leaf_file(100)));
693        assert!(matches!(
694            dir.entries.get(OsStr::new("merged_dir")),
695            Some(Inode::Leaf(_))
696        ));
697        assert_eq!(
698            dir.entries
699                .get(OsStr::new("merged_dir"))
700                .unwrap()
701                .stat()
702                .st_mtim_sec,
703            100
704        );
705    }
706
707    #[test]
708    fn test_clear() {
709        let mut dir = Directory::default();
710        dir.insert(OsStr::new("file1"), Inode::Leaf(new_leaf_file(10)));
711        dir.stat.st_mtim_sec = 100;
712
713        dir.clear();
714        assert!(dir.entries.is_empty());
715        assert_eq!(dir.stat.st_mtim_sec, 100); // Stat should be unmodified
716    }
717
718    #[test]
719    fn test_newest_file() {
720        let mut root = Directory::new(stat_with_mtime(5));
721        assert_eq!(root.newest_file(), 5);
722
723        root.insert(OsStr::new("file1"), Inode::Leaf(new_leaf_file(10)));
724        assert_eq!(root.newest_file(), 10);
725
726        let subdir_stat = stat_with_mtime(15);
727        let mut subdir = Box::new(Directory::new(subdir_stat));
728        subdir.insert(OsStr::new("subfile1"), Inode::Leaf(new_leaf_file(12)));
729        root.insert(OsStr::new("subdir"), Inode::Directory(subdir));
730        assert_eq!(root.newest_file(), 15);
731
732        if let Some(Inode::Directory(sd)) = root.entries.get_mut(OsStr::new("subdir")) {
733            sd.insert(OsStr::new("subfile2"), Inode::Leaf(new_leaf_file(20)));
734        }
735        assert_eq!(root.newest_file(), 20);
736
737        root.stat.st_mtim_sec = 25;
738        assert_eq!(root.newest_file(), 25);
739    }
740
741    #[test]
742    fn test_iteration_entries_sorted_inodes() {
743        let mut dir = Directory::default();
744        dir.insert(OsStr::new("b_file"), Inode::Leaf(new_leaf_file(10)));
745        dir.insert(OsStr::new("a_dir"), new_dir_inode(20));
746        dir.insert(
747            OsStr::new("c_link"),
748            Inode::Leaf(new_leaf_symlink("target", 30)),
749        );
750
751        let names_from_entries: Vec<&OsStr> = dir.entries().map(|(name, _)| name).collect();
752        assert_eq!(names_from_entries.len(), 3); // BTreeMap iter is sorted
753        assert!(names_from_entries.contains(&OsStr::new("a_dir")));
754        assert!(names_from_entries.contains(&OsStr::new("b_file")));
755        assert!(names_from_entries.contains(&OsStr::new("c_link")));
756
757        let sorted_names: Vec<&OsStr> = dir.sorted_entries().map(|(name, _)| name).collect();
758        assert_eq!(
759            sorted_names,
760            vec![
761                OsStr::new("a_dir"),
762                OsStr::new("b_file"),
763                OsStr::new("c_link")
764            ]
765        );
766
767        let mut inode_types = vec![];
768        for inode in dir.inodes() {
769            match inode {
770                Inode::Directory(_) => inode_types.push("dir"),
771                Inode::Leaf(_) => inode_types.push("leaf"),
772            }
773        }
774        assert_eq!(inode_types.len(), 3);
775        assert_eq!(inode_types.iter().filter(|&&t| t == "dir").count(), 1);
776        assert_eq!(inode_types.iter().filter(|&&t| t == "leaf").count(), 2);
777    }
778}