Skip to main content

dua/
stacks.rs

1use crate::aggregate::TraversalProgress;
2use crate::traverse::{
3    BackgroundTraversal, EntryData, Traversal, TraversalEntry, TraversalEvent, Tree, TreeIndex,
4};
5use crate::{WalkOptions, WalkResult};
6use anyhow::Result;
7use bstr::ByteSlice;
8use petgraph::Direction;
9use std::ffi::OsStr;
10use std::io;
11use std::path::PathBuf;
12
13/// Traverse `paths` and write the tree to `out` as folded stacks, one entry per line, ready
14/// to pipe into flame-graph tools like [`inferno`](https://github.com/jonhoo/inferno).
15///
16/// Each line is an entry's path from the traversal root, with its components separated by `;`,
17/// followed by a single space and the entry's own size in bytes. A directory contributes only the
18/// size of its own directory entry, as the sizes of everything it contains appear on the lines of
19/// the contained entries.
20///
21/// Without `max_depth`, each entry's own size is known when it is traversed, so its line can be
22/// written immediately without retaining the complete tree. With `max_depth`, sizes below the
23/// limit must be folded into their nearest visible ancestor. Because an ancestor is visited before
24/// its descendants, its final size is unknown until traversal finishes; the requested levels are
25/// therefore retained and written afterward instead of streamed.
26pub fn stacks(
27    mut out: impl io::Write,
28    err: Option<impl io::Write>,
29    walk_options: WalkOptions,
30    paths: Vec<PathBuf>,
31    max_depth: Option<usize>,
32) -> Result<WalkResult> {
33    let mut traversal = Traversal::new();
34    let stream = max_depth.is_none();
35    // Mirror the interactive traversal so root nodes carry their input path as name.
36    let pattern_roots = walk_options.ignore_patterns.as_ref().map(|_| paths.clone());
37    let mut background = BackgroundTraversal::start(
38        traversal.root_index,
39        &walk_options,
40        paths,
41        pattern_roots.as_deref(),
42        false,
43        true,
44    )?
45    .retain_depth(max_depth.or(Some(0)));
46    let mut progress = TraversalProgress::new(err);
47
48    while let Ok(event) = background.event_rx.recv() {
49        let stack = stream.then(|| stack_path(&event)).flatten();
50        let size_before = traversal.tree[traversal.root_index].size;
51        let finished = background.integrate_traversal_event(&mut traversal, event) == Some(true);
52        let own_size = traversal.tree[traversal.root_index].size - size_before;
53        if let Some(stack) = stack
54            && own_size > 0
55        {
56            progress.clear();
57            writeln!(out, "{stack} {own_size}")?;
58        }
59        progress.update(background.stats.entries_traversed);
60        if finished {
61            break;
62        }
63    }
64    progress.clear();
65
66    if !stream {
67        write_stacks(&mut out, &traversal.tree, traversal.root_index)?;
68    }
69
70    Ok(WalkResult {
71        num_errors: background.stats.io_errors,
72    })
73}
74
75fn stack_path(event: &TraversalEvent) -> Option<String> {
76    let TraversalEvent::Entry(Ok(TraversalEntry(entry)), root, _, _) = event else {
77        return None;
78    };
79    let mut stack = frame_name(root.as_os_str());
80    if entry.depth > 0 {
81        for component in entry
82            .path()
83            .strip_prefix(root.as_path())
84            .expect("walk entries remain below their root")
85            .components()
86        {
87            stack.push(';');
88            stack.push_str(&frame_name(component.as_os_str()));
89        }
90    }
91    Some(stack)
92}
93
94/// Write every entry below `root` as a folded stack line with its own (exclusive) size.
95fn write_stacks(mut out: impl io::Write, tree: &Tree, root: TreeIndex) -> io::Result<()> {
96    // Depth-first, carrying the folded prefix that was built from the ancestors' names.
97    let mut stack: Vec<(TreeIndex, String)> = tree
98        .neighbors_directed(root, Direction::Outgoing)
99        .map(|child| (child, frame(&tree[child])))
100        .collect();
101
102    while let Some((index, prefix)) = stack.pop() {
103        let mut children_size = 0u128;
104        for child in tree.neighbors_directed(index, Direction::Outgoing) {
105            children_size += tree[child].size;
106            stack.push((child, format!("{prefix};{}", frame(&tree[child]))));
107        }
108        // A directory's own size is what remains after accounting for its contents; a file has no
109        // children and so contributes its entire size. Zero-sized entries are left out as they add
110        // nothing to a flame graph.
111        let own_size = tree[index].size.saturating_sub(children_size);
112        if own_size > 0 {
113            writeln!(out, "{prefix} {own_size}")?;
114        }
115    }
116    Ok(())
117}
118
119/// Turn an entry name into a single flame-graph frame, encoding the `;` frame separator, control
120/// characters, and the `\` escape marker.
121fn frame(entry: &EntryData) -> String {
122    frame_name(entry.name.as_os_str())
123}
124
125fn frame_name(name: &OsStr) -> String {
126    let mut encoded = String::new();
127    for chunk in name.as_encoded_bytes().utf8_chunks() {
128        for character in chunk.valid().chars() {
129            match character {
130                '\\' => encoded.push_str(r"\\"),
131                ';' => encoded.push_str(r"\x3b"),
132                character if character.is_control() => encoded.extend(character.escape_default()),
133                character => encoded.push(character),
134            }
135        }
136        encoded.extend(chunk.invalid().escape_bytes());
137    }
138    encoded
139}
140
141#[cfg(test)]
142mod tests {
143    use super::{frame, stacks};
144    use crate::traverse::EntryData;
145    use crate::{TraversalOptions, WalkOptions};
146    use std::collections::BTreeMap;
147
148    fn walk_options() -> WalkOptions {
149        WalkOptions {
150            threads: 1,
151            count_hard_links: true,
152            apparent_size: true,
153            cross_filesystems: true,
154            ignore_dirs: std::collections::BTreeSet::default(),
155            ignore_patterns: None,
156            metadata_options: TraversalOptions::default(),
157        }
158    }
159
160    /// Parse folded output into a map of stack -> size, tolerating names that contain spaces by
161    /// splitting on the final space only.
162    fn folded(out: &[u8]) -> BTreeMap<String, u128> {
163        std::str::from_utf8(out)
164            .unwrap()
165            .lines()
166            .map(|line| {
167                let (stack, size) = line.rsplit_once(' ').expect("a size follows each stack");
168                (stack.to_owned(), size.parse().expect("a numeric size"))
169            })
170            .collect()
171    }
172
173    fn folded_frame(path: impl Into<std::path::PathBuf>) -> String {
174        frame(&EntryData {
175            name: path.into(),
176            ..EntryData::default()
177        })
178    }
179
180    #[test]
181    fn every_file_appears_with_its_size_below_its_directories() {
182        let dir = tempfile::tempdir().unwrap();
183        std::fs::create_dir(dir.path().join("nested")).unwrap();
184        std::fs::write(dir.path().join("nested/file"), b"content").unwrap();
185        std::fs::write(dir.path().join("top"), b"hi").unwrap();
186
187        let root = dir.path().to_owned();
188        let mut out = Vec::new();
189        let result = stacks(
190            &mut out,
191            None::<Vec<u8>>,
192            walk_options(),
193            vec![root.clone()],
194            None,
195        )
196        .unwrap();
197        assert_eq!(result.num_errors, 0);
198
199        let folded = folded(&out);
200        let base = folded_frame(root);
201        assert_eq!(
202            folded.get(&format!("{base};nested;file")),
203            Some(&7),
204            "the nested file is folded under its two directories with its own size"
205        );
206        assert_eq!(
207            folded.get(&format!("{base};top")),
208            Some(&2),
209            "the top-level file appears directly under the root"
210        );
211    }
212
213    #[test]
214    fn folded_sizes_sum_to_the_reported_total() {
215        use crate::traverse::{BackgroundTraversal, Traversal};
216
217        let dir = tempfile::tempdir().unwrap();
218        std::fs::create_dir(dir.path().join("a")).unwrap();
219        std::fs::write(dir.path().join("a/one"), b"12345").unwrap();
220        std::fs::write(dir.path().join("two"), b"678").unwrap();
221
222        let mut out = Vec::new();
223        stacks(
224            &mut out,
225            None::<Vec<u8>>,
226            walk_options(),
227            vec![dir.path().to_owned()],
228            None,
229        )
230        .unwrap();
231        let folded_total: u128 = folded(&out).values().sum();
232
233        // Independently traverse the same tree to obtain the total `dua` itself reports.
234        let mut traversal = Traversal::new();
235        let mut background = BackgroundTraversal::start(
236            traversal.root_index,
237            &walk_options(),
238            vec![dir.path().to_owned()],
239            None,
240            false,
241            true,
242        )
243        .unwrap();
244        while background
245            .integrate_traversal_event(&mut traversal, background.event_rx.recv().unwrap())
246            != Some(true)
247        {}
248
249        assert_eq!(
250            folded_total, traversal.tree[traversal.root_index].size,
251            "the folded lines account for every byte the traversal totals up"
252        );
253    }
254
255    #[test]
256    fn a_single_file_input_is_folded_as_one_line() {
257        let dir = tempfile::tempdir().unwrap();
258        let file = dir.path().join("solo");
259        std::fs::write(&file, b"solo!").unwrap();
260
261        let mut out = Vec::new();
262        stacks(
263            &mut out,
264            None::<Vec<u8>>,
265            walk_options(),
266            vec![file.clone()],
267            None,
268        )
269        .unwrap();
270
271        let folded = folded(&out);
272        assert_eq!(folded.len(), 1);
273        assert_eq!(folded.get(&folded_frame(file)), Some(&5));
274    }
275
276    #[cfg(unix)]
277    #[test]
278    fn output_is_written_while_the_walk_is_still_running() {
279        struct CreateFileOnFirstWrite {
280            out: Vec<u8>,
281            path: std::path::PathBuf,
282        }
283
284        impl std::io::Write for CreateFileOnFirstWrite {
285            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
286                if self.out.is_empty() {
287                    std::fs::write(&self.path, b"x")?;
288                }
289                self.out.extend_from_slice(buf);
290                Ok(buf.len())
291            }
292
293            fn flush(&mut self) -> std::io::Result<()> {
294                Ok(())
295            }
296        }
297
298        let dir = tempfile::tempdir().unwrap();
299        let mut deepest = dir.path().to_owned();
300        for _ in 0..200 {
301            deepest.push("d");
302        }
303        std::fs::create_dir_all(&deepest).unwrap();
304        std::fs::write(dir.path().join("trigger"), b"x").unwrap();
305        let mut out = CreateFileOnFirstWrite {
306            out: Vec::new(),
307            path: deepest.join("late"),
308        };
309
310        stacks(
311            &mut out,
312            None::<Vec<u8>>,
313            walk_options(),
314            vec![dir.path().to_owned()],
315            None,
316        )
317        .unwrap();
318
319        let text = String::from_utf8(out.out).unwrap();
320        assert!(
321            text.lines().any(|line| line.ends_with(";late 1")),
322            "the first stack line should be written before the deepest directory is read: {text:?}"
323        );
324    }
325
326    #[test]
327    fn depth_rolls_hidden_descendants_into_the_last_frame() {
328        let dir = tempfile::tempdir().unwrap();
329        std::fs::create_dir(dir.path().join("nested")).unwrap();
330        std::fs::write(dir.path().join("nested/file"), b"content").unwrap();
331
332        let mut out = Vec::new();
333        stacks(
334            &mut out,
335            None::<Vec<u8>>,
336            walk_options(),
337            vec![dir.path().to_owned()],
338            Some(1),
339        )
340        .unwrap();
341
342        let folded = folded(&out);
343        let nested = format!("{};nested", folded_frame(dir.path()));
344        assert!(folded.contains_key(&nested));
345        assert!(!folded.keys().any(|stack| stack.ends_with(";file")));
346    }
347
348    #[test]
349    fn frame_names_are_encoded_without_collisions() {
350        let encode = |name: &str| {
351            frame(&EntryData {
352                name: name.into(),
353                ..EntryData::default()
354            })
355        };
356
357        assert_eq!(encode("a;b"), r"a\x3bb");
358        assert_eq!(encode("a_b"), "a_b");
359        assert_eq!(encode(r"a\x3bb"), r"a\\x3bb");
360        assert_eq!(encode("a\nb"), r"a\nb");
361    }
362}