Skip to main content

gravityfile_core/
tree.rs

1//! File tree container and statistics.
2
3use std::path::{Path, PathBuf};
4use std::time::{Duration, SystemTime};
5
6use serde::{Deserialize, Serialize};
7
8use crate::config::ScanConfig;
9use crate::error::ScanWarning;
10use crate::node::{FileNode, NodeKind};
11
12/// Summary statistics for a scanned tree.
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14pub struct TreeStats {
15    /// Total size in bytes.
16    pub total_size: u64,
17    /// Total number of files.
18    pub total_files: u64,
19    /// Total number of directories.
20    pub total_dirs: u64,
21    /// Total number of symbolic links.
22    pub total_symlinks: u64,
23    /// Maximum depth reached.
24    pub max_depth: u32,
25    /// Largest file (path, size).
26    #[serde(default, with = "crate::path_serde::optional_pair")]
27    pub largest_file: Option<(PathBuf, u64)>,
28    /// Oldest file (path, time).
29    #[serde(default, with = "crate::path_serde::optional_pair")]
30    pub oldest_file: Option<(PathBuf, SystemTime)>,
31    /// Newest file (path, time).
32    #[serde(default, with = "crate::path_serde::optional_pair")]
33    pub newest_file: Option<(PathBuf, SystemTime)>,
34}
35
36impl TreeStats {
37    /// Create new empty stats.
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Update stats with a file entry. Only clones the path when updating a tracked field.
43    /// Equal sizes/times choose the smallest native path, independent of arrival order.
44    ///
45    /// `modified` is `None` when the file's modification time could not be read;
46    /// such files are excluded from oldest/newest tracking rather than being
47    /// coerced to the Unix epoch (which would poison the "oldest file" result).
48    pub fn record_file(
49        &mut self,
50        path: &Path,
51        size: u64,
52        modified: Option<SystemTime>,
53        depth: u32,
54    ) {
55        self.record_file_with(size, modified, depth, || path.to_path_buf());
56    }
57
58    /// [`Self::record_file`] that builds the path only when it is needed:
59    /// when this file may become the largest, oldest or newest.
60    pub fn record_file_with(
61        &mut self,
62        size: u64,
63        modified: Option<SystemTime>,
64        depth: u32,
65        path: impl FnOnce() -> PathBuf,
66    ) {
67        self.total_files += 1;
68        self.total_size = self.total_size.saturating_add(size);
69        self.max_depth = self.max_depth.max(depth);
70
71        let largest = self.largest_file.as_ref().is_none_or(|(_, s)| size >= *s);
72        let (oldest, newest) = match modified {
73            Some(modified) => (
74                self.oldest_file
75                    .as_ref()
76                    .is_none_or(|(_, t)| modified <= *t),
77                self.newest_file
78                    .as_ref()
79                    .is_none_or(|(_, t)| modified >= *t),
80            ),
81            None => (false, false),
82        };
83        if !(largest || oldest || newest) {
84            return;
85        }
86        // Ties are broken by the smaller path, independent of arrival order.
87        let path = path();
88        if largest
89            && self
90                .largest_file
91                .as_ref()
92                .is_none_or(|(previous, s)| size > *s || path < *previous)
93        {
94            self.largest_file = Some((path.clone(), size));
95        }
96        if let Some(modified) = modified {
97            if oldest
98                && self
99                    .oldest_file
100                    .as_ref()
101                    .is_none_or(|(previous, t)| modified < *t || path < *previous)
102            {
103                self.oldest_file = Some((path.clone(), modified));
104            }
105            if newest
106                && self
107                    .newest_file
108                    .as_ref()
109                    .is_none_or(|(previous, t)| modified > *t || path < *previous)
110            {
111                self.newest_file = Some((path, modified));
112            }
113        }
114    }
115
116    /// Record a directory.
117    pub fn record_dir(&mut self, depth: u32) {
118        self.total_dirs += 1;
119        self.max_depth = self.max_depth.max(depth);
120    }
121
122    /// Record a symlink.
123    pub fn record_symlink(&mut self) {
124        self.total_symlinks += 1;
125    }
126
127    /// Statistics of an existing tree whose root is at `root_path`, as a scan
128    /// of it would have recorded them.
129    pub fn collect(root: &FileNode, root_path: &Path) -> Self {
130        let mut stats = Self::new();
131        stats.record_dir(0);
132        let mut path = root_path.to_path_buf();
133        let mut stack = vec![root.children.iter()];
134        while let Some(children) = stack.last_mut() {
135            let Some(node) = children.next() else {
136                stack.pop();
137                if !stack.is_empty() {
138                    path.pop();
139                }
140                continue;
141            };
142            let depth = stack.len() as u32;
143            match node.kind {
144                NodeKind::Directory { .. } => {
145                    stats.record_dir(depth);
146                    if !node.children.is_empty() {
147                        path.push(node.file_name());
148                        stack.push(node.children.iter());
149                    }
150                }
151                NodeKind::File { .. } => {
152                    stats.record_file_with(
153                        node.size,
154                        Some(node.timestamps.modified),
155                        depth,
156                        || path.join(node.file_name()),
157                    );
158                }
159                NodeKind::Symlink { .. } => stats.record_symlink(),
160                NodeKind::Other => {}
161            }
162        }
163        stats
164    }
165}
166
167/// Complete scanned file tree with metadata.
168///
169/// **Warning:** `Clone` performs a deep clone of the entire tree — O(n) in the number of nodes.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct FileTree {
172    /// Root node of the tree.
173    pub root: FileNode,
174
175    /// Root path that was scanned.
176    #[serde(with = "crate::path_serde")]
177    pub root_path: PathBuf,
178
179    /// When this scan was performed.
180    pub scanned_at: SystemTime,
181
182    /// Duration of the scan.
183    pub scan_duration: Duration,
184
185    /// Scan configuration used.
186    pub config: ScanConfig,
187
188    /// Summary statistics.
189    pub stats: TreeStats,
190
191    /// Warnings encountered during scan.
192    pub warnings: Vec<ScanWarning>,
193}
194
195impl FileTree {
196    /// Create a new file tree.
197    ///
198    /// `scanned_at` is stamped at construction time, which is when the scan
199    /// finished. `scan_duration` is supplied by the caller, so the scan's start
200    /// time is `scanned_at - scan_duration`.
201    pub fn new(
202        root: FileNode,
203        root_path: PathBuf,
204        config: ScanConfig,
205        stats: TreeStats,
206        scan_duration: Duration,
207        warnings: Vec<ScanWarning>,
208    ) -> Self {
209        Self {
210            root,
211            root_path,
212            scanned_at: SystemTime::now(),
213            scan_duration,
214            config,
215            stats,
216            warnings,
217        }
218    }
219
220    /// Get the total size of the tree.
221    #[inline]
222    pub fn total_size(&self) -> u64 {
223        self.root.size
224    }
225
226    /// Get the total number of files.
227    #[inline]
228    pub fn total_files(&self) -> u64 {
229        self.stats.total_files
230    }
231
232    /// Get the total number of directories.
233    #[inline]
234    pub fn total_dirs(&self) -> u64 {
235        self.stats.total_dirs
236    }
237
238    /// Check if there were any warnings during scanning.
239    #[inline]
240    pub fn has_warnings(&self) -> bool {
241        !self.warnings.is_empty()
242    }
243
244    /// Observed warning count, including explicitly omitted details.
245    pub fn warning_count(&self) -> u64 {
246        self.warnings.iter().fold(0u64, |count, warning| {
247            count.saturating_add(warning.represented_count())
248        })
249    }
250
251    /// The entry at `path`, if it is the root or below it.
252    pub fn find(&self, path: &Path) -> Option<&FileNode> {
253        let mut node = &self.root;
254        for name in relative_names(&self.root_path, path)? {
255            node = node
256                .children
257                .iter()
258                .find(|child| child.file_name() == name)?;
259        }
260        Some(node)
261    }
262
263    /// Take the entry at `path` out of the tree, keeping every ancestor's size
264    /// and counts consistent. The root cannot be removed. Call
265    /// [`Self::recount`] after a batch of changes.
266    pub fn remove(&mut self, path: &Path) -> Option<FileNode> {
267        let mut names = relative_names(&self.root_path, path)?;
268        let name = names.pop()?;
269        let parent = descend_mut(&mut self.root, &names)?;
270        let index = parent
271            .children
272            .iter()
273            .position(|child| child.file_name() == name)?;
274        let removed = parent.children.remove(index);
275        adjust_chain(&mut self.root, &names, Contribution::of(&removed), false);
276        Some(removed)
277    }
278
279    /// Put `node` into the directory at `parent`, replacing any entry with
280    /// the same name, and keep every ancestor's size and counts consistent.
281    /// Returns false, dropping `node`, if `parent` is not a directory in the
282    /// tree. Call [`Self::recount`] after a batch of changes.
283    pub fn insert(&mut self, parent: &Path, node: FileNode) -> bool {
284        let Some(names) = relative_names(&self.root_path, parent) else {
285            return false;
286        };
287        match descend_mut(&mut self.root, &names) {
288            Some(directory) if directory.is_dir() => {}
289            _ => return false,
290        }
291        let mut path = parent.to_path_buf();
292        path.push(node.file_name());
293        self.remove(&path);
294        let added = Contribution::of(&node);
295        let directory = descend_mut(&mut self.root, &names).expect("checked above");
296        directory.children.push(node);
297        adjust_chain(&mut self.root, &names, added, true);
298        true
299    }
300
301    /// Recompute the summary statistics from the tree, after changes.
302    pub fn recount(&mut self) {
303        self.stats = TreeStats::collect(&self.root, &self.root_path);
304    }
305
306    /// Re-express every size in another measure without rescanning: apparent
307    /// lengths or allocated blocks, with hard links counted once or each time.
308    pub fn remeasure(&mut self, apparent: bool, count_hard_links: bool) {
309        self.root.remeasure(apparent, count_hard_links);
310        self.config.apparent_size = apparent;
311        self.config.count_hard_links = count_hard_links;
312        self.stats = TreeStats::collect(&self.root, &self.root_path);
313    }
314}
315
316/// The names leading from `root` to `path`, or `None` if `path` is not
317/// `root` or below it.
318fn relative_names<'a>(root: &Path, path: &'a Path) -> Option<Vec<&'a std::ffi::OsStr>> {
319    path.strip_prefix(root)
320        .ok()?
321        .components()
322        .map(|component| match component {
323            std::path::Component::Normal(name) => Some(name),
324            _ => None,
325        })
326        .collect()
327}
328
329fn descend_mut<'a>(
330    mut node: &'a mut FileNode,
331    names: &[&std::ffi::OsStr],
332) -> Option<&'a mut FileNode> {
333    for name in names {
334        node = node
335            .children
336            .iter_mut()
337            .find(|child| child.file_name() == *name)?;
338    }
339    Some(node)
340}
341
342/// What an entry adds to each of its ancestors.
343#[derive(Clone, Copy)]
344struct Contribution {
345    size: u64,
346    files: u64,
347    dirs: u64,
348}
349
350impl Contribution {
351    fn of(node: &FileNode) -> Self {
352        Self {
353            size: node.size,
354            files: if node.is_dir() { node.file_count() } else { 1 },
355            dirs: if node.is_dir() {
356                node.dir_count() + 1
357            } else {
358                0
359            },
360        }
361    }
362}
363
364/// Add or subtract `change` along the root and the directories named by
365/// `names`, re-sorting each one's children by size, largest first.
366fn adjust_chain(root: &mut FileNode, names: &[&std::ffi::OsStr], change: Contribution, add: bool) {
367    let apply = |node: &mut FileNode| {
368        let step = |value: u64, by: u64| {
369            if add {
370                value.saturating_add(by)
371            } else {
372                value.saturating_sub(by)
373            }
374        };
375        node.size = step(node.size, change.size);
376        if let NodeKind::Directory {
377            file_count,
378            dir_count,
379        } = &mut node.kind
380        {
381            *file_count = step(*file_count, change.files);
382            *dir_count = step(*dir_count, change.dirs);
383        }
384    };
385    let sort = |node: &mut FileNode| {
386        node.children
387            .sort_unstable_by(|a, b| b.size.cmp(&a.size).then_with(|| a.compare_name(b)));
388    };
389    // Sizes first, top-down; then each level's order, which depends on the
390    // updated sizes of its children.
391    let mut node = &mut *root;
392    apply(node);
393    for name in names {
394        node = node
395            .children
396            .iter_mut()
397            .find(|child| child.file_name() == *name)
398            .expect("the chain exists");
399        apply(node);
400    }
401    for depth in (0..=names.len()).rev() {
402        sort(descend_mut(root, &names[..depth]).expect("the chain exists"));
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use crate::node::Timestamps;
410
411    fn sample() -> FileTree {
412        let time = Timestamps::with_modified(SystemTime::UNIX_EPOCH);
413        let file = |name: &str, size| FileNode::new_file(name, size, 0, time, false);
414        let mut sub = FileNode::new_directory("sub", time);
415        sub.children = vec![file("a", 300), file("b", 100)];
416        let mut root = FileNode::new_directory("root", time);
417        root.children = vec![sub, file("c", 200)];
418        root.update_counts();
419        root.size = 600;
420        root.children[0].size = 400;
421        let mut tree = FileTree::new(
422            root,
423            PathBuf::from("/root"),
424            ScanConfig::new("/root"),
425            TreeStats::new(),
426            Duration::ZERO,
427            Vec::new(),
428        );
429        tree.recount();
430        tree
431    }
432
433    #[test]
434    fn removing_an_entry_updates_every_ancestor_and_order() {
435        let mut tree = sample();
436        let removed = tree.remove(Path::new("/root/sub/a")).unwrap();
437        assert_eq!(removed.name, "a");
438        assert_eq!(tree.root.size, 300);
439        let sub = tree.find(Path::new("/root/sub")).unwrap();
440        assert_eq!((sub.size, sub.file_count()), (100, 1));
441        assert_eq!(tree.root.file_count(), 2);
442        assert_eq!(
443            tree.root.children[0].name, "c",
444            "c (200) now outranks sub (100)"
445        );
446        tree.recount();
447        assert_eq!(tree.stats.total_files, 2);
448        assert_eq!(tree.stats.total_size, 300);
449        // A directory takes its whole subtree with it.
450        tree.remove(Path::new("/root/sub")).unwrap();
451        assert_eq!(
452            (
453                tree.root.size,
454                tree.root.file_count(),
455                tree.root.dir_count()
456            ),
457            (200, 1, 0)
458        );
459        assert!(tree.remove(Path::new("/root")).is_none());
460        assert!(tree.remove(Path::new("/elsewhere/c")).is_none());
461        assert!(tree.remove(Path::new("/root/missing")).is_none());
462    }
463
464    #[test]
465    fn inserting_an_entry_replaces_one_of_the_same_name() {
466        let mut tree = sample();
467        let time = Timestamps::with_modified(SystemTime::UNIX_EPOCH);
468        let mut grown = FileNode::new_directory("sub", time);
469        grown.children = vec![FileNode::new_file("x", 1000, 0, time, false)];
470        grown.update_counts();
471        grown.size = 1000;
472        assert!(tree.insert(Path::new("/root"), grown));
473        assert_eq!(tree.root.size, 1200);
474        assert_eq!(tree.root.file_count(), 2);
475        assert_eq!(tree.find(Path::new("/root/sub/x")).unwrap().size, 1000);
476        assert!(tree.find(Path::new("/root/sub/a")).is_none());
477
478        let new = FileNode::new_file("d", 50, 0, time, false);
479        assert!(tree.insert(Path::new("/root/sub"), new));
480        assert_eq!(tree.root.size, 1250);
481        assert_eq!(tree.find(Path::new("/root/sub")).unwrap().file_count(), 2);
482        let file = FileNode::new_file("e", 1, 0, time, false);
483        assert!(!tree.insert(Path::new("/root/c"), file), "c is a file");
484    }
485
486    #[test]
487    fn tied_extremes_are_independent_of_arrival_order() {
488        let time = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
489        for order in [["c", "b", "a"], ["a", "c", "b"], ["b", "a", "c"]] {
490            let mut stats = TreeStats::new();
491            for path in order {
492                stats.record_file(Path::new(path), 42, Some(time), 1);
493            }
494            assert_eq!(stats.largest_file, Some((PathBuf::from("a"), 42)));
495            assert_eq!(stats.oldest_file, Some((PathBuf::from("a"), time)));
496            assert_eq!(stats.newest_file, Some((PathBuf::from("a"), time)));
497            assert_eq!(stats.total_files, 3);
498            assert_eq!(stats.total_size, 126);
499        }
500    }
501
502    #[cfg(unix)]
503    #[test]
504    fn tied_extremes_compare_native_paths_not_lossy_labels() {
505        use std::os::unix::ffi::OsStrExt;
506        let paths = [b"/file-\xfe".as_slice(), b"/file-\xff".as_slice()]
507            .map(|bytes| PathBuf::from(std::ffi::OsStr::from_bytes(bytes)));
508        assert_eq!(paths[0].to_string_lossy(), paths[1].to_string_lossy());
509        for order in [[0, 1], [1, 0]] {
510            let mut stats = TreeStats::new();
511            for index in order {
512                stats.record_file(&paths[index], 10, Some(SystemTime::UNIX_EPOCH), 1);
513            }
514            assert_eq!(stats.largest_file.unwrap().0, paths[0]);
515            assert_eq!(stats.oldest_file.unwrap().0, paths[0]);
516            assert_eq!(stats.newest_file.unwrap().0, paths[0]);
517        }
518    }
519
520    #[test]
521    fn test_tree_stats_default() {
522        let stats = TreeStats::default();
523        assert_eq!(stats.total_size, 0);
524        assert_eq!(stats.total_files, 0);
525        assert_eq!(stats.total_dirs, 0);
526    }
527
528    #[test]
529    fn test_tree_stats_record_file() {
530        let mut stats = TreeStats::new();
531        let now = SystemTime::now();
532
533        stats.record_file(Path::new("/test/file.txt"), 1024, Some(now), 2);
534
535        assert_eq!(stats.total_files, 1);
536        assert_eq!(stats.total_size, 1024);
537        assert_eq!(stats.max_depth, 2);
538        assert!(stats.largest_file.is_some());
539    }
540
541    #[test]
542    fn test_tree_stats_record_file_tracks_extremes() {
543        let mut stats = TreeStats::new();
544        let t1 = SystemTime::UNIX_EPOCH + Duration::from_secs(1000);
545        let t2 = SystemTime::UNIX_EPOCH + Duration::from_secs(2000);
546
547        stats.record_file(Path::new("/small_old"), 100, Some(t1), 1);
548        stats.record_file(Path::new("/big_new"), 999, Some(t2), 2);
549
550        assert_eq!(stats.largest_file.as_ref().unwrap().1, 999);
551        assert_eq!(stats.oldest_file.as_ref().unwrap().1, t1);
552        assert_eq!(stats.newest_file.as_ref().unwrap().1, t2);
553    }
554
555    #[test]
556    fn test_record_file_unknown_mtime_does_not_poison_extremes() {
557        let mut stats = TreeStats::new();
558        let t = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
559
560        // A file with a real mtime, then one with an unreadable mtime (None).
561        stats.record_file(Path::new("/has_time"), 10, Some(t), 1);
562        stats.record_file(Path::new("/no_time"), 20, None, 1);
563
564        // Both files counted, but the unknown-mtime file must not become the
565        // oldest/newest (which previously happened via a Unix-epoch fallback).
566        assert_eq!(stats.total_files, 2);
567        assert_eq!(stats.total_size, 30);
568        assert_eq!(
569            stats.oldest_file.as_ref().unwrap().0,
570            Path::new("/has_time")
571        );
572        assert_eq!(
573            stats.newest_file.as_ref().unwrap().0,
574            Path::new("/has_time")
575        );
576    }
577}