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(¤t) {
301 Ok(entries) => {
302 if let Some(repo) = self.absorb(¤t, 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}