Skip to main content

dua/
stacks.rs

1use crate::aggregate::TraversalProgress;
2use crate::snapshot::Replay;
3use crate::traverse::{
4    BackgroundTraversal, Traversal, TraversalEntry, TraversalEvent, Tree, TreeIndex,
5};
6use crate::tree::metadata_io_error_count;
7use crate::{WalkOptions, WalkResult};
8use anyhow::{Context, Result};
9use bstr::ByteSlice;
10use std::ffi::OsStr;
11use std::io;
12use std::path::PathBuf;
13
14/// Traverse `paths` and write the tree to `out` as folded stacks, one entry per line, ready
15/// to pipe into flame-graph tools like [`inferno`](https://github.com/jonhoo/inferno).
16///
17/// Each line is an entry's path from the traversal root, with its components separated by `;`,
18/// followed by a single space and the entry's own size in bytes. A directory contributes only the
19/// size of its own directory entry, as the sizes of everything it contains appear on the lines of
20/// the contained entries.
21///
22/// Without `max_depth`, each entry's own size is known when it is traversed, so its line can be
23/// written immediately without retaining the complete tree. With `max_depth`, sizes below the
24/// limit must be folded into their nearest visible ancestor. Because an ancestor is visited before
25/// its descendants, its final size is unknown until traversal finishes; the requested levels are
26/// therefore retained and written afterward instead of streamed.
27pub fn stacks(
28    mut out: impl io::Write,
29    err: Option<impl io::Write>,
30    walk_options: WalkOptions,
31    paths: Vec<PathBuf>,
32    max_depth: Option<usize>,
33) -> Result<WalkResult> {
34    let mut traversal = Traversal::new();
35    let stream = max_depth.is_none();
36    // Mirror the interactive traversal so root nodes carry their input path as name.
37    let pattern_roots = walk_options.ignore_patterns.as_ref().map(|_| paths.clone());
38    let mut background = BackgroundTraversal::start(
39        traversal.root_index,
40        &walk_options,
41        paths,
42        pattern_roots.as_deref(),
43        false,
44        true,
45    )?
46    .retain_depth(max_depth.or(Some(0)));
47    let mut progress = TraversalProgress::new(err);
48
49    while let Ok(event) = background.event_rx.recv() {
50        let stack = stream.then(|| stack_path(&event)).flatten();
51        let size_before = traversal
52            .tree
53            .data(traversal.root_index)
54            .context("traversal root is missing")?
55            .size;
56        let finished = background.integrate_traversal_event(&mut traversal, event) == Some(true);
57        let own_size = traversal
58            .tree
59            .data(traversal.root_index)
60            .context("traversal root is missing")?
61            .size
62            .checked_sub(size_before)
63            .context("traversal size decreased")?;
64        if let Some(stack) = stack
65            && own_size > 0
66        {
67            progress.clear();
68            writeln!(out, "{stack} {own_size}")?;
69        }
70        progress.update(background.stats.entries_traversed);
71        if finished {
72            break;
73        }
74    }
75    progress.clear();
76
77    if !stream {
78        let roots = background
79            .root_nodes()
80            .context("traversal did not produce a node for every root")?;
81        write_stacks(&mut out, &traversal.tree, &roots, max_depth)?;
82    }
83
84    Ok(WalkResult {
85        num_errors: background.stats.io_errors,
86    })
87}
88
89/// Write an already completed traversal as folded stacks.
90///
91/// `roots` must contain the traversal's top-level nodes in their original input order. At
92/// `max_depth`, an entry's line contains its full aggregate size, including hidden descendants.
93/// The returned error count is derived from the stored metadata-error flags.
94pub fn stacks_from_traversal(
95    mut out: impl io::Write,
96    traversal: &Traversal,
97    roots: &[TreeIndex],
98    max_depth: Option<usize>,
99) -> Result<WalkResult> {
100    write_stacks(&mut out, &traversal.tree, roots, max_depth)?;
101    Ok(WalkResult {
102        num_errors: metadata_io_error_count(&traversal.tree, roots),
103    })
104}
105
106struct ReplayStackEntry {
107    size: u128,
108    children_size: u128,
109    prefix_len: usize,
110}
111
112/// Replay a verified snapshot as folded stacks while retaining only the current path.
113pub fn stacks_from_replay<R: io::Read + io::Seek>(
114    mut out: impl io::Write,
115    replay: &mut Replay<R>,
116    max_depth: Option<usize>,
117) -> Result<WalkResult> {
118    let mut num_errors = 0u64;
119    let mut open = Vec::new();
120    let mut prefix = String::new();
121    replay.for_each_entry(|entry| {
122        num_errors = num_errors.saturating_add(u64::from(entry.data.metadata_io_error));
123        while open.len() > entry.depth {
124            write_replay_stack(&mut out, &mut open, &mut prefix)?;
125        }
126        if max_depth.is_some_and(|max_depth| entry.depth > max_depth) {
127            return Ok(());
128        }
129
130        if let Some(parent) = open.last_mut() {
131            parent.children_size = parent
132                .children_size
133                .checked_add(entry.data.size)
134                .context("stack child sizes overflowed")?;
135        }
136        if !open.is_empty() {
137            prefix.push(';');
138        }
139        push_frame(&mut prefix, entry.name().as_os_str());
140        open.push(ReplayStackEntry {
141            size: entry.data.size,
142            children_size: 0,
143            prefix_len: prefix.len(),
144        });
145        Ok(())
146    })?;
147    while !open.is_empty() {
148        write_replay_stack(&mut out, &mut open, &mut prefix)?;
149    }
150    Ok(WalkResult { num_errors })
151}
152
153fn write_replay_stack(
154    out: &mut impl io::Write,
155    open: &mut Vec<ReplayStackEntry>,
156    prefix: &mut String,
157) -> Result<()> {
158    let entry = open.pop().expect("called with an open stack entry");
159    let own_size = entry
160        .size
161        .checked_sub(entry.children_size)
162        .context("stack children exceed their parent's size")?;
163    debug_assert_eq!(prefix.len(), entry.prefix_len);
164    if own_size > 0 {
165        writeln!(out, "{prefix} {own_size}")?;
166    }
167    prefix.truncate(open.last().map_or(0, |entry| entry.prefix_len));
168    Ok(())
169}
170
171fn stack_path(event: &TraversalEvent) -> Option<String> {
172    let TraversalEvent::Entry(Ok(TraversalEntry(entry)), root, _, _) = event else {
173        return None;
174    };
175    let mut stack = frame_name(root.as_os_str());
176    if entry.depth > 0 {
177        for component in entry
178            .path()
179            .strip_prefix(root.as_path())
180            .expect("walk entries remain below their root")
181            .components()
182        {
183            stack.push(';');
184            stack.push_str(&frame_name(component.as_os_str()));
185        }
186    }
187    Some(stack)
188}
189
190/// Write every entry below `roots` as a folded stack line with its own (exclusive) size.
191fn write_stacks(
192    mut out: impl io::Write,
193    tree: &Tree,
194    roots: &[TreeIndex],
195    max_depth: Option<usize>,
196) -> Result<()> {
197    // Depth-first, carrying the folded prefix that was built from the ancestors' names.
198    let mut stack: Vec<(TreeIndex, usize, String)> = roots
199        .iter()
200        .rev()
201        .map(|&root| (root, 0, frame(tree, root)))
202        .collect();
203
204    while let Some((index, depth, prefix)) = stack.pop() {
205        let mut children_size = 0u128;
206        if max_depth.is_none_or(|max_depth| depth < max_depth) {
207            for child in tree.children(index) {
208                children_size = children_size
209                    .checked_add(tree.data(child).expect("tree child exists").size)
210                    .context("stack child sizes overflowed")?;
211                stack.push((child, depth + 1, format!("{prefix};{}", frame(tree, child))));
212            }
213        }
214        // A directory's own size is what remains after accounting for its contents; a file has no
215        // children and so contributes its entire size. Zero-sized entries are left out as they add
216        // nothing to a flame graph.
217        let own_size = tree
218            .data(index)
219            .expect("tree entry exists")
220            .size
221            .checked_sub(children_size)
222            .context("stack children exceed their parent's size")?;
223        if own_size > 0 {
224            writeln!(out, "{prefix} {own_size}")?;
225        }
226    }
227    Ok(())
228}
229
230/// Turn an entry name into a single flame-graph frame, encoding the `;` frame separator, control
231/// characters, and the `\` escape marker.
232fn frame(tree: &Tree, index: TreeIndex) -> String {
233    frame_name(tree.name(index).expect("tree entry exists").as_os_str())
234}
235
236fn frame_name(name: &OsStr) -> String {
237    let mut encoded = String::new();
238    push_frame(&mut encoded, name);
239    encoded
240}
241
242fn push_frame(encoded: &mut String, name: &OsStr) {
243    for chunk in name.as_encoded_bytes().utf8_chunks() {
244        for character in chunk.valid().chars() {
245            match character {
246                '\\' => encoded.push_str(r"\\"),
247                ';' => encoded.push_str(r"\x3b"),
248                character if character.is_control() => encoded.extend(character.escape_default()),
249                character => encoded.push(character),
250            }
251        }
252        encoded.extend(chunk.invalid().escape_bytes());
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::{Replay, frame_name, stacks, stacks_from_replay, stacks_from_traversal};
259    use crate::traverse::{EntryData, Traversal};
260    use crate::{TraversalOptions, WalkOptions};
261    use bstr::ByteSlice;
262    use std::{collections::BTreeMap, ffi::OsStr};
263
264    fn walk_options() -> WalkOptions {
265        WalkOptions {
266            threads: 1,
267            count_hard_links: true,
268            apparent_size: true,
269            cross_filesystems: true,
270            ignore_dirs: std::collections::BTreeSet::default(),
271            ignore_patterns: None,
272            metadata_options: TraversalOptions::default(),
273        }
274    }
275
276    /// Parse folded output into a map of stack -> size, tolerating names that contain spaces by
277    /// splitting on the final space only.
278    fn folded(out: &[u8]) -> BTreeMap<String, u128> {
279        std::str::from_utf8(out)
280            .unwrap()
281            .lines()
282            .map(|line| {
283                let (stack, size) = line.rsplit_once(' ').expect("a size follows each stack");
284                (stack.to_owned(), size.parse().expect("a numeric size"))
285            })
286            .collect()
287    }
288
289    fn folded_frame(path: impl Into<std::path::PathBuf>) -> String {
290        frame_name(path.into().as_os_str())
291    }
292
293    #[test]
294    fn every_file_appears_with_its_size_below_its_directories() {
295        let dir = tempfile::tempdir().unwrap();
296        std::fs::create_dir(dir.path().join("nested")).unwrap();
297        std::fs::write(dir.path().join("nested/file"), b"content").unwrap();
298        std::fs::write(dir.path().join("top"), b"hi").unwrap();
299
300        let root = dir.path().to_owned();
301        let mut out = Vec::new();
302        let result = stacks(
303            &mut out,
304            None::<Vec<u8>>,
305            walk_options(),
306            vec![root.clone()],
307            None,
308        )
309        .unwrap();
310        assert_eq!(result.num_errors, 0);
311
312        let folded = folded(&out);
313        let base = folded_frame(root);
314        assert_eq!(
315            folded.get(&format!("{base};nested;file")),
316            Some(&7),
317            "the nested file is folded under its two directories with its own size"
318        );
319        assert_eq!(
320            folded.get(&format!("{base};top")),
321            Some(&2),
322            "the top-level file appears directly under the root"
323        );
324    }
325
326    #[test]
327    fn folded_sizes_sum_to_the_reported_total() {
328        use crate::traverse::{BackgroundTraversal, Traversal};
329
330        let dir = tempfile::tempdir().unwrap();
331        std::fs::create_dir(dir.path().join("a")).unwrap();
332        std::fs::write(dir.path().join("a/one"), b"12345").unwrap();
333        std::fs::write(dir.path().join("two"), b"678").unwrap();
334
335        let mut out = Vec::new();
336        stacks(
337            &mut out,
338            None::<Vec<u8>>,
339            walk_options(),
340            vec![dir.path().to_owned()],
341            None,
342        )
343        .unwrap();
344        let folded_total: u128 = folded(&out).values().sum();
345
346        // Independently traverse the same tree to obtain the total `dua` itself reports.
347        let mut traversal = Traversal::new();
348        let mut background = BackgroundTraversal::start(
349            traversal.root_index,
350            &walk_options(),
351            vec![dir.path().to_owned()],
352            None,
353            false,
354            true,
355        )
356        .unwrap();
357        while background
358            .integrate_traversal_event(&mut traversal, background.event_rx.recv().unwrap())
359            != Some(true)
360        {}
361
362        assert_eq!(
363            folded_total,
364            traversal
365                .tree
366                .data(traversal.root_index)
367                .expect("traversal root exists")
368                .size,
369            "the folded lines account for every byte the traversal totals up"
370        );
371    }
372
373    #[test]
374    fn a_single_file_input_is_folded_as_one_line() {
375        let dir = tempfile::tempdir().unwrap();
376        let file = dir.path().join("solo");
377        std::fs::write(&file, b"solo!").unwrap();
378
379        let mut out = Vec::new();
380        stacks(
381            &mut out,
382            None::<Vec<u8>>,
383            walk_options(),
384            vec![file.clone()],
385            None,
386        )
387        .unwrap();
388
389        let folded = folded(&out);
390        assert_eq!(folded.len(), 1);
391        assert_eq!(folded.get(&folded_frame(file)), Some(&5));
392    }
393
394    #[cfg(unix)]
395    #[test]
396    fn output_is_written_while_the_walk_is_still_running() {
397        struct CreateFileOnFirstWrite {
398            out: Vec<u8>,
399            path: std::path::PathBuf,
400        }
401
402        impl std::io::Write for CreateFileOnFirstWrite {
403            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
404                if self.out.is_empty() {
405                    std::fs::write(&self.path, b"x")?;
406                }
407                self.out.extend_from_slice(buf);
408                Ok(buf.len())
409            }
410
411            fn flush(&mut self) -> std::io::Result<()> {
412                Ok(())
413            }
414        }
415
416        let dir = tempfile::tempdir().unwrap();
417        let mut deepest = dir.path().to_owned();
418        for _ in 0..200 {
419            deepest.push("d");
420        }
421        std::fs::create_dir_all(&deepest).unwrap();
422        std::fs::write(dir.path().join("trigger"), b"x").unwrap();
423        let mut out = CreateFileOnFirstWrite {
424            out: Vec::new(),
425            path: deepest.join("late"),
426        };
427
428        stacks(
429            &mut out,
430            None::<Vec<u8>>,
431            walk_options(),
432            vec![dir.path().to_owned()],
433            None,
434        )
435        .unwrap();
436
437        let text = String::from_utf8(out.out).unwrap();
438        assert!(
439            text.lines().any(|line| line.ends_with(";late 1")),
440            "the first stack line should be written before the deepest directory is read: {text:?}"
441        );
442    }
443
444    #[test]
445    fn depth_rolls_hidden_descendants_into_the_last_frame() {
446        let dir = tempfile::tempdir().unwrap();
447        std::fs::create_dir(dir.path().join("nested")).unwrap();
448        std::fs::write(dir.path().join("nested/file"), b"content").unwrap();
449
450        let mut out = Vec::new();
451        stacks(
452            &mut out,
453            None::<Vec<u8>>,
454            walk_options(),
455            vec![dir.path().to_owned()],
456            Some(1),
457        )
458        .unwrap();
459
460        let folded = folded(&out);
461        let nested = format!("{};nested", folded_frame(dir.path()));
462        assert!(folded.contains_key(&nested));
463        assert!(!folded.keys().any(|stack| stack.ends_with(";file")));
464    }
465
466    #[test]
467    fn completed_traversal_rolls_hidden_entries_into_the_cutoff() {
468        let mut traversal = Traversal::new();
469        let root = traversal.tree.add_child(
470            traversal.root_index,
471            "first",
472            EntryData {
473                size: 9,
474                is_dir: true,
475                ..EntryData::default()
476            },
477        );
478        let cutoff = traversal.tree.add_child(
479            root,
480            "cutoff",
481            EntryData {
482                size: 9,
483                is_dir: true,
484                ..EntryData::default()
485            },
486        );
487        traversal.tree.add_child(
488            cutoff,
489            "hidden",
490            EntryData {
491                size: 7,
492                metadata_io_error: true,
493                ..EntryData::default()
494            },
495        );
496        let second = traversal.tree.add_child(
497            traversal.root_index,
498            "second",
499            EntryData {
500                size: 2,
501                ..EntryData::default()
502            },
503        );
504
505        let mut out = Vec::new();
506        let result = stacks_from_traversal(&mut out, &traversal, &[root, second], Some(1)).unwrap();
507        let mut snapshot = Vec::new();
508        crate::snapshot::write(&mut snapshot, &traversal, &[root, second], None).unwrap();
509        let mut replay = Replay::new(std::io::Cursor::new(snapshot)).unwrap();
510        let mut replayed = Vec::new();
511        let replayed_result = stacks_from_replay(&mut replayed, &mut replay, Some(1)).unwrap();
512        assert_eq!(folded(&replayed), folded(&out));
513        assert_eq!(replayed_result.num_errors, result.num_errors);
514        insta::assert_snapshot!(out.as_bstr(), "depth cutoff rolls hidden descendants into parent", @r"
515        first;cutoff 9
516        second 2
517        ");
518        assert_eq!(result.num_errors, 1);
519    }
520
521    #[test]
522    fn completed_traversal_rejects_invalid_aggregate_sizes() {
523        for (parent_size, child_sizes, expected) in [
524            (1, vec![2], "stack children exceed their parent's size"),
525            (
526                u128::MAX,
527                vec![u128::MAX, 1],
528                "stack child sizes overflowed",
529            ),
530        ] {
531            let mut traversal = Traversal::new();
532            let root = traversal.tree.add_child(
533                traversal.root_index,
534                "root",
535                EntryData {
536                    size: parent_size,
537                    is_dir: true,
538                    ..EntryData::default()
539                },
540            );
541            for size in child_sizes {
542                traversal.tree.add_child(
543                    root,
544                    "child",
545                    EntryData {
546                        size,
547                        ..EntryData::default()
548                    },
549                );
550            }
551
552            let Err(error) = stacks_from_traversal(Vec::new(), &traversal, &[root], None) else {
553                panic!("invalid aggregate sizes must fail");
554            };
555            assert_eq!(error.to_string(), expected);
556        }
557    }
558
559    #[test]
560    fn frame_names_are_encoded_without_collisions() {
561        let encode = |name: &str| frame_name(OsStr::new(name));
562
563        assert_eq!(encode("a;b"), r"a\x3bb");
564        assert_eq!(encode("a_b"), "a_b");
565        assert_eq!(encode(r"a\x3bb"), r"a\\x3bb");
566        assert_eq!(encode("a\nb"), r"a\nb");
567    }
568}