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;