From 6f797d6810793c5f57b3cee3132f96d1d32f9fce Mon Sep 17 00:00:00 2001 From: Byron Date: Mon, 31 Aug 2026 15:37:58 +0200 Subject: [PATCH 1/2] feat: add `stacks` and `flamegraph` commands ## Summary - Promote folded output previously at `dua aggregate --stacks` to the first-class `dua stacks` command - Add `dua flamegraph`, backed by Inferno, with temporary opening by default and `--output` for file-only output. - Keep `aggregate --stack` as a hidden compatibility path and limit the new commands to relevant traversal flags. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- Cargo.lock | 92 ++++++- Cargo.toml | 4 +- README.md | 17 +- src/main.rs | 533 +++++++++++++++++++++------------------- src/options.rs | 136 ++++++++-- tests/stack_commands.rs | 32 +++ 6 files changed, 540 insertions(+), 274 deletions(-) create mode 100644 tests/stack_commands.rs diff --git a/Cargo.lock b/Cargo.lock index 254e27ef..ab29b5a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -261,6 +274,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" @@ -684,6 +703,7 @@ dependencies = [ "filesize", "gix", "human_format", + "inferno", "insta", "itertools 0.15.0", "jiff", @@ -865,6 +885,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -873,7 +905,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -1605,7 +1637,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", ] [[package]] @@ -1733,6 +1765,22 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inferno" +version = "0.12.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c460d4fa06223667240720ab69a8045133755ae6dfbe100cf481b95e3a014f1" +dependencies = [ + "ahash 0.8.12", + "itoa", + "log", + "num-format", + "once_cell", + "quick-xml", + "rgb", + "str_stack", +] + [[package]] name = "insta" version = "1.48.0" @@ -2000,6 +2048,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2253,6 +2311,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.41" @@ -2262,6 +2329,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -2425,6 +2498,15 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "rkyv" version = "0.7.46" @@ -2712,6 +2794,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str_stack" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f446288b699d66d0fd2e30d1cfe7869194312524b3b9252594868ed26ef056a" + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index 92697749..7a39dbdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,6 @@ tui-crossplatform = [ "crossterm", "dep:signal-hook", "tui", - "open", "unicode-segmentation", "unicode-width", ] @@ -66,6 +65,7 @@ byte-unit = { version = "5.2.5", features = ["u128"] } itertools = "0.15.0" num_cpus = "1.10.0" anyhow = "1.0.31" +inferno = { version = "0.12.8", default-features = false } trash = { version = "5.2.0", optional = true, default-features = false, features = [ "coinit_apartmentthreaded", "chrono" ] } @@ -78,7 +78,7 @@ crossterm = { version = "0.29.0", optional = true } tui = { package = "ratatui", version = "0.30.2", optional = true, default-features = false, features = [ "crossterm", ] } -open = { version = "5.0", optional = true } +open = "5.0" wild = "2.0.4" owo-colors = "4.0.0" human_format = "1.0.3" diff --git a/README.md b/README.md index 9bd80297..0a4b23ce 100644 --- a/README.md +++ b/README.md @@ -245,13 +245,20 @@ LANG=ja_JP.UTF-8 dua i # then press '?' for the Japanese help screen ### Flame graphs -The `aggregate --stack` option prints the traversal as folded stacks - the "collapsed" format read -by flame-graph tools like [`inferno`](https://github.com/jonhoo/inferno). -Each line is an entry's path with `;` between its components, a space, and its size -in bytes, so an interactive drill-down can be turned into a single shareable SVG: +`dua stacks` prints folded stacks—the "collapsed" interchange format read by flame-graph tools. +Each line is an entry's path with `;` between its components, a space, and its size in bytes: ```bash -dua aggregate --stack | inferno-flamegraph > disk-usage.svg +dua stacks > disk-usage.folded +``` + +`dua flamegraph` renders the same data with [`inferno`](https://github.com/jonhoo/inferno), writes +the SVG to a temporary file, and opens it. Pass an output path to write the SVG without opening it. +Both commands accept the usual traversal options as well as `--depth` and `--import`: + +```bash +dua flamegraph +dua flamegraph -o disk-usage.svg ``` ### Configuration diff --git a/src/main.rs b/src/main.rs index 1fadcbe2..fef8c971 100644 --- a/src/main.rs +++ b/src/main.rs @@ -94,10 +94,12 @@ fn marked_path_for_output(path: &Path, stdout_is_terminal: bool) -> std::borrow: fn main() -> Result<()> { #[cfg(feature = "tui-crossplatform")] use options::Command::Interactive; - use options::Command::{Aggregate, Completions, Config, Diff}; + use options::Command::{Aggregate, Completions, Config, Diff, Flamegraph, Stacks}; let matches = options::Args::command().get_matches_from(wild::args_os()); let global_traversal_options_used = traversal_options_on_command_line(&matches); + let global_format_used = + matches.value_source("format") == Some(clap::parser::ValueSource::CommandLine); let opt = options::Args::from_arg_matches(&matches)?; if let Some(log_file) = &opt.log_file { @@ -160,39 +162,40 @@ fn main() -> Result<()> { let snapshot = import.as_deref().map(read_snapshot_file).transpose()?; let snapshot_load_duration = snapshot_load_start.map(|start| start.elapsed()); let read_only = snapshot.is_some(); - let (input_paths, initial_traversal, walk_options, root_path) = - if let Some(snapshot) = snapshot { - let input_paths = snapshot - .roots - .iter() - .map(|root| { - snapshot - .traversal - .tree - .name(*root) - .expect("snapshot root exists") - .into_owned() - }) - .collect(); - ( - input_paths, - snapshot.traversal, - snapshot_walk_options(), - None, - ) - } else { - let walk_options = walk_options_from(&traversal)?; - let has_complete_root = traversal.input.is_empty() - || traversal.input.len() == 1 && traversal.input[0].is_dir(); - let input_paths = extract_paths_maybe_set_cwd(traversal.input, &walk_options)?; - let root_path = has_complete_root.then(std::env::current_dir).transpose()?; - ( - input_paths, - dua::traverse::Traversal::new(), - walk_options, - root_path, - ) - }; + let (input_paths, initial_traversal, walk_options, root_path) = if let Some(snapshot) = + snapshot + { + let input_paths = snapshot + .roots + .iter() + .map(|root| { + snapshot + .traversal + .tree + .name(*root) + .expect("snapshot root exists") + .into_owned() + }) + .collect(); + ( + input_paths, + snapshot.traversal, + snapshot_walk_options(), + None, + ) + } else { + let walk_options = walk_options_from(&traversal.scan)?; + let has_complete_root = traversal.scan.input.is_empty() + || traversal.scan.input.len() == 1 && traversal.scan.input[0].is_dir(); + let input_paths = extract_paths_maybe_set_cwd(traversal.scan.input, &walk_options)?; + let root_path = has_complete_root.then(std::env::current_dir).transpose()?; + ( + input_paths, + dua::traverse::Traversal::new(), + walk_options, + root_path, + ) + }; let no_tty_msg = "Interactive mode requires a connected terminal"; if !io::stderr().is_terminal() { @@ -311,6 +314,38 @@ fn main() -> Result<()> { )?; dua::WalkResult::default() } + Some(Stacks { args }) => { + if global_format_used { + bail!("stacks cannot be used with --format"); + } + let stdout = io::stdout(); + run_stacks( + &global_traversal.scan, + global_traversal_options_used, + args, + stdout.lock(), + )? + } + Some(Flamegraph { args, output }) => { + if global_format_used { + bail!("flamegraph cannot be used with --format"); + } + let output = output.map(std::path::absolute).transpose()?; + let open = output.is_none(); + let mut stacks = Vec::new(); + let result = run_stacks( + &global_traversal.scan, + global_traversal_options_used, + args, + &mut stacks, + )?; + let path = write_flamegraph(stacks, output)?; + if open { + open::that(&path) + .with_context(|| format!("Could not open flame graph {}", path.display()))?; + } + result + } Some(Aggregate { traversal: subcommand_traversal, import, @@ -320,25 +355,31 @@ fn main() -> Result<()> { stack, depth, }) => { - if import.is_some() && global_traversal_options_used { - bail!("--import cannot be used with traversal options or input paths"); - } - let traversal = merge_traversal_args(&global_traversal, &subcommand_traversal); - if let Some(path) = import { - let mut replay = replay_snapshot_file(&path)?; - writeln!( - io::stderr(), - "Results are from a traversal snapshot; no filesystem traversal was performed" - ) - .ok(); + if stack { let stdout = io::stdout(); - if stack { - dua::stacks_from_replay( - stdout.lock(), - &mut replay, - depth.map(|depth| depth.saturating_sub(1)), - )? - } else { + run_stacks( + &global_traversal.scan, + global_traversal_options_used, + options::StackArgs { + traversal: subcommand_traversal.scan, + import, + depth, + }, + stdout.lock(), + )? + } else { + if import.is_some() && global_traversal_options_used { + bail!("--import cannot be used with traversal options or input paths"); + } + let traversal = merge_traversal_args(&global_traversal, &subcommand_traversal); + if let Some(path) = import { + let mut replay = replay_snapshot_file(&path)?; + writeln!( + io::stderr(), + "Results are from a traversal snapshot; no filesystem traversal was performed" + ) + .ok(); + let stdout = io::stdout(); let config = dua::Config::load()?; let byte_format = traversal.byte_format(&config); let out_supports_colors = stdout.is_terminal(); @@ -360,48 +401,41 @@ fn main() -> Result<()> { byte_format, )? } - } - } else { - let walk_options = walk_options_from(&traversal)?; - if stack { - let inputs = extract_paths_maybe_set_cwd(traversal.input, &walk_options)?; - let stdout = io::stdout(); - dua::stacks( - stdout.lock(), - stderr_if_tty(), - walk_options, - inputs, - depth.map(|depth| depth.saturating_sub(1)), - )? - } else if let Some(depth) = depth { - let config = dua::Config::load()?; - let byte_format = traversal.byte_format(&config); - let paths = extract_paths_maybe_set_cwd(traversal.input, &walk_options)?; - let stdout = io::stdout(); - let out_supports_colors = stdout.is_terminal(); - dua::aggregate_tree( - (stdout.lock(), out_supports_colors), - stderr_if_tty(), - walk_options, - byte_format, - paths, - depth.saturating_sub(1), - !no_total, - !no_sort, - )? } else { - let config = dua::Config::load()?; - let byte_format = traversal.byte_format(&config); - let inputs = - extract_aggregate_inputs_maybe_set_cwd(traversal.input, &walk_options)?; - run_aggregation( - inputs, - walk_options, - !no_total, - !no_sort, - byte_format, - statistics, - )? + let walk_options = walk_options_from(&traversal.scan)?; + if let Some(depth) = depth { + let config = dua::Config::load()?; + let byte_format = traversal.byte_format(&config); + let paths = + extract_paths_maybe_set_cwd(traversal.scan.input, &walk_options)?; + let stdout = io::stdout(); + let out_supports_colors = stdout.is_terminal(); + dua::aggregate_tree( + (stdout.lock(), out_supports_colors), + stderr_if_tty(), + walk_options, + byte_format, + paths, + depth.saturating_sub(1), + !no_total, + !no_sort, + )? + } else { + let config = dua::Config::load()?; + let byte_format = traversal.byte_format(&config); + let inputs = extract_aggregate_inputs_maybe_set_cwd( + traversal.scan.input, + &walk_options, + )?; + run_aggregation( + inputs, + walk_options, + !no_total, + !no_sort, + byte_format, + statistics, + )? + } } } } @@ -424,9 +458,9 @@ fn main() -> Result<()> { None => { let config = dua::Config::load()?; let byte_format = global_traversal.byte_format(&config); - let walk_options = walk_options_from(&global_traversal)?; + let walk_options = walk_options_from(&global_traversal.scan)?; let inputs = - extract_aggregate_inputs_maybe_set_cwd(global_traversal.input, &walk_options)?; + extract_aggregate_inputs_maybe_set_cwd(global_traversal.scan.input, &walk_options)?; run_aggregation(inputs, walk_options, true, true, byte_format, false)? } }; @@ -434,6 +468,62 @@ fn main() -> Result<()> { process::exit(res.to_exit_code()); } +fn run_stacks( + global: &options::ScanArgs, + global_options_used: bool, + args: options::StackArgs, + out: impl io::Write, +) -> Result { + let options::StackArgs { + traversal, + import, + depth, + } = args; + let max_depth = depth.map(|depth| depth.saturating_sub(1)); + if let Some(path) = import { + if global_options_used { + bail!("--import cannot be used with traversal options or input paths"); + } + let mut replay = replay_snapshot_file(&path)?; + writeln!( + io::stderr(), + "Results are from a traversal snapshot; no filesystem traversal was performed" + ) + .ok(); + dua::stacks_from_replay(out, &mut replay, max_depth) + } else { + let traversal = merge_scan_args(global, &traversal); + let walk_options = walk_options_from(&traversal)?; + let inputs = extract_paths_maybe_set_cwd(traversal.input, &walk_options)?; + dua::stacks(out, stderr_if_tty(), walk_options, inputs, max_depth) + } +} + +fn write_flamegraph(stacks: Vec, output: Option) -> Result { + let stacks = String::from_utf8(stacks).expect("folded stacks are valid UTF-8"); + let mut options = inferno::flamegraph::Options::default(); + if let Some(path) = output { + let file = fs::File::create(&path) + .with_context(|| format!("Could not create flame graph {}", path.display()))?; + inferno::flamegraph::from_lines(&mut options, stacks.lines(), file) + .with_context(|| format!("Could not write flame graph {}", path.display()))?; + Ok(path) + } else { + let mut file = tempfile::Builder::new() + .prefix("dua-flamegraph-") + .suffix(".svg") + .tempfile() + .context("Could not create temporary flame graph")?; + inferno::flamegraph::from_lines(&mut options, stacks.lines(), &mut file) + .with_context(|| format!("Could not write flame graph {}", file.path().display()))?; + let (file, path) = file + .keep() + .context("Could not retain temporary flame graph")?; + drop(file); + Ok(path) + } +} + enum AggregateInputs { Paths(Vec), #[cfg(any(windows, target_os = "macos"))] @@ -525,12 +615,21 @@ fn merge_traversal_args( subcommand: &options::TraversalArgs, ) -> options::TraversalArgs { options::TraversalArgs { + scan: merge_scan_args(&global.scan, &subcommand.scan), + format: global.format.or(subcommand.format), + } +} + +fn merge_scan_args( + global: &options::ScanArgs, + subcommand: &options::ScanArgs, +) -> options::ScanArgs { + options::ScanArgs { threads: if global.threads == options::DEFAULT_THREADS { subcommand.threads } else { global.threads }, - format: global.format.or(subcommand.format), apparent_size: global.apparent_size || subcommand.apparent_size, count_hard_links: global.count_hard_links || subcommand.count_hard_links, #[cfg(target_os = "macos")] @@ -552,7 +651,7 @@ fn merge_traversal_args( } } -fn walk_options_from(traversal: &options::TraversalArgs) -> Result { +fn walk_options_from(traversal: &options::ScanArgs) -> Result { let mut walk_options = dua::WalkOptions { threads: traversal.threads, apparent_size: traversal.apparent_size, @@ -798,11 +897,34 @@ fn write_default_config_file(path: &Path) -> Result<()> { #[cfg(test)] mod tests { use super::traversal_options_on_command_line; - use super::{marked_path_for_output, merge_traversal_args, write_default_config_file}; + use super::{ + marked_path_for_output, merge_traversal_args, write_default_config_file, write_flamegraph, + }; use clap::CommandFactory as _; use std::fs; use std::path::PathBuf; + fn scan_args() -> super::options::ScanArgs { + super::options::ScanArgs { + threads: super::options::DEFAULT_THREADS, + apparent_size: false, + count_hard_links: false, + #[cfg(target_os = "macos")] + deduplicate_apfs_clones: false, + stay_on_filesystem: false, + ignore_dirs: vec![], + ignore_from: vec![], + input: vec![], + } + } + + fn traversal_args() -> super::options::TraversalArgs { + super::options::TraversalArgs { + scan: scan_args(), + format: None, + } + } + #[test] fn marked_paths_are_only_sanitized_for_terminals() { let path = std::path::Path::new("marked\t\x1b[31m"); @@ -883,6 +1005,22 @@ mod tests { ); } + #[test] + fn writes_explicit_and_temporary_flamegraphs() { + let dir = tempfile::tempdir().expect("temporary directory"); + let explicit = dir.path().join("usage.svg"); + let path = write_flamegraph(b"root;child 4\n".to_vec(), Some(explicit.clone())) + .expect("explicit flame graph"); + assert_eq!(path, explicit); + assert!(fs::read_to_string(&path).unwrap().contains("child")); + + let path = + write_flamegraph(b"root;child 4\n".to_vec(), None).expect("temporary flame graph"); + assert_eq!(path.extension().and_then(|ext| ext.to_str()), Some("svg")); + assert!(fs::read_to_string(&path).unwrap().contains(", +} + #[derive(Debug, Clone, clap::Args)] #[cfg_attr( target_os = "macos", @@ -96,7 +113,7 @@ impl TraversalArgs { reason = "independent command-line switches map directly to booleans" ) )] -pub struct TraversalArgs { +pub struct ScanArgs { /// The amount of threads to use. Defaults to 0, indicating the amount of logical processors. /// Set to 1 to use only a single thread. #[clap( @@ -108,17 +125,6 @@ pub struct TraversalArgs { )] pub threads: usize, - /// The format with which to print byte counts. - #[clap( - short = 'f', - long, - value_enum, - ignore_case = true, - env = "DUA_FORMAT", - help_heading = "Traversal Options" - )] - pub format: Option, - /// Display apparent size instead of disk usage. #[clap( short = 'A', @@ -186,6 +192,33 @@ pub struct TraversalArgs { pub input: Vec, } +#[derive(Debug, clap::Args)] +pub struct StackArgs { + #[clap(flatten)] + pub traversal: ScanArgs, + + /// Load a traversal snapshot instead of scanning the filesystem. + #[clap( + long, + value_name = "FILE", + conflicts_with_all = [ + "input", + "threads", + "apparent_size", + "count_hard_links", + "stay_on_filesystem", + "ignore_dirs", + "ignore_from" + ] + )] + #[cfg_attr(target_os = "macos", clap(conflicts_with = "deduplicate_apfs_clones"))] + pub import: Option, + + /// Limit folded output to this many levels. The inputs form the first level. + #[clap(short = 'd', long, value_name = "DEPTH", value_parser = clap::builder::RangedU64ValueParser::::new().range(1..))] + pub depth: Option, +} + #[derive(Debug, clap::Subcommand)] pub enum Command { /// Launch the terminal user interface @@ -256,6 +289,19 @@ pub enum Command { #[clap(long, value_name = "COUNT", default_value_t = DEFAULT_DIFF_SUMMARY_LIMIT)] summary_limit: usize, }, + /// Print folded stacks for flame-graph tools + Stacks { + #[clap(flatten)] + args: StackArgs, + }, + /// Render disk usage as an SVG flame graph + Flamegraph { + #[clap(flatten)] + args: StackArgs, + /// Write the SVG to this file instead of opening a temporary file. + #[clap(short = 'o', long, value_name = "FILE")] + output: Option, + }, /// Aggregate the consumed space of one or more directories or files #[clap(name = "aggregate", visible_alias = "a")] Aggregate { @@ -289,11 +335,14 @@ pub enum Command { #[clap(long)] no_total: bool, /// Print folded stacks for flame-graph tools instead of a table or tree. - #[clap(long, conflicts_with_all = ["statistics", "no_sort", "no_total"])] + #[clap( + long, + hide = true, + conflicts_with_all = ["statistics", "no_sort", "no_total"] + )] stack: bool, /// Print an indented tree that descends this many levels into each input, instead of the - /// flat listing. With `--stack`, limit the folded output to the same depth. The inputs form - /// the first level, so a depth of 1 lists just them. + /// flat listing. The inputs form the first level, so a depth of 1 lists just them. #[clap(short = 'd', long, conflicts_with = "statistics", value_name = "DEPTH", value_parser = clap::builder::RangedU64ValueParser::::new().range(1..))] depth: Option, }, @@ -351,6 +400,40 @@ mod tests { .expect("aggregate accepts traversal options"); } + #[test] + fn stack_commands_accept_only_stack_options() { + let args = Args::try_parse_from(["dua", "stacks", "--import", "scan.dua", "--depth", "2"]) + .expect("stacks accepts snapshot and depth options"); + assert!(matches!( + args.command, + Some(super::Command::Stacks { + args: super::StackArgs { + import: Some(path), + depth: Some(2), + .. + } + }) if path == std::path::Path::new("scan.dua") + )); + + let args = + Args::try_parse_from(["dua", "flamegraph", "--output", "usage.svg", "somewhere"]) + .expect("flamegraph accepts an output and traversal input"); + assert!(matches!( + args.command, + Some(super::Command::Flamegraph { + args: super::StackArgs { traversal, .. }, + output: Some(path), + }) if path == std::path::Path::new("usage.svg") + && traversal.input == [PathBuf::from("somewhere")] + )); + + for flag in ["--format", "--stats", "--no-sort", "--no-total", "--stack"] { + let err = Args::try_parse_from(["dua", "stacks", flag]) + .expect_err("display option should not be available to stacks"); + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); + } + } + #[test] fn traversal_options_before_aggregate_still_parse_as_subcommand() { let args = Args::try_parse_from(["dua", "--format", "metric", "aggregate", "--stats", "."]) @@ -365,7 +448,7 @@ mod tests { panic!("expected aggregate subcommand"); }; assert!(statistics); - assert_eq!(traversal.input, [std::path::PathBuf::from(".")]); + assert_eq!(traversal.scan.input, [std::path::PathBuf::from(".")]); } #[test] @@ -382,12 +465,12 @@ mod tests { ]) .expect("ignore-from parses at both levels"); - assert_eq!(args.traversal.ignore_from, [PathBuf::from("global")]); + assert_eq!(args.traversal.scan.ignore_from, [PathBuf::from("global")]); let Some(super::Command::Aggregate { traversal, .. }) = args.command else { panic!("expected aggregate subcommand"); }; assert_eq!( - traversal.ignore_from, + traversal.scan.ignore_from, [PathBuf::from("sub-one"), PathBuf::from("sub-two")] ); } @@ -513,6 +596,21 @@ mod tests { .to_string(); assert!(aggregate_help.contains("Traversal Options")); assert!(aggregate_help.contains("--format")); + assert!(!aggregate_help.contains("--stack")); + + for name in ["stacks", "flamegraph"] { + let help = cmd + .find_subcommand_mut(name) + .expect("stack command") + .render_long_help() + .to_string(); + assert!(help.contains("Traversal Options")); + assert!(help.contains("--import")); + assert!(help.contains("--depth")); + for irrelevant in ["--format", "--stats", "--no-sort", "--no-total"] { + assert!(!help.contains(irrelevant), "{name} exposes {irrelevant}"); + } + } } #[test] @@ -646,7 +744,7 @@ mod tests { assert_eq!(import, None); assert_eq!(export, Some(PathBuf::from("scan.dua"))); assert_eq!(compression, 2); - assert_eq!(traversal.input, [PathBuf::from("somewhere")]); + assert_eq!(traversal.scan.input, [PathBuf::from("somewhere")]); assert!(matches!( Args::try_parse_from([ diff --git a/tests/stack_commands.rs b/tests/stack_commands.rs new file mode 100644 index 00000000..de5f3a00 --- /dev/null +++ b/tests/stack_commands.rs @@ -0,0 +1,32 @@ +use std::{fs, process::Command}; + +#[test] +fn stacks_and_flamegraph_commands_use_the_folded_output() { + let fixture = tempfile::tempdir().unwrap(); + let input = fixture.path().join("input"); + fs::create_dir(&input).unwrap(); + fs::write(input.join("payload"), b"data").unwrap(); + + let stacks = Command::new(env!("CARGO_BIN_EXE_dua")) + .args(["stacks", input.to_str().unwrap()]) + .output() + .unwrap(); + let legacy = Command::new(env!("CARGO_BIN_EXE_dua")) + .args(["aggregate", "--stack", input.to_str().unwrap()]) + .output() + .unwrap(); + assert!(stacks.status.success()); + assert!(legacy.status.success()); + assert_eq!(stacks.stdout, legacy.stdout); + + let flamegraph = Command::new(env!("CARGO_BIN_EXE_dua")) + .current_dir(fixture.path()) + .args(["flamegraph", "--output", "usage.svg", "input"]) + .output() + .unwrap(); + assert!(flamegraph.status.success()); + assert!(flamegraph.stdout.is_empty()); + let svg = fs::read_to_string(fixture.path().join("usage.svg")).unwrap(); + assert!(svg.contains(" Date: Mon, 31 Aug 2026 16:05:37 +0200 Subject: [PATCH 2/2] feat: configure flamegraph SVG rendering - Add palette, width, minimum-frame-width, title, and inverted-layout flags. - Label generated graphs in disk-usage terms with byte counts and paths. - Validate rendering arguments and exercise them in parser and end-to-end tests. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- src/main.rs | 49 +++++++++++++++---- src/options.rs | 106 +++++++++++++++++++++++++++++++++++----- tests/stack_commands.rs | 20 +++++++- 3 files changed, 151 insertions(+), 24 deletions(-) diff --git a/src/main.rs b/src/main.rs index fef8c971..a4b51bc4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -326,7 +326,15 @@ fn main() -> Result<()> { stdout.lock(), )? } - Some(Flamegraph { args, output }) => { + Some(Flamegraph { + args, + output, + palette, + width, + min_width, + title, + inverted, + }) => { if global_format_used { bail!("flamegraph cannot be used with --format"); } @@ -339,7 +347,19 @@ fn main() -> Result<()> { args, &mut stacks, )?; - let path = write_flamegraph(stacks, output)?; + let mut options = inferno::flamegraph::Options::default(); + options.colors = palette; + options.image_width = width; + options.min_width = min_width; + options.title = title; + options.direction = if inverted { + inferno::flamegraph::Direction::Inverted + } else { + inferno::flamegraph::Direction::Straight + }; + "bytes".clone_into(&mut options.count_name); + "Path:".clone_into(&mut options.name_type); + let path = write_flamegraph(stacks, output, &mut options)?; if open { open::that(&path) .with_context(|| format!("Could not open flame graph {}", path.display()))?; @@ -499,13 +519,16 @@ fn run_stacks( } } -fn write_flamegraph(stacks: Vec, output: Option) -> Result { +fn write_flamegraph( + stacks: Vec, + output: Option, + options: &mut inferno::flamegraph::Options<'_>, +) -> Result { let stacks = String::from_utf8(stacks).expect("folded stacks are valid UTF-8"); - let mut options = inferno::flamegraph::Options::default(); if let Some(path) = output { let file = fs::File::create(&path) .with_context(|| format!("Could not create flame graph {}", path.display()))?; - inferno::flamegraph::from_lines(&mut options, stacks.lines(), file) + inferno::flamegraph::from_lines(options, stacks.lines(), file) .with_context(|| format!("Could not write flame graph {}", path.display()))?; Ok(path) } else { @@ -514,7 +537,7 @@ fn write_flamegraph(stacks: Vec, output: Option) -> Result .suffix(".svg") .tempfile() .context("Could not create temporary flame graph")?; - inferno::flamegraph::from_lines(&mut options, stacks.lines(), &mut file) + inferno::flamegraph::from_lines(options, stacks.lines(), &mut file) .with_context(|| format!("Could not write flame graph {}", file.path().display()))?; let (file, path) = file .keep() @@ -1009,13 +1032,19 @@ mod tests { fn writes_explicit_and_temporary_flamegraphs() { let dir = tempfile::tempdir().expect("temporary directory"); let explicit = dir.path().join("usage.svg"); - let path = write_flamegraph(b"root;child 4\n".to_vec(), Some(explicit.clone())) - .expect("explicit flame graph"); + let mut options = inferno::flamegraph::Options::default(); + let path = write_flamegraph( + b"root;child 4\n".to_vec(), + Some(explicit.clone()), + &mut options, + ) + .expect("explicit flame graph"); assert_eq!(path, explicit); assert!(fs::read_to_string(&path).unwrap().contains("child")); - let path = - write_flamegraph(b"root;child 4\n".to_vec(), None).expect("temporary flame graph"); + let mut options = inferno::flamegraph::Options::default(); + let path = write_flamegraph(b"root;child 4\n".to_vec(), None, &mut options) + .expect("temporary flame graph"); assert_eq!(path.extension().and_then(|ext| ext.to_str()), Some("svg")); assert!(fs::read_to_string(&path).unwrap().contains(" ByteFormat { const DEFAULT_DIFF_SUMMARY_LIMIT: usize = 5; +fn parse_percentage(value: &str) -> Result { + let percentage = value + .parse::() + .map_err(|_| format!("invalid percentage: {value}"))?; + (0.0..=100.0) + .contains(&percentage) + .then_some(percentage) + .ok_or_else(|| "percentage must be between 0 and 100".to_owned()) +} + #[cfg(feature = "tui-crossplatform")] fn parse_snapshot_compression_level(value: &str) -> Result { let level = value @@ -299,8 +310,34 @@ pub enum Command { #[clap(flatten)] args: StackArgs, /// Write the SVG to this file instead of opening a temporary file. - #[clap(short = 'o', long, value_name = "FILE")] + #[clap(short = 'o', long, value_name = "FILE", help_heading = "SVG Options")] output: Option, + /// Set the frame color palette. + #[clap( + long, + default_value = inferno::flamegraph::defaults::COLORS, + value_parser = clap::builder::PossibleValuesParser::new(inferno::flamegraph::Palette::VARIANTS).map(|name| name.parse::().expect("known palette")), + value_name = "PALETTE", + help_heading = "SVG Options" + )] + palette: inferno::flamegraph::Palette, + /// Set the image width in pixels. By default, the SVG uses the available width. + #[clap(long, value_name = "PIXELS", value_parser = clap::builder::RangedU64ValueParser::::new().range(1..), help_heading = "SVG Options")] + width: Option, + /// Omit frames narrower than this percentage. + #[clap(long, default_value_t = inferno::flamegraph::defaults::MIN_WIDTH, value_name = "PERCENT", value_parser = parse_percentage, help_heading = "SVG Options")] + min_width: f64, + /// Set the graph title. + #[clap( + long, + default_value = "Disk Usage Flame Graph", + value_name = "TEXT", + help_heading = "SVG Options" + )] + title: String, + /// Grow stacks from top to bottom. + #[clap(long, help_heading = "SVG Options")] + inverted: bool, }, /// Aggregate the consumed space of one or more directories or files #[clap(name = "aggregate", visible_alias = "a")] @@ -401,7 +438,7 @@ mod tests { } #[test] - fn stack_commands_accept_only_stack_options() { + fn stack_commands_accept_only_relevant_options() { let args = Args::try_parse_from(["dua", "stacks", "--import", "scan.dua", "--depth", "2"]) .expect("stacks accepts snapshot and depth options"); assert!(matches!( @@ -415,17 +452,47 @@ mod tests { }) if path == std::path::Path::new("scan.dua") )); - let args = - Args::try_parse_from(["dua", "flamegraph", "--output", "usage.svg", "somewhere"]) - .expect("flamegraph accepts an output and traversal input"); - assert!(matches!( - args.command, - Some(super::Command::Flamegraph { - args: super::StackArgs { traversal, .. }, - output: Some(path), - }) if path == std::path::Path::new("usage.svg") - && traversal.input == [PathBuf::from("somewhere")] - )); + let args = Args::try_parse_from([ + "dua", + "flamegraph", + "--output", + "usage.svg", + "--palette", + "blue", + "--width", + "640", + "--min-width", + "0.5", + "--title", + "Disk map", + "--inverted", + "somewhere", + ]) + .expect("flamegraph accepts SVG and traversal options"); + let Some(super::Command::Flamegraph { + args: super::StackArgs { traversal, .. }, + output: Some(path), + palette, + width, + min_width, + title, + inverted, + }) = args.command + else { + panic!("expected flamegraph subcommand"); + }; + assert_eq!(path, std::path::Path::new("usage.svg")); + assert_eq!(traversal.input, [PathBuf::from("somewhere")]); + assert_eq!(palette, "blue".parse().unwrap()); + assert_eq!(width, Some(640)); + assert!((min_width - 0.5).abs() < f64::EPSILON); + assert_eq!(title, "Disk map"); + assert!(inverted); + + for value in ["-1", "101", "NaN"] { + Args::try_parse_from(["dua", "flamegraph", "--min-width", value]) + .expect_err("minimum width must be a finite percentage"); + } for flag in ["--format", "--stats", "--no-sort", "--no-total", "--stack"] { let err = Args::try_parse_from(["dua", "stacks", flag]) @@ -610,6 +677,19 @@ mod tests { for irrelevant in ["--format", "--stats", "--no-sort", "--no-total"] { assert!(!help.contains(irrelevant), "{name} exposes {irrelevant}"); } + for svg_option in [ + "--palette", + "--width", + "--min-width", + "--title", + "--inverted", + ] { + assert_eq!( + help.contains(svg_option), + name == "flamegraph", + "{name} has the wrong visibility for {svg_option}" + ); + } } } diff --git a/tests/stack_commands.rs b/tests/stack_commands.rs index de5f3a00..5d11d018 100644 --- a/tests/stack_commands.rs +++ b/tests/stack_commands.rs @@ -21,12 +21,30 @@ fn stacks_and_flamegraph_commands_use_the_folded_output() { let flamegraph = Command::new(env!("CARGO_BIN_EXE_dua")) .current_dir(fixture.path()) - .args(["flamegraph", "--output", "usage.svg", "input"]) + .args([ + "flamegraph", + "--output", + "usage.svg", + "--palette", + "blue", + "--width", + "640", + "--min-width", + "0", + "--title", + "Fixture Disk Usage", + "--inverted", + "input", + ]) .output() .unwrap(); assert!(flamegraph.status.success()); assert!(flamegraph.stdout.is_empty()); let svg = fs::read_to_string(fixture.path().join("usage.svg")).unwrap(); assert!(svg.contains("Fixture Disk Usage")); + assert!(svg.contains("bytes")); + assert!(svg.contains("Path:")); assert!(svg.contains("payload")); }