Skip to main content

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 &current = 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}