Skip to main content

dua/
stacks.rs

1use crate::aggregate::TraversalProgress;
2use crate::traverse::{BackgroundTraversal, EntryData, Traversal, Tree, TreeIndex};
3use crate::{WalkOptions, WalkResult};
4use anyhow::Result;
5use bstr::ByteSlice;
6use petgraph::Direction;
7use std::io;
8use std::path::PathBuf;
9
10/// Traverse `paths` and write the tree to `out` as folded stacks, one entry per line, ready
11/// to pipe into flame-graph tools like [`inferno`](https://github.com/jonhoo/inferno).
12///
13/// Each line is an entry's path from the traversal root, with its components separated by `;`,
14/// followed by a single space and the entry's own size in bytes. A directory contributes only the
15/// size of its own directory entry, as the sizes of everything it contains appear on the lines of
16/// the contained entries.
17pub fn stacks(
18    mut out: impl io::Write,
19    err: Option<impl io::Write>,
20    walk_options: WalkOptions,
21    paths: Vec<PathBuf>,
22    max_depth: Option<usize>,
23) -> Result<WalkResult> {
24    let mut traversal = Traversal::new();
25    // Mirror the interactive traversal so root nodes carry their input path as name.
26    let pattern_roots = walk_options.ignore_patterns.as_ref().map(|_| paths.clone());
27    let mut background = BackgroundTraversal::start(
28        traversal.root_index,
29        &walk_options,
30        paths,
31        pattern_roots.as_deref(),
32        false,
33        true,
34    )?
35    .retain_depth(max_depth);
36    let mut progress = TraversalProgress::new(err);
37
38    while let Ok(event) = background.event_rx.recv() {
39        let finished = background.integrate_traversal_event(&mut traversal, event) == Some(true);
40        progress.update(background.stats.entries_traversed);
41        if finished {
42            break;
43        }
44    }
45    progress.clear();
46
47    write_stacks(&mut out, &traversal.tree, traversal.root_index)?;
48
49    Ok(WalkResult {
50        num_errors: background.stats.io_errors,
51    })
52}
53
54/// Write every entry below `root` as a folded stack line with its own (exclusive) size.
55fn write_stacks(mut out: impl io::Write, tree: &Tree, root: TreeIndex) -> io::Result<()> {
56    // Depth-first, carrying the folded prefix that was built from the ancestors' names.
57    let mut stack: Vec<(TreeIndex, String)> = tree
58        .neighbors_directed(root, Direction::Outgoing)
59        .map(|child| (child, frame(&tree[child])))
60        .collect();
61
62    while let Some((index, prefix)) = stack.pop() {
63        let mut children_size = 0u128;
64        for child in tree.neighbors_directed(index, Direction::Outgoing) {
65            children_size += tree[child].size;
66            stack.push((child, format!("{prefix};{}", frame(&tree[child]))));
67        }
68        // A directory's own size is what remains after accounting for its contents; a file has no
69        // children and so contributes its entire size. Zero-sized entries are left out as they add
70        // nothing to a flame graph.
71        let own_size = tree[index].size.saturating_sub(children_size);
72        if own_size > 0 {
73            writeln!(out, "{prefix} {own_size}")?;
74        }
75    }
76    Ok(())
77}
78
79/// Turn an entry name into a single flame-graph frame, encoding the `;` frame separator, control
80/// characters, and the `\` escape marker.
81fn frame(entry: &EntryData) -> String {
82    let mut encoded = String::new();
83    for chunk in entry.name.as_os_str().as_encoded_bytes().utf8_chunks() {
84        for character in chunk.valid().chars() {
85            match character {
86                '\\' => encoded.push_str(r"\\"),
87                ';' => encoded.push_str(r"\x3b"),
88                character if character.is_control() => encoded.extend(character.escape_default()),
89                character => encoded.push(character),
90            }
91        }
92        encoded.extend(chunk.invalid().escape_bytes());
93    }
94    encoded
95}
96
97#[cfg(test)]
98mod tests {
99    use super::{frame, stacks};
100    use crate::traverse::EntryData;
101    use crate::{TraversalOptions, WalkOptions};
102    use std::collections::BTreeMap;
103
104    fn walk_options() -> WalkOptions {
105        WalkOptions {
106            threads: 1,
107            count_hard_links: true,
108            apparent_size: true,
109            cross_filesystems: true,
110            ignore_dirs: std::collections::BTreeSet::default(),
111            ignore_patterns: None,
112            metadata_options: TraversalOptions::default(),
113        }
114    }
115
116    /// Parse folded output into a map of stack -> size, tolerating names that contain spaces by
117    /// splitting on the final space only.
118    fn folded(out: &[u8]) -> BTreeMap<String, u128> {
119        std::str::from_utf8(out)
120            .unwrap()
121            .lines()
122            .map(|line| {
123                let (stack, size) = line.rsplit_once(' ').expect("a size follows each stack");
124                (stack.to_owned(), size.parse().expect("a numeric size"))
125            })
126            .collect()
127    }
128
129    fn folded_frame(path: impl Into<std::path::PathBuf>) -> String {
130        frame(&EntryData {
131            name: path.into(),
132            ..EntryData::default()
133        })
134    }
135
136    #[test]
137    fn every_file_appears_with_its_size_below_its_directories() {
138        let dir = tempfile::tempdir().unwrap();
139        std::fs::create_dir(dir.path().join("nested")).unwrap();
140        std::fs::write(dir.path().join("nested/file"), b"content").unwrap();
141        std::fs::write(dir.path().join("top"), b"hi").unwrap();
142
143        let root = dir.path().to_owned();
144        let mut out = Vec::new();
145        let result = stacks(
146            &mut out,
147            None::<Vec<u8>>,
148            walk_options(),
149            vec![root.clone()],
150            None,
151        )
152        .unwrap();
153        assert_eq!(result.num_errors, 0);
154
155        let folded = folded(&out);
156        let base = folded_frame(root);
157        assert_eq!(
158            folded.get(&format!("{base};nested;file")),
159            Some(&7),
160            "the nested file is folded under its two directories with its own size"
161        );
162        assert_eq!(
163            folded.get(&format!("{base};top")),
164            Some(&2),
165            "the top-level file appears directly under the root"
166        );
167    }
168
169    #[test]
170    fn folded_sizes_sum_to_the_reported_total() {
171        use crate::traverse::{BackgroundTraversal, Traversal};
172
173        let dir = tempfile::tempdir().unwrap();
174        std::fs::create_dir(dir.path().join("a")).unwrap();
175        std::fs::write(dir.path().join("a/one"), b"12345").unwrap();
176        std::fs::write(dir.path().join("two"), b"678").unwrap();
177
178        let mut out = Vec::new();
179        stacks(
180            &mut out,
181            None::<Vec<u8>>,
182            walk_options(),
183            vec![dir.path().to_owned()],
184            None,
185        )
186        .unwrap();
187        let folded_total: u128 = folded(&out).values().sum();
188
189        // Independently traverse the same tree to obtain the total `dua` itself reports.
190        let mut traversal = Traversal::new();
191        let mut background = BackgroundTraversal::start(
192            traversal.root_index,
193            &walk_options(),
194            vec![dir.path().to_owned()],
195            None,
196            false,
197            true,
198        )
199        .unwrap();
200        while background
201            .integrate_traversal_event(&mut traversal, background.event_rx.recv().unwrap())
202            != Some(true)
203        {}
204
205        assert_eq!(
206            folded_total, traversal.tree[traversal.root_index].size,
207            "the folded lines account for every byte the traversal totals up"
208        );
209    }
210
211    #[test]
212    fn a_single_file_input_is_folded_as_one_line() {
213        let dir = tempfile::tempdir().unwrap();
214        let file = dir.path().join("solo");
215        std::fs::write(&file, b"solo!").unwrap();
216
217        let mut out = Vec::new();
218        stacks(
219            &mut out,
220            None::<Vec<u8>>,
221            walk_options(),
222            vec![file.clone()],
223            None,
224        )
225        .unwrap();
226
227        let folded = folded(&out);
228        assert_eq!(folded.len(), 1);
229        assert_eq!(folded.get(&folded_frame(file)), Some(&5));
230    }
231
232    #[test]
233    fn depth_rolls_hidden_descendants_into_the_last_frame() {
234        let dir = tempfile::tempdir().unwrap();
235        std::fs::create_dir(dir.path().join("nested")).unwrap();
236        std::fs::write(dir.path().join("nested/file"), b"content").unwrap();
237
238        let mut out = Vec::new();
239        stacks(
240            &mut out,
241            None::<Vec<u8>>,
242            walk_options(),
243            vec![dir.path().to_owned()],
244            Some(1),
245        )
246        .unwrap();
247
248        let folded = folded(&out);
249        let nested = format!("{};nested", folded_frame(dir.path()));
250        assert!(folded.contains_key(&nested));
251        assert!(!folded.keys().any(|stack| stack.ends_with(";file")));
252    }
253
254    #[test]
255    fn frame_names_are_encoded_without_collisions() {
256        let encode = |name: &str| {
257            frame(&EntryData {
258                name: name.into(),
259                ..EntryData::default()
260            })
261        };
262
263        assert_eq!(encode("a;b"), r"a\x3bb");
264        assert_eq!(encode("a_b"), "a_b");
265        assert_eq!(encode(r"a\x3bb"), r"a\\x3bb");
266        assert_eq!(encode("a\nb"), r"a\nb");
267    }
268}