diff --git a/dylint.toml b/dylint.toml index 7aae5151..0bcf008e 100644 --- a/dylint.toml +++ b/dylint.toml @@ -26,6 +26,7 @@ prefix = [ ["perfectionist::macro_argument_binding"] deny_extra = ["debug_assert_op", "debug_assert_op_expr"] +allow_extra = ["assert_op_expr"] ["perfectionist::single_letter_closure_param"] extra_trivial_callback_methods = ["sort_reflection_by"] diff --git a/src/app.rs b/src/app.rs index 8c48afd0..f48e9870 100644 --- a/src/app.rs +++ b/src/app.rs @@ -30,6 +30,17 @@ pub struct App { args: Args, } +/// Tree-shaping options applied to a deserialized `--json-input` tree before visualization. +#[derive(Clone, Copy)] +struct JsonInputShaping { + /// Maximum number of levels to display. + max_depth: u64, + /// Minimal size proportion required to appear. + min_ratio: f32, + /// Whether to preserve the input order of the entries. + no_sort: bool, +} + impl App { /// Initialize the application from the environment. pub fn from_env() -> Self { @@ -58,10 +69,18 @@ impl App { bytes_format, top_down, align_right, + max_depth, + min_ratio, + no_sort, .. } = self.args; let direction = Direction::from_top_down(top_down); let bar_alignment = BarAlignment::from_align_right(align_right); + let shaping = JsonInputShaping { + max_depth: max_depth.get(), + min_ratio: min_ratio.into(), + no_sort, + }; let body = stdin() .pipe(serde_json::from_reader::<_, JsonData>) @@ -75,12 +94,25 @@ impl App { column_width_distribution: ColumnWidthDistribution, direction: Direction, bar_alignment: BarAlignment, + shaping: JsonInputShaping, ) -> Result { let JsonTree { tree, shared } = tree; + let JsonInputShaping { + max_depth, + min_ratio, + no_sort, + } = shaping; - let data_tree = tree + let mut data_tree = tree .par_try_into_tree() - .map_err(|error| RuntimeError::InvalidInputReflection(error.to_string()))?; + .map_err(|error| RuntimeError::InvalidInputReflection(error.to_string()))? + .into_par_retained(|_, depth| depth + 1 < max_depth); + data_tree.par_cull_insignificant_data(min_ratio); + if !no_sort { + data_tree + .par_sort_by(|left, right| left.size().cmp(&right.size()).reverse()); + } + let visualizer = Visualizer { data_tree: &data_tree, bytes_format, @@ -114,6 +146,7 @@ impl App { column_width_distribution, direction, bar_alignment, + shaping, ) }; } diff --git a/tests/json.rs b/tests/json.rs index 74689526..7bc40622 100644 --- a/tests/json.rs +++ b/tests/json.rs @@ -4,6 +4,7 @@ pub mod _utils; pub use _utils::*; +use assert_cmp::assert_op_expr; use command_extra::CommandExtra; use parallel_disk_usage::{ bytes_format::BytesFormat, @@ -58,6 +59,56 @@ fn sample_tree() -> SampleTree { .into_par_sorted(|left, right| left.size().cmp(&right.size()).reverse()) } +/// Sample tree whose entries are deliberately stored in ascending order of size, +/// which is the opposite of the descending order produced by the default sorting. +fn ascending_sample_tree() -> SampleTree { + let file = + |name: &'static str, size: u64| SampleTree::file(name.to_string(), Bytes::from(size)); + SampleTree::dir( + "root".to_string(), + 1024.into(), + vec![file("a", 50), file("b", 500), file("c", 5000)], + ) +} + +/// Feed a tree to `pdu --json-input` and return its trimmed stdout. +fn run_json_input(tree: SampleTree, extra_args: &[&str]) -> String { + let json_tree = JsonTree { + tree: tree.into_reflection(), + shared: Default::default(), + }; + let json_data = JsonData { + schema_version: SchemaVersion, + binary_version: None, + body: json_tree.into(), + }; + let json = serde_json::to_string_pretty(&json_data).expect("convert sample tree to JSON"); + let workspace = Temp::new_dir().expect("create temporary directory"); + let mut child = Command::new(PDU) + .with_current_dir(&workspace) + .with_arg("--json-input") + .with_arg("--bytes-format=metric") + .with_arg("--total-width=100") + .with_args(extra_args) + .with_stdin(Stdio::piped()) + .with_stdout(Stdio::piped()) + .with_stderr(Stdio::piped()) + .spawn() + .expect("spawn command"); + child + .stdin + .as_mut() + .expect("get stdin of child process") + .write_all(json.as_bytes()) + .expect("write JSON string to child process's stdin"); + child + .wait_with_output() + .expect("wait for output of child process") + .pipe(stdout_text) + .trim_end() + .to_string() +} + #[test] fn json_output() { let workspace = SampleWorkspace::default(); @@ -119,6 +170,7 @@ fn json_input() { .with_arg("--bytes-format=metric") .with_arg("--total-width=100") .with_arg("--max-depth=10") + .with_arg("--min-ratio=0") .with_stdin(Stdio::piped()) .with_stdout(Stdio::piped()) .with_stderr(Stdio::piped()) @@ -151,6 +203,59 @@ fn json_input() { assert_eq!(actual, expected); } +#[test] +fn json_input_max_depth() { + let actual = run_json_input(sample_tree(), &["--max-depth=2", "--min-ratio=0"]); + eprintln!("ACTUAL:\n{actual}\n"); + let unlimited = run_json_input(sample_tree(), &["--max-depth=10", "--min-ratio=0"]); + eprintln!("UNLIMITED:\n{unlimited}\n"); + + // Limiting the depth must change the output. + assert_ne!(actual, unlimited); + + // With two levels, the root's direct children appear while their deeper + // descendants do not. `subdirectory with a really long name` lives at depth 2 + // and renders in the unlimited run, so its absence here is caused by the limit. + assert!(actual.contains("foo")); + assert!(!actual.contains("subdirectory with a really long name")); + assert!(unlimited.contains("subdirectory with a really long name")); +} + +#[test] +fn json_input_min_ratio() { + let actual = run_json_input(sample_tree(), &["--max-depth=10", "--min-ratio=0.1"]); + eprintln!("ACTUAL:\n{actual}\n"); + let unculled = run_json_input(sample_tree(), &["--max-depth=10", "--min-ratio=0"]); + eprintln!("UNCULLED:\n{unculled}\n"); + + // Culling must change the output. + assert_ne!(actual, unculled); + + // `foo` is far above the 10% threshold and survives, while `bar` is far below + // it and is culled. `bar` renders in the unculled run, so its absence here is + // caused by the culling. + assert!(actual.contains("foo")); + assert!(!actual.contains("bar")); + assert!(unculled.contains("bar")); +} + +#[test] +fn json_input_no_sort() { + let actual = run_json_input(ascending_sample_tree(), &["--no-sort", "--min-ratio=0"]); + eprintln!("ACTUAL:\n{actual}\n"); + let sorted = run_json_input(ascending_sample_tree(), &["--min-ratio=0"]); + eprintln!("SORTED:\n{sorted}\n"); + + // Sorting must change the output. + assert_ne!(actual, sorted); + + // `--no-sort` preserves the ascending input order `a, b, c`, whereas the + // default sorts by descending size, so their relative positions flip. + let position = |text: &str, name: &str| text.find(name).expect("entry must be present"); + assert_op_expr!(position(&actual, "a"), >, position(&actual, "c")); + assert_op_expr!(position(&sorted, "a"), <, position(&sorted, "c")); +} + #[test] fn json_output_json_input() { let workspace = SampleWorkspace::default();