Skip to main content

pristine/
size.rs

1//! How many bytes a claimed directory is worth, and why a normal scan does not ask.
2//!
3//! ## The default is not to measure
4//!
5//! Prune-on-match is the whole performance thesis, and a recursive measurement would undo it:
6//! sizing `node_modules` means enumerating the tens of thousands of inodes the scan just
7//! declined to walk. So a normal scan records the claim and reports [`Size::Unmeasured`]. The
8//! traversal happens only under [`SizeMode::Breakdown`], which is what "the user asked for a
9//! breakdown" compiles down to.
10//!
11//! ## Why not the directory's own block accounting
12//!
13//! The concept doc asked for "sizes from the directory's own block accounting where the
14//! platform offers it". No platform pristine targets offers a *recursive* one. A directory
15//! inode's block count on APFS, ext4, btrfs and ZFS alike describes the directory's own entry
16//! table, not the tree beneath it — which is why `du` walks. Reporting it as the claim's size
17//! would call a 40 GB `node_modules` about 48 KB, so the honest answer is `Unmeasured` rather
18//! than a number that is wrong by six orders of magnitude.
19//!
20//! A claim that is a *symlink* — Bazel's `bazel-*` — is different: `lstat` is the complete
21//! answer for a link in constant time, so those are measured even in the default mode.
22//!
23//! When a breakdown is asked for, the subtree goes through the tight `read_dir` + `lstat`
24//! loop below rather than back through the scan that found it: no ignore stack, no rule
25//! evaluation, no path bookkeeping, one pass, on the walker thread that found the claim.
26//! Bytes are *allocated* blocks rather than apparent length, because allocated is what
27//! deleting gives back.
28//!
29//! ## The one thing a default scan does have to look at
30//!
31//! Tier two cannot claim a directory without walking it. Its size floor cannot be inferred, and
32//! neither can "holds no git repository" — which is a negative, and a negative is only proved
33//! by covering everything. So [`Measurer::survey`] walks in every mode, and tier-two claims
34//! carry a real size even on a default scan while tier-one claims do not.
35//!
36//! That is a smaller dent in the performance thesis than it sounds. For a candidate that is
37//! *claimed*, the survey replaces work the walk would have done anyway — the walker would have
38//! descended into all of it — with the same tight loop and no ignore stack or rule evaluation
39//! per entry. The cost is in the candidates that are refused, which get surveyed and then
40//! walked. Over `~/repos`: 2.9 s with tier two off, 4.1 s with it on, for 75 tier-two claims
41//! that arrive priced.
42
43use std::collections::HashSet;
44use std::ffi::OsStr;
45use std::path::{Path, PathBuf};
46use std::{fs, io};
47
48/// What is known about a claim's size.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum Size {
51    /// Not measured, because the scan pruned here instead of enumerating the subtree. Not a
52    /// failure and not a zero: ask for a breakdown to turn it into a number.
53    #[default]
54    Unmeasured,
55    /// Allocated bytes, summed over everything beneath the claim.
56    Measured(u64),
57}
58
59impl Size {
60    /// The byte count, or `None` when nothing was measured.
61    #[must_use]
62    pub fn bytes(self) -> Option<u64> {
63        match self {
64            Self::Unmeasured => None,
65            Self::Measured(bytes) => Some(bytes),
66        }
67    }
68
69    /// What a person reads: a size, or a dash when nothing has looked.
70    ///
71    /// Not zero and not an error. Measuring a tier-one claim means enumerating the subtree the
72    /// scan deliberately pruned at, so "no number yet" is the ordinary state of a claim rather
73    /// than a fault — and a row of dashes has to be legible as *unpriced* rather than as
74    /// empty, in the listing and in the tree alike.
75    #[must_use]
76    pub fn label(self) -> String {
77        match self {
78            Self::Measured(bytes) => human(bytes),
79            Self::Unmeasured => UNPRICED.to_owned(),
80        }
81    }
82}
83
84/// What an unpriced row shows instead of a number.
85pub const UNPRICED: &str = "—";
86
87/// Bytes in the units a person reads, binary because that is what the sizes are.
88#[must_use]
89pub fn human(bytes: u64) -> String {
90    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
91    #[expect(
92        clippy::cast_precision_loss,
93        reason = "a display rounded to one decimal place has none to lose"
94    )]
95    let mut value = bytes as f64;
96    let mut unit = 0;
97    while value >= 1024.0 && unit + 1 < UNITS.len() {
98        value /= 1024.0;
99        unit += 1;
100    }
101    if unit == 0 {
102        format!("{bytes} B")
103    } else {
104        format!("{value:.1} {}", UNITS[unit])
105    }
106}
107
108/// How much work a scan may do to size what it claims.
109#[derive(Debug, Clone, PartialEq, Eq, Default)]
110pub enum SizeMode {
111    /// Record claims without enumerating them. The default, and the performance thesis.
112    #[default]
113    Skip,
114    /// Sum each claim's subtree. What "show me a breakdown" costs — an order of magnitude over
115    /// the scan it prices (4.6 s to 55.8 s over one real `~/repos`), which is why nothing does
116    /// it unasked.
117    Breakdown,
118    /// Sum the subtree of every claim the named path touches, and nothing else.
119    ///
120    /// The whole-tree breakdown is the only honest answer to "how much do I get back from all
121    /// of this", and it is also the one nobody wants to wait for twice. Scoping it is what
122    /// makes the number reachable at a price the user chooses: pay for the one subtree in
123    /// question and leave the rest reading `Unmeasured`, which it already was.
124    ///
125    /// It is also what `--breakdown-under` hands the tree: a reader who wants one subtree
126    /// priced and the rest left alone gets exactly that, and every other row keeps its dash.
127    ///
128    /// The scope has to be spelled the way the walk spells its hits — the scan root's own
129    /// prefix and all — because the comparison is by path. Anchoring it is the caller's job;
130    /// see the command line's `anchor`.
131    BreakdownUnder(PathBuf),
132}
133
134impl SizeMode {
135    /// Whether a claim at `dir` is one this mode pays to measure.
136    ///
137    /// A scope *containing* the claim is the obvious case. A scope *inside* it counts too, and
138    /// that is not a courtesy: a claim is the smallest thing that can be priced, since the walk
139    /// pruned there and nothing below it was ever enumerated. Reading "under" strictly would
140    /// mean `--breakdown-under repo/node_modules/.pnpm` prices nothing at all and says so with
141    /// a straight face — a confident empty answer, which is the failure shape this crate keeps
142    /// meeting in other clothes.
143    fn prices(&self, dir: &Path) -> bool {
144        match self {
145            Self::Skip => false,
146            Self::Breakdown => true,
147            Self::BreakdownUnder(scope) => dir.starts_with(scope) || scope.starts_with(dir),
148        }
149    }
150}
151
152/// The result of measuring one directory.
153#[derive(Debug, Clone, Default)]
154pub struct Measurement {
155    /// What is known about the size.
156    pub size: Size,
157    /// Entries that could not be read, so the total is a lower bound. Reported rather than
158    /// swallowed: a number that silently excludes an unreadable half of the tree is worse
159    /// than one labelled incomplete.
160    pub unreadable: Vec<PathBuf>,
161}
162
163/// Everything one pass over a tier-two candidate found.
164#[derive(Debug, Clone, Default)]
165pub struct Survey {
166    /// The total. [`Size::Unmeasured`] only when the survey gave up early, which it does only
167    /// once `nested_repo` is set and the candidate is dead anyway.
168    pub size: Size,
169    /// A git repository living inside the candidate, if there is one. Its presence is what
170    /// stops the directory above it being removed wholesale.
171    pub nested_repo: Option<PathBuf>,
172    /// Entries that could not be read. Not merely a caveat on the total here: a survey that
173    /// could not see all of the subtree has not established `nested_repo` either, so the
174    /// caller has no grounds to claim the directory at all.
175    pub unreadable: Vec<PathBuf>,
176    /// Subtrees on another filesystem, which the survey does not enter.
177    ///
178    /// Reported for the same reason as `unreadable` and not silently skipped, which is what an
179    /// earlier version did. A mount point inside a candidate hides everything under it,
180    /// including a checkout — so "holds no repository", which is a claim about the whole
181    /// subtree, is not established when one is present.
182    pub not_crossed: Vec<PathBuf>,
183}
184
185/// Measures directories under a fixed policy.
186#[derive(Debug, Clone)]
187pub struct Measurer {
188    mode: SizeMode,
189    same_file_system: bool,
190}
191
192impl Measurer {
193    /// A measurer with the given mode, staying on one filesystem.
194    #[must_use]
195    pub fn new(mode: SizeMode) -> Self {
196        Self {
197            mode,
198            same_file_system: true,
199        }
200    }
201
202    /// Whether to descend across a mount point. Off by default, matching the safety model:
203    /// what the deleter will not cross, the measurer must not count.
204    #[must_use]
205    pub fn same_file_system(mut self, same_file_system: bool) -> Self {
206        self.same_file_system = same_file_system;
207        self
208    }
209
210    /// Whether measuring `dir` means traversing it.
211    ///
212    /// This is what decides whether a claim goes to the pricing pool instead of being sized on
213    /// the walker thread that found it. Two things are false here and both matter: a claim
214    /// this mode does not price at all, and a *symlink*, whose one `lstat` the walk has
215    /// already done. Queueing either would trade a constant-time answer for a thread handoff
216    /// and delay the claim's own publication for nothing.
217    #[must_use]
218    pub fn traverses(&self, dir: &Path, metadata: &fs::Metadata) -> bool {
219        metadata.is_dir() && self.mode.prices(dir)
220    }
221
222    /// Measures `dir`, whose metadata the caller already has from the walk.
223    ///
224    /// Returns without touching the filesystem under [`SizeMode::Skip`], which is the point.
225    #[must_use]
226    pub fn measure(&self, dir: &Path, metadata: &fs::Metadata) -> Measurement {
227        // A symlinked claim is worth its own inode and nothing more: the bytes are wherever
228        // it points, which is outside the tree and not ours to delete. One `lstat` — already
229        // done — is the complete answer, so it needs no traversal and no opt-in.
230        if !metadata.is_dir() {
231            return Measurement {
232                size: Size::Measured(allocated(metadata)),
233                unreadable: Vec::new(),
234            };
235        }
236        if !self.mode.prices(dir) {
237            return Measurement::default();
238        }
239
240        let walked = self.walk(dir, metadata, false);
241        Measurement {
242            size: Size::Measured(walked.bytes),
243            unreadable: walked.unreadable,
244        }
245    }
246
247    /// One pass over a tier-two candidate, answering both questions the tier has left: how big
248    /// it is, and whether it holds a git repository.
249    ///
250    /// This walks whatever the mode is, because neither answer can be inferred, and it walks
251    /// the *whole* subtree. An earlier version stopped as soon as it had enough bytes to clear
252    /// the floor, which is much cheaper — and useless here, because "holds no repository" is a
253    /// negative and a negative is only proved by covering everything. The consolation is that
254    /// this pass is still cheaper than what the walker would have done had tier two not
255    /// claimed the directory at all: a tight `read_dir` + `lstat` loop with no ignore stack and
256    /// no rule evaluation, and then a prune.
257    ///
258    /// Because it always covers everything, a tier-two claim arrives with a real size even on a
259    /// default scan. Tier one's claims stay [`Size::Unmeasured`]: nothing forces the walk to
260    /// look inside those.
261    #[must_use]
262    pub fn survey(&self, dir: &Path, metadata: &fs::Metadata) -> Survey {
263        // A link is worth its own inode and nothing more, and one `lstat` — already done — is
264        // the whole truth about it.
265        if !metadata.is_dir() {
266            return Survey {
267                size: Size::Measured(allocated(metadata)),
268                nested_repo: None,
269                unreadable: Vec::new(),
270                not_crossed: Vec::new(),
271            };
272        }
273        let walked = self.walk(dir, metadata, true);
274        Survey {
275            size: if walked.nested_repo.is_some() {
276                Size::Unmeasured
277            } else {
278                Size::Measured(walked.bytes)
279            },
280            nested_repo: walked.nested_repo,
281            unreadable: walked.unreadable,
282            not_crossed: walked.not_crossed,
283        }
284    }
285
286    /// The one traversal, summing allocated blocks below `dir`.
287    ///
288    /// With `watch_for_repos` it also stops the moment it finds a `.git`, because whatever
289    /// asked for that has no further use for the total.
290    fn walk(&self, dir: &Path, metadata: &fs::Metadata, watch_for_repos: bool) -> Walked {
291        let mut pass = Pass {
292            bytes: allocated(metadata),
293            boundary: device(metadata),
294            watch_for_repos,
295            ..Pass::default()
296        };
297        pass.stack.push(dir.to_path_buf());
298
299        while let Some(current) = pass.stack.pop() {
300            match fs::read_dir(&current) {
301                Ok(entries) => {
302                    if let Some(repo) = self.absorb(&current, entries, &mut pass) {
303                        return pass.stopped_at(repo);
304                    }
305                }
306                Err(_) => pass.unreadable.push(current),
307            }
308        }
309        pass.finished()
310    }
311
312    /// Folds one directory's entries into `pass`, returning the directory when a `.git` among
313    /// them ends the walk.
314    ///
315    /// Split out of [`Measurer::walk`] so its error branch can be driven by a hand-made
316    /// iterator. `readdir` failing part-way through a directory it had already opened is not
317    /// something a test can arrange on a real filesystem, and it is the branch that most needs
318    /// one.
319    fn absorb<I>(&self, current: &Path, entries: I, pass: &mut Pass) -> Option<PathBuf>
320    where
321        I: IntoIterator<Item = io::Result<fs::DirEntry>>,
322    {
323        for entry in entries {
324            // `readdir` gave up part-way through a directory it had opened, so this listing
325            // is short by an unknown amount. An earlier version skipped the entry, which left
326            // the survey looking complete and let a caller claim a directory nobody had
327            // finished reading.
328            let Ok(entry) = entry else {
329                pass.unreadable.push(current.to_path_buf());
330                continue;
331            };
332            let path = entry.path();
333            // A `.git` marks a checkout, and it counts whether it is a directory or the
334            // file a linked work tree and a submodule use.
335            if pass.watch_for_repos && entry.file_name() == OsStr::new(".git") {
336                return Some(current.to_path_buf());
337            }
338            // `symlink_metadata`, never `metadata`: following a link would count bytes
339            // that live somewhere else and, if it pointed upward, would not terminate.
340            let Ok(metadata) = path.symlink_metadata() else {
341                pass.unreadable.push(path);
342                continue;
343            };
344            if self.same_file_system && device(&metadata) != pass.boundary {
345                // A mount point. `measure` may pass over one silently, because there it
346                // only makes a size a lower bound. A survey may not: everything under the
347                // mount is unseen, including a `.git`, so passing over it silently would
348                // let "holds no repository" be asserted about ground nobody looked at.
349                if pass.watch_for_repos && metadata.is_dir() {
350                    pass.not_crossed.push(path);
351                }
352                continue;
353            }
354            if let Some(identity) = multiply_linked(&metadata) {
355                if !pass.linked.insert(identity) {
356                    continue;
357                }
358            }
359            pass.bytes += allocated(&metadata);
360            if metadata.is_dir() {
361                pass.stack.push(path);
362            }
363        }
364        None
365    }
366}
367
368/// The running state of one traversal.
369#[derive(Debug, Default)]
370struct Pass {
371    bytes: u64,
372    boundary: u64,
373    watch_for_repos: bool,
374    unreadable: Vec<PathBuf>,
375    not_crossed: Vec<PathBuf>,
376    /// Multiply-linked files, so a hard-linked artefact is counted once per claim rather than
377    /// once per link. Only populated by files that actually carry more than one link, which on
378    /// an ordinary tree is none of them.
379    linked: HashSet<(u64, u64)>,
380    stack: Vec<PathBuf>,
381}
382
383impl Pass {
384    fn stopped_at(self, nested_repo: PathBuf) -> Walked {
385        Walked {
386            bytes: self.bytes,
387            nested_repo: Some(nested_repo),
388            unreadable: self.unreadable,
389            not_crossed: self.not_crossed,
390        }
391    }
392
393    fn finished(self) -> Walked {
394        Walked {
395            bytes: self.bytes,
396            nested_repo: None,
397            unreadable: self.unreadable,
398            not_crossed: self.not_crossed,
399        }
400    }
401}
402
403/// What one traversal came back with.
404struct Walked {
405    bytes: u64,
406    /// The directory holding the `.git` that stopped the walk, when one did. `bytes` is then a
407    /// lower bound rather than a total.
408    nested_repo: Option<PathBuf>,
409    unreadable: Vec<PathBuf>,
410    not_crossed: Vec<PathBuf>,
411}
412
413/// The stat fields the byte accounting needs, from whichever `stat` produced them.
414///
415/// There are two, and they are not interchangeable types. The measurer walks by *path* and so
416/// holds [`std::fs::Metadata`]; the deleter walks by *descriptor* and so holds
417/// `cap_primitives`' metadata, which is what an `fstatat` against an open directory returns.
418/// The rules below — allocated blocks, a hard link counted once — have to be the same for
419/// both, or a plan's estimate and the bytes it reports freeing would be measured differently.
420#[cfg(unix)]
421pub(crate) trait Stat {
422    fn is_dir(&self) -> bool;
423    fn dev(&self) -> u64;
424    fn ino(&self) -> u64;
425    fn nlink(&self) -> u64;
426    fn blocks(&self) -> u64;
427}
428
429#[cfg(unix)]
430impl Stat for fs::Metadata {
431    fn is_dir(&self) -> bool {
432        Self::is_dir(self)
433    }
434    fn dev(&self) -> u64 {
435        std::os::unix::fs::MetadataExt::dev(self)
436    }
437    fn ino(&self) -> u64 {
438        std::os::unix::fs::MetadataExt::ino(self)
439    }
440    fn nlink(&self) -> u64 {
441        std::os::unix::fs::MetadataExt::nlink(self)
442    }
443    fn blocks(&self) -> u64 {
444        std::os::unix::fs::MetadataExt::blocks(self)
445    }
446}
447
448#[cfg(unix)]
449impl Stat for cap_primitives::fs::Metadata {
450    fn is_dir(&self) -> bool {
451        Self::is_dir(self)
452    }
453    fn dev(&self) -> u64 {
454        cap_primitives::fs::MetadataExt::dev(self)
455    }
456    fn ino(&self) -> u64 {
457        cap_primitives::fs::MetadataExt::ino(self)
458    }
459    fn nlink(&self) -> u64 {
460        cap_primitives::fs::MetadataExt::nlink(self)
461    }
462    fn blocks(&self) -> u64 {
463        cap_primitives::fs::MetadataExt::blocks(self)
464    }
465}
466
467/// The same two sources, where there are no block or link counts to be had.
468#[cfg(not(unix))]
469pub(crate) trait Stat {
470    fn is_dir(&self) -> bool;
471    fn apparent_len(&self) -> u64;
472}
473
474#[cfg(not(unix))]
475impl Stat for fs::Metadata {
476    fn is_dir(&self) -> bool {
477        Self::is_dir(self)
478    }
479    fn apparent_len(&self) -> u64 {
480        self.len()
481    }
482}
483
484#[cfg(not(unix))]
485impl Stat for cap_primitives::fs::Metadata {
486    fn is_dir(&self) -> bool {
487        Self::is_dir(self)
488    }
489    fn apparent_len(&self) -> u64 {
490        self.len()
491    }
492}
493
494/// Bytes actually allocated on disk, which is what deleting gives back.
495#[cfg(unix)]
496pub(crate) fn allocated(stat: &impl Stat) -> u64 {
497    stat.blocks() * 512
498}
499
500#[cfg(not(unix))]
501pub(crate) fn allocated(stat: &impl Stat) -> u64 {
502    stat.apparent_len()
503}
504
505#[cfg(unix)]
506pub(crate) fn device(stat: &impl Stat) -> u64 {
507    stat.dev()
508}
509
510#[cfg(not(unix))]
511pub(crate) fn device(_stat: &impl Stat) -> u64 {
512    0
513}
514
515/// What names a directory for as long as it exists, which a *path* does not.
516///
517/// The deleter records this for the scan root while the plan is built and checks it against the
518/// descriptor it later opens, because the root is the one name that still has to be resolved
519/// and a renamed-away root can be replaced by something that answers to the same name on the
520/// same device.
521///
522/// `None` off unix, where there is no stable pair to be had. The whole `st_dev` family of
523/// checks is equally inert there — see [`device`] — and the crate claims macOS and Linux.
524// The `Option` is not redundant: it is `None` in the `not(unix)` arm below, and a caller has
525// to be able to tell "this platform cannot answer" from an answer.
526#[cfg(unix)]
527#[allow(clippy::unnecessary_wraps)]
528pub(crate) fn identity(stat: &impl Stat) -> Option<(u64, u64)> {
529    Some((stat.dev(), stat.ino()))
530}
531
532#[cfg(not(unix))]
533pub(crate) fn identity(_stat: &impl Stat) -> Option<(u64, u64)> {
534    None
535}
536
537/// The `(device, inode)` identity of a file with more than one hard link, or `None` when it
538/// has exactly one and cannot be double-counted.
539///
540/// Deduplicating here makes a claim's total agree with `du`, which is the number the user
541/// will check it against. It still overstates a pnpm `node_modules`, whose links point into
542/// a store *outside* the claim: deleting the tree frees only the links. Answering that would
543/// mean proving no link lives elsewhere, which costs a scan of the whole filesystem.
544#[cfg(unix)]
545pub(crate) fn multiply_linked(stat: &impl Stat) -> Option<(u64, u64)> {
546    (stat.nlink() > 1 && !stat.is_dir()).then(|| (stat.dev(), stat.ino()))
547}
548
549#[cfg(not(unix))]
550pub(crate) fn multiply_linked(_stat: &impl Stat) -> Option<(u64, u64)> {
551    None
552}
553
554#[cfg(test)]
555mod tests {
556    use super::{Measurer, Size, SizeMode};
557    use std::path::Path;
558    use std::{fs, io};
559    use tempfile::TempDir;
560
561    fn write(dir: &TempDir, name: &str, bytes: usize) {
562        let path = dir.path().join(name);
563        fs::create_dir_all(path.parent().unwrap()).unwrap();
564        fs::write(path, vec![b'x'; bytes]).unwrap();
565    }
566
567    #[test]
568    fn a_breakdown_sums_the_whole_subtree() {
569        let tmp = TempDir::new().unwrap();
570        write(&tmp, "a/one.bin", 64 * 1024);
571        write(&tmp, "a/b/two.bin", 64 * 1024);
572
573        let metadata = tmp.path().symlink_metadata().unwrap();
574        let measured = Measurer::new(SizeMode::Breakdown).measure(tmp.path(), &metadata);
575
576        assert!(measured.size.bytes().unwrap() >= 128 * 1024, "{measured:?}");
577        assert!(measured.unreadable.is_empty());
578    }
579
580    #[test]
581    fn the_default_mode_reports_unmeasured_without_reading_anything() {
582        let tmp = TempDir::new().unwrap();
583        write(&tmp, "a/big.bin", 4 * 1024 * 1024);
584        // Unreadable, so any traversal would have to report it. Silence is the proof.
585        let sealed = tmp.path().join("sealed");
586        fs::create_dir(&sealed).unwrap();
587        seal(&sealed);
588
589        let metadata = tmp.path().symlink_metadata().unwrap();
590        let measured = Measurer::new(SizeMode::Skip).measure(tmp.path(), &metadata);
591        unseal(&sealed);
592
593        assert_eq!(measured.size, Size::Unmeasured);
594        assert!(measured.unreadable.is_empty(), "{measured:?}");
595    }
596
597    #[cfg(unix)]
598    #[test]
599    fn a_breakdown_reports_what_it_could_not_read() {
600        let tmp = TempDir::new().unwrap();
601        let sealed = tmp.path().join("sealed");
602        fs::create_dir(&sealed).unwrap();
603        seal(&sealed);
604        if fs::read_dir(&sealed).is_ok() {
605            unseal(&sealed);
606            return; // running as root, where permissions prove nothing
607        }
608
609        let metadata = tmp.path().symlink_metadata().unwrap();
610        let measured = Measurer::new(SizeMode::Breakdown).measure(tmp.path(), &metadata);
611        unseal(&sealed);
612
613        assert_eq!(measured.unreadable, [sealed]);
614    }
615
616    #[cfg(unix)]
617    #[test]
618    fn a_hard_linked_file_is_counted_once() {
619        let tmp = TempDir::new().unwrap();
620        write(&tmp, "a/artifact.bin", 512 * 1024);
621        let metadata = tmp.path().symlink_metadata().unwrap();
622        let measurer = Measurer::new(SizeMode::Breakdown);
623        let once = measurer.measure(tmp.path(), &metadata).size;
624
625        fs::hard_link(
626            tmp.path().join("a/artifact.bin"),
627            tmp.path().join("a/copy.bin"),
628        )
629        .unwrap();
630        let twice = measurer.measure(tmp.path(), &metadata).size;
631
632        assert_eq!(once, twice, "the second link added its blocks again");
633    }
634
635    #[cfg(unix)]
636    #[test]
637    fn a_symlink_out_of_the_tree_is_worth_its_own_inode_only() {
638        let tmp = TempDir::new().unwrap();
639        write(&tmp, "elsewhere/big.bin", 4 * 1024 * 1024);
640        let link = tmp.path().join("link");
641        std::os::unix::fs::symlink(tmp.path().join("elsewhere"), &link).unwrap();
642
643        let metadata = link.symlink_metadata().unwrap();
644        // Even the default mode measures a link: one `lstat` is the whole truth about it.
645        let measured = Measurer::new(SizeMode::Skip).measure(&link, &metadata);
646
647        assert!(measured.size.bytes().unwrap() < 1024 * 1024, "{measured:?}");
648    }
649
650    #[test]
651    fn a_scoped_breakdown_prices_what_is_under_the_scope_and_leaves_the_rest_alone() {
652        let tmp = TempDir::new().unwrap();
653        write(&tmp, "wanted/big.bin", 256 * 1024);
654        write(&tmp, "elsewhere/big.bin", 256 * 1024);
655        let wanted = tmp.path().join("wanted");
656        let elsewhere = tmp.path().join("elsewhere");
657
658        let measurer = Measurer::new(SizeMode::BreakdownUnder(wanted.clone()));
659
660        let priced = measurer.measure(&wanted, &wanted.symlink_metadata().unwrap());
661        assert!(priced.size.bytes().unwrap() >= 256 * 1024, "{priced:?}");
662        // The whole point of the scope: everything outside it costs nothing, so a user can
663        // price one subtree without paying for the tree.
664        let untouched = measurer.measure(&elsewhere, &elsewhere.symlink_metadata().unwrap());
665        assert_eq!(untouched.size, Size::Unmeasured);
666    }
667
668    #[test]
669    fn a_scope_inside_a_claim_prices_that_claim_rather_than_nothing() {
670        // Drilling into `node_modules/.pnpm` and being told the whole scan is unpriced is the
671        // failure this codebase keeps finding in other clothes: a confident empty answer. A
672        // claim is the smallest thing that can be priced, so a scope that lands inside one is
673        // a request to price it.
674        let tmp = TempDir::new().unwrap();
675        write(&tmp, "claim/deep/big.bin", 256 * 1024);
676        let claim = tmp.path().join("claim");
677
678        let measurer = Measurer::new(SizeMode::BreakdownUnder(claim.join("deep")));
679        let priced = measurer.measure(&claim, &claim.symlink_metadata().unwrap());
680
681        assert!(priced.size.bytes().unwrap() >= 256 * 1024, "{priced:?}");
682    }
683
684    #[test]
685    fn a_survey_prices_the_whole_subtree_whatever_the_mode() {
686        let tmp = TempDir::new().unwrap();
687        write(&tmp, "a/one.bin", 256 * 1024);
688        write(&tmp, "a/b/two.bin", 256 * 1024);
689
690        let metadata = tmp.path().symlink_metadata().unwrap();
691        for mode in [SizeMode::Skip, SizeMode::Breakdown] {
692            let surveyed = Measurer::new(mode.clone()).survey(tmp.path(), &metadata);
693            assert!(surveyed.nested_repo.is_none());
694            assert!(
695                surveyed.size.bytes().unwrap() >= 512 * 1024,
696                "{mode:?}: {surveyed:?}"
697            );
698        }
699    }
700
701    #[test]
702    fn a_survey_stops_at_the_first_checkout_it_finds() {
703        let tmp = TempDir::new().unwrap();
704        let checkout = tmp.path().join("deep/checkout");
705        fs::create_dir_all(checkout.join(".git")).unwrap();
706        write(&tmp, "deep/checkout/src/main.rs", 1024);
707
708        let metadata = tmp.path().symlink_metadata().unwrap();
709        let surveyed = Measurer::new(SizeMode::Breakdown).survey(tmp.path(), &metadata);
710
711        assert_eq!(surveyed.nested_repo.as_deref(), Some(checkout.as_path()));
712        // The total is meaningless once the answer is "not removable", and reporting a lower
713        // bound as if it were a size would be worse than reporting nothing.
714        assert_eq!(surveyed.size, Size::Unmeasured);
715    }
716
717    #[test]
718    fn a_directory_that_stops_listing_part_way_through_is_reported_as_unread() {
719        // `readdir` can fail after the directory was opened, and the listing is then short by
720        // an unknown amount. Skipping the entry — which an earlier version did — left the
721        // survey looking complete, so a caller would go on to claim a directory nobody had
722        // finished reading. No filesystem can be talked into this on demand, hence the
723        // hand-made iterator.
724        let tmp = TempDir::new().unwrap();
725        let mut pass = super::Pass {
726            watch_for_repos: true,
727            ..super::Pass::default()
728        };
729        let entries = vec![Err(io::Error::other("readdir gave up"))];
730
731        let stopped = Measurer::new(SizeMode::Skip).absorb(tmp.path(), entries, &mut pass);
732
733        assert!(stopped.is_none());
734        assert_eq!(pass.unreadable, [tmp.path().to_path_buf()]);
735    }
736
737    #[test]
738    fn a_survey_reports_a_subtree_it_will_not_cross_rather_than_passing_over_it() {
739        let tmp = TempDir::new().unwrap();
740        // A checkout two levels down, behind what will look like a mount point.
741        fs::create_dir_all(tmp.path().join("mounted/checkout/.git")).unwrap();
742
743        // `survey` takes the caller's metadata for `dir`, and the boundary it will not cross
744        // comes from that. Handing it metadata from another device is therefore the same
745        // situation the walker meets when a mount point sits inside a candidate — without
746        // needing a real mount, which no portable test can arrange.
747        let here = tmp.path().symlink_metadata().unwrap();
748        let elsewhere = Path::new("/dev").symlink_metadata().unwrap();
749        if super::device(&here) == super::device(&elsewhere) {
750            return; // one filesystem on this machine, so there is no boundary to prove
751        }
752
753        let surveyed = Measurer::new(SizeMode::Skip).survey(tmp.path(), &elsewhere);
754
755        // The checkout is on the far side, so the survey genuinely did not see it. Saying so is
756        // the whole point: silence here would let "holds no repository" be asserted about
757        // ground nobody looked at, and the caller would claim the directory.
758        assert!(surveyed.nested_repo.is_none());
759        assert_eq!(surveyed.not_crossed, [tmp.path().join("mounted")]);
760    }
761
762    #[test]
763    fn a_dot_git_file_marks_a_checkout_just_as_a_directory_does() {
764        // A linked work tree and a submodule both keep a `.git` *file* naming the real gitdir.
765        let tmp = TempDir::new().unwrap();
766        write(&tmp, "worktree/.git", 64);
767
768        let metadata = tmp.path().symlink_metadata().unwrap();
769        let surveyed = Measurer::new(SizeMode::Skip).survey(tmp.path(), &metadata);
770
771        assert_eq!(
772            surveyed.nested_repo.as_deref(),
773            Some(tmp.path().join("worktree").as_path())
774        );
775    }
776
777    /// Makes a directory unreadable, so that any traversal of it has to report a failure.
778    #[cfg(unix)]
779    fn seal(dir: &std::path::Path) {
780        use std::os::unix::fs::PermissionsExt;
781        fs::set_permissions(dir, fs::Permissions::from_mode(0o000)).unwrap();
782    }
783
784    /// Puts the permissions back, so the temporary directory can still be cleaned up.
785    #[cfg(unix)]
786    fn unseal(dir: &std::path::Path) {
787        use std::os::unix::fs::PermissionsExt;
788        fs::set_permissions(dir, fs::Permissions::from_mode(0o755)).unwrap();
789    }
790
791    #[cfg(not(unix))]
792    fn seal(_dir: &std::path::Path) {}
793
794    #[cfg(not(unix))]
795    fn unseal(_dir: &std::path::Path) {}
796}