diff --git a/Cargo.lock b/Cargo.lock index 33c9c1e8..cc877bbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.21" @@ -569,6 +578,16 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lscolors" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d60e266dfb1426eb2d24792602e041131fdc0236bb7007abc0e589acafd60929" +dependencies = [ + "aho-corasick", + "nu-ansi-term", +] + [[package]] name = "maplit" version = "1.0.2" @@ -596,6 +615,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.1" @@ -643,6 +671,7 @@ dependencies = [ "fmt-iter", "into-sorted", "itertools 0.14.0", + "lscolors", "maplit", "normalize-path", "pipe-trait", @@ -653,6 +682,7 @@ dependencies = [ "serde", "serde_json", "smart-default", + "strip-ansi-escapes", "sysinfo", "terminal_size", "text-block-macros", @@ -887,6 +917,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.10.0" @@ -987,6 +1026,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" diff --git a/Cargo.toml b/Cargo.toml index 92b0ed2b..723b4bf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ required-features = ["cli"] [features] default = ["cli"] json = ["serde/derive", "serde_json"] -cli = ["clap/derive", "clap_complete", "clap-utilities", "json"] +cli = ["clap/derive", "clap_complete", "clap-utilities", "json", "lscolors"] cli-completions = ["cli"] [dependencies] @@ -65,6 +65,7 @@ derive_setters = "0.1.9" fmt-iter = "0.2.1" into-sorted = "0.0.3" itertools = "0.14.0" +lscolors = { version = "0.21.0", optional = true } pipe-trait = "0.4.0" rayon = "1.10.0" rounded-div = "0.1.4" @@ -83,3 +84,4 @@ maplit = "1.0.2" normalize-path = "0.2.1" pretty_assertions = "1.4.1" rand = "0.10.0" +strip-ansi-escapes = "0.2.1" diff --git a/USAGE.md b/USAGE.md index 98d1f36c..3fa1f330 100644 --- a/USAGE.md +++ b/USAGE.md @@ -125,6 +125,17 @@ Do not output `.shared.details` in the JSON output. Do not output `.shared.summary` in the JSON output. + +### `--color` + +* _Default:_ `auto`. +* _Choices:_ + - `auto`: Colorize output only when stdout is a terminal + - `always`: Always colorize the output + - `never`: Never colorize the output + +When to colorize the output. + ### `--help` diff --git a/exports/completion.bash b/exports/completion.bash index 8b06a03b..33c4c947 100644 --- a/exports/completion.bash +++ b/exports/completion.bash @@ -23,7 +23,7 @@ _pdu() { case "${cmd}" in pdu) - opts="-b -H -q -d -w -m -s -p -h -V --json-input --json-output --bytes-format --detect-links --dedupe-links --deduplicate-hardlinks --top-down --align-right --quantity --depth --max-depth --width --total-width --column-width --min-ratio --no-sort --no-errors --silent-errors --progress --threads --omit-json-shared-details --omit-json-shared-summary --help --version [FILES]..." + opts="-b -H -q -d -w -m -s -p -h -V --json-input --json-output --bytes-format --detect-links --dedupe-links --deduplicate-hardlinks --top-down --align-right --quantity --depth --max-depth --width --total-width --column-width --min-ratio --no-sort --no-errors --silent-errors --progress --threads --omit-json-shared-details --omit-json-shared-summary --color --help --version [FILES]..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -85,6 +85,10 @@ _pdu() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --color) + COMPREPLY=($(compgen -W "auto always never" -- "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; diff --git a/exports/completion.elv b/exports/completion.elv index d3cda52c..e4792dc2 100644 --- a/exports/completion.elv +++ b/exports/completion.elv @@ -32,6 +32,7 @@ set edit:completion:arg-completer[pdu] = {|@words| cand -m 'Minimal size proportion required to appear' cand --min-ratio 'Minimal size proportion required to appear' cand --threads 'Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer' + cand --color 'When to colorize the output' cand --json-input 'Read JSON data from stdin' cand --json-output 'Print JSON data instead of an ASCII chart' cand -H 'Detect and subtract the sizes of hardlinks from their parent directory totals' diff --git a/exports/completion.fish b/exports/completion.fish index 41cc6448..d6c91c21 100644 --- a/exports/completion.fish +++ b/exports/completion.fish @@ -9,6 +9,9 @@ complete -c pdu -s w -l total-width -l width -d 'Width of the visualization' -r complete -c pdu -l column-width -d 'Maximum widths of the tree column and width of the bar column' -r complete -c pdu -s m -l min-ratio -d 'Minimal size proportion required to appear' -r complete -c pdu -l threads -d 'Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer' -r +complete -c pdu -l color -d 'When to colorize the output' -r -f -a "auto\t'Colorize output only when stdout is a terminal' +always\t'Always colorize the output' +never\t'Never colorize the output'" complete -c pdu -l json-input -d 'Read JSON data from stdin' complete -c pdu -l json-output -d 'Print JSON data instead of an ASCII chart' complete -c pdu -s H -l deduplicate-hardlinks -l detect-links -l dedupe-links -d 'Detect and subtract the sizes of hardlinks from their parent directory totals' diff --git a/exports/completion.ps1 b/exports/completion.ps1 index 8814bf76..9c71b159 100644 --- a/exports/completion.ps1 +++ b/exports/completion.ps1 @@ -35,6 +35,7 @@ Register-ArgumentCompleter -Native -CommandName 'pdu' -ScriptBlock { [CompletionResult]::new('-m', '-m', [CompletionResultType]::ParameterName, 'Minimal size proportion required to appear') [CompletionResult]::new('--min-ratio', '--min-ratio', [CompletionResultType]::ParameterName, 'Minimal size proportion required to appear') [CompletionResult]::new('--threads', '--threads', [CompletionResultType]::ParameterName, 'Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer') + [CompletionResult]::new('--color', '--color', [CompletionResultType]::ParameterName, 'When to colorize the output') [CompletionResult]::new('--json-input', '--json-input', [CompletionResultType]::ParameterName, 'Read JSON data from stdin') [CompletionResult]::new('--json-output', '--json-output', [CompletionResultType]::ParameterName, 'Print JSON data instead of an ASCII chart') [CompletionResult]::new('-H', '-H ', [CompletionResultType]::ParameterName, 'Detect and subtract the sizes of hardlinks from their parent directory totals') diff --git a/exports/completion.zsh b/exports/completion.zsh index dec1cef4..343bf7af 100644 --- a/exports/completion.zsh +++ b/exports/completion.zsh @@ -37,6 +37,9 @@ block-count\:"Count numbers of blocks"))' \ '-m+[Minimal size proportion required to appear]:MIN_RATIO:_default' \ '--min-ratio=[Minimal size proportion required to appear]:MIN_RATIO:_default' \ '--threads=[Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer]:THREADS:_default' \ +'--color=[When to colorize the output]:COLOR:((auto\:"Colorize output only when stdout is a terminal" +always\:"Always colorize the output" +never\:"Never colorize the output"))' \ '(-q --quantity -H --deduplicate-hardlinks)--json-input[Read JSON data from stdin]' \ '--json-output[Print JSON data instead of an ASCII chart]' \ '-H[Detect and subtract the sizes of hardlinks from their parent directory totals]' \ diff --git a/exports/long.help b/exports/long.help index efe31299..3b332bd3 100644 --- a/exports/long.help +++ b/exports/long.help @@ -88,6 +88,16 @@ Options: --omit-json-shared-summary Do not output `.shared.summary` in the JSON output + --color + When to colorize the output + + Possible values: + - auto: Colorize output only when stdout is a terminal + - always: Always colorize the output + - never: Never colorize the output + + [default: auto] + -h, --help Print help (see a summary with '-h') diff --git a/exports/short.help b/exports/short.help index 1835edbc..95439c9e 100644 --- a/exports/short.help +++ b/exports/short.help @@ -40,6 +40,8 @@ Options: Do not output `.shared.details` in the JSON output --omit-json-shared-summary Do not output `.shared.summary` in the JSON output + --color + When to colorize the output [default: auto] [possible values: auto, always, never] -h, --help Print help (see more with '--help') -V, --version diff --git a/src/app.rs b/src/app.rs index 4ad14aa5..067d32fb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -86,6 +86,7 @@ impl App { column_width_distribution, direction, bar_alignment, + coloring: None, }; let JsonShared { details, summary } = shared; @@ -291,6 +292,7 @@ impl App { max_depth, min_ratio, no_sort, + color, omit_json_shared_details, omit_json_shared_summary, .. @@ -307,6 +309,7 @@ impl App { max_depth, min_ratio, no_sort, + color, } .run(), )*} }; diff --git a/src/app/sub.rs b/src/app/sub.rs index 3500a5f3..fa55fe91 100644 --- a/src/app/sub.rs +++ b/src/app/sub.rs @@ -1,5 +1,6 @@ use crate::{ - args::{Depth, Fraction}, + args::{ColorOption, Depth, Fraction}, + coloring::Coloring, data_tree::DataTree, fs_tree_builder::FsTreeBuilder, get_size::GetSize, @@ -49,6 +50,8 @@ where pub min_ratio: Fraction, /// Preserve order of entries. pub no_sort: bool, + /// When to colorize the output. + pub color: ColorOption, } impl Sub @@ -74,6 +77,7 @@ where reporter, min_ratio, no_sort, + color, } = self; let max_depth = max_depth.get(); @@ -187,12 +191,27 @@ where .or(deduplication_result); } + // Build color map AFTER pruning to save CPU/IO cycles + let coloring = match color { + ColorOption::Always => Some(Coloring::from_tree(&data_tree)), + ColorOption::Never => None, + ColorOption::Auto => { + use std::io::IsTerminal; + if std::io::stdout().is_terminal() { + Some(Coloring::from_tree(&data_tree)) + } else { + None + } + } + }; + let visualizer = Visualizer { data_tree: &data_tree, bytes_format, direction, bar_alignment, column_width_distribution, + coloring: coloring.as_ref(), }; print!("{visualizer}"); // visualizer already ends with "\n", println! isn't needed here. diff --git a/src/args.rs b/src/args.rs index db6698c8..c7768622 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,8 +1,10 @@ +pub mod color_option; pub mod depth; pub mod fraction; pub mod quantity; pub mod threads; +pub use color_option::ColorOption; pub use depth::Depth; pub use fraction::Fraction; pub use quantity::Quantity; @@ -170,6 +172,10 @@ pub struct Args { /// Do not output `.shared.summary` in the JSON output. #[clap(long, requires = "json_output", requires = "deduplicate_hardlinks")] pub omit_json_shared_summary: bool, + + /// When to colorize the output. + #[clap(long, value_enum, default_value_t = ColorOption::Auto)] + pub color: ColorOption, } impl Args { diff --git a/src/args/color_option.rs b/src/args/color_option.rs new file mode 100644 index 00000000..df5a74d2 --- /dev/null +++ b/src/args/color_option.rs @@ -0,0 +1,15 @@ +#[cfg(feature = "cli")] +use clap::ValueEnum; + +/// When to colorize the output. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "cli", derive(ValueEnum))] +pub enum ColorOption { + /// Colorize output only when stdout is a terminal. + #[default] + Auto, + /// Always colorize the output. + Always, + /// Never colorize the output. + Never, +} diff --git a/src/coloring.rs b/src/coloring.rs new file mode 100644 index 00000000..4cb6fdf2 --- /dev/null +++ b/src/coloring.rs @@ -0,0 +1,172 @@ +use crate::{data_tree::DataTree, os_string_display::OsStringDisplay, size}; +use std::collections::HashMap; + +/// Color information for visualizing a tree. +/// +/// The `file_colors` field maps the `Display` representation of leaf/childless +/// node names (type `Name` in `Visualizer`, i.e. `OsStringDisplay`) to +/// pre-rendered ANSI escape sequence prefixes. +#[derive(Debug)] +pub struct Coloring { + /// Maps leaf/childless node name strings to their ANSI prefix escape sequence. + file_colors: HashMap, + /// ANSI prefix escape sequence for directories (nodes with children). + dir_prefix: String, +} + +/// The ANSI reset sequence. +const ANSI_RESET: &str = "\x1b[0m"; + +impl Coloring { + /// Build a `Coloring` from a pruned `DataTree` by querying `LS_COLORS`. + /// + /// This should be called only after pruning to save CPU/IO cycles. + #[cfg(feature = "lscolors")] + pub fn from_tree(tree: &DataTree) -> Self + where + Size: size::Size, + { + use lscolors::{Indicator, LsColors}; + + let ls_colors = LsColors::from_env().unwrap_or_default(); + let mut file_colors = HashMap::new(); + collect_leaf_colors(tree, &ls_colors, &mut file_colors); + let dir_prefix = ls_colors + .style_for_indicator(Indicator::Directory) + .map(style_to_ansi_prefix) + .unwrap_or_default(); + Coloring { + file_colors, + dir_prefix, + } + } + + /// Get the ANSI prefix and suffix for a given name. + /// + /// If the name is in `file_colors`, it's a file or childless directory. + /// Otherwise it's a directory with children — use `dir_prefix`. + /// + /// Returns `(prefix, suffix)` where suffix is the ANSI reset sequence. + /// Returns `("", "")` if no style applies. + pub fn ansi_for(&self, name: &str) -> (&str, &str) { + let prefix = self + .file_colors + .get(name) + .map(String::as_str) + .unwrap_or(&self.dir_prefix); + if prefix.is_empty() { + ("", "") + } else { + (prefix, ANSI_RESET) + } + } +} + +/// Recursively collect ANSI prefix strings for all childless nodes in the tree. +#[cfg(feature = "lscolors")] +fn collect_leaf_colors( + tree: &DataTree, + ls_colors: &lscolors::LsColors, + map: &mut HashMap, +) where + Size: size::Size, +{ + if tree.children().is_empty() { + // Leaf node: file or childless directory + let prefix = ls_colors + .style_for_path(tree.name().as_os_str()) + .map(style_to_ansi_prefix) + .unwrap_or_default(); + map.insert(tree.name().to_string(), prefix); + } else { + for child in tree.children() { + collect_leaf_colors(child, ls_colors, map); + } + } +} + +/// Convert an `lscolors::style::Style` to an ANSI escape sequence prefix string. +#[cfg(feature = "lscolors")] +fn style_to_ansi_prefix(style: &lscolors::style::Style) -> String { + let mut codes: Vec = Vec::new(); + + let fs = &style.font_style; + if fs.bold { + codes.push("1".into()); + } + if fs.dimmed { + codes.push("2".into()); + } + if fs.italic { + codes.push("3".into()); + } + if fs.underline { + codes.push("4".into()); + } + if fs.slow_blink { + codes.push("5".into()); + } + if fs.rapid_blink { + codes.push("6".into()); + } + if fs.reverse { + codes.push("7".into()); + } + if fs.hidden { + codes.push("8".into()); + } + if fs.strikethrough { + codes.push("9".into()); + } + + if let Some(ref color) = style.foreground { + color_to_ansi_codes(color, 30, &mut codes); + } + + if let Some(ref color) = style.background { + color_to_ansi_codes(color, 40, &mut codes); + } + + if let Some(ref color) = style.underline { + match color { + lscolors::style::Color::Fixed(n) => { + codes.push(format!("58;5;{n}")); + } + lscolors::style::Color::RGB(r, g, b) => { + codes.push(format!("58;2;{r};{g};{b}")); + } + _ => {} + } + } + + if codes.is_empty() { + String::new() + } else { + format!("\x1b[{}m", codes.join(";")) + } +} + +#[cfg(feature = "lscolors")] +fn color_to_ansi_codes(color: &lscolors::style::Color, base: u8, codes: &mut Vec) { + use lscolors::style::Color; + match color { + Color::Black => codes.push(format!("{}", base)), + Color::Red => codes.push(format!("{}", base + 1)), + Color::Green => codes.push(format!("{}", base + 2)), + Color::Yellow => codes.push(format!("{}", base + 3)), + Color::Blue => codes.push(format!("{}", base + 4)), + Color::Magenta => codes.push(format!("{}", base + 5)), + Color::Cyan => codes.push(format!("{}", base + 6)), + Color::White => codes.push(format!("{}", base + 7)), + Color::BrightBlack => codes.push(format!("{}", base + 60)), + Color::BrightRed => codes.push(format!("{}", base + 61)), + Color::BrightGreen => codes.push(format!("{}", base + 62)), + Color::BrightYellow => codes.push(format!("{}", base + 63)), + Color::BrightBlue => codes.push(format!("{}", base + 64)), + Color::BrightMagenta => codes.push(format!("{}", base + 65)), + Color::BrightCyan => codes.push(format!("{}", base + 66)), + Color::BrightWhite => codes.push(format!("{}", base + 67)), + Color::Fixed(n) => codes.push(format!("{};5;{n}", base + 8)), + Color::RGB(r, g, b) => codes.push(format!("{};2;{r};{g};{b}", base + 8)), + } +} diff --git a/src/lib.rs b/src/lib.rs index 23765add..bb99b642 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub use serde_json; pub mod app; #[cfg(feature = "cli")] pub mod args; +pub mod coloring; #[cfg(feature = "cli")] pub mod runtime_error; #[cfg(feature = "cli")] diff --git a/src/os_string_display.rs b/src/os_string_display.rs index e713aff6..cb6a1dd8 100644 --- a/src/os_string_display.rs +++ b/src/os_string_display.rs @@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize}; Default, Clone, Copy, + Hash, PartialEq, Eq, PartialOrd, diff --git a/src/visualizer.rs b/src/visualizer.rs index 71effaaf..391989b5 100644 --- a/src/visualizer.rs +++ b/src/visualizer.rs @@ -14,7 +14,7 @@ pub use parenthood::Parenthood; pub use proportion_bar::{ProportionBar, ProportionBarBlock}; pub use tree::{TreeHorizontalSlice, TreeSkeletalComponent}; -use super::{data_tree::DataTree, size}; +use super::{coloring::Coloring, data_tree::DataTree, size}; use std::fmt::Display; /// Visualize a [`DataTree`]. @@ -38,6 +38,7 @@ use std::fmt::Display; /// direction: Direction::BottomUp, /// bar_alignment: BarAlignment::Right, /// column_width_distribution: ColumnWidthDistribution::total(100), +/// coloring: None, /// }; /// println!("{visualizer}"); /// # } @@ -58,6 +59,9 @@ where pub bar_alignment: BarAlignment, /// Distribution and total number of characters/blocks can be placed in a line. pub column_width_distribution: ColumnWidthDistribution, + /// Optional coloring for file/directory names. + /// `None` means no color; `Some` means color according to the map. + pub coloring: Option<&'a Coloring>, } mod copy; diff --git a/src/visualizer/methods.rs b/src/visualizer/methods.rs index f4e99d62..d831f240 100644 --- a/src/visualizer/methods.rs +++ b/src/visualizer/methods.rs @@ -82,10 +82,17 @@ where bar_table .into_iter() .map(|row| { + let tree = if let Some(coloring) = self.coloring { + let (prefix, suffix) = + coloring.ansi_for(&row.tree_horizontal_slice.name); + row.tree_horizontal_slice + .display_colored(prefix, suffix, tree_width) + } else { + align_left(&row.tree_horizontal_slice, tree_width).to_string() + }; format!( "{size} {tree}│{bar}│{ratio}", size = align_right(&row.size, size_width), - tree = align_left(&row.tree_horizontal_slice, tree_width), bar = row.proportion_bar.display(self.bar_alignment), ratio = align_right(&row.percentage, PERCENTAGE_COLUMN_MAX_WIDTH), ) diff --git a/src/visualizer/tree.rs b/src/visualizer/tree.rs index 133b58c1..3602fe57 100644 --- a/src/visualizer/tree.rs +++ b/src/visualizer/tree.rs @@ -110,6 +110,28 @@ impl Width for TreeHorizontalSlice { } } +impl TreeHorizontalSlice { + /// Format with ANSI color codes wrapping the name portion. + /// + /// `name_prefix` and `name_suffix` are the ANSI escape sequences to wrap the name. + /// `total_width` is the target column width; the output is padded with spaces. + pub fn display_colored( + &self, + name_prefix: &str, + name_suffix: &str, + total_width: usize, + ) -> String { + let padding_len = total_width.saturating_sub(self.width()); + format!( + "{}{}{name_prefix}{}{name_suffix}{:padding_len$}", + self.indent(), + self.skeletal_component, + self.name, + "", + ) + } +} + impl TreeHorizontalSlice { /// Truncate the name to fit specified `max_width`. /// diff --git a/tests/_utils.rs b/tests/_utils.rs index 75b4a326..0b06644b 100644 --- a/tests/_utils.rs +++ b/tests/_utils.rs @@ -101,6 +101,50 @@ impl Default for SampleWorkspace { /// POSIX-exclusive functions #[cfg(unix)] impl SampleWorkspace { + /// Set up a temporary directory for tests. + /// + /// This directory has a diverse mix of file kinds: non-empty directories, empty directories, + /// regular files, and symbolic links — multiple of each kind. + pub fn simple_tree_with_diverse_kinds() -> Self { + use std::os::unix::fs::symlink; + let temp = Temp::new_dir().expect("create working directory for sample workspace"); + + MergeableFileSystemTree::<&str, String>::from(dir! { + "dir-a" => dir! { + "file-a1.txt" => file!("a".repeat(100_000)) + "file-a2.txt" => file!("a".repeat(200_000)) + "subdir-a" => dir! { + "file-a3.txt" => file!("a".repeat(300_000)) + } + } + "dir-b" => dir! { + "file-b1.txt" => file!("a".repeat(150_000)) + } + "empty-dir-1" => dir! {} + "empty-dir-2" => dir! {} + "file-root.txt" => file!("a".repeat(50_000)) + }) + .build(&temp) + .expect("build filesystem tree for diverse-kinds sample workspace"); + + macro_rules! symlink { + ($link_name:literal -> $target:literal) => { + let link_name = $link_name; + let target = $target; + if let Err(error) = symlink(target, temp.join(link_name)) { + panic!( + "Failed to create symbolic link {link_name} pointing to {target}: {error}" + ); + } + }; + } + + symlink!("link-dir" -> "dir-a"); + symlink!("link-file.txt" -> "file-root.txt"); + + SampleWorkspace(temp) + } + /// Set up a temporary directory for tests. /// /// This directory would have a couple of normal files and a couple of hardlinks. diff --git a/tests/cli_errors.rs b/tests/cli_errors.rs index c33f0d9a..35787fa1 100644 --- a/tests/cli_errors.rs +++ b/tests/cli_errors.rs @@ -146,6 +146,7 @@ fn fs_errors() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected_stdout = format!("{visualizer}"); eprintln!("EXPECTED STDOUT:\n{}\n", &expected_stdout); diff --git a/tests/flag_combinations.rs b/tests/flag_combinations.rs index 70c1c6b8..c7ce4a2b 100644 --- a/tests/flag_combinations.rs +++ b/tests/flag_combinations.rs @@ -18,6 +18,7 @@ fn flag_combinations() { let list = CommandList::default() .option_matrix("--quantity", quantity) + .option_matrix("--color", ["auto", "always", "never"]) .flag_matrix("--progress"); for command in list.commands() { diff --git a/tests/json.rs b/tests/json.rs index 95aee0b9..94714c83 100644 --- a/tests/json.rs +++ b/tests/json.rs @@ -141,6 +141,7 @@ fn json_input() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); diff --git a/tests/usual_cli.rs b/tests/usual_cli.rs index cbacb9f2..28618d64 100644 --- a/tests/usual_cli.rs +++ b/tests/usual_cli.rs @@ -57,6 +57,7 @@ fn total_width() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -96,6 +97,7 @@ fn column_width() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::components(10, 90), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -134,6 +136,7 @@ fn min_ratio_0() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -173,6 +176,7 @@ fn min_ratio() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -212,6 +216,7 @@ fn max_depth_2() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -251,6 +256,7 @@ fn max_depth_1() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -289,6 +295,7 @@ fn top_down() { direction: Direction::TopDown, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -327,6 +334,7 @@ fn align_right() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Right, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -365,6 +373,7 @@ fn quantity_apparent_size() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -404,6 +413,7 @@ fn quantity_block_size() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -443,6 +453,7 @@ fn quantity_block_count() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -482,6 +493,7 @@ fn bytes_format_plain() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -521,6 +533,7 @@ fn bytes_format_metric() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -560,6 +573,7 @@ fn bytes_format_binary() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -597,6 +611,7 @@ fn path_to_workspace() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -650,6 +665,7 @@ fn multiple_names() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -715,6 +731,7 @@ fn multiple_names_max_depth_2() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -776,6 +793,7 @@ fn multiple_names_max_depth_1() { direction: Direction::BottomUp, bar_alignment: BarAlignment::Left, column_width_distribution: ColumnWidthDistribution::total(100), + coloring: None, }; let expected = format!("{visualizer}"); let expected = expected.trim_end(); @@ -787,3 +805,86 @@ fn multiple_names_max_depth_1() { assert!(lines.next().unwrap().contains("┌──(total)")); assert_eq!(lines.next(), None); } + +/// Test that `--color=always` with multiple arguments correctly colors leaf nodes. +/// +/// When multiple path arguments are provided, a synthetic `(total)` root is created. +/// The coloring logic must still correctly resolve filesystem types (symlink, directory, etc.) +/// for the original paths — not for non-existent paths prefixed with `(total)/...`. +/// +/// We verify this by comparing the `--color=always` output (with ANSI stripped) against +/// `--color=never` output, AND checking that symlinks and empty directories actually +/// receive their expected ANSI color codes from LS_COLORS. +#[cfg(unix)] +#[test] +fn color_always_multiple_args() { + // LS_COLORS: di=01;34 (bold blue for dirs), ln=01;36 (bold cyan for symlinks) + let ls_colors = "rs=0:di=01;34:ln=01;36:ex=01;32:fi=00"; + let workspace = SampleWorkspace::simple_tree_with_diverse_kinds(); + + let actual = Command::new(PDU) + .with_current_dir(&workspace) + .with_arg("--color=always") + .with_arg("--quantity=apparent-size") + .with_arg("--total-width=100") + .with_arg("--min-ratio=0") + .with_arg("dir-a") + .with_arg("dir-b") + .with_arg("empty-dir-1") + .with_arg("link-dir") + .with_arg("link-file.txt") + .with_env("LS_COLORS", ls_colors) + .pipe(stdio) + .output() + .expect("spawn command with --color=always and multiple args") + .pipe(stdout_text); + eprintln!("ACTUAL:\n{actual}\n"); + + let colorless = Command::new(PDU) + .with_current_dir(&workspace) + .with_arg("--color=never") + .with_arg("--quantity=apparent-size") + .with_arg("--total-width=100") + .with_arg("--min-ratio=0") + .with_arg("dir-a") + .with_arg("dir-b") + .with_arg("empty-dir-1") + .with_arg("link-dir") + .with_arg("link-file.txt") + .with_env("LS_COLORS", ls_colors) + .pipe(stdio) + .output() + .expect("spawn command with --color=never and multiple args") + .pipe(stdout_text); + eprintln!("COLORLESS:\n{colorless}\n"); + + // Stripping ANSI from the colorful output should match the colorless output. + let stripped = strip_ansi_escapes::strip_str(&actual); + let stripped = stripped.trim_end(); + assert_eq!(stripped, colorless, "stripped colorful output must match colorless output"); + + // Verify that symlinks receive the symlink color (bold cyan = \x1b[1;36m). + // With the `(total)` bug, these would have no ANSI codes at all because + // the path `(total)/link-dir` doesn't exist on the filesystem. + let symlink_prefix = "\x1b[1;36m"; + let has_colored_symlink = actual.lines().any(|line| { + line.contains(symlink_prefix) && (line.contains("link-dir") || line.contains("link-file.txt")) + }); + assert!( + has_colored_symlink, + "symlinks (link-dir, link-file.txt) should be colored with the symlink prefix ({symlink_prefix}), \ + but no line contains it. This likely means the `(total)` synthetic root caused \ + filesystem type detection to fail for leaf nodes." + ); + + // Verify that the empty directory leaf receives the directory color (bold blue = \x1b[1;34m). + let dir_prefix = "\x1b[1;34m"; + let has_colored_empty_dir = actual.lines().any(|line| { + line.contains(dir_prefix) && line.contains("empty-dir-1") + }); + assert!( + has_colored_empty_dir, + "empty-dir-1 should be colored with the directory prefix ({dir_prefix}), \ + but the matching line doesn't contain it." + ); +} diff --git a/tests/visualizer.rs b/tests/visualizer.rs index 8c839f26..0d785900 100644 --- a/tests/visualizer.rs +++ b/tests/visualizer.rs @@ -41,6 +41,7 @@ macro_rules! test_case { bytes_format: $bytes_format, direction: Direction::$direction, bar_alignment: BarAlignment::$bar_alignment, + coloring: None, } .to_string(); let expected = $expected;