Skip to main content

gravityfile_scan/
inode.rs

1//! Scan-lifetime inode tracking for physical-size deduplication.
2
3use gravityfile_core::InodeInfo;
4use std::collections::HashSet;
5
6/// Tracks physical identities already counted during one scan.
7/// Hard-link counts are mutable hints, not safe eviction criteria. Retain
8/// observed multi-link identities until the scan ends. Following directory
9/// symlinks also requires tracking single-link files reached through aliases.
10#[derive(Debug, Default)]
11pub struct InodeTracker {
12    seen: HashSet<InodeInfo>,
13    track_single_links: bool,
14}
15
16impl InodeTracker {
17    /// Track hard-linked identities; ordinary single-link files need no entry.
18    pub fn new() -> Self {
19        Self::default()
20    }
21
22    /// Track every identity when traversal may revisit a file through aliases.
23    pub(crate) fn with_alias_tracking() -> Self {
24        Self {
25            track_single_links: true,
26            ..Self::default()
27        }
28    }
29
30    /// Return true only when this identity should contribute physical size.
31    /// A previously seen identity remains suppressed even if its link count
32    /// subsequently falls to one. This is best-effort accounting, not a snapshot
33    /// guarantee against inode reuse or concurrent mutation.
34    pub fn track(&mut self, info: InodeInfo, nlink: u64) -> bool {
35        if self.track_single_links || nlink > 1 {
36            self.seen.insert(info)
37        } else {
38            !self.seen.contains(&info)
39        }
40    }
41
42    /// Number of retained identities. Entries remain until this tracker drops.
43    #[doc(hidden)]
44    pub fn pending_count(&self) -> usize {
45        self.seen.len()
46    }
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52
53    #[test]
54    fn changing_link_counts_and_extra_aliases_do_not_recount_seen_identity() {
55        let mut tracker = InodeTracker::new();
56        let info = InodeInfo::new(42, 1);
57        assert!(tracker.track(info, 2));
58        for count in [2, 1, 0, 3, 2] {
59            assert!(!tracker.track(info, count));
60        }
61        let mut aliases = InodeTracker::with_alias_tracking();
62        assert!(aliases.track(info, 1));
63        assert!(!aliases.track(info, 1));
64    }
65
66    #[test]
67    fn test_track_new_inode_nlink_1() {
68        let mut tracker = InodeTracker::new();
69        let info = InodeInfo::new(12345, 1);
70        // nlink=1 means no hard links; always fresh
71        assert!(tracker.track(info, 1));
72        assert!(tracker.track(info, 1));
73    }
74
75    #[test]
76    fn test_track_hardlink_nlink_2() {
77        let mut tracker = InodeTracker::new();
78        let info = InodeInfo::new(12345, 1);
79        // Two hard links: first returns true, second returns false
80        assert!(tracker.track(info, 2));
81        assert!(!tracker.track(info, 2));
82        // Retain identity so later aliases cannot count this inode again
83        assert_eq!(tracker.pending_count(), 1);
84    }
85
86    #[test]
87    fn test_track_hardlink_nlink_3() {
88        let mut tracker = InodeTracker::new();
89        let info = InodeInfo::new(99, 2);
90        // Three hard links: first is counted, next two are duplicates
91        assert!(tracker.track(info, 3));
92        assert!(!tracker.track(info, 3));
93        assert!(!tracker.track(info, 3));
94        assert_eq!(tracker.pending_count(), 1);
95    }
96
97    #[test]
98    fn test_different_devices() {
99        let mut tracker = InodeTracker::new();
100        let info1 = InodeInfo::new(12345, 1);
101        let info2 = InodeInfo::new(12345, 2); // Same inode, different device
102
103        assert!(tracker.track(info1, 2));
104        assert!(tracker.track(info2, 2)); // Different device, so it's new
105    }
106}