pristine/tree.rs
1//! The rollup tree: the filesystem tree pruned to paths that lead to something reclaimable,
2//! with each node carrying the bytes recoverable beneath it.
3//!
4//! A node's number is not "how big is this directory" — that is `dua`'s question — but "how
5//! much would I get back by emptying this subtree". A source directory with nothing
6//! reclaimable under it never appears.
7//!
8//! The rollup is a post-order sum, accumulated on the way down rather than in a second pass.
9//! It can be, because the only nodes carrying weight are the claims and claims are always
10//! leaves (the walk prunes there). So totals are correct after every insert, which is what lets
11//! the TUI render a partial tree while the scan is still running.
12//!
13//! Nodes do leave, and only one way: [`Tree::remove`], when the deleter reports a claim gone.
14//! Every rollup here is therefore a quantity that can be taken back out again — which is a
15//! constraint on what may be rolled up rather than an incidental property. Sums subtract.
16//! [`Node::modified`] does not, and is recomputed from what is left.
17
18use std::collections::HashMap;
19use std::ffi::OsString;
20use std::path::{Path, PathBuf};
21use std::time::SystemTime;
22
23use crate::size::Size;
24use crate::walk::Hit;
25
26/// A handle to a node. Stable for the life of the tree.
27pub type NodeId = usize;
28
29/// One directory on a path to something reclaimable.
30#[derive(Debug)]
31pub struct Node {
32 /// The final path component. The root node carries the whole scan root instead.
33 pub name: OsString,
34 /// The full path.
35 pub path: PathBuf,
36 /// The directory this one is in, and `None` for the root.
37 ///
38 /// Carried rather than derived, because everything the front end does to a row is a
39 /// statement about its ancestry: a mark covers a subtree, so "is this row marked" is a
40 /// walk upwards, and so is putting a cursor back on the nearest surviving ancestor of a
41 /// row the deleter has just taken away. Ten steps at this tree's real depth.
42 pub parent: Option<NodeId>,
43 /// Measured reclaimable bytes in this subtree, this node included.
44 pub reclaimable: u64,
45 /// How many claims in this subtree were recorded but not measured, because the scan
46 /// pruned at them. A node with `reclaimable == 0` and `unmeasured > 0` is not empty; it
47 /// is unpriced, and a breakdown is what puts a number on it.
48 pub unmeasured: usize,
49 /// How many claims are in this subtree, priced or not.
50 ///
51 /// The count a selection is stated in. Bytes alone cannot be, while a default scan
52 /// prices 8% of what it finds: "41.2 GiB marked" over a tree that is mostly unpriced
53 /// reads as a small selection, and "120 directories" is the part that is always true.
54 pub claims: usize,
55 /// The newest mtime anywhere in this subtree, which is what the age column reports.
56 ///
57 /// The *newest* rather than the oldest, because the question a row answers is "has
58 /// anything under here been touched lately" — one file rebuilt this morning is what
59 /// makes a subtree still in use, and an average or an oldest would hide it behind
60 /// everything beside it that is stale.
61 pub modified: Option<SystemTime>,
62 /// Set when this node is itself a claimed directory, in which case it has no children.
63 pub hit: Option<Hit>,
64 /// Children, in insertion order until [`Tree::sort_by`] is called.
65 pub children: Vec<NodeId>,
66 /// Whether this node is still part of the tree. See [`Tree::remove`].
67 ///
68 /// Private, and the one field here that is: it is the tree's own bookkeeping rather than
69 /// anything about the directory, and [`Tree::is_attached`] is the question worth asking.
70 attached: bool,
71 /// Which change was the last one at or below this node. See [`Tree::stamp`].
72 stamp: u64,
73}
74
75/// How a level orders its children.
76///
77/// Per level, because a tree and a global sort are not compatible: children have to stay under
78/// their parent, so the only ordering a tree can express is a sibling ordering.
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
80pub enum Order {
81 /// Biggest subtree first — what the tool is for.
82 #[default]
83 Size,
84 /// By name, which is the only order that does not move while the scan is running.
85 Path,
86 /// Stalest first: the subtree nothing has touched for longest, first.
87 ///
88 /// The opposite direction from [`Size`](Self::Size) and deliberately so — both put the
89 /// best candidate for deletion at the top, and "sorted by age" meaning "newest first"
90 /// would put the one directory you are still building at the top of a cleanup list.
91 Age,
92}
93
94impl Order {
95 /// Every order, in the sequence the sort key cycles through.
96 pub const ALL: [Self; 3] = [Self::Size, Self::Path, Self::Age];
97
98 /// The next one round.
99 #[must_use]
100 pub fn next(self) -> Self {
101 match self {
102 Self::Size => Self::Path,
103 Self::Path => Self::Age,
104 Self::Age => Self::Size,
105 }
106 }
107
108 /// What the header calls it.
109 #[must_use]
110 pub fn label(self) -> &'static str {
111 match self {
112 Self::Size => "size",
113 Self::Path => "path",
114 Self::Age => "age",
115 }
116 }
117
118 /// What the tree's column heading calls the column this orders by.
119 ///
120 /// Not [`Order::label`], which is what the footer calls the *sort*. The column over the
121 /// names is headed `directory`, because that is what is under it; the footer says
122 /// `sort (path)`, because that is the key being cycled.
123 #[must_use]
124 pub fn column(self) -> &'static str {
125 match self {
126 Self::Size => "size",
127 Self::Path => "directory",
128 Self::Age => "age",
129 }
130 }
131}
132
133/// An order and whether it is upside down.
134#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
135pub struct Sort {
136 /// Which key.
137 pub by: Order,
138 /// Whether to run it backwards.
139 pub reverse: bool,
140}
141
142impl Sort {
143 /// A sort on `by`, the right way up.
144 #[must_use]
145 pub fn by(by: Order) -> Self {
146 Self { by, reverse: false }
147 }
148}
149
150/// The pruned filesystem tree produced by a walk.
151#[derive(Debug)]
152pub struct Tree {
153 nodes: Vec<Node>,
154 /// `(parent, name) -> child`, so inserting into a directory with thousands of children
155 /// stays linear in the number of hits rather than quadratic.
156 index: HashMap<(NodeId, OsString), NodeId>,
157 root: PathBuf,
158 /// How many nodes are still attached to the root.
159 ///
160 /// Counted rather than read off `nodes.len()`, because [`Tree::remove`] detaches a node
161 /// instead of taking it out of the arena — see there for why an id has to stay valid for
162 /// the life of the tree even after the directory behind it is gone.
163 live: usize,
164 /// How many changes this tree has taken. See [`Tree::stamp`].
165 changes: u64,
166}
167
168impl Tree {
169 /// An empty tree rooted at `root`.
170 #[must_use]
171 pub fn new(root: impl Into<PathBuf>) -> Self {
172 let root = root.into();
173 let node = Node {
174 name: root.as_os_str().to_os_string(),
175 path: root.clone(),
176 parent: None,
177 reclaimable: 0,
178 unmeasured: 0,
179 claims: 0,
180 modified: None,
181 hit: None,
182 children: Vec::new(),
183 attached: true,
184 stamp: 0,
185 };
186 Self {
187 nodes: vec![node],
188 index: HashMap::new(),
189 root,
190 live: 1,
191 changes: 0,
192 }
193 }
194
195 /// Files a hit, creating any missing ancestors and adding its bytes to each of them.
196 ///
197 /// Returns `None`, without changing the tree, if the hit is not under the root. The
198 /// walker turns that into a reported error rather than dropping the hit silently.
199 pub fn insert(&mut self, hit: Hit) -> Option<NodeId> {
200 let relative = hit.path.strip_prefix(&self.root).ok()?;
201 let bytes = hit.size.bytes().unwrap_or(0);
202 let unmeasured = usize::from(hit.size.bytes().is_none());
203 let modified = hit.modified;
204
205 let mut parent = self.root();
206 let mut path = self.root.clone();
207 // After the out-of-root refusal above, so a hit this tree will not take stamps
208 // nothing: a caller watching the stamp would otherwise rebuild a picture of a tree
209 // that had not changed.
210 self.changes += 1;
211 self.credit(parent, bytes, unmeasured, modified);
212
213 for component in relative.components() {
214 let name = component.as_os_str().to_os_string();
215 path.push(&name);
216 let id = if let Some(&id) = self.index.get(&(parent, name.clone())) {
217 id
218 } else {
219 let id = self.nodes.len();
220 self.nodes.push(Node {
221 name: name.clone(),
222 path: path.clone(),
223 parent: Some(parent),
224 reclaimable: 0,
225 unmeasured: 0,
226 claims: 0,
227 modified: None,
228 hit: None,
229 children: Vec::new(),
230 attached: true,
231 stamp: self.changes,
232 });
233 self.nodes[parent].children.push(id);
234 self.index.insert((parent, name), id);
235 self.live += 1;
236 id
237 };
238 self.credit(id, bytes, unmeasured, modified);
239 parent = id;
240 }
241
242 self.nodes[parent].hit = Some(hit);
243 Some(parent)
244 }
245
246 /// Adds one claim's worth of everything to a node on its ancestor chain.
247 fn credit(&mut self, id: NodeId, bytes: u64, unmeasured: usize, modified: Option<SystemTime>) {
248 let stamp = self.changes;
249 let node = &mut self.nodes[id];
250 node.reclaimable += bytes;
251 node.unmeasured += unmeasured;
252 node.claims += 1;
253 node.modified = node.modified.max(modified);
254 node.stamp = stamp;
255 }
256
257 /// Puts a number on a claim that was filed without one, and adds it to every ancestor.
258 ///
259 /// This is the other half of streaming: a claim is published as soon as it is judged and
260 /// priced afterwards, so the tree has to be able to learn a size for a node it already
261 /// holds. The rollup stays correct after this as it does after [`Tree::insert`], because
262 /// the ancestor chain is the same one the insert walked.
263 ///
264 /// Returns `None`, changing nothing, when the path is not a claim in this tree or already
265 /// carries a size. Both would be a caller error rather than a fact about the filesystem,
266 /// and pricing one claim twice would count its bytes twice — so it is refused rather than
267 /// absorbed, exactly as an out-of-root insert is.
268 pub fn price(&mut self, path: &Path, size: Size) -> Option<NodeId> {
269 let bytes = size.bytes()?;
270 // Resolved in full before anything is written, so a path that turns out not to be a
271 // claim cannot leave half the chain updated.
272 let chain = self.chain(path)?;
273 let ¤t = chain.last()?;
274 let hit = self.nodes[current].hit.as_mut()?;
275 if hit.size.bytes().is_some() {
276 return None;
277 }
278 hit.size = size;
279
280 self.changes += 1;
281 let stamp = self.changes;
282 for id in chain {
283 self.nodes[id].stamp = stamp;
284 self.nodes[id].reclaimable += bytes;
285 // Saturating because the alternative is a silent wrap to `usize::MAX` in release,
286 // which would render as an enormous unpriced count. The guard above makes it
287 // unreachable: the insert set exactly one on each of these nodes.
288 self.nodes[id].unmeasured = self.nodes[id].unmeasured.saturating_sub(1);
289 }
290 Some(current)
291 }
292
293 /// Takes bytes off a claim that is still there, and off every ancestor with it.
294 ///
295 /// What a **partial** removal is, seen from here. A sweep that went into a target and came
296 /// out again — a checkout inside it, an unreadable corner — leaves a directory that still
297 /// exists and is smaller than it was, which is a state [`Tree::remove`] cannot express and
298 /// [`Tree::price`] refuses to. Without it the reduction can only live in the front end's
299 /// per-frame progress, and progress is by definition dropped when the batch reports: the
300 /// row and its ancestors would spring back to their original sizes the moment a removal
301 /// finished, so a half-emptied directory would report its full weight and the headline
302 /// reclaimable figure would *rise* after a delete. That is the one direction a tool that
303 /// deletes may not be wrong in.
304 ///
305 /// Clamped at the claim's own size rather than saturating on each ancestor separately: a
306 /// deduction bigger than the claim would leave every rollup above it disagreeing with the
307 /// sum of what is beneath, and a rollup that cannot be re-derived from its children is the
308 /// bug [`Tree::remove`]'s mtime recomputation exists to avoid.
309 ///
310 /// Returns `None`, changing nothing, when the path is not a claim in this tree or carries
311 /// no size — an unpriced claim has contributed no bytes to anything, so there are none to
312 /// take back off.
313 pub fn shrink(&mut self, path: &Path, bytes: u64) -> Option<NodeId> {
314 let chain = self.chain(path)?;
315 let &id = chain.last()?;
316 let hit = self.nodes[id].hit.as_mut()?;
317 let held = hit.size.bytes()?;
318 let bytes = bytes.min(held);
319 hit.size = Size::Measured(held - bytes);
320
321 self.changes += 1;
322 let stamp = self.changes;
323 for id in chain {
324 self.nodes[id].stamp = stamp;
325 self.nodes[id].reclaimable -= bytes;
326 }
327 Some(id)
328 }
329
330 /// Takes a claim out of the tree, with everything it was contributing to its ancestors.
331 ///
332 /// This is what a deletion is, seen from here, and it is the only way a node ever leaves.
333 /// A directory that has been removed is not a row that should be marked, counted or
334 /// cursored onto, and the front end learns each removal as the deleter finishes it — so
335 /// the tree has to lose one claim at a time while a reader is looking at it.
336 ///
337 /// **An ancestor with nothing left beneath it goes too**, up to but never including the
338 /// root. The tree's whole premise is that a path only appears because something
339 /// reclaimable is under it; a directory that kept a row after its last claim was deleted
340 /// would be the one row on screen that offers nothing.
341 ///
342 /// The node is *detached* rather than taken out of the arena, because [`NodeId`] promises
343 /// to stay valid for the life of the tree and the front end holds ids across frames —
344 /// marks, the expansion set, the cursor. Compacting would silently point every one of
345 /// them at a different directory. The cost is one dead `Node` per deleted claim, which is
346 /// bounded by what the reader deleted this session.
347 ///
348 /// Returns the nearest ancestor still standing, so a caller that was looking at the row
349 /// has somewhere to look now. `None`, changing nothing, if the path is not a claim in
350 /// this tree.
351 pub fn remove(&mut self, path: &Path) -> Option<NodeId> {
352 let chain = self.chain(path)?;
353 let &id = chain.last()?;
354 let hit = self.nodes[id].hit.as_ref()?;
355 let bytes = hit.size.bytes().unwrap_or(0);
356 let unmeasured = usize::from(hit.size.bytes().is_none());
357
358 self.changes += 1;
359 for &ancestor in &chain {
360 let node = &mut self.nodes[ancestor];
361 node.reclaimable -= bytes;
362 node.unmeasured -= unmeasured;
363 node.claims -= 1;
364 node.stamp = self.changes;
365 }
366
367 self.detach(id);
368 // Upwards while each one is now an empty directory in a tree that only holds
369 // non-empty ones. The root stays whatever happens: an empty scan is still a scan of
370 // somewhere, and the header says where.
371 let mut surviving = self.nodes[id].parent;
372 while let Some(above) = surviving {
373 if above == self.root()
374 || !self.nodes[above].children.is_empty()
375 || self.nodes[above].hit.is_some()
376 {
377 break;
378 }
379 self.detach(above);
380 surviving = self.nodes[above].parent;
381 }
382
383 // The one rollup that cannot be subtracted: a maximum forgets everything that was
384 // not the maximum, so the only way to know the newest mtime left under a node is to
385 // ask what is left. Bottom-up, so each ancestor sees children that have already
386 // answered.
387 let surviving = surviving.unwrap_or_else(|| self.root());
388 let mut at = Some(surviving);
389 while let Some(id) = at {
390 self.nodes[id].modified = self.freshest(id);
391 at = self.nodes[id].parent;
392 }
393 Some(surviving)
394 }
395
396 /// The newest mtime under `id`, read off what is currently attached to it.
397 fn freshest(&self, id: NodeId) -> Option<SystemTime> {
398 let own = self.nodes[id].hit.as_ref().and_then(|hit| hit.modified);
399 self.nodes[id]
400 .children
401 .iter()
402 .map(|&child| self.nodes[child].modified)
403 .fold(own, std::cmp::Ord::max)
404 }
405
406 /// Unhooks a node from its parent and from the path index. Its rollups are already out.
407 fn detach(&mut self, id: NodeId) {
408 let Some(parent) = self.nodes[id].parent else {
409 return;
410 };
411 let name = self.nodes[id].name.clone();
412 self.nodes[parent].children.retain(|&child| child != id);
413 self.index.remove(&(parent, name));
414 self.nodes[id].attached = false;
415 self.live -= 1;
416 }
417
418 /// Every node from the root down to `path`, or `None` if the path is not in this tree.
419 fn chain(&self, path: &Path) -> Option<Vec<NodeId>> {
420 let relative = path.strip_prefix(&self.root).ok()?;
421 let mut chain = vec![self.root()];
422 let mut current = self.root();
423 for component in relative.components() {
424 current = *self
425 .index
426 .get(&(current, component.as_os_str().to_os_string()))?;
427 chain.push(current);
428 }
429 Some(chain)
430 }
431
432 /// The root node's id.
433 #[must_use]
434 pub fn root(&self) -> NodeId {
435 0
436 }
437
438 /// The node behind an id.
439 ///
440 /// # Panics
441 ///
442 /// If `id` did not come from this tree.
443 #[must_use]
444 pub fn node(&self, id: NodeId) -> &Node {
445 &self.nodes[id]
446 }
447
448 /// A node's children.
449 ///
450 /// # Panics
451 ///
452 /// If `id` did not come from this tree.
453 #[must_use]
454 pub fn children(&self, id: NodeId) -> &[NodeId] {
455 &self.nodes[id].children
456 }
457
458 /// Looks a node up by path.
459 #[must_use]
460 pub fn find(&self, path: &Path) -> Option<NodeId> {
461 self.chain(path)?.pop()
462 }
463
464 /// Whether this id still names a directory in the tree, rather than one the deleter has
465 /// taken away. See [`Tree::remove`].
466 ///
467 /// # Panics
468 ///
469 /// If `id` did not come from this tree.
470 #[must_use]
471 pub fn is_attached(&self, id: NodeId) -> bool {
472 self.nodes[id].attached
473 }
474
475 /// The scan root.
476 #[must_use]
477 pub fn root_path(&self) -> &Path {
478 &self.root
479 }
480
481 /// Total measured bytes reclaimable anywhere under the root.
482 #[must_use]
483 pub fn reclaimable(&self) -> u64 {
484 self.nodes[self.root()].reclaimable
485 }
486
487 /// How many claims in the whole tree have no size yet.
488 #[must_use]
489 pub fn unmeasured(&self) -> usize {
490 self.nodes[self.root()].unmeasured
491 }
492
493 /// How many claims the whole tree holds.
494 #[must_use]
495 pub fn claims(&self) -> usize {
496 self.nodes[self.root()].claims
497 }
498
499 /// The number of directories in the tree, the root included.
500 ///
501 /// What is still there: a claim the deleter has removed stops counting the moment it is
502 /// reported, along with any ancestor it was the last thing under.
503 #[must_use]
504 pub fn len(&self) -> usize {
505 self.live
506 }
507
508 /// How many [`NodeId`]s have ever been handed out.
509 ///
510 /// Every id below this has named a directory at some point and no id above it has, so a
511 /// caller holding the value from a moment ago knows exactly which nodes appeared since:
512 /// they are the ids in between. That is what lets the front end light a newly found row
513 /// without the tree having to report arrivals, and it works because ids are minted in
514 /// order and [`Tree::remove`] detaches rather than recycles.
515 #[must_use]
516 pub fn minted(&self) -> usize {
517 self.nodes.len()
518 }
519
520 /// Which change was the last one at or below `id`.
521 ///
522 /// The question is "has anything under this directory moved since I last looked", and the
523 /// answer is this number being different — not bigger, though it always is. It is exact
524 /// in both directions, which is the whole point: a summary of what is under a node
525 /// (bytes, claims, how many are unpriced) is three numbers that a deletion and an arrival
526 /// in the same frame put back exactly where they were, and a caller that spends a
527 /// megabyte redrawing a picture is a caller that would then not redraw it.
528 ///
529 /// Free to maintain, because every rollup here already walks the chain from the claim to
530 /// the root — a claim's bytes reach the root the same way this does. So it costs one
531 /// store per ancestor on a write and nothing at all on a read, where asking the same
532 /// question by rebuilding what the caller draws costs whatever that is.
533 ///
534 /// **What it deliberately does not report is [`Tree::sort_by`]**, which moves children
535 /// about without changing what is under anybody. A caller whose output depends on sibling
536 /// order has to watch the sort itself.
537 ///
538 /// # Panics
539 ///
540 /// If `id` did not come from this tree.
541 #[must_use]
542 pub fn stamp(&self, id: NodeId) -> u64 {
543 self.nodes[id].stamp
544 }
545
546 /// Whether there is nothing left to reclaim — either the walk found nothing, or
547 /// everything it found has been deleted.
548 #[must_use]
549 pub fn is_empty(&self) -> bool {
550 self.live == 1
551 }
552
553 /// Sorts every node's children, deepest-first order within each level.
554 ///
555 /// Per level, because a tree and a global sort are not compatible: children have to stay
556 /// under their parent, so the only ordering a tree can express is a sibling ordering.
557 ///
558 /// Every order breaks its ties by name and every one of them is *total*, which matters
559 /// more here than in a batch listing: the front end re-sorts as prices land, and two rows
560 /// that compare equal under a sort that stopped at the key would swap places on an
561 /// unrelated arrival — a row moving under the cursor for no reason a reader can see.
562 pub fn sort_by(&mut self, sort: Sort) {
563 for id in 0..self.nodes.len() {
564 let mut children = std::mem::take(&mut self.nodes[id].children);
565 children.sort_by(|&a, &b| {
566 let (a, b) = if sort.reverse { (b, a) } else { (a, b) };
567 let ordering = match sort.by {
568 Order::Size => self.nodes[b].reclaimable.cmp(&self.nodes[a].reclaimable),
569 Order::Path => std::cmp::Ordering::Equal,
570 // `None` is not "the beginning of time": it is a directory whose mtime
571 // could not be read, and putting it at the head of a list sorted by
572 // staleness would offer the one row nothing is known about as the best
573 // thing to delete. So unknown sorts last — and `S` does move it to the
574 // top, because reversing this sort reverses all of it, and the top of a
575 // newest-first list is where a row nobody should delete belongs anyway.
576 Order::Age => match (self.nodes[a].modified, self.nodes[b].modified) {
577 (Some(left), Some(right)) => left.cmp(&right),
578 (Some(_), None) => std::cmp::Ordering::Less,
579 (None, Some(_)) => std::cmp::Ordering::Greater,
580 (None, None) => std::cmp::Ordering::Equal,
581 },
582 };
583 ordering.then_with(|| self.nodes[a].name.cmp(&self.nodes[b].name))
584 });
585 self.nodes[id].children = children;
586 }
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use super::{Order, Sort, Tree};
593 use crate::fixture;
594 use crate::size::Size;
595 use crate::walk::Hit;
596 use std::path::{Path, PathBuf};
597 use std::time::{Duration, SystemTime};
598
599 /// A priced claim with no mtime — most of these tests are about the arithmetic.
600 fn hit(path: &str, size: u64) -> Hit {
601 Hit {
602 modified: None,
603 ..fixture::priced(path, size)
604 }
605 }
606
607 /// A claim last touched `seconds` after an arbitrary epoch, so a test can talk about
608 /// relative ages without a real clock.
609 fn aged(path: &str, size: u64, seconds: u64) -> Hit {
610 fixture::hit(path, Size::Measured(size), seconds)
611 }
612
613 fn names(tree: &Tree, at: &str) -> Vec<String> {
614 tree.children(tree.find(Path::new(at)).unwrap())
615 .iter()
616 .map(|&id| tree.node(id).name.to_string_lossy().into_owned())
617 .collect()
618 }
619
620 #[test]
621 fn totals_are_correct_after_every_insert() {
622 let mut tree = Tree::new("/scan");
623 tree.insert(hit("/scan/a/node_modules", 100));
624 assert_eq!(tree.reclaimable(), 100);
625 tree.insert(hit("/scan/a/b/node_modules", 50));
626 assert_eq!(tree.reclaimable(), 150);
627 assert_eq!(
628 tree.node(tree.find(Path::new("/scan/a")).unwrap())
629 .reclaimable,
630 150
631 );
632 assert_eq!(
633 tree.node(tree.find(Path::new("/scan/a/b")).unwrap())
634 .reclaimable,
635 50
636 );
637 }
638
639 #[test]
640 fn a_hit_outside_the_root_is_refused_rather_than_absorbed() {
641 let mut tree = Tree::new("/scan");
642 assert!(tree.insert(hit("/elsewhere/node_modules", 100)).is_none());
643 assert_eq!(tree.reclaimable(), 0);
644 assert!(tree.is_empty());
645 }
646
647 #[test]
648 fn an_intermediate_directory_is_created_once_however_many_hits_hang_off_it() {
649 let mut tree = Tree::new("/scan");
650 tree.insert(hit("/scan/repo/a/node_modules", 1));
651 tree.insert(hit("/scan/repo/b/node_modules", 1));
652 // root, repo, a, a/node_modules, b, b/node_modules
653 assert_eq!(tree.len(), 6);
654 assert_eq!(
655 tree.children(tree.find(Path::new("/scan/repo")).unwrap())
656 .len(),
657 2
658 );
659 }
660
661 #[test]
662 fn every_ancestor_counts_the_claims_beneath_it() {
663 let mut tree = Tree::new("/scan");
664 tree.insert(hit("/scan/repo/a/node_modules", 1));
665 tree.insert(hit("/scan/repo/b/node_modules", 1));
666 tree.insert(hit("/scan/other/target", 1));
667 assert_eq!(tree.claims(), 3);
668 assert_eq!(
669 tree.node(tree.find(Path::new("/scan/repo")).unwrap())
670 .claims,
671 2
672 );
673 }
674
675 #[test]
676 fn an_ancestors_age_is_the_newest_thing_under_it() {
677 let mut tree = Tree::new("/scan");
678 tree.insert(aged("/scan/repo/old/node_modules", 1, 100));
679 tree.insert(aged("/scan/repo/new/node_modules", 1, 900));
680 assert_eq!(
681 tree.node(tree.find(Path::new("/scan/repo")).unwrap())
682 .modified,
683 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(900))
684 );
685 }
686
687 #[test]
688 fn removing_a_claim_takes_its_bytes_off_every_ancestor() {
689 let mut tree = Tree::new("/scan");
690 tree.insert(hit("/scan/repo/a/node_modules", 100));
691 tree.insert(hit("/scan/repo/b/node_modules", 50));
692
693 let surviving = tree.remove(Path::new("/scan/repo/a/node_modules")).unwrap();
694
695 assert_eq!(tree.reclaimable(), 50);
696 assert_eq!(tree.claims(), 1);
697 assert_eq!(
698 tree.node(tree.find(Path::new("/scan/repo")).unwrap())
699 .reclaimable,
700 50
701 );
702 // `a` held nothing else, so it went with its claim and `repo` is what is left.
703 assert_eq!(tree.node(surviving).path, PathBuf::from("/scan/repo"));
704 assert!(tree.find(Path::new("/scan/repo/a")).is_none());
705 assert_eq!(names(&tree, "/scan/repo"), ["b"]);
706 }
707
708 #[test]
709 fn shrinking_a_claim_takes_the_bytes_off_it_and_off_every_ancestor() {
710 let mut tree = Tree::new("/scan");
711 tree.insert(hit("/scan/repo/a/node_modules", 100));
712 tree.insert(hit("/scan/repo/b/node_modules", 50));
713
714 // A sweep that freed 60 of the 100 and then met something it would not cross. The
715 // directory is still there, so the claim stays — worth what survived.
716 let claim = tree
717 .shrink(Path::new("/scan/repo/a/node_modules"), 60)
718 .unwrap();
719
720 assert_eq!(
721 tree.node(claim).hit.as_ref().unwrap().size,
722 Size::Measured(40)
723 );
724 assert_eq!(tree.reclaimable(), 90);
725 assert_eq!(tree.claims(), 2, "a claim that survived stopped counting");
726 assert_eq!(
727 tree.node(tree.find(Path::new("/scan/repo")).unwrap())
728 .reclaimable,
729 90
730 );
731 }
732
733 #[test]
734 fn shrinking_by_more_than_a_claim_holds_empties_it_rather_than_going_negative() {
735 let mut tree = Tree::new("/scan");
736 tree.insert(hit("/scan/repo/node_modules", 100));
737
738 // The deleter counts allocated bytes as it goes and the claim's price was measured
739 // earlier, so the two can disagree — a file grew, or a hard link was counted once
740 // there and not here. Every ancestor is clamped by the same figure, so the rollups
741 // still sum to what is beneath them.
742 tree.shrink(Path::new("/scan/repo/node_modules"), 400)
743 .unwrap();
744
745 assert_eq!(tree.reclaimable(), 0);
746 assert_eq!(tree.claims(), 1);
747 }
748
749 #[test]
750 fn shrinking_an_unpriced_claim_has_nothing_to_take_back_off() {
751 let mut tree = Tree::new("/scan");
752 tree.insert(fixture::hit("/scan/repo/node_modules", Size::Unmeasured, 0));
753
754 // It contributed no bytes to any ancestor, so there are none to subtract — and
755 // inventing a size here would turn "nobody has looked" into a measurement.
756 assert_eq!(tree.shrink(Path::new("/scan/repo/node_modules"), 50), None);
757
758 assert_eq!(tree.reclaimable(), 0);
759 assert_eq!(tree.unmeasured(), 1);
760 }
761
762 #[test]
763 fn a_directory_that_still_holds_something_survives_its_sibling_being_removed() {
764 let mut tree = Tree::new("/scan");
765 tree.insert(hit("/scan/repo/node_modules", 100));
766 tree.insert(hit("/scan/repo/target", 50));
767
768 let surviving = tree.remove(Path::new("/scan/repo/node_modules")).unwrap();
769
770 assert_eq!(tree.node(surviving).path, PathBuf::from("/scan/repo"));
771 assert_eq!(names(&tree, "/scan/repo"), ["target"]);
772 }
773
774 #[test]
775 fn removing_the_last_claim_leaves_the_root_and_nothing_else() {
776 let mut tree = Tree::new("/scan");
777 tree.insert(hit("/scan/a/b/node_modules", 100));
778
779 assert_eq!(
780 tree.remove(Path::new("/scan/a/b/node_modules")),
781 Some(tree.root())
782 );
783
784 assert!(tree.is_empty());
785 assert_eq!(tree.len(), 1);
786 assert_eq!(tree.reclaimable(), 0);
787 assert!(tree.children(tree.root()).is_empty());
788 }
789
790 #[test]
791 fn an_id_the_front_end_is_still_holding_says_it_is_gone_rather_than_naming_a_stranger() {
792 let mut tree = Tree::new("/scan");
793 let marked = tree.insert(hit("/scan/a/node_modules", 100)).unwrap();
794 tree.insert(hit("/scan/b/node_modules", 50));
795
796 tree.remove(Path::new("/scan/a/node_modules"));
797 // The arena is not compacted, so the mark's id still resolves — to the directory it
798 // always named, now detached, rather than to `b`'s claim.
799 assert!(!tree.is_attached(marked));
800 assert_eq!(
801 tree.node(marked).path,
802 PathBuf::from("/scan/a/node_modules")
803 );
804 assert!(tree.is_attached(tree.find(Path::new("/scan/b/node_modules")).unwrap()));
805 }
806
807 #[test]
808 fn removing_the_newest_thing_under_a_directory_makes_it_older() {
809 let mut tree = Tree::new("/scan");
810 tree.insert(aged("/scan/repo/old/node_modules", 1, 100));
811 tree.insert(aged("/scan/repo/new/node_modules", 1, 900));
812
813 tree.remove(Path::new("/scan/repo/new/node_modules"));
814
815 // A maximum cannot be subtracted, so this is the rollup that has to be re-asked
816 // rather than adjusted. Left alone it would report a subtree as touched this morning
817 // on the strength of a directory that no longer exists.
818 assert_eq!(
819 tree.node(tree.find(Path::new("/scan/repo")).unwrap())
820 .modified,
821 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(100))
822 );
823 }
824
825 #[test]
826 fn removing_something_that_is_not_a_claim_changes_nothing() {
827 let mut tree = Tree::new("/scan");
828 tree.insert(hit("/scan/repo/node_modules", 100));
829
830 assert!(tree.remove(Path::new("/scan/repo")).is_none());
831 assert!(tree.remove(Path::new("/scan/nowhere")).is_none());
832 assert!(tree.remove(Path::new("/elsewhere/node_modules")).is_none());
833
834 assert_eq!(tree.reclaimable(), 100);
835 assert_eq!(tree.len(), 3);
836 }
837
838 #[test]
839 fn a_price_still_lands_on_a_claim_beside_one_that_has_been_removed() {
840 let mut tree = Tree::new("/scan");
841 tree.insert(hit("/scan/a/node_modules", 0));
842 let mut unpriced = hit("/scan/b/node_modules", 0);
843 unpriced.size = Size::Unmeasured;
844 tree.insert(unpriced);
845
846 tree.remove(Path::new("/scan/a/node_modules"));
847 tree.price(Path::new("/scan/b/node_modules"), Size::Measured(70));
848
849 assert_eq!(tree.reclaimable(), 70);
850 assert_eq!(tree.unmeasured(), 0);
851 }
852
853 #[test]
854 fn a_change_stamps_every_directory_above_it_and_nothing_beside_it() {
855 let mut tree = Tree::new("/scan");
856 tree.insert(fixture::hit("/scan/here/node_modules", Size::Unmeasured, 0));
857 tree.insert(hit("/scan/there/target", 100));
858 let here = tree.find(Path::new("/scan/here")).unwrap();
859 let there = tree.find(Path::new("/scan/there")).unwrap();
860
861 // Every way a claim's numbers move, and each of them has to reach the root while
862 // leaving the directory beside it alone: a caller asking "has anything under here
863 // changed" asks the ancestor, and gets a wrong answer either way round.
864 let beside = tree.stamp(there);
865 let mut last = tree.stamp(here);
866 for change in ["price", "shrink", "arrive", "remove"] {
867 let root = tree.stamp(tree.root());
868 match change {
869 "price" => tree.price(Path::new("/scan/here/node_modules"), Size::Measured(50)),
870 "shrink" => tree.shrink(Path::new("/scan/here/node_modules"), 10),
871 // The one that happens 16,013 times over a real scan.
872 "arrive" => tree.insert(hit("/scan/here/again/target", 1)),
873 _ => tree.remove(Path::new("/scan/here/node_modules")),
874 }
875 .unwrap_or_else(|| panic!("{change} changed nothing, so it proves nothing"));
876 assert!(
877 tree.stamp(tree.root()) > root,
878 "{change} never reached the root"
879 );
880 assert!(
881 tree.stamp(here) > last,
882 "{change} never reached the directory"
883 );
884 assert_eq!(tree.stamp(there), beside, "{change} stamped a sibling");
885 last = tree.stamp(here);
886 }
887 }
888
889 #[test]
890 fn a_hit_the_tree_refuses_stamps_nothing() {
891 let mut tree = Tree::new("/scan");
892 tree.insert(hit("/scan/a/node_modules", 100));
893 let quiet = tree.stamp(tree.root());
894
895 // Every one of these changes nothing, so a reader watching the stamp to decide
896 // whether to spend a megabyte redrawing must not be told otherwise.
897 assert!(tree.insert(hit("/elsewhere/node_modules", 1)).is_none());
898 assert!(
899 tree.price(Path::new("/scan/nowhere"), Size::Measured(1))
900 .is_none()
901 );
902 assert!(tree.remove(Path::new("/scan/a")).is_none());
903 assert_eq!(tree.stamp(tree.root()), quiet);
904 }
905
906 #[test]
907 fn each_order_sorts_within_its_own_level() {
908 let mut tree = Tree::new("/scan");
909 tree.insert(aged("/scan/small/node_modules", 1, 900));
910 tree.insert(aged("/scan/large/node_modules", 10, 500));
911 tree.insert(aged("/scan/medium/node_modules", 5, 100));
912
913 tree.sort_by(Sort::by(Order::Size));
914 assert_eq!(names(&tree, "/scan"), ["large", "medium", "small"]);
915
916 tree.sort_by(Sort::by(Order::Path));
917 assert_eq!(names(&tree, "/scan"), ["large", "medium", "small"]);
918
919 tree.sort_by(Sort::by(Order::Age));
920 assert_eq!(names(&tree, "/scan"), ["medium", "large", "small"]);
921
922 tree.sort_by(Sort {
923 by: Order::Size,
924 reverse: true,
925 });
926 assert_eq!(names(&tree, "/scan"), ["small", "medium", "large"]);
927 }
928
929 #[test]
930 fn a_directory_whose_age_is_unknown_is_never_the_top_candidate_for_deletion() {
931 let mut tree = Tree::new("/scan");
932 tree.insert(aged("/scan/dated/node_modules", 1, 100));
933 tree.insert(hit("/scan/undated/node_modules", 1));
934
935 // Stalest first, and "no idea" is not stale.
936 tree.sort_by(Sort::by(Order::Age));
937 assert_eq!(names(&tree, "/scan"), ["dated", "undated"]);
938 }
939}