From 96001762d0a0f1f1f883ee39fb2b5a106c0b8255 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 16:34:43 +0000 Subject: [PATCH 01/65] fix(api): skip mounted directories by comparing device IDs Directories on different devices (mount points) were being traversed, leading to inaccurate usage reports and severe performance issues. Compare each entry's device ID against the root's device ID and treat cross-device directories as leaf nodes. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- src/fs_tree_builder.rs | 24 +++++++++++++++++--- src/fs_tree_builder/device_id.rs | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 src/fs_tree_builder/device_id.rs diff --git a/src/fs_tree_builder.rs b/src/fs_tree_builder.rs index 37167b2c..891e1cc1 100644 --- a/src/fs_tree_builder.rs +++ b/src/fs_tree_builder.rs @@ -7,6 +7,7 @@ use super::{ size, tree_builder::{Info, TreeBuilder}, }; +use device_id::get_device_id; use pipe_trait::Pipe; use std::{ fs::{read_dir, symlink_metadata}, @@ -75,13 +76,27 @@ where max_depth, } = builder; + // `root` would be inspected multiple times, but its impact on performance is insignificant + // before the (usually) massive fs tree `root` contains. + let root_dev = match symlink_metadata(&root) { + Err(error) => { + reporter.report(Event::EncounterError(ErrorReport { + operation: SymlinkMetadata, + path: &root, + error, + })); + return DataTree::file(OsStringDisplay::os_string_from(&root), Size::default()); + } + Ok(stats) => get_device_id(&stats), + }; + TreeBuilder:: { name: OsStringDisplay::os_string_from(&root), path: root, get_info: |path| { - let (is_dir, size) = match symlink_metadata(path) { + let (is_dir, dev, size) = match symlink_metadata(path) { Err(error) => { reporter.report(Event::EncounterError(ErrorReport { operation: SymlinkMetadata, @@ -96,6 +111,7 @@ where Ok(stats) => { // `stats` should be dropped ASAP to avoid piling up kernel memory usage let is_dir = stats.is_dir(); + let dev = get_device_id(&stats); let size = size_getter.get_size(&stats); reporter.report(Event::ReceiveData(size)); hardlinks_recorder @@ -103,11 +119,11 @@ where path, &stats, size, reporter, )) .ok(); // ignore the error for now - (is_dir, size) + (is_dir, dev, size) } }; - let children: Vec<_> = if is_dir { + let children: Vec<_> = if is_dir && dev == root_dev { match read_dir(path) { Err(error) => { reporter.report(Event::EncounterError(ErrorReport { @@ -145,3 +161,5 @@ where .into() } } + +mod device_id; diff --git a/src/fs_tree_builder/device_id.rs b/src/fs_tree_builder/device_id.rs new file mode 100644 index 00000000..87e6547a --- /dev/null +++ b/src/fs_tree_builder/device_id.rs @@ -0,0 +1,38 @@ +/// Unique identifier for a device or filesystem. +/// +/// Used to detect mount boundaries so that the tool does not traverse into +/// mounted filesystems. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct DeviceId(Inner); + +#[cfg(unix)] +type Inner = u64; + +#[cfg(windows)] +type Inner = Option; + +#[cfg(not(any(unix, windows)))] +type Inner = (); + +/// Retrieve the [`DeviceId`] from filesystem metadata. +#[cfg(unix)] +pub(super) fn get_device_id(stats: &std::fs::Metadata) -> DeviceId { + use std::os::unix::fs::MetadataExt; + DeviceId(stats.dev()) +} + +/// Retrieve the [`DeviceId`] from filesystem metadata. +#[cfg(windows)] +pub(super) fn get_device_id(stats: &std::fs::Metadata) -> DeviceId { + use std::os::windows::fs::MetadataExt; + DeviceId(stats.volume_serial_number()) +} + +/// Retrieve the [`DeviceId`] from filesystem metadata. +/// +/// On unsupported platforms, all entries share the same [`DeviceId`], +/// effectively disabling cross-device detection. +#[cfg(not(any(unix, windows)))] +pub(super) fn get_device_id(_stats: &std::fs::Metadata) -> DeviceId { + DeviceId(()) +} From 02edc486eb3fb82d4f9ea8edb34c4bfc867333c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 16:51:51 +0000 Subject: [PATCH 02/65] refactor(api): use pub(crate) visibility for device_id items https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- src/fs_tree_builder/device_id.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/fs_tree_builder/device_id.rs b/src/fs_tree_builder/device_id.rs index 87e6547a..3430cc58 100644 --- a/src/fs_tree_builder/device_id.rs +++ b/src/fs_tree_builder/device_id.rs @@ -1,9 +1,6 @@ /// Unique identifier for a device or filesystem. -/// -/// Used to detect mount boundaries so that the tool does not traverse into -/// mounted filesystems. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct DeviceId(Inner); +pub(crate) struct DeviceId(Inner); #[cfg(unix)] type Inner = u64; @@ -16,14 +13,14 @@ type Inner = (); /// Retrieve the [`DeviceId`] from filesystem metadata. #[cfg(unix)] -pub(super) fn get_device_id(stats: &std::fs::Metadata) -> DeviceId { +pub(crate) fn get_device_id(stats: &std::fs::Metadata) -> DeviceId { use std::os::unix::fs::MetadataExt; DeviceId(stats.dev()) } /// Retrieve the [`DeviceId`] from filesystem metadata. #[cfg(windows)] -pub(super) fn get_device_id(stats: &std::fs::Metadata) -> DeviceId { +pub(crate) fn get_device_id(stats: &std::fs::Metadata) -> DeviceId { use std::os::windows::fs::MetadataExt; DeviceId(stats.volume_serial_number()) } @@ -33,6 +30,6 @@ pub(super) fn get_device_id(stats: &std::fs::Metadata) -> DeviceId { /// On unsupported platforms, all entries share the same [`DeviceId`], /// effectively disabling cross-device detection. #[cfg(not(any(unix, windows)))] -pub(super) fn get_device_id(_stats: &std::fs::Metadata) -> DeviceId { +pub(crate) fn get_device_id(_stats: &std::fs::Metadata) -> DeviceId { DeviceId(()) } From 7b4889df23023bd4fd270290306f3ed179b89afc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 17:06:21 +0000 Subject: [PATCH 03/65] docs(readme): remove outdated mounted filesystems limitation https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 67d3c8fd..11cf011c 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,6 @@ The benchmark was generated by [a GitHub Workflow](https://github.com/KSXGitHub/ * Ignorant of reflinks (from COW filesystems such as BTRFS and ZFS). * Does not follow symbolic links. -* Does not differentiate filesystems: Mounted folders are counted as normal folders. * The runtime is optimized at the expense of binary size. ## Usage From ab0aae897b23a592d330cbc3f3cf0d4531795876 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 17:54:40 +0000 Subject: [PATCH 04/65] feat(cli): add -x/--one-file-system flag to skip mounted directories Instead of unconditionally skipping cross-device directories, make it opt-in via -x/--one-file-system (matching du convention). The default behavior now crosses device boundaries like du, dust, and dua. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- src/app.rs | 2 ++ src/app/sub.rs | 4 ++++ src/args.rs | 4 ++++ src/fs_tree_builder.rs | 35 ++++++++++++++++++++++------------- tests/_utils.rs | 1 + tests/cli_errors.rs | 1 + tests/json.rs | 1 + tests/usual_cli.rs | 18 ++++++++++++++++++ 8 files changed, 53 insertions(+), 13 deletions(-) diff --git a/src/app.rs b/src/app.rs index 4569b257..46ced8ef 100644 --- a/src/app.rs +++ b/src/app.rs @@ -288,6 +288,7 @@ impl App { bytes_format, top_down, align_right, + one_file_system, max_depth, min_ratio, no_sort, @@ -304,6 +305,7 @@ impl App { files, json_output: JsonOutputParam::from_cli_flags(json_output, omit_json_shared_details, omit_json_shared_summary), column_width_distribution, + one_file_system, max_depth, min_ratio, no_sort, diff --git a/src/app/sub.rs b/src/app/sub.rs index 3500a5f3..45524c68 100644 --- a/src/app/sub.rs +++ b/src/app/sub.rs @@ -37,6 +37,8 @@ where pub bar_alignment: BarAlignment, /// Distribution and number of characters/blocks can be placed in a line. pub column_width_distribution: ColumnWidthDistribution, + /// Skip directories on different filesystems. + pub one_file_system: bool, /// Maximum number of levels that should be visualized. pub max_depth: Depth, /// [Get the size](GetSize) of files/directories. @@ -68,6 +70,7 @@ where direction, bar_alignment, column_width_distribution, + one_file_system, max_depth, size_getter, hardlinks_handler, @@ -86,6 +89,7 @@ where root, size_getter, hardlinks_recorder: &hardlinks_handler, + one_file_system, max_depth, } .into() diff --git a/src/args.rs b/src/args.rs index db6698c8..4c0dda5b 100644 --- a/src/args.rs +++ b/src/args.rs @@ -155,6 +155,10 @@ pub struct Args { #[clap(long, short, visible_alias = "no-errors")] pub silent_errors: bool, + /// Skip directories on different filesystems. + #[clap(long, short = 'x', visible_alias = "one-file-system")] + pub one_file_system: bool, + /// Report progress being made at the expense of performance. #[clap(long, short)] pub progress: bool, diff --git a/src/fs_tree_builder.rs b/src/fs_tree_builder.rs index 891e1cc1..e4dcc481 100644 --- a/src/fs_tree_builder.rs +++ b/src/fs_tree_builder.rs @@ -33,6 +33,7 @@ use std::{ /// hardlinks_recorder: &HardlinkIgnorant, /// size_getter: GetApparentSize, /// reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), +/// one_file_system: false, /// max_depth: 10, /// }; /// let data_tree: DataTree = builder.into(); @@ -53,6 +54,8 @@ where pub hardlinks_recorder: &'a HardlinksRecorder, /// Reports progress to external system. pub reporter: &'a Report, + /// Skip directories on different filesystems. + pub one_file_system: bool, /// Deepest level of descendant display in the graph. The sizes beyond the max depth still count toward total. pub max_depth: u64, } @@ -73,21 +76,26 @@ where size_getter, hardlinks_recorder, reporter, + one_file_system, max_depth, } = builder; // `root` would be inspected multiple times, but its impact on performance is insignificant // before the (usually) massive fs tree `root` contains. - let root_dev = match symlink_metadata(&root) { - Err(error) => { - reporter.report(Event::EncounterError(ErrorReport { - operation: SymlinkMetadata, - path: &root, - error, - })); - return DataTree::file(OsStringDisplay::os_string_from(&root), Size::default()); + let root_dev = if one_file_system { + match symlink_metadata(&root) { + Err(error) => { + reporter.report(Event::EncounterError(ErrorReport { + operation: SymlinkMetadata, + path: &root, + error, + })); + return DataTree::file(OsStringDisplay::os_string_from(&root), Size::default()); + } + Ok(stats) => Some(get_device_id(&stats)), } - Ok(stats) => get_device_id(&stats), + } else { + None }; TreeBuilder:: { @@ -96,7 +104,7 @@ where path: root, get_info: |path| { - let (is_dir, dev, size) = match symlink_metadata(path) { + let (is_dir, size, same_device) = match symlink_metadata(path) { Err(error) => { reporter.report(Event::EncounterError(ErrorReport { operation: SymlinkMetadata, @@ -111,7 +119,8 @@ where Ok(stats) => { // `stats` should be dropped ASAP to avoid piling up kernel memory usage let is_dir = stats.is_dir(); - let dev = get_device_id(&stats); + let same_device = + root_dev.is_none_or(|root_dev| get_device_id(&stats) == root_dev); let size = size_getter.get_size(&stats); reporter.report(Event::ReceiveData(size)); hardlinks_recorder @@ -119,11 +128,11 @@ where path, &stats, size, reporter, )) .ok(); // ignore the error for now - (is_dir, dev, size) + (is_dir, size, same_device) } }; - let children: Vec<_> = if is_dir && dev == root_dev { + let children: Vec<_> = if is_dir && same_device { match read_dir(path) { Err(error) => { reporter.report(Event::EncounterError(ErrorReport { diff --git a/tests/_utils.rs b/tests/_utils.rs index 75b4a326..a62abab0 100644 --- a/tests/_utils.rs +++ b/tests/_utils.rs @@ -373,6 +373,7 @@ where panic!("Unexpected call to report_error: {error:?}") }), root: root.join(suffix), + one_file_system: false, max_depth: 10, } .pipe(DataTree::::from) diff --git a/tests/cli_errors.rs b/tests/cli_errors.rs index c33f0d9a..361ae2a7 100644 --- a/tests/cli_errors.rs +++ b/tests/cli_errors.rs @@ -135,6 +135,7 @@ fn fs_errors() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); diff --git a/tests/json.rs b/tests/json.rs index 95aee0b9..9cdc9503 100644 --- a/tests/json.rs +++ b/tests/json.rs @@ -85,6 +85,7 @@ fn json_output() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let expected = builder diff --git a/tests/usual_cli.rs b/tests/usual_cli.rs index cbacb9f2..661dd2ac 100644 --- a/tests/usual_cli.rs +++ b/tests/usual_cli.rs @@ -45,6 +45,7 @@ fn total_width() { size_getter: DEFAULT_GET_SIZE, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -84,6 +85,7 @@ fn column_width() { size_getter: DEFAULT_GET_SIZE, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -123,6 +125,7 @@ fn min_ratio_0() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -161,6 +164,7 @@ fn min_ratio() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -200,6 +204,7 @@ fn max_depth_2() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 2, }; let mut data_tree: DataTree = builder.into(); @@ -239,6 +244,7 @@ fn max_depth_1() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 1, }; let mut data_tree: DataTree = builder.into(); @@ -277,6 +283,7 @@ fn top_down() { size_getter: DEFAULT_GET_SIZE, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -315,6 +322,7 @@ fn align_right() { size_getter: DEFAULT_GET_SIZE, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -353,6 +361,7 @@ fn quantity_apparent_size() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -392,6 +401,7 @@ fn quantity_block_size() { size_getter: GetBlockSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -431,6 +441,7 @@ fn quantity_block_count() { size_getter: GetBlockCount, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -470,6 +481,7 @@ fn bytes_format_plain() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -509,6 +521,7 @@ fn bytes_format_metric() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -548,6 +561,7 @@ fn bytes_format_binary() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -586,6 +600,7 @@ fn path_to_workspace() { size_getter: DEFAULT_GET_SIZE, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -629,6 +644,7 @@ fn multiple_names() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -694,6 +710,7 @@ fn multiple_names_max_depth_2() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 1, }; let mut data_tree: DataTree = builder.into(); @@ -754,6 +771,7 @@ fn multiple_names_max_depth_1() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system: false, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); From e7df5d89074afd3bd2c47ca104ffb180c3fcac4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 18:30:19 +0000 Subject: [PATCH 05/65] chore(cli): regenerate completions, help text, and usage docs https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- USAGE.md | 7 +++++++ exports/completion.bash | 2 +- exports/completion.elv | 3 +++ exports/completion.fish | 1 + exports/completion.ps1 | 3 +++ exports/completion.zsh | 3 +++ exports/long.help | 5 +++++ exports/short.help | 2 ++ 8 files changed, 25 insertions(+), 1 deletion(-) diff --git a/USAGE.md b/USAGE.md index 98d1f36c..a6ffe54e 100644 --- a/USAGE.md +++ b/USAGE.md @@ -101,6 +101,13 @@ Do not sort the branches in the tree. Prevent filesystem error messages from appearing in stderr. + +### `--one-file-system` + +* _Aliases:_ `-x`, `--one-file-system`. + +Skip directories on different filesystems. + ### `--progress` diff --git a/exports/completion.bash b/exports/completion.bash index 8b06a03b..a3ebb2d4 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 -x -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 --one-file-system --one-file-system --progress --threads --omit-json-shared-details --omit-json-shared-summary --help --version [FILES]..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 diff --git a/exports/completion.elv b/exports/completion.elv index d3cda52c..62e3acfb 100644 --- a/exports/completion.elv +++ b/exports/completion.elv @@ -44,6 +44,9 @@ set edit:completion:arg-completer[pdu] = {|@words| cand -s 'Prevent filesystem error messages from appearing in stderr' cand --silent-errors 'Prevent filesystem error messages from appearing in stderr' cand --no-errors 'Prevent filesystem error messages from appearing in stderr' + cand -x 'Skip directories on different filesystems' + cand --one-file-system 'Skip directories on different filesystems' + cand --one-file-system 'Skip directories on different filesystems' cand -p 'Report progress being made at the expense of performance' cand --progress 'Report progress being made at the expense of performance' cand --omit-json-shared-details 'Do not output `.shared.details` in the JSON output' diff --git a/exports/completion.fish b/exports/completion.fish index 41cc6448..a8318440 100644 --- a/exports/completion.fish +++ b/exports/completion.fish @@ -16,6 +16,7 @@ complete -c pdu -l top-down -d 'Print the tree top-down instead of bottom-up' complete -c pdu -l align-right -d 'Set the root of the bars to the right' complete -c pdu -l no-sort -d 'Do not sort the branches in the tree' complete -c pdu -s s -l silent-errors -l no-errors -d 'Prevent filesystem error messages from appearing in stderr' +complete -c pdu -s x -l one-file-system -l one-file-system -d 'Skip directories on different filesystems' complete -c pdu -s p -l progress -d 'Report progress being made at the expense of performance' complete -c pdu -l omit-json-shared-details -d 'Do not output `.shared.details` in the JSON output' complete -c pdu -l omit-json-shared-summary -d 'Do not output `.shared.summary` in the JSON output' diff --git a/exports/completion.ps1 b/exports/completion.ps1 index 8814bf76..0e09376e 100644 --- a/exports/completion.ps1 +++ b/exports/completion.ps1 @@ -47,6 +47,9 @@ Register-ArgumentCompleter -Native -CommandName 'pdu' -ScriptBlock { [CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'Prevent filesystem error messages from appearing in stderr') [CompletionResult]::new('--silent-errors', '--silent-errors', [CompletionResultType]::ParameterName, 'Prevent filesystem error messages from appearing in stderr') [CompletionResult]::new('--no-errors', '--no-errors', [CompletionResultType]::ParameterName, 'Prevent filesystem error messages from appearing in stderr') + [CompletionResult]::new('-x', '-x', [CompletionResultType]::ParameterName, 'Skip directories on different filesystems') + [CompletionResult]::new('--one-file-system', '--one-file-system', [CompletionResultType]::ParameterName, 'Skip directories on different filesystems') + [CompletionResult]::new('--one-file-system', '--one-file-system', [CompletionResultType]::ParameterName, 'Skip directories on different filesystems') [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Report progress being made at the expense of performance') [CompletionResult]::new('--progress', '--progress', [CompletionResultType]::ParameterName, 'Report progress being made at the expense of performance') [CompletionResult]::new('--omit-json-shared-details', '--omit-json-shared-details', [CompletionResultType]::ParameterName, 'Do not output `.shared.details` in the JSON output') diff --git a/exports/completion.zsh b/exports/completion.zsh index dec1cef4..423c43bb 100644 --- a/exports/completion.zsh +++ b/exports/completion.zsh @@ -49,6 +49,9 @@ block-count\:"Count numbers of blocks"))' \ '-s[Prevent filesystem error messages from appearing in stderr]' \ '--silent-errors[Prevent filesystem error messages from appearing in stderr]' \ '--no-errors[Prevent filesystem error messages from appearing in stderr]' \ +'-x[Skip directories on different filesystems]' \ +'--one-file-system[Skip directories on different filesystems]' \ +'--one-file-system[Skip directories on different filesystems]' \ '-p[Report progress being made at the expense of performance]' \ '--progress[Report progress being made at the expense of performance]' \ '--omit-json-shared-details[Do not output \`.shared.details\` in the JSON output]' \ diff --git a/exports/long.help b/exports/long.help index efe31299..a3241dd2 100644 --- a/exports/long.help +++ b/exports/long.help @@ -74,6 +74,11 @@ Options: [aliases: --no-errors] + -x, --one-file-system + Skip directories on different filesystems + + [aliases: --one-file-system] + -p, --progress Report progress being made at the expense of performance diff --git a/exports/short.help b/exports/short.help index 1835edbc..7b15dc80 100644 --- a/exports/short.help +++ b/exports/short.help @@ -32,6 +32,8 @@ Options: Do not sort the branches in the tree -s, --silent-errors Prevent filesystem error messages from appearing in stderr [aliases: --no-errors] + -x, --one-file-system + Skip directories on different filesystems [aliases: --one-file-system] -p, --progress Report progress being made at the expense of performance --threads From ad794366b3e5e4c2775917ba6d8d26f7f40898a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 18:39:37 +0000 Subject: [PATCH 06/65] fix(one-file-system): return UnsupportedFeature error on non-unix platforms The `volume_serial_number()` API requires `windows_by_handle` which is an unstable library feature (rust-lang/rust#63010), so --one-file-system cannot be supported on Windows with stable Rust. Instead, return a RuntimeError::UnsupportedFeature(OneFileSystem) early and remove the Windows-specific device_id implementation. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- src/app.rs | 7 +++++++ src/fs_tree_builder/device_id.rs | 14 ++------------ src/runtime_error.rs | 4 ++++ 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/app.rs b/src/app.rs index 46ced8ef..e950e790 100644 --- a/src/app.rs +++ b/src/app.rs @@ -133,6 +133,13 @@ impl App { .pipe(Err); } + #[cfg(not(unix))] + if self.args.one_file_system { + return crate::runtime_error::UnsupportedFeature::OneFileSystem + .pipe(RuntimeError::UnsupportedFeature) + .pipe(Err); + } + let threads = match self.args.threads { Threads::Auto => { let disks = Disks::new_with_refreshed_list(); diff --git a/src/fs_tree_builder/device_id.rs b/src/fs_tree_builder/device_id.rs index 3430cc58..4d40569a 100644 --- a/src/fs_tree_builder/device_id.rs +++ b/src/fs_tree_builder/device_id.rs @@ -5,10 +5,7 @@ pub(crate) struct DeviceId(Inner); #[cfg(unix)] type Inner = u64; -#[cfg(windows)] -type Inner = Option; - -#[cfg(not(any(unix, windows)))] +#[cfg(not(unix))] type Inner = (); /// Retrieve the [`DeviceId`] from filesystem metadata. @@ -18,18 +15,11 @@ pub(crate) fn get_device_id(stats: &std::fs::Metadata) -> DeviceId { DeviceId(stats.dev()) } -/// Retrieve the [`DeviceId`] from filesystem metadata. -#[cfg(windows)] -pub(crate) fn get_device_id(stats: &std::fs::Metadata) -> DeviceId { - use std::os::windows::fs::MetadataExt; - DeviceId(stats.volume_serial_number()) -} - /// Retrieve the [`DeviceId`] from filesystem metadata. /// /// On unsupported platforms, all entries share the same [`DeviceId`], /// effectively disabling cross-device detection. -#[cfg(not(any(unix, windows)))] +#[cfg(not(unix))] pub(crate) fn get_device_id(_stats: &std::fs::Metadata) -> DeviceId { DeviceId(()) } diff --git a/src/runtime_error.rs b/src/runtime_error.rs index dba94c00..815c4be7 100644 --- a/src/runtime_error.rs +++ b/src/runtime_error.rs @@ -32,6 +32,10 @@ pub enum UnsupportedFeature { #[cfg(not(unix))] #[display("Feature --deduplicate-hardlinks is not available on this platform")] DeduplicateHardlink, + /// Using `--one-file-system` on non-POSIX. + #[cfg(not(unix))] + #[display("Feature --one-file-system is not available on this platform")] + OneFileSystem, } impl From for RuntimeError { From 3f7db4b726f6958e597df733cdde74fe95cf7af7 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Mon, 23 Mar 2026 01:45:01 +0700 Subject: [PATCH 07/65] docs(cli): hide `-x` on windows --- src/args.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/args.rs b/src/args.rs index 4c0dda5b..5fd47944 100644 --- a/src/args.rs +++ b/src/args.rs @@ -157,6 +157,7 @@ pub struct Args { /// Skip directories on different filesystems. #[clap(long, short = 'x', visible_alias = "one-file-system")] + #[cfg_attr(not(unix), clap(hide = true))] pub one_file_system: bool, /// Report progress being made at the expense of performance. From 184872bddba522f92cbfc7a6cc1b3753872d4fe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 19:30:36 +0000 Subject: [PATCH 08/65] fix(cli): remove redundant visible_alias for --one-file-system The `#[clap(long)]` attribute on `one_file_system` already generates `--one-file-system`, so `visible_alias = "one-file-system"` was a duplicate causing doubled anchors in USAGE.md and redundant entries in shell completions. Regenerate all exports. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- USAGE.md | 4 ++-- exports/completion.bash | 2 +- exports/completion.elv | 1 - exports/completion.fish | 2 +- exports/completion.ps1 | 1 - exports/completion.zsh | 1 - exports/long.help | 2 -- exports/short.help | 2 +- src/args.rs | 2 +- 9 files changed, 6 insertions(+), 11 deletions(-) diff --git a/USAGE.md b/USAGE.md index a6ffe54e..98c4dd6d 100644 --- a/USAGE.md +++ b/USAGE.md @@ -101,10 +101,10 @@ Do not sort the branches in the tree. Prevent filesystem error messages from appearing in stderr. - + ### `--one-file-system` -* _Aliases:_ `-x`, `--one-file-system`. +* _Aliases:_ `-x`. Skip directories on different filesystems. diff --git a/exports/completion.bash b/exports/completion.bash index a3ebb2d4..609bf197 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 -x -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 --one-file-system --one-file-system --progress --threads --omit-json-shared-details --omit-json-shared-summary --help --version [FILES]..." + opts="-b -H -q -d -w -m -s -x -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 --one-file-system --progress --threads --omit-json-shared-details --omit-json-shared-summary --help --version [FILES]..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 diff --git a/exports/completion.elv b/exports/completion.elv index 62e3acfb..d4db1e14 100644 --- a/exports/completion.elv +++ b/exports/completion.elv @@ -46,7 +46,6 @@ set edit:completion:arg-completer[pdu] = {|@words| cand --no-errors 'Prevent filesystem error messages from appearing in stderr' cand -x 'Skip directories on different filesystems' cand --one-file-system 'Skip directories on different filesystems' - cand --one-file-system 'Skip directories on different filesystems' cand -p 'Report progress being made at the expense of performance' cand --progress 'Report progress being made at the expense of performance' cand --omit-json-shared-details 'Do not output `.shared.details` in the JSON output' diff --git a/exports/completion.fish b/exports/completion.fish index a8318440..60812b89 100644 --- a/exports/completion.fish +++ b/exports/completion.fish @@ -16,7 +16,7 @@ complete -c pdu -l top-down -d 'Print the tree top-down instead of bottom-up' complete -c pdu -l align-right -d 'Set the root of the bars to the right' complete -c pdu -l no-sort -d 'Do not sort the branches in the tree' complete -c pdu -s s -l silent-errors -l no-errors -d 'Prevent filesystem error messages from appearing in stderr' -complete -c pdu -s x -l one-file-system -l one-file-system -d 'Skip directories on different filesystems' +complete -c pdu -s x -l one-file-system -d 'Skip directories on different filesystems' complete -c pdu -s p -l progress -d 'Report progress being made at the expense of performance' complete -c pdu -l omit-json-shared-details -d 'Do not output `.shared.details` in the JSON output' complete -c pdu -l omit-json-shared-summary -d 'Do not output `.shared.summary` in the JSON output' diff --git a/exports/completion.ps1 b/exports/completion.ps1 index 0e09376e..8a5802f5 100644 --- a/exports/completion.ps1 +++ b/exports/completion.ps1 @@ -49,7 +49,6 @@ Register-ArgumentCompleter -Native -CommandName 'pdu' -ScriptBlock { [CompletionResult]::new('--no-errors', '--no-errors', [CompletionResultType]::ParameterName, 'Prevent filesystem error messages from appearing in stderr') [CompletionResult]::new('-x', '-x', [CompletionResultType]::ParameterName, 'Skip directories on different filesystems') [CompletionResult]::new('--one-file-system', '--one-file-system', [CompletionResultType]::ParameterName, 'Skip directories on different filesystems') - [CompletionResult]::new('--one-file-system', '--one-file-system', [CompletionResultType]::ParameterName, 'Skip directories on different filesystems') [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Report progress being made at the expense of performance') [CompletionResult]::new('--progress', '--progress', [CompletionResultType]::ParameterName, 'Report progress being made at the expense of performance') [CompletionResult]::new('--omit-json-shared-details', '--omit-json-shared-details', [CompletionResultType]::ParameterName, 'Do not output `.shared.details` in the JSON output') diff --git a/exports/completion.zsh b/exports/completion.zsh index 423c43bb..d8b86cf1 100644 --- a/exports/completion.zsh +++ b/exports/completion.zsh @@ -51,7 +51,6 @@ block-count\:"Count numbers of blocks"))' \ '--no-errors[Prevent filesystem error messages from appearing in stderr]' \ '-x[Skip directories on different filesystems]' \ '--one-file-system[Skip directories on different filesystems]' \ -'--one-file-system[Skip directories on different filesystems]' \ '-p[Report progress being made at the expense of performance]' \ '--progress[Report progress being made at the expense of performance]' \ '--omit-json-shared-details[Do not output \`.shared.details\` in the JSON output]' \ diff --git a/exports/long.help b/exports/long.help index a3241dd2..01d52dcf 100644 --- a/exports/long.help +++ b/exports/long.help @@ -77,8 +77,6 @@ Options: -x, --one-file-system Skip directories on different filesystems - [aliases: --one-file-system] - -p, --progress Report progress being made at the expense of performance diff --git a/exports/short.help b/exports/short.help index 7b15dc80..6e5666ed 100644 --- a/exports/short.help +++ b/exports/short.help @@ -33,7 +33,7 @@ Options: -s, --silent-errors Prevent filesystem error messages from appearing in stderr [aliases: --no-errors] -x, --one-file-system - Skip directories on different filesystems [aliases: --one-file-system] + Skip directories on different filesystems -p, --progress Report progress being made at the expense of performance --threads diff --git a/src/args.rs b/src/args.rs index 5fd47944..8631f644 100644 --- a/src/args.rs +++ b/src/args.rs @@ -156,7 +156,7 @@ pub struct Args { pub silent_errors: bool, /// Skip directories on different filesystems. - #[clap(long, short = 'x', visible_alias = "one-file-system")] + #[clap(long, short = 'x')] #[cfg_attr(not(unix), clap(hide = true))] pub one_file_system: bool, From 81414f69b6a0635cb4460a6c8cc8319eacaf8f9b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 20:20:06 +0000 Subject: [PATCH 09/65] test(one-file-system): add unit and integration tests for -x flag Unit tests in device_id.rs verify that get_device_id returns equal IDs for the same filesystem and different IDs across filesystems (/ vs /proc). Integration tests in one_file_system.rs: - same_device_on_sample_workspace: one_file_system=true produces the same tree as false when all files are on one device - cross_device_excludes_mount: uses unshare --user --mount --map-root-user to create a tmpfs mount inside a user namespace (no root required) and verifies -x excludes entries on the mounted filesystem https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- src/fs_tree_builder/device_id.rs | 29 ++++++ tests/one_file_system.rs | 159 +++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 tests/one_file_system.rs diff --git a/src/fs_tree_builder/device_id.rs b/src/fs_tree_builder/device_id.rs index 4d40569a..0dfedf91 100644 --- a/src/fs_tree_builder/device_id.rs +++ b/src/fs_tree_builder/device_id.rs @@ -23,3 +23,32 @@ pub(crate) fn get_device_id(stats: &std::fs::Metadata) -> DeviceId { pub(crate) fn get_device_id(_stats: &std::fs::Metadata) -> DeviceId { DeviceId(()) } + +#[cfg(test)] +#[cfg(unix)] +mod tests { + use super::get_device_id; + use std::fs::symlink_metadata; + + #[test] + fn same_filesystem_returns_equal_ids() { + let root_stats = symlink_metadata("/").expect("stat /"); + let root_stats2 = symlink_metadata("/").expect("stat / again"); + assert_eq!( + get_device_id(&root_stats), + get_device_id(&root_stats2), + "same path should yield the same DeviceId", + ); + } + + #[test] + fn different_filesystem_returns_different_ids() { + let root_stats = symlink_metadata("/").expect("stat /"); + let proc_stats = symlink_metadata("/proc").expect("stat /proc"); + assert_ne!( + get_device_id(&root_stats), + get_device_id(&proc_stats), + "/ and /proc should be on different devices", + ); + } +} diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs new file mode 100644 index 00000000..56454843 --- /dev/null +++ b/tests/one_file_system.rs @@ -0,0 +1,159 @@ +//! Tests for the `--one-file-system` / `-x` flag. +//! +//! ## Unit-style test +//! +//! [`same_device_on_sample_workspace`] verifies that enabling `--one-file-system` on a +//! single-device workspace produces the same tree as without it. +//! +//! ## Integration test via `unshare` +//! +//! [`cross_device_excludes_mount`] uses `unshare --user --mount --map-root-user` to create +//! a tmpfs mount inside a user namespace (no root required) and checks that `-x` correctly +//! excludes entries on the mounted filesystem. +//! +//! The `unshare` test is skipped when user namespaces are unavailable. + +#![cfg(unix)] +#![cfg(feature = "cli")] + +pub mod _utils; +pub use _utils::*; + +use parallel_disk_usage::{ + data_tree::DataTree, + fs_tree_builder::FsTreeBuilder, + get_size::GetApparentSize, + hardlink::HardlinkIgnorant, + os_string_display::OsStringDisplay, + reporter::{ErrorOnlyReporter, ErrorReport}, + size::Bytes, +}; + +/// When all files reside on a single filesystem, `one_file_system: true` should produce +/// the same tree as `one_file_system: false`. +#[test] +fn same_device_on_sample_workspace() { + let workspace = SampleWorkspace::default(); + + let build_tree = |one_file_system: bool| -> DataTree { + FsTreeBuilder { + root: workspace.to_path_buf(), + size_getter: GetApparentSize, + hardlinks_recorder: &HardlinkIgnorant, + reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system, + max_depth: 10, + } + .into() + }; + + let tree_without = build_tree(false) + .into_par_sorted(|left, right| left.name().cmp(right.name())) + .into_reflection(); + let tree_with = build_tree(true) + .into_par_sorted(|left, right| left.name().cmp(right.name())) + .into_reflection(); + + pretty_assertions::assert_eq!( + sanitize_tree_reflection(tree_without), + sanitize_tree_reflection(tree_with), + "one_file_system should not change the result when all files are on the same device", + ); +} + +/// Returns `true` if `unshare --user --mount --map-root-user` is available. +fn unshare_available() -> bool { + std::process::Command::new("unshare") + .args(["--user", "--mount", "--map-root-user", "true"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +/// When a subdirectory is a mount point for a different filesystem, `-x` should exclude it. +/// +/// Uses `unshare --user --mount --map-root-user` to avoid requiring root privileges. +/// Skipped when user namespaces are unavailable. +#[test] +fn cross_device_excludes_mount() { + if !unshare_available() { + eprintln!("skipping cross_device_excludes_mount: unshare not available"); + return; + } + + // Build the pdu binary path + let pdu = env!("CARGO_BIN_EXE_pdu"); + + // Run pdu both with and without -x inside a user namespace that has a tmpfs mount. + // The shell script creates: + // $TMPDIR/outside.txt (on the host filesystem) + // $TMPDIR/mounted/ (a tmpfs mount) + // $TMPDIR/mounted/inside.txt + let script = format!( + r#" +TMPDIR=$(mktemp -d) +mkdir -p "$TMPDIR/mounted" +mount -t tmpfs tmpfs "$TMPDIR/mounted" +printf '%s' '{}' > "$TMPDIR/outside.txt" +printf '%s' '{}' > "$TMPDIR/mounted/inside.txt" +echo "=== WITHOUT_X ===" +"{pdu}" --bytes-format=plain "$TMPDIR" 2>&1 +echo "=== WITH_X ===" +"{pdu}" --bytes-format=plain -x "$TMPDIR" 2>&1 +umount "$TMPDIR/mounted" +rm -rf "$TMPDIR" +"#, + "A".repeat(1000), + "B".repeat(2000), + ); + + let output = std::process::Command::new("unshare") + .args([ + "--user", + "--mount", + "--map-root-user", + "bash", + "-c", + &script, + ]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .expect("run unshare"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("STDOUT:\n{stdout}"); + if !stderr.is_empty() { + eprintln!("STDERR:\n{stderr}"); + } + assert!(output.status.success(), "unshare command failed"); + + let sections: Vec<&str> = stdout.split("===").collect(); + // sections: ["", " WITHOUT_X ", "\n...\n", " WITH_X ", "\n...\n"] + assert!(sections.len() >= 5, "unexpected output format: {stdout}",); + + let without_x = sections[2].trim(); + let with_x = sections[4].trim(); + + // Without -x: should contain both "inside.txt" and "outside.txt" + assert!( + without_x.contains("inside.txt"), + "without -x should show inside.txt:\n{without_x}", + ); + assert!( + without_x.contains("outside.txt"), + "without -x should show outside.txt:\n{without_x}", + ); + + // With -x: should contain "outside.txt" but NOT "inside.txt" + assert!( + with_x.contains("outside.txt"), + "with -x should show outside.txt:\n{with_x}", + ); + assert!( + !with_x.contains("inside.txt"), + "with -x should exclude inside.txt (on different filesystem):\n{with_x}", + ); +} From e7aed335b3b4150893d8af565f8d7a0115abf33c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 21:11:29 +0000 Subject: [PATCH 10/65] fix(test): gate platform-specific tests with target_os The different_filesystem_returns_different_ids unit test uses /proc (Linux-only) and /dev (macOS devfs), so split into two functions gated with #[cfg(target_os = "linux")] and #[cfg(target_os = "macos")]. The cross_device_excludes_mount integration test uses unshare which is Linux-only, so gate it with #[cfg(target_os = "linux")]. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- src/fs_tree_builder/device_id.rs | 15 +++++++++++++++ tests/one_file_system.rs | 2 ++ 2 files changed, 17 insertions(+) diff --git a/src/fs_tree_builder/device_id.rs b/src/fs_tree_builder/device_id.rs index 0dfedf91..5c3deb51 100644 --- a/src/fs_tree_builder/device_id.rs +++ b/src/fs_tree_builder/device_id.rs @@ -41,7 +41,9 @@ mod tests { ); } + /// `/proc` is a virtual filesystem mounted separately from `/` on Linux. #[test] + #[cfg(target_os = "linux")] fn different_filesystem_returns_different_ids() { let root_stats = symlink_metadata("/").expect("stat /"); let proc_stats = symlink_metadata("/proc").expect("stat /proc"); @@ -51,4 +53,17 @@ mod tests { "/ and /proc should be on different devices", ); } + + /// `/dev` is a separate filesystem (`devfs`) from `/` on macOS. + #[test] + #[cfg(target_os = "macos")] + fn different_filesystem_returns_different_ids() { + let root_stats = symlink_metadata("/").expect("stat /"); + let dev_stats = symlink_metadata("/dev").expect("stat /dev"); + assert_ne!( + get_device_id(&root_stats), + get_device_id(&dev_stats), + "/ and /dev should be on different devices", + ); + } } diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 56454843..cd73dd47 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -62,6 +62,7 @@ fn same_device_on_sample_workspace() { } /// Returns `true` if `unshare --user --mount --map-root-user` is available. +#[cfg(target_os = "linux")] fn unshare_available() -> bool { std::process::Command::new("unshare") .args(["--user", "--mount", "--map-root-user", "true"]) @@ -76,6 +77,7 @@ fn unshare_available() -> bool { /// Uses `unshare --user --mount --map-root-user` to avoid requiring root privileges. /// Skipped when user namespaces are unavailable. #[test] +#[cfg(target_os = "linux")] fn cross_device_excludes_mount() { if !unshare_available() { eprintln!("skipping cross_device_excludes_mount: unshare not available"); From 33f096e10991b59c88f0caca7b23c1d44d371d80 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 21:38:47 +0000 Subject: [PATCH 11/65] refactor(test): improve one_file_system test style - Use `writeln!` to build the shell script instead of `format!` with inline braces, making the script construction clearer - Use `command_extra::CommandExtra` with `.with_args`/`.with_stdout`/ `.with_stderr` instead of bare `std::process` methods - Import `pretty_assertions::assert_eq` at module level https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 78 ++++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index cd73dd47..8e1f7d6f 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -28,6 +28,7 @@ use parallel_disk_usage::{ reporter::{ErrorOnlyReporter, ErrorReport}, size::Bytes, }; +use pretty_assertions::assert_eq; /// When all files reside on a single filesystem, `one_file_system: true` should produce /// the same tree as `one_file_system: false`. @@ -54,7 +55,7 @@ fn same_device_on_sample_workspace() { .into_par_sorted(|left, right| left.name().cmp(right.name())) .into_reflection(); - pretty_assertions::assert_eq!( + assert_eq!( sanitize_tree_reflection(tree_without), sanitize_tree_reflection(tree_with), "one_file_system should not change the result when all files are on the same device", @@ -64,10 +65,12 @@ fn same_device_on_sample_workspace() { /// Returns `true` if `unshare --user --mount --map-root-user` is available. #[cfg(target_os = "linux")] fn unshare_available() -> bool { - std::process::Command::new("unshare") - .args(["--user", "--mount", "--map-root-user", "true"]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) + use command_extra::CommandExtra; + use std::process::{Command, Stdio}; + Command::new("unshare") + .with_args(["--user", "--mount", "--map-root-user", "true"]) + .with_stdout(Stdio::null()) + .with_stderr(Stdio::null()) .status() .is_ok_and(|status| status.success()) } @@ -79,39 +82,46 @@ fn unshare_available() -> bool { #[test] #[cfg(target_os = "linux")] fn cross_device_excludes_mount() { + use command_extra::CommandExtra; + use std::{ + fmt::Write, + process::{Command, Stdio}, + }; + if !unshare_available() { eprintln!("skipping cross_device_excludes_mount: unshare not available"); return; } - // Build the pdu binary path let pdu = env!("CARGO_BIN_EXE_pdu"); - - // Run pdu both with and without -x inside a user namespace that has a tmpfs mount. - // The shell script creates: - // $TMPDIR/outside.txt (on the host filesystem) - // $TMPDIR/mounted/ (a tmpfs mount) - // $TMPDIR/mounted/inside.txt - let script = format!( - r#" -TMPDIR=$(mktemp -d) -mkdir -p "$TMPDIR/mounted" -mount -t tmpfs tmpfs "$TMPDIR/mounted" -printf '%s' '{}' > "$TMPDIR/outside.txt" -printf '%s' '{}' > "$TMPDIR/mounted/inside.txt" -echo "=== WITHOUT_X ===" -"{pdu}" --bytes-format=plain "$TMPDIR" 2>&1 -echo "=== WITH_X ===" -"{pdu}" --bytes-format=plain -x "$TMPDIR" 2>&1 -umount "$TMPDIR/mounted" -rm -rf "$TMPDIR" -"#, - "A".repeat(1000), - "B".repeat(2000), - ); - - let output = std::process::Command::new("unshare") - .args([ + let outside_content = "A".repeat(1000); + let inside_content = "B".repeat(2000); + + // Build a shell script that creates a tmpfs mount inside a user namespace, + // writes files on both filesystems, and runs pdu with and without -x. + let mut script = String::new(); + writeln!(script, "TMPDIR=$(mktemp -d)").unwrap(); + writeln!(script, "mkdir -p \"$TMPDIR/mounted\"").unwrap(); + writeln!(script, "mount -t tmpfs tmpfs \"$TMPDIR/mounted\"").unwrap(); + writeln!( + script, + "printf '%s' '{outside_content}' > \"$TMPDIR/outside.txt\"" + ) + .unwrap(); + writeln!( + script, + "printf '%s' '{inside_content}' > \"$TMPDIR/mounted/inside.txt\"" + ) + .unwrap(); + writeln!(script, "echo '=== WITHOUT_X ==='").unwrap(); + writeln!(script, "\"{pdu}\" --bytes-format=plain \"$TMPDIR\" 2>&1").unwrap(); + writeln!(script, "echo '=== WITH_X ==='").unwrap(); + writeln!(script, "\"{pdu}\" --bytes-format=plain -x \"$TMPDIR\" 2>&1").unwrap(); + writeln!(script, "umount \"$TMPDIR/mounted\"").unwrap(); + writeln!(script, "rm -rf \"$TMPDIR\"").unwrap(); + + let output = Command::new("unshare") + .with_args([ "--user", "--mount", "--map-root-user", @@ -119,8 +129,8 @@ rm -rf "$TMPDIR" "-c", &script, ]) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) + .with_stdout(Stdio::piped()) + .with_stderr(Stdio::piped()) .output() .expect("run unshare"); From f7d1405651e022671fc36b6cfe0b1b61b37aefe7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 21:52:31 +0000 Subject: [PATCH 12/65] refactor(test): replace brittle section parsing with NUL-delimited output Write each pdu invocation's output to a separate temp file, then emit them NUL-delimited with labels. Use find_section() to locate each section by label instead of splitting on '===' and indexing. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 47 ++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 8e1f7d6f..4cf81f1d 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -113,12 +113,28 @@ fn cross_device_excludes_mount() { "printf '%s' '{inside_content}' > \"$TMPDIR/mounted/inside.txt\"" ) .unwrap(); - writeln!(script, "echo '=== WITHOUT_X ==='").unwrap(); - writeln!(script, "\"{pdu}\" --bytes-format=plain \"$TMPDIR\" 2>&1").unwrap(); - writeln!(script, "echo '=== WITH_X ==='").unwrap(); - writeln!(script, "\"{pdu}\" --bytes-format=plain -x \"$TMPDIR\" 2>&1").unwrap(); + // Write each pdu invocation's output to a separate file so we don't need + // to parse markers from a combined stdout. + writeln!(script, "WITHOUT_X=$(mktemp)").unwrap(); + writeln!(script, "WITH_X=$(mktemp)").unwrap(); + writeln!( + script, + "\"{pdu}\" --bytes-format=plain \"$TMPDIR\" >\"$WITHOUT_X\" 2>&1" + ) + .unwrap(); + writeln!( + script, + "\"{pdu}\" --bytes-format=plain -x \"$TMPDIR\" >\"$WITH_X\" 2>&1" + ) + .unwrap(); writeln!(script, "umount \"$TMPDIR/mounted\"").unwrap(); writeln!(script, "rm -rf \"$TMPDIR\"").unwrap(); + writeln!(script, "printf 'WITHOUT_X\\0'").unwrap(); + writeln!(script, "cat \"$WITHOUT_X\"").unwrap(); + writeln!(script, "printf '\\0WITH_X\\0'").unwrap(); + writeln!(script, "cat \"$WITH_X\"").unwrap(); + writeln!(script, "printf '\\0'").unwrap(); + writeln!(script, "rm -f \"$WITHOUT_X\" \"$WITH_X\"").unwrap(); let output = Command::new("unshare") .with_args([ @@ -134,20 +150,29 @@ fn cross_device_excludes_mount() { .output() .expect("run unshare"); - let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!("STDOUT:\n{stdout}"); if !stderr.is_empty() { eprintln!("STDERR:\n{stderr}"); } assert!(output.status.success(), "unshare command failed"); - let sections: Vec<&str> = stdout.split("===").collect(); - // sections: ["", " WITHOUT_X ", "\n...\n", " WITH_X ", "\n...\n"] - assert!(sections.len() >= 5, "unexpected output format: {stdout}",); + let stdout = String::from_utf8_lossy(&output.stdout); + eprintln!("STDOUT:\n{stdout}"); + + let find_section = |label: &str| -> &str { + let label_start = stdout + .find(label) + .unwrap_or_else(|| panic!("missing {label} section in output:\n{stdout}")); + let content_start = label_start + label.len() + 1; // skip label + NUL + let content_end = stdout[content_start..] + .find('\0') + .map(|pos| content_start + pos) + .unwrap_or(stdout.len()); + stdout[content_start..content_end].trim() + }; - let without_x = sections[2].trim(); - let with_x = sections[4].trim(); + let without_x = find_section("WITHOUT_X"); + let with_x = find_section("WITH_X"); // Without -x: should contain both "inside.txt" and "outside.txt" assert!( From a168848a5f6a213f609500508e817c5c520248eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 23:40:56 +0000 Subject: [PATCH 13/65] test(one-file-system): fail loudly when unshare is unavailable Replace the silent skip with a panic and actionable error message, matching the pattern from `fs_errors`. The test can be excluded via `RUSTFLAGS='--cfg pdu_test_skip_cross_device'`. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- Cargo.toml | 2 +- tests/one_file_system.rs | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7ba21ff4..02828868 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,4 +93,4 @@ pretty_assertions = "1.4.1" rand = "0.10.0" [lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(pdu_test_skip_fs_errors)'] } +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(pdu_test_skip_fs_errors)', 'cfg(pdu_test_skip_cross_device)'] } diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 4cf81f1d..a814b404 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -11,7 +11,8 @@ //! a tmpfs mount inside a user namespace (no root required) and checks that `-x` correctly //! excludes entries on the mounted filesystem. //! -//! The `unshare` test is skipped when user namespaces are unavailable. +//! The `unshare` test panics when user namespaces are unavailable. +//! It can be excluded via `RUSTFLAGS='--cfg pdu_test_skip_cross_device'`. #![cfg(unix)] #![cfg(feature = "cli")] @@ -64,6 +65,7 @@ fn same_device_on_sample_workspace() { /// Returns `true` if `unshare --user --mount --map-root-user` is available. #[cfg(target_os = "linux")] +#[cfg(not(pdu_test_skip_cross_device))] fn unshare_available() -> bool { use command_extra::CommandExtra; use std::process::{Command, Stdio}; @@ -81,6 +83,7 @@ fn unshare_available() -> bool { /// Skipped when user namespaces are unavailable. #[test] #[cfg(target_os = "linux")] +#[cfg(not(pdu_test_skip_cross_device))] fn cross_device_excludes_mount() { use command_extra::CommandExtra; use std::{ @@ -89,8 +92,11 @@ fn cross_device_excludes_mount() { }; if !unshare_available() { - eprintln!("skipping cross_device_excludes_mount: unshare not available"); - return; + panic!( + "{}\n{}", + "error: This test requires `unshare --user --mount --map-root-user` but the command is not available.", + "hint: Either enable user namespaces or set `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` to skip this test.", + ); } let pdu = env!("CARGO_BIN_EXE_pdu"); From e6702a97a60992c3c04eea760173bede984b696c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 23:45:56 +0000 Subject: [PATCH 14/65] fix(test): probe tmpfs mount in unshare availability check The previous check only verified that `unshare --user --mount --map-root-user` succeeds, but not that tmpfs mounting is permitted inside the namespace. On systems where user namespaces work but unprivileged mounts are blocked, the test would fail instead of being caught by the probe. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index a814b404..acd63a1c 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -63,14 +63,22 @@ fn same_device_on_sample_workspace() { ); } -/// Returns `true` if `unshare --user --mount --map-root-user` is available. +/// Returns `true` if `unshare --user --mount --map-root-user` is available and allows +/// mounting a tmpfs inside the created namespace. #[cfg(target_os = "linux")] #[cfg(not(pdu_test_skip_cross_device))] fn unshare_available() -> bool { use command_extra::CommandExtra; use std::process::{Command, Stdio}; Command::new("unshare") - .with_args(["--user", "--mount", "--map-root-user", "true"]) + .with_args([ + "--user", + "--mount", + "--map-root-user", + "sh", + "-c", + "mountpoint=$(mktemp -d) && mount -t tmpfs tmpfs \"$mountpoint\" && umount \"$mountpoint\"", + ]) .with_stdout(Stdio::null()) .with_stderr(Stdio::null()) .status() From 51af07b85e63d9c27143cfbe53b0a199bd340b87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 23:50:09 +0000 Subject: [PATCH 15/65] refactor(test): return Result<(), String> from unshare_available A bool discards the reason for failure. Return a Result with the stderr output from the probe command so the panic message includes actionable diagnostics (e.g. "unshare: unshare failed: Operation not permitted"). https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index acd63a1c..fdfadf03 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -63,14 +63,16 @@ fn same_device_on_sample_workspace() { ); } -/// Returns `true` if `unshare --user --mount --map-root-user` is available and allows +/// Checks that `unshare --user --mount --map-root-user` is available and allows /// mounting a tmpfs inside the created namespace. +/// +/// Returns `Ok(())` on success, or `Err` with a diagnostic message on failure. #[cfg(target_os = "linux")] #[cfg(not(pdu_test_skip_cross_device))] -fn unshare_available() -> bool { +fn unshare_available() -> Result<(), String> { use command_extra::CommandExtra; - use std::process::{Command, Stdio}; - Command::new("unshare") + use std::process::Command; + let output = Command::new("unshare") .with_args([ "--user", "--mount", @@ -79,10 +81,18 @@ fn unshare_available() -> bool { "-c", "mountpoint=$(mktemp -d) && mount -t tmpfs tmpfs \"$mountpoint\" && umount \"$mountpoint\"", ]) - .with_stdout(Stdio::null()) - .with_stderr(Stdio::null()) - .status() - .is_ok_and(|status| status.success()) + .output() + .map_err(|error| format!("failed to execute unshare: {error}"))?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!( + "unshare probe exited with {}: {}", + output.status, + stderr.trim(), + )) + } } /// When a subdirectory is a mount point for a different filesystem, `-x` should exclude it. @@ -99,11 +109,11 @@ fn cross_device_excludes_mount() { process::{Command, Stdio}, }; - if !unshare_available() { + if let Err(reason) = unshare_available() { panic!( - "{}\n{}", - "error: This test requires `unshare --user --mount --map-root-user` but the command is not available.", - "hint: Either enable user namespaces or set `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` to skip this test.", + "error: This test requires `unshare --user --mount --map-root-user` but the probe failed.\n\ + reason: {reason}\n\ + hint: Either enable user namespaces or set `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` to skip this test.", ); } From d81a9603b6e5d879f9bde65447f6e97e0932c0c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 00:39:43 +0000 Subject: [PATCH 16/65] refactor(test): replace `unshare` with `fuse2fs` for cross-device test Replace the user-namespace-based (`unshare --user --mount`) cross-device test with a FUSE-based approach using `fuse2fs`. User namespaces are often disabled in CI containers, making the unshare test unreliable. The new test creates a small ext2 filesystem image, mounts it via `fuse2fs` (which only needs `/dev/fuse` and `fusermount`), and verifies that `pdu -x` correctly excludes entries on the mounted filesystem. Diagnostic messages clearly report which FUSE component is missing. The `--cfg pdu_test_skip_cross_device` skip flag is preserved. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 287 ++++++++++++++++++++++----------------- 1 file changed, 163 insertions(+), 124 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index fdfadf03..264ffea8 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -5,13 +5,13 @@ //! [`same_device_on_sample_workspace`] verifies that enabling `--one-file-system` on a //! single-device workspace produces the same tree as without it. //! -//! ## Integration test via `unshare` +//! ## Integration test via FUSE //! -//! [`cross_device_excludes_mount`] uses `unshare --user --mount --map-root-user` to create -//! a tmpfs mount inside a user namespace (no root required) and checks that `-x` correctly -//! excludes entries on the mounted filesystem. +//! [`cross_device_excludes_mount`] uses `fuse2fs` to mount an ext2 filesystem image via FUSE +//! (no root or user namespaces required) and checks that `-x` correctly excludes entries on +//! the mounted filesystem. //! -//! The `unshare` test panics when user namespaces are unavailable. +//! The FUSE test panics when `fuse2fs`, `/dev/fuse`, or `fusermount` are unavailable. //! It can be excluded via `RUSTFLAGS='--cfg pdu_test_skip_cross_device'`. #![cfg(unix)] @@ -63,158 +63,197 @@ fn same_device_on_sample_workspace() { ); } -/// Checks that `unshare --user --mount --map-root-user` is available and allows -/// mounting a tmpfs inside the created namespace. +/// Checks that `fuse2fs` and FUSE infrastructure are available. +/// +/// Verifies: +/// 1. `fuse2fs` binary exists +/// 2. `/dev/fuse` is accessible +/// 3. `fusermount` (or `fusermount3`) binary exists /// /// Returns `Ok(())` on success, or `Err` with a diagnostic message on failure. #[cfg(target_os = "linux")] #[cfg(not(pdu_test_skip_cross_device))] -fn unshare_available() -> Result<(), String> { - use command_extra::CommandExtra; - use std::process::Command; - let output = Command::new("unshare") - .with_args([ - "--user", - "--mount", - "--map-root-user", - "sh", - "-c", - "mountpoint=$(mktemp -d) && mount -t tmpfs tmpfs \"$mountpoint\" && umount \"$mountpoint\"", - ]) +fn fuse_available() -> Result<(), String> { + use std::{path::Path, process::Command}; + + // Check that fuse2fs is installed + Command::new("fuse2fs") + .arg("--help") .output() - .map_err(|error| format!("failed to execute unshare: {error}"))?; - if output.status.success() { - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!( - "unshare probe exited with {}: {}", - output.status, - stderr.trim(), - )) + .map_err(|error| format!("`fuse2fs` not found: {error}. Install e2fsprogs or fuse2fs."))?; + + // Check that /dev/fuse is accessible + if !Path::new("/dev/fuse").exists() { + return Err( + "/dev/fuse does not exist. The FUSE kernel module may not be loaded. \ + Try `modprobe fuse`." + .to_string(), + ); } + + // Check that fusermount is available (needed for unmounting) + let has_fusermount = Command::new("fusermount").arg("-V").output().is_ok(); + let has_fusermount3 = Command::new("fusermount3").arg("-V").output().is_ok(); + if !has_fusermount && !has_fusermount3 { + return Err( + "Neither `fusermount` nor `fusermount3` found. Install fuse or fuse3.".to_string(), + ); + } + + Ok(()) } /// When a subdirectory is a mount point for a different filesystem, `-x` should exclude it. /// -/// Uses `unshare --user --mount --map-root-user` to avoid requiring root privileges. -/// Skipped when user namespaces are unavailable. +/// Uses `fuse2fs` to mount an ext2 filesystem image via FUSE — no root privileges or +/// user namespaces required. +/// Skipped when FUSE infrastructure is unavailable. #[test] #[cfg(target_os = "linux")] #[cfg(not(pdu_test_skip_cross_device))] fn cross_device_excludes_mount() { use command_extra::CommandExtra; use std::{ - fmt::Write, + fs, process::{Command, Stdio}, + thread, + time::Duration, }; - if let Err(reason) = unshare_available() { + if let Err(reason) = fuse_available() { panic!( - "error: This test requires `unshare --user --mount --map-root-user` but the probe failed.\n\ + "error: This test requires FUSE (`fuse2fs`, `/dev/fuse`, `fusermount`) but the probe failed.\n\ reason: {reason}\n\ - hint: Either enable user namespaces or set `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` to skip this test.", + hint: Install e2fsprogs and fuse, or set `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` to skip this test.", ); } let pdu = env!("CARGO_BIN_EXE_pdu"); + let temp = Temp::new_dir().expect("create temp dir for cross-device test"); + let workspace = temp.join("workspace"); + let mount_point = workspace.join("mounted"); + let image_path = temp.join("ext2.img"); + + fs::create_dir_all(&mount_point).expect("create workspace and mount point"); + + // Write a file on the root filesystem let outside_content = "A".repeat(1000); - let inside_content = "B".repeat(2000); + fs::write(workspace.join("outside.txt"), &outside_content).expect("write outside.txt"); - // Build a shell script that creates a tmpfs mount inside a user namespace, - // writes files on both filesystems, and runs pdu with and without -x. - let mut script = String::new(); - writeln!(script, "TMPDIR=$(mktemp -d)").unwrap(); - writeln!(script, "mkdir -p \"$TMPDIR/mounted\"").unwrap(); - writeln!(script, "mount -t tmpfs tmpfs \"$TMPDIR/mounted\"").unwrap(); - writeln!( - script, - "printf '%s' '{outside_content}' > \"$TMPDIR/outside.txt\"" - ) - .unwrap(); - writeln!( - script, - "printf '%s' '{inside_content}' > \"$TMPDIR/mounted/inside.txt\"" - ) - .unwrap(); - // Write each pdu invocation's output to a separate file so we don't need - // to parse markers from a combined stdout. - writeln!(script, "WITHOUT_X=$(mktemp)").unwrap(); - writeln!(script, "WITH_X=$(mktemp)").unwrap(); - writeln!( - script, - "\"{pdu}\" --bytes-format=plain \"$TMPDIR\" >\"$WITHOUT_X\" 2>&1" - ) - .unwrap(); - writeln!( - script, - "\"{pdu}\" --bytes-format=plain -x \"$TMPDIR\" >\"$WITH_X\" 2>&1" - ) - .unwrap(); - writeln!(script, "umount \"$TMPDIR/mounted\"").unwrap(); - writeln!(script, "rm -rf \"$TMPDIR\"").unwrap(); - writeln!(script, "printf 'WITHOUT_X\\0'").unwrap(); - writeln!(script, "cat \"$WITHOUT_X\"").unwrap(); - writeln!(script, "printf '\\0WITH_X\\0'").unwrap(); - writeln!(script, "cat \"$WITH_X\"").unwrap(); - writeln!(script, "printf '\\0'").unwrap(); - writeln!(script, "rm -f \"$WITHOUT_X\" \"$WITH_X\"").unwrap(); - - let output = Command::new("unshare") - .with_args([ - "--user", - "--mount", - "--map-root-user", - "bash", - "-c", - &script, - ]) + // Create a small ext2 filesystem image (4 MiB) + let mkfs_output = Command::new("mkfs.ext2") + .with_args(["-F", "-q"]) + .with_arg(&image_path) + .with_arg("4096") // 4096 × 1K blocks = 4 MiB .with_stdout(Stdio::piped()) .with_stderr(Stdio::piped()) .output() - .expect("run unshare"); - - let stderr = String::from_utf8_lossy(&output.stderr); - if !stderr.is_empty() { - eprintln!("STDERR:\n{stderr}"); - } - assert!(output.status.success(), "unshare command failed"); - - let stdout = String::from_utf8_lossy(&output.stdout); - eprintln!("STDOUT:\n{stdout}"); - - let find_section = |label: &str| -> &str { - let label_start = stdout - .find(label) - .unwrap_or_else(|| panic!("missing {label} section in output:\n{stdout}")); - let content_start = label_start + label.len() + 1; // skip label + NUL - let content_end = stdout[content_start..] - .find('\0') - .map(|pos| content_start + pos) - .unwrap_or(stdout.len()); - stdout[content_start..content_end].trim() - }; - - let without_x = find_section("WITHOUT_X"); - let with_x = find_section("WITH_X"); - - // Without -x: should contain both "inside.txt" and "outside.txt" - assert!( - without_x.contains("inside.txt"), - "without -x should show inside.txt:\n{without_x}", - ); + .expect("run mkfs.ext2"); assert!( - without_x.contains("outside.txt"), - "without -x should show outside.txt:\n{without_x}", + mkfs_output.status.success(), + "mkfs.ext2 failed: {}", + String::from_utf8_lossy(&mkfs_output.stderr), ); - // With -x: should contain "outside.txt" but NOT "inside.txt" - assert!( - with_x.contains("outside.txt"), - "with -x should show outside.txt:\n{with_x}", - ); + // Mount the image via fuse2fs + let mount_output = Command::new("fuse2fs") + .with_arg(&image_path) + .with_arg(&mount_point) + .with_args(["-o", "rw"]) + .with_stdout(Stdio::piped()) + .with_stderr(Stdio::piped()) + .output() + .expect("run fuse2fs"); assert!( - !with_x.contains("inside.txt"), - "with -x should exclude inside.txt (on different filesystem):\n{with_x}", + mount_output.status.success(), + "fuse2fs mount failed: {}", + String::from_utf8_lossy(&mount_output.stderr), ); + + // Small delay to let FUSE settle + thread::sleep(Duration::from_millis(100)); + + // Write a file on the mounted (different) filesystem + let inside_content = "B".repeat(2000); + let write_result = fs::write(mount_point.join("inside.txt"), &inside_content); + + // Ensure we unmount even if assertions fail + let test_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + write_result.expect("write inside.txt on mounted filesystem"); + + // Run pdu WITHOUT -x — should see both files + let without_x = Command::new(pdu) + .with_args(["--bytes-format=plain"]) + .with_arg(&workspace) + .with_stdout(Stdio::piped()) + .with_stderr(Stdio::piped()) + .output() + .expect("run pdu without -x"); + let without_x_stdout = String::from_utf8_lossy(&without_x.stdout); + let without_x_stderr = String::from_utf8_lossy(&without_x.stderr); + if !without_x_stderr.is_empty() { + eprintln!("pdu (no -x) STDERR:\n{without_x_stderr}"); + } + eprintln!("pdu (no -x) STDOUT:\n{without_x_stdout}"); + assert!( + without_x.status.success(), + "pdu without -x failed: {without_x_stderr}", + ); + assert!( + without_x_stdout.contains("inside.txt"), + "without -x should show inside.txt:\n{without_x_stdout}", + ); + assert!( + without_x_stdout.contains("outside.txt"), + "without -x should show outside.txt:\n{without_x_stdout}", + ); + + // Run pdu WITH -x — should only see outside.txt + let with_x = Command::new(pdu) + .with_args(["--bytes-format=plain", "-x"]) + .with_arg(&workspace) + .with_stdout(Stdio::piped()) + .with_stderr(Stdio::piped()) + .output() + .expect("run pdu with -x"); + let with_x_stdout = String::from_utf8_lossy(&with_x.stdout); + let with_x_stderr = String::from_utf8_lossy(&with_x.stderr); + if !with_x_stderr.is_empty() { + eprintln!("pdu (-x) STDERR:\n{with_x_stderr}"); + } + eprintln!("pdu (-x) STDOUT:\n{with_x_stdout}"); + assert!( + with_x.status.success(), + "pdu with -x failed: {with_x_stderr}", + ); + assert!( + with_x_stdout.contains("outside.txt"), + "with -x should show outside.txt:\n{with_x_stdout}", + ); + assert!( + !with_x_stdout.contains("inside.txt"), + "with -x should exclude inside.txt (on different filesystem):\n{with_x_stdout}", + ); + })); + + // Always unmount — try fusermount first, fall back to fusermount3 + let unmount_status = Command::new("fusermount") + .with_arg("-u") + .with_arg(&mount_point) + .status() + .or_else(|_| { + Command::new("fusermount3") + .with_arg("-u") + .with_arg(&mount_point) + .status() + }); + match unmount_status { + Ok(status) if status.success() => {} + Ok(status) => eprintln!("warning: fusermount exited with {status}"), + Err(error) => eprintln!("warning: failed to run fusermount: {error}"), + } + + if let Err(payload) = test_result { + std::panic::resume_unwind(payload); + } } From a33c41783186389b5a5f866aaf3a67aec72486cc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 01:26:42 +0000 Subject: [PATCH 17/65] ci(test): install FUSE dependencies for cross-device test The `cross_device_excludes_mount` test requires `fuse2fs` (from e2fsprogs) and `fusermount3` (from fuse3) to mount an ext2 image via FUSE. Install these packages on Linux runners in both test.yaml and deploy.yaml so the test can run in CI without being skipped. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- .github/workflows/deploy.yaml | 4 ++++ .github/workflows/test.yaml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 4f9481a5..1c4a05e0 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -29,6 +29,10 @@ jobs: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > $installer bash $installer --default-toolchain $(cat rust-toolchain) -y + - name: Install FUSE dependencies + if: runner.os == 'Linux' + run: sudo apt-get install -y e2fsprogs fuse3 + - name: Test (dev) shell: bash env: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 34be7cfb..15f5b9b3 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -42,6 +42,10 @@ jobs: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > $installer bash $installer --default-toolchain $(cat rust-toolchain) -y + - name: Install FUSE dependencies + if: runner.os == 'Linux' + run: sudo apt-get install -y e2fsprogs fuse3 + - name: Test (dev) shell: bash env: From 6506aa3ab0958281da2b74aa47032c212b6ba6e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 01:36:37 +0000 Subject: [PATCH 18/65] ci(test): add fuse2fs package to FUSE dependency installation On Ubuntu, `fuse2fs` is a separate package from `e2fsprogs`. The previous commit only installed `e2fsprogs`, which does not include the `fuse2fs` binary needed by the cross-device test. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- .github/workflows/deploy.yaml | 2 +- .github/workflows/test.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 1c4a05e0..8e33b31e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -31,7 +31,7 @@ jobs: - name: Install FUSE dependencies if: runner.os == 'Linux' - run: sudo apt-get install -y e2fsprogs fuse3 + run: sudo apt-get install -y e2fsprogs fuse2fs fuse3 - name: Test (dev) shell: bash diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 15f5b9b3..53497634 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -44,7 +44,7 @@ jobs: - name: Install FUSE dependencies if: runner.os == 'Linux' - run: sudo apt-get install -y e2fsprogs fuse3 + run: sudo apt-get install -y e2fsprogs fuse2fs fuse3 - name: Test (dev) shell: bash From 02d93a53bdc3c6fa8de7487f0d3a4885a04bc4b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 01:39:58 +0000 Subject: [PATCH 19/65] refactor(test): return probe results from fuse_probe instead of discarding them Rename `fuse_available` to `fuse_probe` and have it return `FuseTools` containing the discovered fusermount command. The caller now uses this instead of re-probing at unmount time. Also fix misleading error messages: on Ubuntu, `fuse2fs` is a separate package from `e2fsprogs`, so the hints now recommend the correct package names. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 65 ++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 264ffea8..20b24ea9 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -63,24 +63,37 @@ fn same_device_on_sample_workspace() { ); } -/// Checks that `fuse2fs` and FUSE infrastructure are available. +/// Information about the available FUSE tools, discovered by [`fuse_probe`]. +#[cfg(target_os = "linux")] +#[cfg(not(pdu_test_skip_cross_device))] +struct FuseTools { + /// The fusermount command to use for unmounting (`"fusermount"` or `"fusermount3"`). + fusermount: &'static str, +} + +/// Probes for `fuse2fs` and FUSE infrastructure. /// /// Verifies: /// 1. `fuse2fs` binary exists /// 2. `/dev/fuse` is accessible /// 3. `fusermount` (or `fusermount3`) binary exists /// -/// Returns `Ok(())` on success, or `Err` with a diagnostic message on failure. +/// Returns `Ok(FuseTools)` with the discovered tool paths, or `Err` with a diagnostic message. #[cfg(target_os = "linux")] #[cfg(not(pdu_test_skip_cross_device))] -fn fuse_available() -> Result<(), String> { +fn fuse_probe() -> Result { use std::{path::Path, process::Command}; // Check that fuse2fs is installed Command::new("fuse2fs") .arg("--help") .output() - .map_err(|error| format!("`fuse2fs` not found: {error}. Install e2fsprogs or fuse2fs."))?; + .map_err(|error| { + format!( + "`fuse2fs` not found: {error}. \ + Install the `fuse2fs` package (or `e2fsprogs` on distros that bundle it)." + ) + })?; // Check that /dev/fuse is accessible if !Path::new("/dev/fuse").exists() { @@ -94,13 +107,17 @@ fn fuse_available() -> Result<(), String> { // Check that fusermount is available (needed for unmounting) let has_fusermount = Command::new("fusermount").arg("-V").output().is_ok(); let has_fusermount3 = Command::new("fusermount3").arg("-V").output().is_ok(); - if !has_fusermount && !has_fusermount3 { - return Err( - "Neither `fusermount` nor `fusermount3` found. Install fuse or fuse3.".to_string(), - ); - } + let fusermount = match (has_fusermount, has_fusermount3) { + (true, _) => "fusermount", + (_, true) => "fusermount3", + _ => { + return Err( + "Neither `fusermount` nor `fusermount3` found. Install fuse or fuse3.".to_string(), + ); + } + }; - Ok(()) + Ok(FuseTools { fusermount }) } /// When a subdirectory is a mount point for a different filesystem, `-x` should exclude it. @@ -120,13 +137,15 @@ fn cross_device_excludes_mount() { time::Duration, }; - if let Err(reason) = fuse_available() { - panic!( + let fuse_tools = match fuse_probe() { + Ok(tools) => tools, + Err(reason) => panic!( "error: This test requires FUSE (`fuse2fs`, `/dev/fuse`, `fusermount`) but the probe failed.\n\ reason: {reason}\n\ - hint: Install e2fsprogs and fuse, or set `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` to skip this test.", - ); - } + hint: Install `fuse2fs` and `fuse3` packages, or set \ + `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` to skip this test.", + ), + }; let pdu = env!("CARGO_BIN_EXE_pdu"); let temp = Temp::new_dir().expect("create temp dir for cross-device test"); @@ -236,21 +255,15 @@ fn cross_device_excludes_mount() { ); })); - // Always unmount — try fusermount first, fall back to fusermount3 - let unmount_status = Command::new("fusermount") + // Always unmount using the fusermount variant discovered by fuse_probe + let unmount_status = Command::new(fuse_tools.fusermount) .with_arg("-u") .with_arg(&mount_point) - .status() - .or_else(|_| { - Command::new("fusermount3") - .with_arg("-u") - .with_arg(&mount_point) - .status() - }); + .status(); match unmount_status { Ok(status) if status.success() => {} - Ok(status) => eprintln!("warning: fusermount exited with {status}"), - Err(error) => eprintln!("warning: failed to run fusermount: {error}"), + Ok(status) => eprintln!("warning: {} exited with {status}", fuse_tools.fusermount), + Err(error) => eprintln!("warning: failed to run {}: {error}", fuse_tools.fusermount), } if let Err(payload) = test_result { From 18b0d068e482d5dc798dca6d98ed75feb8ecd02f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 01:54:53 +0000 Subject: [PATCH 20/65] fix(test): use fakeroot option for fuse2fs mount The ext2 root directory is owned by root, so a non-root user cannot write to the mounted filesystem. Adding `fakeroot` to the mount options makes fuse2fs treat the calling user as root for permission checks. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 20b24ea9..80cac76b 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -178,7 +178,7 @@ fn cross_device_excludes_mount() { let mount_output = Command::new("fuse2fs") .with_arg(&image_path) .with_arg(&mount_point) - .with_args(["-o", "rw"]) + .with_args(["-o", "rw,fakeroot"]) .with_stdout(Stdio::piped()) .with_stderr(Stdio::piped()) .output() From c931b4229ec872cbbdd723f05bc732b6b816255a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 02:44:20 +0000 Subject: [PATCH 21/65] refactor(test): replace fuse2fs with squashfuse for cross-device test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use `mksquashfs` + `squashfuse` instead of `mkfs.ext2` + `fuse2fs`. The squashfs image is pre-built with the test file baked in, so the mount is read-only — which is sufficient since `pdu` only reads. This removes the `e2fsprogs` dependency and the need for `fakeroot` mount options. The new dependencies are `squashfs-tools` (for `mksquashfs`) and `squashfuse`. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 89 +++++++++++++++++++++++----------------- 1 file changed, 51 insertions(+), 38 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 80cac76b..b48b7ef6 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -7,12 +7,12 @@ //! //! ## Integration test via FUSE //! -//! [`cross_device_excludes_mount`] uses `fuse2fs` to mount an ext2 filesystem image via FUSE +//! [`cross_device_excludes_mount`] uses `squashfuse` to mount a squashfs image via FUSE //! (no root or user namespaces required) and checks that `-x` correctly excludes entries on //! the mounted filesystem. //! -//! The FUSE test panics when `fuse2fs`, `/dev/fuse`, or `fusermount` are unavailable. -//! It can be excluded via `RUSTFLAGS='--cfg pdu_test_skip_cross_device'`. +//! The FUSE test panics when `mksquashfs`, `squashfuse`, `/dev/fuse`, or `fusermount` are +//! unavailable. It can be excluded via `RUSTFLAGS='--cfg pdu_test_skip_cross_device'`. #![cfg(unix)] #![cfg(feature = "cli")] @@ -71,12 +71,13 @@ struct FuseTools { fusermount: &'static str, } -/// Probes for `fuse2fs` and FUSE infrastructure. +/// Probes for `squashfuse`, `mksquashfs`, and FUSE infrastructure. /// /// Verifies: -/// 1. `fuse2fs` binary exists -/// 2. `/dev/fuse` is accessible -/// 3. `fusermount` (or `fusermount3`) binary exists +/// 1. `mksquashfs` binary exists +/// 2. `squashfuse` binary exists +/// 3. `/dev/fuse` is accessible +/// 4. `fusermount` (or `fusermount3`) binary exists /// /// Returns `Ok(FuseTools)` with the discovered tool paths, or `Err` with a diagnostic message. #[cfg(target_os = "linux")] @@ -84,14 +85,25 @@ struct FuseTools { fn fuse_probe() -> Result { use std::{path::Path, process::Command}; - // Check that fuse2fs is installed - Command::new("fuse2fs") + // Check that mksquashfs is installed + Command::new("mksquashfs") + .arg("-version") + .output() + .map_err(|error| { + format!( + "`mksquashfs` not found: {error}. \ + Install via `apt install squashfs-tools`." + ) + })?; + + // Check that squashfuse is installed + Command::new("squashfuse") .arg("--help") .output() .map_err(|error| { format!( - "`fuse2fs` not found: {error}. \ - Install the `fuse2fs` package (or `e2fsprogs` on distros that bundle it)." + "`squashfuse` not found: {error}. \ + Install via `apt install squashfuse`." ) })?; @@ -111,9 +123,9 @@ fn fuse_probe() -> Result { (true, _) => "fusermount", (_, true) => "fusermount3", _ => { - return Err( - "Neither `fusermount` nor `fusermount3` found. Install fuse or fuse3.".to_string(), - ); + return Err("Neither `fusermount` nor `fusermount3` found. \ + Install via `apt install fuse3`." + .to_string()); } }; @@ -122,8 +134,9 @@ fn fuse_probe() -> Result { /// When a subdirectory is a mount point for a different filesystem, `-x` should exclude it. /// -/// Uses `fuse2fs` to mount an ext2 filesystem image via FUSE — no root privileges or -/// user namespaces required. +/// Uses `squashfuse` to mount a squashfs image via FUSE — no root privileges or +/// user namespaces required. The image is pre-built with `mksquashfs` containing the +/// test file, so the mount is read-only (which is fine since `pdu` only reads). /// Skipped when FUSE infrastructure is unavailable. #[test] #[cfg(target_os = "linux")] @@ -140,9 +153,10 @@ fn cross_device_excludes_mount() { let fuse_tools = match fuse_probe() { Ok(tools) => tools, Err(reason) => panic!( - "error: This test requires FUSE (`fuse2fs`, `/dev/fuse`, `fusermount`) but the probe failed.\n\ + "error: This test requires FUSE (`mksquashfs`, `squashfuse`, `/dev/fuse`, \ + `fusermount`) but the probe failed.\n\ reason: {reason}\n\ - hint: Install `fuse2fs` and `fuse3` packages, or set \ + hint: Install via `apt install squashfs-tools squashfuse fuse3`, or set \ `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` to skip this test.", ), }; @@ -151,55 +165,54 @@ fn cross_device_excludes_mount() { let temp = Temp::new_dir().expect("create temp dir for cross-device test"); let workspace = temp.join("workspace"); let mount_point = workspace.join("mounted"); - let image_path = temp.join("ext2.img"); + let image_path = temp.join("squash.img"); + let staging_dir = temp.join("staging"); fs::create_dir_all(&mount_point).expect("create workspace and mount point"); + fs::create_dir_all(&staging_dir).expect("create staging directory"); // Write a file on the root filesystem let outside_content = "A".repeat(1000); fs::write(workspace.join("outside.txt"), &outside_content).expect("write outside.txt"); - // Create a small ext2 filesystem image (4 MiB) - let mkfs_output = Command::new("mkfs.ext2") - .with_args(["-F", "-q"]) + // Create a file in the staging directory to be packed into the squashfs image + let inside_content = "B".repeat(2000); + fs::write(staging_dir.join("inside.txt"), &inside_content).expect("write staging/inside.txt"); + + // Build a squashfs image from the staging directory + let mksquashfs_output = Command::new("mksquashfs") + .with_arg(&staging_dir) .with_arg(&image_path) - .with_arg("4096") // 4096 × 1K blocks = 4 MiB + .with_args(["-noappend", "-quiet"]) .with_stdout(Stdio::piped()) .with_stderr(Stdio::piped()) .output() - .expect("run mkfs.ext2"); + .expect("run mksquashfs"); assert!( - mkfs_output.status.success(), - "mkfs.ext2 failed: {}", - String::from_utf8_lossy(&mkfs_output.stderr), + mksquashfs_output.status.success(), + "mksquashfs failed: {}", + String::from_utf8_lossy(&mksquashfs_output.stderr), ); - // Mount the image via fuse2fs - let mount_output = Command::new("fuse2fs") + // Mount the squashfs image via squashfuse (read-only) + let mount_output = Command::new("squashfuse") .with_arg(&image_path) .with_arg(&mount_point) - .with_args(["-o", "rw,fakeroot"]) .with_stdout(Stdio::piped()) .with_stderr(Stdio::piped()) .output() - .expect("run fuse2fs"); + .expect("run squashfuse"); assert!( mount_output.status.success(), - "fuse2fs mount failed: {}", + "squashfuse mount failed: {}", String::from_utf8_lossy(&mount_output.stderr), ); // Small delay to let FUSE settle thread::sleep(Duration::from_millis(100)); - // Write a file on the mounted (different) filesystem - let inside_content = "B".repeat(2000); - let write_result = fs::write(mount_point.join("inside.txt"), &inside_content); - // Ensure we unmount even if assertions fail let test_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - write_result.expect("write inside.txt on mounted filesystem"); - // Run pdu WITHOUT -x — should see both files let without_x = Command::new(pdu) .with_args(["--bytes-format=plain"]) From bbbef2af7cd79538c9b763970c19a967691afcfe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 02:48:49 +0000 Subject: [PATCH 22/65] ci: update FUSE dependencies from e2fsprogs to squashfs-tools Replace `e2fsprogs fuse2fs` with `squashfs-tools squashfuse` to match the test's switch to squashfuse. Also use `apt` instead of `apt-get` for consistency with the rest of the workflow files. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- .github/workflows/deploy.yaml | 2 +- .github/workflows/test.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 8e33b31e..73245c8b 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -31,7 +31,7 @@ jobs: - name: Install FUSE dependencies if: runner.os == 'Linux' - run: sudo apt-get install -y e2fsprogs fuse2fs fuse3 + run: sudo apt install -y squashfs-tools squashfuse fuse3 - name: Test (dev) shell: bash diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 53497634..9de91c95 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -44,7 +44,7 @@ jobs: - name: Install FUSE dependencies if: runner.os == 'Linux' - run: sudo apt-get install -y e2fsprogs fuse2fs fuse3 + run: sudo apt install -y squashfs-tools squashfuse fuse3 - name: Test (dev) shell: bash From 1af976daeae95c1a1f52fa2f4558d95dce1a6c6f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 02:53:17 +0000 Subject: [PATCH 23/65] fix(ci): add apt update and use generic error messages Add `apt update` before installing squashfuse packages in CI workflows to ensure the package index is fresh. Replace distro-specific `apt install` suggestions in test error messages with generic "install for your platform" phrasing. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- .github/workflows/deploy.yaml | 2 +- .github/workflows/test.yaml | 2 +- tests/one_file_system.rs | 29 +++++++++++------------------ 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 73245c8b..5a7c9cc6 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -31,7 +31,7 @@ jobs: - name: Install FUSE dependencies if: runner.os == 'Linux' - run: sudo apt install -y squashfs-tools squashfuse fuse3 + run: sudo apt update && sudo apt install -y squashfs-tools squashfuse fuse3 - name: Test (dev) shell: bash diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 9de91c95..f718b710 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -44,7 +44,7 @@ jobs: - name: Install FUSE dependencies if: runner.os == 'Linux' - run: sudo apt install -y squashfs-tools squashfuse fuse3 + run: sudo apt update && sudo apt install -y squashfs-tools squashfuse fuse3 - name: Test (dev) shell: bash diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index b48b7ef6..6a5f0362 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -90,10 +90,7 @@ fn fuse_probe() -> Result { .arg("-version") .output() .map_err(|error| { - format!( - "`mksquashfs` not found: {error}. \ - Install via `apt install squashfs-tools`." - ) + format!("`mksquashfs` not found: {error}. Install squashfs-tools for your platform.") })?; // Check that squashfuse is installed @@ -101,19 +98,14 @@ fn fuse_probe() -> Result { .arg("--help") .output() .map_err(|error| { - format!( - "`squashfuse` not found: {error}. \ - Install via `apt install squashfuse`." - ) + format!("`squashfuse` not found: {error}. Install squashfuse for your platform.") })?; // Check that /dev/fuse is accessible if !Path::new("/dev/fuse").exists() { - return Err( - "/dev/fuse does not exist. The FUSE kernel module may not be loaded. \ - Try `modprobe fuse`." - .to_string(), - ); + return Err("/dev/fuse does not exist. \ + The FUSE kernel module may not be loaded (`modprobe fuse`)." + .to_string()); } // Check that fusermount is available (needed for unmounting) @@ -123,9 +115,10 @@ fn fuse_probe() -> Result { (true, _) => "fusermount", (_, true) => "fusermount3", _ => { - return Err("Neither `fusermount` nor `fusermount3` found. \ - Install via `apt install fuse3`." - .to_string()); + return Err( + "Neither `fusermount` nor `fusermount3` found. Install FUSE for your platform." + .to_string(), + ); } }; @@ -156,8 +149,8 @@ fn cross_device_excludes_mount() { "error: This test requires FUSE (`mksquashfs`, `squashfuse`, `/dev/fuse`, \ `fusermount`) but the probe failed.\n\ reason: {reason}\n\ - hint: Install via `apt install squashfs-tools squashfuse fuse3`, or set \ - `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` to skip this test.", + hint: Install `squashfs-tools`, `squashfuse`, and FUSE for your platform, \ + or set `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` to skip this test.", ), }; From f3a73e06d04a45a62f8209a22055eac702c9a8a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 03:26:59 +0000 Subject: [PATCH 24/65] refactor(test): improve FUSE probe and cleanup in cross-device test - Use `which` crate instead of executing binaries to probe for tools - Prefer `fusermount3` (libfuse v3) over `fusermount` (libfuse v2) - Replace `catch_unwind` with `FuseMount` Drop guard for cleanup - Use `unwrap_or_else` instead of match for `fuse_probe` result - Use `pipe-trait` for `Command::new` in `FuseMount::drop` - Use generic error messages instead of distro-specific `apt install` - Reformat `unexpected_cfgs` in Cargo.toml to multi-line style https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- Cargo.lock | 10 ++ Cargo.toml | 7 +- tests/one_file_system.rs | 218 ++++++++++++++++++++------------------- 3 files changed, 127 insertions(+), 108 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa28fb2e..dcf9b338 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -657,6 +657,7 @@ dependencies = [ "sysinfo", "terminal_size", "text-block-macros", + "which", "zero-copy-pads", ] @@ -1040,6 +1041,15 @@ dependencies = [ "semver", ] +[[package]] +name = "which" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +dependencies = [ + "libc", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 02828868..13b65853 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,11 @@ maplit = "1.0.2" normalize-path = "0.2.1" pretty_assertions = "1.4.1" rand = "0.10.0" +which = "8.0.2" [lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(pdu_test_skip_fs_errors)', 'cfg(pdu_test_skip_cross_device)'] } +unexpected_cfgs.level = "warn" +unexpected_cfgs.check-cfg = [ + 'cfg(pdu_test_skip_fs_errors)', + 'cfg(pdu_test_skip_cross_device)', +] diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 6a5f0362..dc32c657 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -29,6 +29,7 @@ use parallel_disk_usage::{ reporter::{ErrorOnlyReporter, ErrorReport}, size::Bytes, }; +use pipe_trait::Pipe; use pretty_assertions::assert_eq; /// When all files reside on a single filesystem, `one_file_system: true` should produce @@ -67,7 +68,7 @@ fn same_device_on_sample_workspace() { #[cfg(target_os = "linux")] #[cfg(not(pdu_test_skip_cross_device))] struct FuseTools { - /// The fusermount command to use for unmounting (`"fusermount"` or `"fusermount3"`). + /// The fusermount command to use for unmounting (`"fusermount3"` or `"fusermount"`). fusermount: &'static str, } @@ -77,54 +78,71 @@ struct FuseTools { /// 1. `mksquashfs` binary exists /// 2. `squashfuse` binary exists /// 3. `/dev/fuse` is accessible -/// 4. `fusermount` (or `fusermount3`) binary exists +/// 4. `fusermount3` (or `fusermount`) binary exists /// /// Returns `Ok(FuseTools)` with the discovered tool paths, or `Err` with a diagnostic message. #[cfg(target_os = "linux")] #[cfg(not(pdu_test_skip_cross_device))] fn fuse_probe() -> Result { - use std::{path::Path, process::Command}; + use std::path::Path; - // Check that mksquashfs is installed - Command::new("mksquashfs") - .arg("-version") - .output() - .map_err(|error| { - format!("`mksquashfs` not found: {error}. Install squashfs-tools for your platform.") - })?; + which::which("mksquashfs").map_err(|error| { + format!("`mksquashfs` not found: {error}. Install squashfs-tools for your platform.") + })?; - // Check that squashfuse is installed - Command::new("squashfuse") - .arg("--help") - .output() - .map_err(|error| { - format!("`squashfuse` not found: {error}. Install squashfuse for your platform.") - })?; + which::which("squashfuse").map_err(|error| { + format!("`squashfuse` not found: {error}. Install squashfuse for your platform.") + })?; - // Check that /dev/fuse is accessible if !Path::new("/dev/fuse").exists() { - return Err("/dev/fuse does not exist. \ - The FUSE kernel module may not be loaded (`modprobe fuse`)." - .to_string()); + return Err( + "/dev/fuse does not exist. The FUSE kernel module may not be loaded (`modprobe fuse`)." + .to_string(), + ); } - // Check that fusermount is available (needed for unmounting) - let has_fusermount = Command::new("fusermount").arg("-V").output().is_ok(); - let has_fusermount3 = Command::new("fusermount3").arg("-V").output().is_ok(); - let fusermount = match (has_fusermount, has_fusermount3) { - (true, _) => "fusermount", - (_, true) => "fusermount3", - _ => { - return Err( - "Neither `fusermount` nor `fusermount3` found. Install FUSE for your platform." - .to_string(), - ); - } + // Prefer fusermount3 (libfuse v3, actively developed) over fusermount (libfuse v2) + let fusermount = if which::which("fusermount3").is_ok() { + "fusermount3" + } else if which::which("fusermount").is_ok() { + "fusermount" + } else { + return Err( + "Neither `fusermount3` nor `fusermount` found. Install FUSE for your platform." + .to_string(), + ); }; Ok(FuseTools { fusermount }) } +/// RAII guard that unmounts a FUSE mount point on drop. +#[cfg(target_os = "linux")] +#[cfg(not(pdu_test_skip_cross_device))] +struct FuseMount { + mount_point: std::path::PathBuf, + fusermount: &'static str, +} + +#[cfg(target_os = "linux")] +#[cfg(not(pdu_test_skip_cross_device))] +impl Drop for FuseMount { + fn drop(&mut self) { + use command_extra::CommandExtra; + let status = self + .fusermount + .pipe(std::process::Command::new) + .with_arg("-u") + .with_arg(&self.mount_point) + .status(); + match status { + Ok(status) if status.success() => {} + Ok(status) => eprintln!("warning: {} exited with {status}", self.fusermount), + Err(error) => eprintln!("warning: failed to run {}: {error}", self.fusermount), + } + } +} + /// When a subdirectory is a mount point for a different filesystem, `-x` should exclude it. /// /// Uses `squashfuse` to mount a squashfs image via FUSE — no root privileges or @@ -143,16 +161,15 @@ fn cross_device_excludes_mount() { time::Duration, }; - let fuse_tools = match fuse_probe() { - Ok(tools) => tools, - Err(reason) => panic!( + let fuse_tools = fuse_probe().unwrap_or_else(|reason| { + panic!( "error: This test requires FUSE (`mksquashfs`, `squashfuse`, `/dev/fuse`, \ `fusermount`) but the probe failed.\n\ reason: {reason}\n\ hint: Install `squashfs-tools`, `squashfuse`, and FUSE for your platform, \ or set `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` to skip this test.", - ), - }; + ) + }); let pdu = env!("CARGO_BIN_EXE_pdu"); let temp = Temp::new_dir().expect("create temp dir for cross-device test"); @@ -187,7 +204,8 @@ fn cross_device_excludes_mount() { String::from_utf8_lossy(&mksquashfs_output.stderr), ); - // Mount the squashfs image via squashfuse (read-only) + // Mount the squashfs image via squashfuse (read-only). + // The _fuse_mount guard ensures we unmount even if assertions panic. let mount_output = Command::new("squashfuse") .with_arg(&image_path) .with_arg(&mount_point) @@ -200,79 +218,65 @@ fn cross_device_excludes_mount() { "squashfuse mount failed: {}", String::from_utf8_lossy(&mount_output.stderr), ); + let _fuse_mount = FuseMount { + mount_point: mount_point.clone(), + fusermount: fuse_tools.fusermount, + }; // Small delay to let FUSE settle thread::sleep(Duration::from_millis(100)); - // Ensure we unmount even if assertions fail - let test_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - // Run pdu WITHOUT -x — should see both files - let without_x = Command::new(pdu) - .with_args(["--bytes-format=plain"]) - .with_arg(&workspace) - .with_stdout(Stdio::piped()) - .with_stderr(Stdio::piped()) - .output() - .expect("run pdu without -x"); - let without_x_stdout = String::from_utf8_lossy(&without_x.stdout); - let without_x_stderr = String::from_utf8_lossy(&without_x.stderr); - if !without_x_stderr.is_empty() { - eprintln!("pdu (no -x) STDERR:\n{without_x_stderr}"); - } - eprintln!("pdu (no -x) STDOUT:\n{without_x_stdout}"); - assert!( - without_x.status.success(), - "pdu without -x failed: {without_x_stderr}", - ); - assert!( - without_x_stdout.contains("inside.txt"), - "without -x should show inside.txt:\n{without_x_stdout}", - ); - assert!( - without_x_stdout.contains("outside.txt"), - "without -x should show outside.txt:\n{without_x_stdout}", - ); - - // Run pdu WITH -x — should only see outside.txt - let with_x = Command::new(pdu) - .with_args(["--bytes-format=plain", "-x"]) - .with_arg(&workspace) - .with_stdout(Stdio::piped()) - .with_stderr(Stdio::piped()) - .output() - .expect("run pdu with -x"); - let with_x_stdout = String::from_utf8_lossy(&with_x.stdout); - let with_x_stderr = String::from_utf8_lossy(&with_x.stderr); - if !with_x_stderr.is_empty() { - eprintln!("pdu (-x) STDERR:\n{with_x_stderr}"); - } - eprintln!("pdu (-x) STDOUT:\n{with_x_stdout}"); - assert!( - with_x.status.success(), - "pdu with -x failed: {with_x_stderr}", - ); - assert!( - with_x_stdout.contains("outside.txt"), - "with -x should show outside.txt:\n{with_x_stdout}", - ); - assert!( - !with_x_stdout.contains("inside.txt"), - "with -x should exclude inside.txt (on different filesystem):\n{with_x_stdout}", - ); - })); - - // Always unmount using the fusermount variant discovered by fuse_probe - let unmount_status = Command::new(fuse_tools.fusermount) - .with_arg("-u") - .with_arg(&mount_point) - .status(); - match unmount_status { - Ok(status) if status.success() => {} - Ok(status) => eprintln!("warning: {} exited with {status}", fuse_tools.fusermount), - Err(error) => eprintln!("warning: failed to run {}: {error}", fuse_tools.fusermount), + // Run pdu WITHOUT -x — should see both files + let without_x = Command::new(pdu) + .with_args(["--bytes-format=plain"]) + .with_arg(&workspace) + .with_stdout(Stdio::piped()) + .with_stderr(Stdio::piped()) + .output() + .expect("run pdu without -x"); + let without_x_stdout = String::from_utf8_lossy(&without_x.stdout); + let without_x_stderr = String::from_utf8_lossy(&without_x.stderr); + if !without_x_stderr.is_empty() { + eprintln!("pdu (no -x) STDERR:\n{without_x_stderr}"); } + eprintln!("pdu (no -x) STDOUT:\n{without_x_stdout}"); + assert!( + without_x.status.success(), + "pdu without -x failed: {without_x_stderr}", + ); + assert!( + without_x_stdout.contains("inside.txt"), + "without -x should show inside.txt:\n{without_x_stdout}", + ); + assert!( + without_x_stdout.contains("outside.txt"), + "without -x should show outside.txt:\n{without_x_stdout}", + ); - if let Err(payload) = test_result { - std::panic::resume_unwind(payload); + // Run pdu WITH -x — should only see outside.txt + let with_x = Command::new(pdu) + .with_args(["--bytes-format=plain", "-x"]) + .with_arg(&workspace) + .with_stdout(Stdio::piped()) + .with_stderr(Stdio::piped()) + .output() + .expect("run pdu with -x"); + let with_x_stdout = String::from_utf8_lossy(&with_x.stdout); + let with_x_stderr = String::from_utf8_lossy(&with_x.stderr); + if !with_x_stderr.is_empty() { + eprintln!("pdu (-x) STDERR:\n{with_x_stderr}"); } + eprintln!("pdu (-x) STDOUT:\n{with_x_stdout}"); + assert!( + with_x.status.success(), + "pdu with -x failed: {with_x_stderr}", + ); + assert!( + with_x_stdout.contains("outside.txt"), + "with -x should show outside.txt:\n{with_x_stdout}", + ); + assert!( + !with_x_stdout.contains("inside.txt"), + "with -x should exclude inside.txt (on different filesystem):\n{with_x_stdout}", + ); } From 17a7c1d2d80c745be5880f055c1ff608bb842598 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 03:48:06 +0000 Subject: [PATCH 25/65] fix(test): replace fixed sleep with exponential backoff for FUSE mount Poll `read_dir` on the mount point with exponential backoff (100ms, 200ms, 400ms, 800ms, 1600ms) instead of a fixed 100ms sleep, to handle slow CI environments where FUSE takes longer to settle. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index dc32c657..414dfa17 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -223,8 +223,22 @@ fn cross_device_excludes_mount() { fusermount: fuse_tools.fusermount, }; - // Small delay to let FUSE settle - thread::sleep(Duration::from_millis(100)); + // Wait for the FUSE mount to become readable (exponential backoff) + let mut wait_ms = 100; + for attempt in 0..6 { + if mount_point + .read_dir() + .is_ok_and(|mut entries| entries.next().is_some()) + { + break; + } + assert!( + attempt < 5, + "FUSE mount at {mount_point:?} not ready after retries" + ); + thread::sleep(Duration::from_millis(wait_ms)); + wait_ms *= 2; + } // Run pdu WITHOUT -x — should see both files let without_x = Command::new(pdu) From 43e80b9c5fb5c3a5c3d26f62440ff34df5631eb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 03:49:53 +0000 Subject: [PATCH 26/65] refactor(test): use cfg_attr(ignore) for cross-device test skipping Replace `#[cfg(not(pdu_test_skip_cross_device))]` with `#[cfg_attr(pdu_test_skip_cross_device, ignore)]` on the test function, and remove the same gate from FuseTools, fuse_probe, and FuseMount so they are still compiled when the flag is set, catching type errors early. Gate `pipe_trait::Pipe` import with `#[cfg(target_os = "linux")]` to fix unused import warning on macOS. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 414dfa17..a3089ab5 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -29,6 +29,7 @@ use parallel_disk_usage::{ reporter::{ErrorOnlyReporter, ErrorReport}, size::Bytes, }; +#[cfg(target_os = "linux")] use pipe_trait::Pipe; use pretty_assertions::assert_eq; @@ -66,7 +67,6 @@ fn same_device_on_sample_workspace() { /// Information about the available FUSE tools, discovered by [`fuse_probe`]. #[cfg(target_os = "linux")] -#[cfg(not(pdu_test_skip_cross_device))] struct FuseTools { /// The fusermount command to use for unmounting (`"fusermount3"` or `"fusermount"`). fusermount: &'static str, @@ -82,7 +82,6 @@ struct FuseTools { /// /// Returns `Ok(FuseTools)` with the discovered tool paths, or `Err` with a diagnostic message. #[cfg(target_os = "linux")] -#[cfg(not(pdu_test_skip_cross_device))] fn fuse_probe() -> Result { use std::path::Path; @@ -118,14 +117,12 @@ fn fuse_probe() -> Result { /// RAII guard that unmounts a FUSE mount point on drop. #[cfg(target_os = "linux")] -#[cfg(not(pdu_test_skip_cross_device))] struct FuseMount { mount_point: std::path::PathBuf, fusermount: &'static str, } #[cfg(target_os = "linux")] -#[cfg(not(pdu_test_skip_cross_device))] impl Drop for FuseMount { fn drop(&mut self) { use command_extra::CommandExtra; @@ -151,7 +148,10 @@ impl Drop for FuseMount { /// Skipped when FUSE infrastructure is unavailable. #[test] #[cfg(target_os = "linux")] -#[cfg(not(pdu_test_skip_cross_device))] +#[cfg_attr( + pdu_test_skip_cross_device, + ignore = "pdu_test_skip_cross_device is set" +)] fn cross_device_excludes_mount() { use command_extra::CommandExtra; use std::{ From 25822458019d9dca7b7d1375260b5fb27749b5fa Mon Sep 17 00:00:00 2001 From: khai96_ Date: Mon, 23 Mar 2026 11:00:27 +0700 Subject: [PATCH 27/65] test: remove the `cfg` --- tests/one_file_system.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index a3089ab5..8b980fef 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -29,7 +29,6 @@ use parallel_disk_usage::{ reporter::{ErrorOnlyReporter, ErrorReport}, size::Bytes, }; -#[cfg(target_os = "linux")] use pipe_trait::Pipe; use pretty_assertions::assert_eq; @@ -66,7 +65,6 @@ fn same_device_on_sample_workspace() { } /// Information about the available FUSE tools, discovered by [`fuse_probe`]. -#[cfg(target_os = "linux")] struct FuseTools { /// The fusermount command to use for unmounting (`"fusermount3"` or `"fusermount"`). fusermount: &'static str, @@ -81,7 +79,6 @@ struct FuseTools { /// 4. `fusermount3` (or `fusermount`) binary exists /// /// Returns `Ok(FuseTools)` with the discovered tool paths, or `Err` with a diagnostic message. -#[cfg(target_os = "linux")] fn fuse_probe() -> Result { use std::path::Path; @@ -116,13 +113,11 @@ fn fuse_probe() -> Result { } /// RAII guard that unmounts a FUSE mount point on drop. -#[cfg(target_os = "linux")] struct FuseMount { mount_point: std::path::PathBuf, fusermount: &'static str, } -#[cfg(target_os = "linux")] impl Drop for FuseMount { fn drop(&mut self) { use command_extra::CommandExtra; @@ -147,7 +142,7 @@ impl Drop for FuseMount { /// test file, so the mount is read-only (which is fine since `pdu` only reads). /// Skipped when FUSE infrastructure is unavailable. #[test] -#[cfg(target_os = "linux")] +#[cfg_attr(not(target_os = "linux"), ignore = "this test only works on Linux")] #[cfg_attr( pdu_test_skip_cross_device, ignore = "pdu_test_skip_cross_device is set" From b8fc756c148129efa3f055f16e2a5ff195f464e6 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Mon, 23 Mar 2026 11:06:22 +0700 Subject: [PATCH 28/65] refactor: unify `use` --- tests/one_file_system.rs | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 8b980fef..f200fc22 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -20,6 +20,7 @@ pub mod _utils; pub use _utils::*; +use command_extra::CommandExtra; use parallel_disk_usage::{ data_tree::DataTree, fs_tree_builder::FsTreeBuilder, @@ -31,6 +32,14 @@ use parallel_disk_usage::{ }; use pipe_trait::Pipe; use pretty_assertions::assert_eq; +use std::{ + fs::{create_dir_all, write as write_file}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + thread, + time::Duration, +}; +use which::which; /// When all files reside on a single filesystem, `one_file_system: true` should produce /// the same tree as `one_file_system: false`. @@ -80,13 +89,11 @@ struct FuseTools { /// /// Returns `Ok(FuseTools)` with the discovered tool paths, or `Err` with a diagnostic message. fn fuse_probe() -> Result { - use std::path::Path; - - which::which("mksquashfs").map_err(|error| { + which("mksquashfs").map_err(|error| { format!("`mksquashfs` not found: {error}. Install squashfs-tools for your platform.") })?; - which::which("squashfuse").map_err(|error| { + which("squashfuse").map_err(|error| { format!("`squashfuse` not found: {error}. Install squashfuse for your platform.") })?; @@ -98,9 +105,9 @@ fn fuse_probe() -> Result { } // Prefer fusermount3 (libfuse v3, actively developed) over fusermount (libfuse v2) - let fusermount = if which::which("fusermount3").is_ok() { + let fusermount = if which("fusermount3").is_ok() { "fusermount3" - } else if which::which("fusermount").is_ok() { + } else if which("fusermount").is_ok() { "fusermount" } else { return Err( @@ -114,16 +121,15 @@ fn fuse_probe() -> Result { /// RAII guard that unmounts a FUSE mount point on drop. struct FuseMount { - mount_point: std::path::PathBuf, + mount_point: PathBuf, fusermount: &'static str, } impl Drop for FuseMount { fn drop(&mut self) { - use command_extra::CommandExtra; let status = self .fusermount - .pipe(std::process::Command::new) + .pipe(Command::new) .with_arg("-u") .with_arg(&self.mount_point) .status(); @@ -148,14 +154,6 @@ impl Drop for FuseMount { ignore = "pdu_test_skip_cross_device is set" )] fn cross_device_excludes_mount() { - use command_extra::CommandExtra; - use std::{ - fs, - process::{Command, Stdio}, - thread, - time::Duration, - }; - let fuse_tools = fuse_probe().unwrap_or_else(|reason| { panic!( "error: This test requires FUSE (`mksquashfs`, `squashfuse`, `/dev/fuse`, \ @@ -173,16 +171,16 @@ fn cross_device_excludes_mount() { let image_path = temp.join("squash.img"); let staging_dir = temp.join("staging"); - fs::create_dir_all(&mount_point).expect("create workspace and mount point"); - fs::create_dir_all(&staging_dir).expect("create staging directory"); + create_dir_all(&mount_point).expect("create workspace and mount point"); + create_dir_all(&staging_dir).expect("create staging directory"); // Write a file on the root filesystem let outside_content = "A".repeat(1000); - fs::write(workspace.join("outside.txt"), &outside_content).expect("write outside.txt"); + write_file(workspace.join("outside.txt"), &outside_content).expect("write outside.txt"); // Create a file in the staging directory to be packed into the squashfs image let inside_content = "B".repeat(2000); - fs::write(staging_dir.join("inside.txt"), &inside_content).expect("write staging/inside.txt"); + write_file(staging_dir.join("inside.txt"), &inside_content).expect("write staging/inside.txt"); // Build a squashfs image from the staging directory let mksquashfs_output = Command::new("mksquashfs") From 9dc746543077d854858d463067c819a854a41743 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Mon, 23 Mar 2026 11:18:40 +0700 Subject: [PATCH 29/65] refactor: use `with_arg` --- tests/one_file_system.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index f200fc22..7e9adc6d 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -186,7 +186,8 @@ fn cross_device_excludes_mount() { let mksquashfs_output = Command::new("mksquashfs") .with_arg(&staging_dir) .with_arg(&image_path) - .with_args(["-noappend", "-quiet"]) + .with_arg("-noappend") + .with_arg("-quiet") .with_stdout(Stdio::piped()) .with_stderr(Stdio::piped()) .output() @@ -235,7 +236,7 @@ fn cross_device_excludes_mount() { // Run pdu WITHOUT -x — should see both files let without_x = Command::new(pdu) - .with_args(["--bytes-format=plain"]) + .with_arg("--bytes-format=plain") .with_arg(&workspace) .with_stdout(Stdio::piped()) .with_stderr(Stdio::piped()) @@ -262,7 +263,8 @@ fn cross_device_excludes_mount() { // Run pdu WITH -x — should only see outside.txt let with_x = Command::new(pdu) - .with_args(["--bytes-format=plain", "-x"]) + .with_arg("--bytes-format=plain") + .with_arg("-x") .with_arg(&workspace) .with_stdout(Stdio::piped()) .with_stderr(Stdio::piped()) From e653d9bb829ea7e57b27b5567d181ed13a65978a Mon Sep 17 00:00:00 2001 From: khai96_ Date: Mon, 23 Mar 2026 11:25:42 +0700 Subject: [PATCH 30/65] docs: remove some useless comments --- tests/one_file_system.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 7e9adc6d..8108cc47 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -104,7 +104,6 @@ fn fuse_probe() -> Result { ); } - // Prefer fusermount3 (libfuse v3, actively developed) over fusermount (libfuse v2) let fusermount = if which("fusermount3").is_ok() { "fusermount3" } else if which("fusermount").is_ok() { @@ -119,7 +118,6 @@ fn fuse_probe() -> Result { Ok(FuseTools { fusermount }) } -/// RAII guard that unmounts a FUSE mount point on drop. struct FuseMount { mount_point: PathBuf, fusermount: &'static str, From 884bca2812540f904e179debad8b82f01a08da18 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 04:52:02 +0000 Subject: [PATCH 31/65] refactor(test): use long flags and full tree comparison in cross-device test Replace `-x` with `--one-file-system` in CLI invocations and comments. Replace contains-based assertions with full tree comparison using `Visualizer` with `--quantity=apparent-size --total-width=100 --bytes-format=plain`, matching the pattern in `usual_cli` tests. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 119 ++++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 58 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 8108cc47..969014d5 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -1,4 +1,4 @@ -//! Tests for the `--one-file-system` / `-x` flag. +//! Tests for the `--one-file-system` flag. //! //! ## Unit-style test //! @@ -8,8 +8,8 @@ //! ## Integration test via FUSE //! //! [`cross_device_excludes_mount`] uses `squashfuse` to mount a squashfs image via FUSE -//! (no root or user namespaces required) and checks that `-x` correctly excludes entries on -//! the mounted filesystem. +//! (no root or user namespaces required) and checks that `--one-file-system` correctly +//! excludes entries on the mounted filesystem. //! //! The FUSE test panics when `mksquashfs`, `squashfuse`, `/dev/fuse`, or `fusermount` are //! unavailable. It can be excluded via `RUSTFLAGS='--cfg pdu_test_skip_cross_device'`. @@ -22,6 +22,7 @@ pub use _utils::*; use command_extra::CommandExtra; use parallel_disk_usage::{ + bytes_format::BytesFormat, data_tree::DataTree, fs_tree_builder::FsTreeBuilder, get_size::GetApparentSize, @@ -29,6 +30,7 @@ use parallel_disk_usage::{ os_string_display::OsStringDisplay, reporter::{ErrorOnlyReporter, ErrorReport}, size::Bytes, + visualizer::{BarAlignment, ColumnWidthDistribution, Direction, Visualizer}, }; use pipe_trait::Pipe; use pretty_assertions::assert_eq; @@ -139,7 +141,8 @@ impl Drop for FuseMount { } } -/// When a subdirectory is a mount point for a different filesystem, `-x` should exclude it. +/// When a subdirectory is a mount point for a different filesystem, +/// `--one-file-system` should exclude it. /// /// Uses `squashfuse` to mount a squashfs image via FUSE — no root privileges or /// user namespaces required. The image is pre-built with `mksquashfs` containing the @@ -162,7 +165,6 @@ fn cross_device_excludes_mount() { ) }); - let pdu = env!("CARGO_BIN_EXE_pdu"); let temp = Temp::new_dir().expect("create temp dir for cross-device test"); let workspace = temp.join("workspace"); let mount_point = workspace.join("mounted"); @@ -232,58 +234,59 @@ fn cross_device_excludes_mount() { wait_ms *= 2; } - // Run pdu WITHOUT -x — should see both files - let without_x = Command::new(pdu) - .with_arg("--bytes-format=plain") - .with_arg(&workspace) - .with_stdout(Stdio::piped()) - .with_stderr(Stdio::piped()) - .output() - .expect("run pdu without -x"); - let without_x_stdout = String::from_utf8_lossy(&without_x.stdout); - let without_x_stderr = String::from_utf8_lossy(&without_x.stderr); - if !without_x_stderr.is_empty() { - eprintln!("pdu (no -x) STDERR:\n{without_x_stderr}"); - } - eprintln!("pdu (no -x) STDOUT:\n{without_x_stdout}"); - assert!( - without_x.status.success(), - "pdu without -x failed: {without_x_stderr}", - ); - assert!( - without_x_stdout.contains("inside.txt"), - "without -x should show inside.txt:\n{without_x_stdout}", - ); - assert!( - without_x_stdout.contains("outside.txt"), - "without -x should show outside.txt:\n{without_x_stdout}", - ); + let build_expected_tree = |one_file_system: bool| -> String { + let builder = FsTreeBuilder { + root: workspace.clone(), + size_getter: GetApparentSize, + hardlinks_recorder: &HardlinkIgnorant, + reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), + one_file_system, + max_depth: 10, + }; + let mut data_tree: DataTree = builder.into(); + data_tree.par_cull_insignificant_data(0.01); + data_tree.par_sort_by(|left, right| left.size().cmp(&right.size()).reverse()); + *data_tree.name_mut() = OsStringDisplay::os_string_from("."); + let visualizer = Visualizer:: { + data_tree: &data_tree, + bytes_format: BytesFormat::PlainNumber, + direction: Direction::BottomUp, + bar_alignment: BarAlignment::Left, + column_width_distribution: ColumnWidthDistribution::total(100), + }; + let expected = format!("{visualizer}"); + expected.trim_end().to_string() + }; - // Run pdu WITH -x — should only see outside.txt - let with_x = Command::new(pdu) - .with_arg("--bytes-format=plain") - .with_arg("-x") - .with_arg(&workspace) - .with_stdout(Stdio::piped()) - .with_stderr(Stdio::piped()) - .output() - .expect("run pdu with -x"); - let with_x_stdout = String::from_utf8_lossy(&with_x.stdout); - let with_x_stderr = String::from_utf8_lossy(&with_x.stderr); - if !with_x_stderr.is_empty() { - eprintln!("pdu (-x) STDERR:\n{with_x_stderr}"); - } - eprintln!("pdu (-x) STDOUT:\n{with_x_stdout}"); - assert!( - with_x.status.success(), - "pdu with -x failed: {with_x_stderr}", - ); - assert!( - with_x_stdout.contains("outside.txt"), - "with -x should show outside.txt:\n{with_x_stdout}", - ); - assert!( - !with_x_stdout.contains("inside.txt"), - "with -x should exclude inside.txt (on different filesystem):\n{with_x_stdout}", - ); + let run_pdu = |one_file_system: bool| -> String { + let command = Command::new(PDU) + .with_arg("--quantity=apparent-size") + .with_arg("--total-width=100") + .with_arg("--bytes-format=plain") + .with_stdin(Stdio::null()) + .with_stdout(Stdio::piped()) + .with_stderr(Stdio::piped()); + let command = if one_file_system { + command.with_arg("--one-file-system") + } else { + command + }; + command + .with_arg(&workspace) + .output() + .expect("run pdu") + .pipe(stdout_text) + }; + + // Run pdu WITHOUT --one-file-system — should see both files + let actual = run_pdu(false); + let expected = build_expected_tree(false); + eprintln!("WITHOUT --one-file-system:\nACTUAL:\n{actual}\n\nEXPECTED:\n{expected}\n"); + assert_eq!(actual, expected); + + // Run pdu WITH --one-file-system — should only see outside.txt + let actual = run_pdu(true); + let expected = build_expected_tree(true); + eprintln!("WITH --one-file-system:\nACTUAL:\n{actual}\n\nEXPECTED:\n{expected}\n"); + assert_eq!(actual, expected); } From 368f1ffcea6e3a1d5bfb27434a186e71a31e21d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 05:10:48 +0000 Subject: [PATCH 32/65] fix(test): match root name between CLI output and expected tree The CLI shows the full workspace path as root, not ".". Remove the name override so the Visualizer-built expected tree uses the same root path. Also restore with_arg(&workspace) instead of with_current_dir to keep both sides consistent. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 969014d5..ef7df427 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -246,7 +246,6 @@ fn cross_device_excludes_mount() { let mut data_tree: DataTree = builder.into(); data_tree.par_cull_insignificant_data(0.01); data_tree.par_sort_by(|left, right| left.size().cmp(&right.size()).reverse()); - *data_tree.name_mut() = OsStringDisplay::os_string_from("."); let visualizer = Visualizer:: { data_tree: &data_tree, bytes_format: BytesFormat::PlainNumber, From 1d064d2b4a04eb4bc619ee4af5a23f7af60b2dae Mon Sep 17 00:00:00 2001 From: khai96_ Date: Mon, 23 Mar 2026 12:56:25 +0700 Subject: [PATCH 33/65] refactor: reduce verbose control flow into clever iterator chain --- tests/one_file_system.rs | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index ef7df427..b928a392 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -218,21 +218,17 @@ fn cross_device_excludes_mount() { }; // Wait for the FUSE mount to become readable (exponential backoff) - let mut wait_ms = 100; - for attempt in 0..6 { - if mount_point - .read_dir() - .is_ok_and(|mut entries| entries.next().is_some()) - { - break; - } - assert!( - attempt < 5, - "FUSE mount at {mount_point:?} not ready after retries" - ); - thread::sleep(Duration::from_millis(wait_ms)); - wait_ms *= 2; - } + let wait_ms_base = 100; + let poll_result = (0..5) + .map(|exponent| wait_ms_base << exponent) + .map(Duration::from_millis) + .map(thread::sleep) + .filter_map(|()| mount_point.read_dir().ok()) + .find_map(|mut entry| entry.next()); + assert!( + poll_result.is_some(), + "FUSE mount at {mount_point:?} not ready after retries" + ); let build_expected_tree = |one_file_system: bool| -> String { let builder = FsTreeBuilder { From 0b57e7e587801c12a715f2f6c111214a861daa59 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Mon, 23 Mar 2026 12:57:37 +0700 Subject: [PATCH 34/65] refactor: stop qualifying --- tests/one_file_system.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index b928a392..6bd74a76 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -38,7 +38,7 @@ use std::{ fs::{create_dir_all, write as write_file}, path::{Path, PathBuf}, process::{Command, Stdio}, - thread, + thread::sleep, time::Duration, }; use which::which; @@ -222,7 +222,7 @@ fn cross_device_excludes_mount() { let poll_result = (0..5) .map(|exponent| wait_ms_base << exponent) .map(Duration::from_millis) - .map(thread::sleep) + .map(sleep) .filter_map(|()| mount_point.read_dir().ok()) .find_map(|mut entry| entry.next()); assert!( From 5cc4cf290680e7d779c6bc3e187b29f94daac045 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Mon, 23 Mar 2026 12:59:52 +0700 Subject: [PATCH 35/65] refactor: reduce allocation, reduce import --- tests/one_file_system.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 6bd74a76..f885c2c4 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -36,7 +36,7 @@ use pipe_trait::Pipe; use pretty_assertions::assert_eq; use std::{ fs::{create_dir_all, write as write_file}, - path::{Path, PathBuf}, + path::Path, process::{Command, Stdio}, thread::sleep, time::Duration, @@ -120,18 +120,18 @@ fn fuse_probe() -> Result { Ok(FuseTools { fusermount }) } -struct FuseMount { - mount_point: PathBuf, +struct FuseMount<'a> { + mount_point: &'a Path, fusermount: &'static str, } -impl Drop for FuseMount { +impl Drop for FuseMount<'_> { fn drop(&mut self) { let status = self .fusermount .pipe(Command::new) .with_arg("-u") - .with_arg(&self.mount_point) + .with_arg(self.mount_point) .status(); match status { Ok(status) if status.success() => {} @@ -213,7 +213,7 @@ fn cross_device_excludes_mount() { String::from_utf8_lossy(&mount_output.stderr), ); let _fuse_mount = FuseMount { - mount_point: mount_point.clone(), + mount_point: &mount_point, fusermount: fuse_tools.fusermount, }; From 175f73d204d2c07e14b87d57ae8ac2c3cc394357 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Mon, 23 Mar 2026 13:04:27 +0700 Subject: [PATCH 36/65] refactor: rename some variables --- tests/one_file_system.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index f885c2c4..2fb30c5f 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -61,16 +61,16 @@ fn same_device_on_sample_workspace() { .into() }; - let tree_without = build_tree(false) + let without_1fs = build_tree(false) .into_par_sorted(|left, right| left.name().cmp(right.name())) .into_reflection(); - let tree_with = build_tree(true) + let with_1fs = build_tree(true) .into_par_sorted(|left, right| left.name().cmp(right.name())) .into_reflection(); assert_eq!( - sanitize_tree_reflection(tree_without), - sanitize_tree_reflection(tree_with), + sanitize_tree_reflection(without_1fs), + sanitize_tree_reflection(with_1fs), "one_file_system should not change the result when all files are on the same device", ); } From c763557d889507555769313167800398b7fb8011 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 06:19:15 +0000 Subject: [PATCH 37/65] test(one-file-system): add contains assertions and FuseMount documentation Re-add assertions checking that inside.txt and outside.txt appear or are excluded in pdu output, complementing the full tree comparison. Add doc comment to FuseMount explaining its sole purpose as an RAII unmount guard. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 2fb30c5f..c878bfde 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -120,6 +120,10 @@ fn fuse_probe() -> Result { Ok(FuseTools { fusermount }) } +/// RAII guard that unmounts a FUSE mount point when dropped. +/// +/// Its sole purpose is to ensure the FUSE filesystem is cleanly unmounted (via `fusermount -u`) +/// even if the test panics, preventing stale mounts from accumulating. struct FuseMount<'a> { mount_point: &'a Path, fusermount: &'static str, @@ -278,10 +282,26 @@ fn cross_device_excludes_mount() { let expected = build_expected_tree(false); eprintln!("WITHOUT --one-file-system:\nACTUAL:\n{actual}\n\nEXPECTED:\n{expected}\n"); assert_eq!(actual, expected); + assert!( + actual.contains("inside.txt"), + "without --one-file-system should show inside.txt:\n{actual}", + ); + assert!( + actual.contains("outside.txt"), + "without --one-file-system should show outside.txt:\n{actual}", + ); // Run pdu WITH --one-file-system — should only see outside.txt let actual = run_pdu(true); let expected = build_expected_tree(true); eprintln!("WITH --one-file-system:\nACTUAL:\n{actual}\n\nEXPECTED:\n{expected}\n"); assert_eq!(actual, expected); + assert!( + actual.contains("outside.txt"), + "with --one-file-system should show outside.txt:\n{actual}", + ); + assert!( + !actual.contains("inside.txt"), + "with --one-file-system should exclude inside.txt (on different filesystem):\n{actual}", + ); } From 90f9e202d11323de4729f0e38a7613e10c2328f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 06:30:17 +0000 Subject: [PATCH 38/65] docs(test): fix inaccurate skip description in cross-device test The doc comment said "Skipped when FUSE infrastructure is unavailable" but the test actually panics with a diagnostic message. Updated to accurately describe the behavior and how to exclude the test. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index c878bfde..fc35d5f2 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -151,7 +151,8 @@ impl Drop for FuseMount<'_> { /// Uses `squashfuse` to mount a squashfs image via FUSE — no root privileges or /// user namespaces required. The image is pre-built with `mksquashfs` containing the /// test file, so the mount is read-only (which is fine since `pdu` only reads). -/// Skipped when FUSE infrastructure is unavailable. +/// Panics when FUSE infrastructure is unavailable; can be excluded via +/// `RUSTFLAGS='--cfg pdu_test_skip_cross_device'`. #[test] #[cfg_attr(not(target_os = "linux"), ignore = "this test only works on Linux")] #[cfg_attr( From 1a9b4b6dc32a126f7a4a2b3878ed9b9826685d15 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 11:45:51 +0000 Subject: [PATCH 39/65] docs(ci): document external test dependencies and clean up CI steps - Rename "Install FUSE dependencies" to "Install external test dependencies" in CI workflows - Split single-line apt commands into multiline format - Document optional external dependencies (squashfs-tools, squashfuse, fuse3) in CONTRIBUTING.md and AI instruction templates - Regenerate AI instruction files https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- .github/copilot-instructions.md | 1 + .github/workflows/deploy.yaml | 6 ++++-- .github/workflows/test.yaml | 6 ++++-- AGENTS.md | 1 + CLAUDE.md | 1 + CONTRIBUTING.md | 22 ++++++++++++++++++++++ template/ai-instructions/shared.md | 1 + 7 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3cf78ddf..8beb047a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,3 +19,4 @@ Read and follow the CONTRIBUTING.md file in this repository for all code style c - Run `FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh` to validate changes. If a test fails with a hint about `RUSTFLAGS` and `--cfg pdu_test_skip_*`, follow the hint and rerun with the suggested flags. - **ALWAYS run the full test suite** (`FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh`) before committing, regardless of how trivial the change seems — this includes documentation-only changes, comment edits, config changes, and refactors. The test suite checks formatting, linting, building, tests, and docs across multiple feature combinations; any type of change can break any of these checks. - Set `PDU_NO_FAIL_FAST=true` to run all checks instead of stopping at the first failure — this lets you see which checks pass and which fail +- Some integration tests require external (non-Cargo) dependencies: `squashfs-tools`, `squashfuse`, `fuse3` (see CONTRIBUTING.md for details). If unavailable, skip with `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 5a7c9cc6..b8b410f6 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -29,9 +29,11 @@ jobs: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > $installer bash $installer --default-toolchain $(cat rust-toolchain) -y - - name: Install FUSE dependencies + - name: Install external test dependencies if: runner.os == 'Linux' - run: sudo apt update && sudo apt install -y squashfs-tools squashfuse fuse3 + run: | + sudo apt update + sudo apt install -y squashfs-tools squashfuse fuse3 - name: Test (dev) shell: bash diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f718b710..c942c29d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -42,9 +42,11 @@ jobs: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > $installer bash $installer --default-toolchain $(cat rust-toolchain) -y - - name: Install FUSE dependencies + - name: Install external test dependencies if: runner.os == 'Linux' - run: sudo apt update && sudo apt install -y squashfs-tools squashfuse fuse3 + run: | + sudo apt update + sudo apt install -y squashfs-tools squashfuse fuse3 - name: Test (dev) shell: bash diff --git a/AGENTS.md b/AGENTS.md index 3cf78ddf..8beb047a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,3 +19,4 @@ Read and follow the CONTRIBUTING.md file in this repository for all code style c - Run `FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh` to validate changes. If a test fails with a hint about `RUSTFLAGS` and `--cfg pdu_test_skip_*`, follow the hint and rerun with the suggested flags. - **ALWAYS run the full test suite** (`FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh`) before committing, regardless of how trivial the change seems — this includes documentation-only changes, comment edits, config changes, and refactors. The test suite checks formatting, linting, building, tests, and docs across multiple feature combinations; any type of change can break any of these checks. - Set `PDU_NO_FAIL_FAST=true` to run all checks instead of stopping at the first failure — this lets you see which checks pass and which fail +- Some integration tests require external (non-Cargo) dependencies: `squashfs-tools`, `squashfuse`, `fuse3` (see CONTRIBUTING.md for details). If unavailable, skip with `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` diff --git a/CLAUDE.md b/CLAUDE.md index 5aa709ae..2e4b2ec0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,4 +19,5 @@ Read and follow the CONTRIBUTING.md file in this repository for all code style c - Run `FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh` to validate changes. If a test fails with a hint about `RUSTFLAGS` and `--cfg pdu_test_skip_*`, follow the hint and rerun with the suggested flags. - **ALWAYS run the full test suite** (`FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh`) before committing, regardless of how trivial the change seems — this includes documentation-only changes, comment edits, config changes, and refactors. The test suite checks formatting, linting, building, tests, and docs across multiple feature combinations; any type of change can break any of these checks. - Set `PDU_NO_FAIL_FAST=true` to run all checks instead of stopping at the first failure — this lets you see which checks pass and which fail +- Some integration tests require external (non-Cargo) dependencies: `squashfs-tools`, `squashfuse`, `fuse3` (see CONTRIBUTING.md for details). If unavailable, skip with `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` - `gh` (GitHub CLI) is not installed — do not attempt to use it diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index edbd1049..916e4d06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -361,6 +361,28 @@ rustup toolchain install "$(< rust-toolchain)" rustup component add --toolchain "$(< rust-toolchain)" rustfmt clippy ``` +## Optional External Dependencies + +Some integration tests require external (non-Cargo) tools that are **not** managed by `Cargo.toml`. These tests are skipped or excluded when the tools are absent, but CI installs them to get full coverage. + +| Package | Provides | Used by | +|---|---|---| +| `squashfs-tools` | `mksquashfs` | Cross-device (`--one-file-system`) FUSE test | +| `squashfuse` | `squashfuse` | Cross-device (`--one-file-system`) FUSE test | +| `fuse3` | `fusermount3`, `/dev/fuse` | Cross-device (`--one-file-system`) FUSE test | + +On Debian/Ubuntu: + +```sh +sudo apt install squashfs-tools squashfuse fuse3 +``` + +Tests that need these tools will panic with a diagnostic message if they are missing. To skip them instead, set: + +```sh +export RUSTFLAGS='--cfg pdu_test_skip_cross_device' +``` + ## Automated Checks Before submitting, ensure: diff --git a/template/ai-instructions/shared.md b/template/ai-instructions/shared.md index 3cf78ddf..8beb047a 100644 --- a/template/ai-instructions/shared.md +++ b/template/ai-instructions/shared.md @@ -19,3 +19,4 @@ Read and follow the CONTRIBUTING.md file in this repository for all code style c - Run `FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh` to validate changes. If a test fails with a hint about `RUSTFLAGS` and `--cfg pdu_test_skip_*`, follow the hint and rerun with the suggested flags. - **ALWAYS run the full test suite** (`FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh`) before committing, regardless of how trivial the change seems — this includes documentation-only changes, comment edits, config changes, and refactors. The test suite checks formatting, linting, building, tests, and docs across multiple feature combinations; any type of change can break any of these checks. - Set `PDU_NO_FAIL_FAST=true` to run all checks instead of stopping at the first failure — this lets you see which checks pass and which fail +- Some integration tests require external (non-Cargo) dependencies: `squashfs-tools`, `squashfuse`, `fuse3` (see CONTRIBUTING.md for details). If unavailable, skip with `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` From e4fe9f4165c5eb884d1bcc0283cf73a94c0b0814 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 12:00:45 +0000 Subject: [PATCH 40/65] docs: generalize skip flag references and fix fuse_probe doc - Use generic pdu_test_skip_* wording in AI instructions instead of naming the specific cross_device flag - Mention that panic messages include the specific skip flag - Fix fuse_probe doc: say "exists" not "is accessible" for /dev/fuse, say "tool names" not "tool paths" for the return value - Regenerate AI instruction files https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- .github/copilot-instructions.md | 2 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- template/ai-instructions/shared.md | 2 +- tests/one_file_system.rs | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8beb047a..9b91b71c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,4 +19,4 @@ Read and follow the CONTRIBUTING.md file in this repository for all code style c - Run `FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh` to validate changes. If a test fails with a hint about `RUSTFLAGS` and `--cfg pdu_test_skip_*`, follow the hint and rerun with the suggested flags. - **ALWAYS run the full test suite** (`FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh`) before committing, regardless of how trivial the change seems — this includes documentation-only changes, comment edits, config changes, and refactors. The test suite checks formatting, linting, building, tests, and docs across multiple feature combinations; any type of change can break any of these checks. - Set `PDU_NO_FAIL_FAST=true` to run all checks instead of stopping at the first failure — this lets you see which checks pass and which fail -- Some integration tests require external (non-Cargo) dependencies: `squashfs-tools`, `squashfuse`, `fuse3` (see CONTRIBUTING.md for details). If unavailable, skip with `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` +- Some integration tests require external (non-Cargo) dependencies (see CONTRIBUTING.md for details). If unavailable, the tests will panic with a hint showing the `RUSTFLAGS='--cfg pdu_test_skip_*'` flag to skip them diff --git a/AGENTS.md b/AGENTS.md index 8beb047a..9b91b71c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,4 +19,4 @@ Read and follow the CONTRIBUTING.md file in this repository for all code style c - Run `FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh` to validate changes. If a test fails with a hint about `RUSTFLAGS` and `--cfg pdu_test_skip_*`, follow the hint and rerun with the suggested flags. - **ALWAYS run the full test suite** (`FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh`) before committing, regardless of how trivial the change seems — this includes documentation-only changes, comment edits, config changes, and refactors. The test suite checks formatting, linting, building, tests, and docs across multiple feature combinations; any type of change can break any of these checks. - Set `PDU_NO_FAIL_FAST=true` to run all checks instead of stopping at the first failure — this lets you see which checks pass and which fail -- Some integration tests require external (non-Cargo) dependencies: `squashfs-tools`, `squashfuse`, `fuse3` (see CONTRIBUTING.md for details). If unavailable, skip with `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` +- Some integration tests require external (non-Cargo) dependencies (see CONTRIBUTING.md for details). If unavailable, the tests will panic with a hint showing the `RUSTFLAGS='--cfg pdu_test_skip_*'` flag to skip them diff --git a/CLAUDE.md b/CLAUDE.md index 2e4b2ec0..84a2fbb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,5 +19,5 @@ Read and follow the CONTRIBUTING.md file in this repository for all code style c - Run `FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh` to validate changes. If a test fails with a hint about `RUSTFLAGS` and `--cfg pdu_test_skip_*`, follow the hint and rerun with the suggested flags. - **ALWAYS run the full test suite** (`FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh`) before committing, regardless of how trivial the change seems — this includes documentation-only changes, comment edits, config changes, and refactors. The test suite checks formatting, linting, building, tests, and docs across multiple feature combinations; any type of change can break any of these checks. - Set `PDU_NO_FAIL_FAST=true` to run all checks instead of stopping at the first failure — this lets you see which checks pass and which fail -- Some integration tests require external (non-Cargo) dependencies: `squashfs-tools`, `squashfuse`, `fuse3` (see CONTRIBUTING.md for details). If unavailable, skip with `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` +- Some integration tests require external (non-Cargo) dependencies (see CONTRIBUTING.md for details). If unavailable, the tests will panic with a hint showing the `RUSTFLAGS='--cfg pdu_test_skip_*'` flag to skip them - `gh` (GitHub CLI) is not installed — do not attempt to use it diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 916e4d06..794c5938 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -377,7 +377,7 @@ On Debian/Ubuntu: sudo apt install squashfs-tools squashfuse fuse3 ``` -Tests that need these tools will panic with a diagnostic message if they are missing. To skip them instead, set: +Tests that need these tools will panic with a diagnostic message if they are missing. The panic message includes the specific `RUSTFLAGS='--cfg pdu_test_skip_*'` flag to skip the test. For example: ```sh export RUSTFLAGS='--cfg pdu_test_skip_cross_device' diff --git a/template/ai-instructions/shared.md b/template/ai-instructions/shared.md index 8beb047a..9b91b71c 100644 --- a/template/ai-instructions/shared.md +++ b/template/ai-instructions/shared.md @@ -19,4 +19,4 @@ Read and follow the CONTRIBUTING.md file in this repository for all code style c - Run `FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh` to validate changes. If a test fails with a hint about `RUSTFLAGS` and `--cfg pdu_test_skip_*`, follow the hint and rerun with the suggested flags. - **ALWAYS run the full test suite** (`FMT=true LINT=true BUILD=true TEST=true DOC=true ./test.sh`) before committing, regardless of how trivial the change seems — this includes documentation-only changes, comment edits, config changes, and refactors. The test suite checks formatting, linting, building, tests, and docs across multiple feature combinations; any type of change can break any of these checks. - Set `PDU_NO_FAIL_FAST=true` to run all checks instead of stopping at the first failure — this lets you see which checks pass and which fail -- Some integration tests require external (non-Cargo) dependencies: `squashfs-tools`, `squashfuse`, `fuse3` (see CONTRIBUTING.md for details). If unavailable, skip with `RUSTFLAGS='--cfg pdu_test_skip_cross_device'` +- Some integration tests require external (non-Cargo) dependencies (see CONTRIBUTING.md for details). If unavailable, the tests will panic with a hint showing the `RUSTFLAGS='--cfg pdu_test_skip_*'` flag to skip them diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index fc35d5f2..1060fd01 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -86,10 +86,10 @@ struct FuseTools { /// Verifies: /// 1. `mksquashfs` binary exists /// 2. `squashfuse` binary exists -/// 3. `/dev/fuse` is accessible +/// 3. `/dev/fuse` exists /// 4. `fusermount3` (or `fusermount`) binary exists /// -/// Returns `Ok(FuseTools)` with the discovered tool paths, or `Err` with a diagnostic message. +/// Returns `Ok(FuseTools)` with the discovered tool names, or `Err` with a diagnostic message. fn fuse_probe() -> Result { which("mksquashfs").map_err(|error| { format!("`mksquashfs` not found: {error}. Install squashfs-tools for your platform.") From bf6ee32ccbf7d3de761b33bd96316092a464e5e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 13:20:19 +0000 Subject: [PATCH 41/65] docs: convert external dependencies table to bullet list https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- CONTRIBUTING.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 794c5938..265dd127 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -365,11 +365,9 @@ rustup component add --toolchain "$(< rust-toolchain)" rustfmt clippy Some integration tests require external (non-Cargo) tools that are **not** managed by `Cargo.toml`. These tests are skipped or excluded when the tools are absent, but CI installs them to get full coverage. -| Package | Provides | Used by | -|---|---|---| -| `squashfs-tools` | `mksquashfs` | Cross-device (`--one-file-system`) FUSE test | -| `squashfuse` | `squashfuse` | Cross-device (`--one-file-system`) FUSE test | -| `fuse3` | `fusermount3`, `/dev/fuse` | Cross-device (`--one-file-system`) FUSE test | +- `squashfs-tools` (provides `mksquashfs`) — cross-device (`--one-file-system`) FUSE test +- `squashfuse` (provides `squashfuse`) — cross-device (`--one-file-system`) FUSE test +- `fuse3` (provides `fusermount3`, `/dev/fuse`) — cross-device (`--one-file-system`) FUSE test On Debian/Ubuntu: From 9e1e135d8218e25149eec41abdc5450b1f3f613a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 01:07:28 +0000 Subject: [PATCH 42/65] refactor: trim docs and include retry count in poll message - Remove Debian/Ubuntu install guide and RUSTFLAGS example from CONTRIBUTING.md (audience can figure these out) - Extract retry count to a variable and include it in the FUSE mount poll panic message https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- CONTRIBUTING.md | 12 +----------- tests/one_file_system.rs | 5 +++-- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 265dd127..b0a5f7e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -369,17 +369,7 @@ Some integration tests require external (non-Cargo) tools that are **not** manag - `squashfuse` (provides `squashfuse`) — cross-device (`--one-file-system`) FUSE test - `fuse3` (provides `fusermount3`, `/dev/fuse`) — cross-device (`--one-file-system`) FUSE test -On Debian/Ubuntu: - -```sh -sudo apt install squashfs-tools squashfuse fuse3 -``` - -Tests that need these tools will panic with a diagnostic message if they are missing. The panic message includes the specific `RUSTFLAGS='--cfg pdu_test_skip_*'` flag to skip the test. For example: - -```sh -export RUSTFLAGS='--cfg pdu_test_skip_cross_device' -``` +Tests that need these tools will panic with a diagnostic message if they are missing. The panic message includes the specific `RUSTFLAGS='--cfg pdu_test_skip_*'` flag to skip the test. ## Automated Checks diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 1060fd01..1225fdca 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -224,7 +224,8 @@ fn cross_device_excludes_mount() { // Wait for the FUSE mount to become readable (exponential backoff) let wait_ms_base = 100; - let poll_result = (0..5) + let retries = 5; + let poll_result = (0..retries) .map(|exponent| wait_ms_base << exponent) .map(Duration::from_millis) .map(sleep) @@ -232,7 +233,7 @@ fn cross_device_excludes_mount() { .find_map(|mut entry| entry.next()); assert!( poll_result.is_some(), - "FUSE mount at {mount_point:?} not ready after retries" + "FUSE mount at {mount_point:?} not ready after {retries} retries" ); let build_expected_tree = |one_file_system: bool| -> String { From 4c5bad0753d649c6cc2d092841132fa528a0e2ae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 01:23:14 +0000 Subject: [PATCH 43/65] refactor(test): use pipe to inline conditional arg in method chain https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- tests/one_file_system.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 1225fdca..a1583991 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -260,19 +260,20 @@ fn cross_device_excludes_mount() { }; let run_pdu = |one_file_system: bool| -> String { - let command = Command::new(PDU) + Command::new(PDU) .with_arg("--quantity=apparent-size") .with_arg("--total-width=100") .with_arg("--bytes-format=plain") .with_stdin(Stdio::null()) .with_stdout(Stdio::piped()) - .with_stderr(Stdio::piped()); - let command = if one_file_system { - command.with_arg("--one-file-system") - } else { - command - }; - command + .with_stderr(Stdio::piped()) + .pipe(|command| { + if one_file_system { + command.with_arg("--one-file-system") + } else { + command + } + }) .with_arg(&workspace) .output() .expect("run pdu") From ddd221f1b46e1909f8673eeac4da9ef744f9e17f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kh=E1=BA=A3i?= Date: Tue, 24 Mar 2026 01:49:04 +0000 Subject: [PATCH 44/65] refactor: simplify conditional argument addition --- tests/one_file_system.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index a1583991..99f6bada 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -267,13 +267,7 @@ fn cross_device_excludes_mount() { .with_stdin(Stdio::null()) .with_stdout(Stdio::piped()) .with_stderr(Stdio::piped()) - .pipe(|command| { - if one_file_system { - command.with_arg("--one-file-system") - } else { - command - } - }) + .with_args(one_file_system.then_some("--one-file-system")) .with_arg(&workspace) .output() .expect("run pdu") From d066ce2edc67b30393dfbbdce6617f7d65a78cb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 02:04:39 +0000 Subject: [PATCH 45/65] ci(devcontainer): expose FUSE device and install test dependencies Add --device=/dev/fuse and --cap-add=SYS_ADMIN to both devcontainer configs so FUSE-based tests can run in Codespaces. Install squashfs-tools, squashfuse, and fuse3 in post-create scripts. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- .devcontainer/devcontainer.json | 4 ++++ .devcontainer/full/devcontainer.json | 4 ++++ .devcontainer/full/post-create.sh | 3 +++ .devcontainer/post-create.sh | 3 +++ 4 files changed, 14 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 568c24fc..6a77851c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,10 @@ { "name": "parallel-disk-usage (Rust only)", "image": "mcr.microsoft.com/devcontainers/rust:1", + "runArgs": [ + "--device=/dev/fuse", + "--cap-add=SYS_ADMIN" + ], "customizations": { "vscode": { "extensions": [ diff --git a/.devcontainer/full/devcontainer.json b/.devcontainer/full/devcontainer.json index d95b5a76..c7e2aa40 100644 --- a/.devcontainer/full/devcontainer.json +++ b/.devcontainer/full/devcontainer.json @@ -1,6 +1,10 @@ { "name": "parallel-disk-usage (full)", "image": "mcr.microsoft.com/devcontainers/rust:1", + "runArgs": [ + "--device=/dev/fuse", + "--cap-add=SYS_ADMIN" + ], "features": { "ghcr.io/devcontainers/features/node:1": { "version": "lts", diff --git a/.devcontainer/full/post-create.sh b/.devcontainer/full/post-create.sh index 67c482bc..2cb4a265 100755 --- a/.devcontainer/full/post-create.sh +++ b/.devcontainer/full/post-create.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash set -euo pipefail +echo "Installing external test dependencies..." >&2 +sudo apt update && sudo apt install -y squashfs-tools squashfuse fuse3 + echo "Installing Python dependencies..." >&2 python3 -m pip install --user toml diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 3597be9d..c1d6b923 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -3,6 +3,9 @@ set -euo pipefail mkdir -p "$HOME/.local/bin" +echo "Installing external test dependencies..." >&2 +sudo apt update && sudo apt install -y squashfs-tools squashfuse fuse3 + bash "$(dirname "$0")/install-rust-toolchain.sh" bash "$(dirname "$0")/install-hyperfine.sh" From ee76a991626639dacae68e5d8cd7645861ca2489 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 02:30:36 +0000 Subject: [PATCH 46/65] ci(devcontainer): skip FUSE and fs-error tests in Codespaces Replace --device=/dev/fuse, --cap-add=SYS_ADMIN, and apt-installed FUSE packages with RUSTFLAGS that skip the cross-device and fs-errors tests. FUSE mounts fail with permission denied in Codespaces even with the device exposed and SYS_ADMIN capability. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- .devcontainer/devcontainer.json | 7 +++---- .devcontainer/full/devcontainer.json | 7 +++---- .devcontainer/full/post-create.sh | 3 --- .devcontainer/post-create.sh | 3 --- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6a77851c..1777eddf 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,10 +1,9 @@ { "name": "parallel-disk-usage (Rust only)", "image": "mcr.microsoft.com/devcontainers/rust:1", - "runArgs": [ - "--device=/dev/fuse", - "--cap-add=SYS_ADMIN" - ], + "remoteEnv": { + "RUSTFLAGS": "--cfg pdu_test_skip_cross_device --cfg pdu_test_skip_fs_errors" + }, "customizations": { "vscode": { "extensions": [ diff --git a/.devcontainer/full/devcontainer.json b/.devcontainer/full/devcontainer.json index c7e2aa40..d1e5ed3a 100644 --- a/.devcontainer/full/devcontainer.json +++ b/.devcontainer/full/devcontainer.json @@ -1,10 +1,9 @@ { "name": "parallel-disk-usage (full)", "image": "mcr.microsoft.com/devcontainers/rust:1", - "runArgs": [ - "--device=/dev/fuse", - "--cap-add=SYS_ADMIN" - ], + "remoteEnv": { + "RUSTFLAGS": "--cfg pdu_test_skip_cross_device --cfg pdu_test_skip_fs_errors" + }, "features": { "ghcr.io/devcontainers/features/node:1": { "version": "lts", diff --git a/.devcontainer/full/post-create.sh b/.devcontainer/full/post-create.sh index 2cb4a265..67c482bc 100755 --- a/.devcontainer/full/post-create.sh +++ b/.devcontainer/full/post-create.sh @@ -1,9 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -echo "Installing external test dependencies..." >&2 -sudo apt update && sudo apt install -y squashfs-tools squashfuse fuse3 - echo "Installing Python dependencies..." >&2 python3 -m pip install --user toml diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index c1d6b923..3597be9d 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -3,9 +3,6 @@ set -euo pipefail mkdir -p "$HOME/.local/bin" -echo "Installing external test dependencies..." >&2 -sudo apt update && sudo apt install -y squashfs-tools squashfuse fuse3 - bash "$(dirname "$0")/install-rust-toolchain.sh" bash "$(dirname "$0")/install-hyperfine.sh" From be7c805f485810eacc8035683602eb4262e713be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 02:41:41 +0000 Subject: [PATCH 47/65] ci(devcontainer): only skip cross-device test, not fs-errors The fs_errors test works fine in Codespaces where the user is non-root. It only fails in our current CI environment because we run as root. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- .devcontainer/devcontainer.json | 2 +- .devcontainer/full/devcontainer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1777eddf..2dfe6712 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,7 @@ "name": "parallel-disk-usage (Rust only)", "image": "mcr.microsoft.com/devcontainers/rust:1", "remoteEnv": { - "RUSTFLAGS": "--cfg pdu_test_skip_cross_device --cfg pdu_test_skip_fs_errors" + "RUSTFLAGS": "--cfg pdu_test_skip_cross_device" }, "customizations": { "vscode": { diff --git a/.devcontainer/full/devcontainer.json b/.devcontainer/full/devcontainer.json index d1e5ed3a..039aa577 100644 --- a/.devcontainer/full/devcontainer.json +++ b/.devcontainer/full/devcontainer.json @@ -2,7 +2,7 @@ "name": "parallel-disk-usage (full)", "image": "mcr.microsoft.com/devcontainers/rust:1", "remoteEnv": { - "RUSTFLAGS": "--cfg pdu_test_skip_cross_device --cfg pdu_test_skip_fs_errors" + "RUSTFLAGS": "--cfg pdu_test_skip_cross_device" }, "features": { "ghcr.io/devcontainers/features/node:1": { From f422c66f68a6e5c4a21159d8d9b6140cc9121dd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 03:23:15 +0000 Subject: [PATCH 48/65] fix: preserve directory size on read_dir failure and strengthen FUSE mount probe When read_dir() fails, return Info with the already-computed size instead of Info::default(), which was discarding the directory's own inode size. Also tighten the FUSE mount readiness check to require Some(Ok(_)) so the probe only succeeds when a directory entry can actually be read. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- src/fs_tree_builder.rs | 5 ++++- tests/one_file_system.rs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/fs_tree_builder.rs b/src/fs_tree_builder.rs index e4dcc481..bdce590c 100644 --- a/src/fs_tree_builder.rs +++ b/src/fs_tree_builder.rs @@ -140,7 +140,10 @@ where path, error, })); - return Info::default(); + return Info { + size, + children: Vec::new(), + }; } Ok(entries) => entries, } diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 99f6bada..9d1a9361 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -230,7 +230,7 @@ fn cross_device_excludes_mount() { .map(Duration::from_millis) .map(sleep) .filter_map(|()| mount_point.read_dir().ok()) - .find_map(|mut entry| entry.next()); + .find_map(|mut entry| entry.next()?.ok()); assert!( poll_result.is_some(), "FUSE mount at {mount_point:?} not ready after {retries} retries" From 60de739720b367bc9c5dee80b4743a879f15a2a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 11:54:12 +0000 Subject: [PATCH 49/65] docs(contributing): fix stale test-skip references Replace RUSTFLAGS/cfg references with TEST_SKIP, and correct the claim that tests are "skipped or excluded" to accurately say they panic. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2bcf5a30..8e761518 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -353,13 +353,13 @@ rustup component add --toolchain "$(< rust-toolchain)" rustfmt clippy ## Optional External Dependencies -Some integration tests require external (non-Cargo) tools that are **not** managed by `Cargo.toml`. These tests are skipped or excluded when the tools are absent, but CI installs them to get full coverage. +Some integration tests require external (non-Cargo) tools that are **not** managed by `Cargo.toml`. These tests panic when the tools are absent; CI installs them to get full coverage. - `squashfs-tools` (provides `mksquashfs`) — cross-device (`--one-file-system`) FUSE test - `squashfuse` (provides `squashfuse`) — cross-device (`--one-file-system`) FUSE test - `fuse3` (provides `fusermount3`, `/dev/fuse`) — cross-device (`--one-file-system`) FUSE test -Tests that need these tools will panic with a diagnostic message if they are missing. The panic message includes the specific `RUSTFLAGS='--cfg pdu_test_skip_*'` flag to skip the test. +Tests that need these tools will panic with a diagnostic message if they are missing. The panic message includes the specific `TEST_SKIP` variable to skip the test via `./test.sh`. ## Automated Checks From b0abbaa06eff50c3ede4e23007925a79a7fc8476 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 14:09:30 +0000 Subject: [PATCH 50/65] refactor(app): move `one_file_system` between `hardlinks_handler` and `reporter` https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- src/app.rs | 4 ++-- src/app/sub.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app.rs b/src/app.rs index e950e790..965885ba 100644 --- a/src/app.rs +++ b/src/app.rs @@ -295,10 +295,10 @@ impl App { bytes_format, top_down, align_right, - one_file_system, max_depth, min_ratio, no_sort, + one_file_system, omit_json_shared_details, omit_json_shared_summary, .. @@ -307,12 +307,12 @@ impl App { bar_alignment: BarAlignment::from_align_right(align_right), size_getter: <$size_getter as GetSizeUtils>::INSTANCE, hardlinks_handler: <$size_getter as CreateHardlinksHandler<{ cfg!(unix) && $hardlinks }, $progress>>::create_hardlinks_handler(), + one_file_system, reporter: <$size_getter as CreateReporter<$progress>>::create_reporter(report_error), bytes_format: <$size_getter as GetSizeUtils>::formatter(bytes_format), files, json_output: JsonOutputParam::from_cli_flags(json_output, omit_json_shared_details, omit_json_shared_summary), column_width_distribution, - one_file_system, max_depth, min_ratio, no_sort, diff --git a/src/app/sub.rs b/src/app/sub.rs index 45524c68..38bf6ca4 100644 --- a/src/app/sub.rs +++ b/src/app/sub.rs @@ -37,14 +37,14 @@ where pub bar_alignment: BarAlignment, /// Distribution and number of characters/blocks can be placed in a line. pub column_width_distribution: ColumnWidthDistribution, - /// Skip directories on different filesystems. - pub one_file_system: bool, /// Maximum number of levels that should be visualized. pub max_depth: Depth, /// [Get the size](GetSize) of files/directories. pub size_getter: SizeGetter, /// Handle to detect, record, and deduplicate hardlinks. pub hardlinks_handler: HardlinksHandler, + /// Skip directories on different filesystems. + pub one_file_system: bool, /// Reports measurement progress. pub reporter: Report, /// Minimal size proportion required to appear. @@ -70,10 +70,10 @@ where direction, bar_alignment, column_width_distribution, - one_file_system, max_depth, size_getter, hardlinks_handler, + one_file_system, reporter, min_ratio, no_sort, From 66dbe5909114ca4044fb036637d3fed11405717f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 14:38:48 +0000 Subject: [PATCH 51/65] refactor(fs_tree_builder): simplify visibility of device_id items to `pub` The module is already private, so `pub(crate)` is redundant. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- src/fs_tree_builder/device_id.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fs_tree_builder/device_id.rs b/src/fs_tree_builder/device_id.rs index 5c3deb51..83e5a854 100644 --- a/src/fs_tree_builder/device_id.rs +++ b/src/fs_tree_builder/device_id.rs @@ -1,6 +1,6 @@ /// Unique identifier for a device or filesystem. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct DeviceId(Inner); +pub struct DeviceId(Inner); #[cfg(unix)] type Inner = u64; @@ -10,7 +10,7 @@ type Inner = (); /// Retrieve the [`DeviceId`] from filesystem metadata. #[cfg(unix)] -pub(crate) fn get_device_id(stats: &std::fs::Metadata) -> DeviceId { +pub fn get_device_id(stats: &std::fs::Metadata) -> DeviceId { use std::os::unix::fs::MetadataExt; DeviceId(stats.dev()) } @@ -20,7 +20,7 @@ pub(crate) fn get_device_id(stats: &std::fs::Metadata) -> DeviceId { /// On unsupported platforms, all entries share the same [`DeviceId`], /// effectively disabling cross-device detection. #[cfg(not(unix))] -pub(crate) fn get_device_id(_stats: &std::fs::Metadata) -> DeviceId { +pub fn get_device_id(_stats: &std::fs::Metadata) -> DeviceId { DeviceId(()) } From 1729ef476e77512c974caf064b00f30ddd9d67ca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 15:21:40 +0000 Subject: [PATCH 52/65] refactor: replace `one_file_system: bool` with `DeviceBoundary` enum Introduce a `device` module with a `DeviceBoundary` enum (`Cross` / `Stay`) to replace the raw boolean. This makes the intent clearer at each callsite and replaces the if/else with a match. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- src/app.rs | 3 ++- src/app/sub.rs | 9 +++++---- src/device.rs | 17 +++++++++++++++++ src/fs_tree_builder.rs | 19 ++++++++++--------- src/lib.rs | 1 + tests/_utils.rs | 3 ++- tests/cli_errors.rs | 3 ++- tests/json.rs | 3 ++- tests/one_file_system.rs | 27 ++++++++++++++------------- tests/usual_cli.rs | 37 +++++++++++++++++++------------------ 10 files changed, 74 insertions(+), 48 deletions(-) create mode 100644 src/device.rs diff --git a/src/app.rs b/src/app.rs index 965885ba..3394a8bd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ pub use sub::Sub; use crate::{ args::{Args, Quantity, Threads}, bytes_format::BytesFormat, + device::DeviceBoundary, get_size::{GetApparentSize, GetSize}, hardlink, json_data::{JsonData, JsonDataBody, JsonShared, JsonTree}, @@ -307,7 +308,7 @@ impl App { bar_alignment: BarAlignment::from_align_right(align_right), size_getter: <$size_getter as GetSizeUtils>::INSTANCE, hardlinks_handler: <$size_getter as CreateHardlinksHandler<{ cfg!(unix) && $hardlinks }, $progress>>::create_hardlinks_handler(), - one_file_system, + device_boundary: DeviceBoundary::from_1fs(one_file_system), reporter: <$size_getter as CreateReporter<$progress>>::create_reporter(report_error), bytes_format: <$size_getter as GetSizeUtils>::formatter(bytes_format), files, diff --git a/src/app/sub.rs b/src/app/sub.rs index 38bf6ca4..f1f72447 100644 --- a/src/app/sub.rs +++ b/src/app/sub.rs @@ -1,6 +1,7 @@ use crate::{ args::{Depth, Fraction}, data_tree::DataTree, + device::DeviceBoundary, fs_tree_builder::FsTreeBuilder, get_size::GetSize, hardlink::{DeduplicateSharedSize, HardlinkIgnorant, RecordHardlinks}, @@ -43,8 +44,8 @@ where pub size_getter: SizeGetter, /// Handle to detect, record, and deduplicate hardlinks. pub hardlinks_handler: HardlinksHandler, - /// Skip directories on different filesystems. - pub one_file_system: bool, + /// Whether to cross device boundary into a different filesystem. + pub device_boundary: DeviceBoundary, /// Reports measurement progress. pub reporter: Report, /// Minimal size proportion required to appear. @@ -73,7 +74,7 @@ where max_depth, size_getter, hardlinks_handler, - one_file_system, + device_boundary, reporter, min_ratio, no_sort, @@ -89,7 +90,7 @@ where root, size_getter, hardlinks_recorder: &hardlinks_handler, - one_file_system, + device_boundary, max_depth, } .into() diff --git a/src/device.rs b/src/device.rs new file mode 100644 index 00000000..62eb0a88 --- /dev/null +++ b/src/device.rs @@ -0,0 +1,17 @@ +/// Whether to cross device boundary into a different filesystem. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceBoundary { + Cross, + Stay, +} + +impl DeviceBoundary { + /// Derive device boundary from `--one-file-system`. + #[cfg(feature = "cli")] + pub(crate) fn from_1fs(one_file_system: bool) -> Self { + match one_file_system { + false => DeviceBoundary::Cross, + true => DeviceBoundary::Stay, + } + } +} diff --git a/src/fs_tree_builder.rs b/src/fs_tree_builder.rs index bdce590c..41deba77 100644 --- a/src/fs_tree_builder.rs +++ b/src/fs_tree_builder.rs @@ -1,5 +1,6 @@ use super::{ data_tree::DataTree, + device::DeviceBoundary, get_size::GetSize, hardlink::{RecordHardlinks, RecordHardlinksArgument}, os_string_display::OsStringDisplay, @@ -22,6 +23,7 @@ use std::{ /// # use parallel_disk_usage::fs_tree_builder::FsTreeBuilder; /// use parallel_disk_usage::{ /// data_tree::DataTree, +/// device::DeviceBoundary, /// get_size::GetApparentSize, /// os_string_display::OsStringDisplay, /// reporter::{ErrorOnlyReporter, ErrorReport}, @@ -33,7 +35,7 @@ use std::{ /// hardlinks_recorder: &HardlinkIgnorant, /// size_getter: GetApparentSize, /// reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), -/// one_file_system: false, +/// device_boundary: DeviceBoundary::Cross, /// max_depth: 10, /// }; /// let data_tree: DataTree = builder.into(); @@ -54,8 +56,8 @@ where pub hardlinks_recorder: &'a HardlinksRecorder, /// Reports progress to external system. pub reporter: &'a Report, - /// Skip directories on different filesystems. - pub one_file_system: bool, + /// Whether to cross device boundary into a different filesystem. + pub device_boundary: DeviceBoundary, /// Deepest level of descendant display in the graph. The sizes beyond the max depth still count toward total. pub max_depth: u64, } @@ -76,14 +78,15 @@ where size_getter, hardlinks_recorder, reporter, - one_file_system, + device_boundary, max_depth, } = builder; // `root` would be inspected multiple times, but its impact on performance is insignificant // before the (usually) massive fs tree `root` contains. - let root_dev = if one_file_system { - match symlink_metadata(&root) { + let root_dev = match device_boundary { + DeviceBoundary::Cross => None, + DeviceBoundary::Stay => match symlink_metadata(&root) { Err(error) => { reporter.report(Event::EncounterError(ErrorReport { operation: SymlinkMetadata, @@ -93,9 +96,7 @@ where return DataTree::file(OsStringDisplay::os_string_from(&root), Size::default()); } Ok(stats) => Some(get_device_id(&stats)), - } - } else { - None + }, }; TreeBuilder:: { diff --git a/src/lib.rs b/src/lib.rs index 23765add..7aeb6e90 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,7 @@ pub use clap_utilities; pub mod bytes_format; pub mod data_tree; +pub mod device; pub mod fs_tree_builder; pub mod get_size; pub mod hardlink; diff --git a/tests/_utils.rs b/tests/_utils.rs index a62abab0..3a9c785a 100644 --- a/tests/_utils.rs +++ b/tests/_utils.rs @@ -4,6 +4,7 @@ use derive_more::{AsRef, Deref}; use into_sorted::IntoSorted; use parallel_disk_usage::{ data_tree::{DataTree, DataTreeReflection}, + device::DeviceBoundary, fs_tree_builder::FsTreeBuilder, get_size::{self, GetSize}, hardlink::HardlinkIgnorant, @@ -373,7 +374,7 @@ where panic!("Unexpected call to report_error: {error:?}") }), root: root.join(suffix), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, } .pipe(DataTree::::from) diff --git a/tests/cli_errors.rs b/tests/cli_errors.rs index 8203318a..d854295f 100644 --- a/tests/cli_errors.rs +++ b/tests/cli_errors.rs @@ -15,6 +15,7 @@ use maplit::btreeset; use parallel_disk_usage::{ bytes_format::BytesFormat, data_tree::DataTree, + device::DeviceBoundary, fs_tree_builder::FsTreeBuilder, get_size::GetApparentSize, hardlink::HardlinkIgnorant, @@ -143,7 +144,7 @@ fn fs_errors() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); diff --git a/tests/json.rs b/tests/json.rs index 9cdc9503..74689526 100644 --- a/tests/json.rs +++ b/tests/json.rs @@ -8,6 +8,7 @@ use command_extra::CommandExtra; use parallel_disk_usage::{ bytes_format::BytesFormat, data_tree::DataTree, + device::DeviceBoundary, fs_tree_builder::FsTreeBuilder, get_size::GetApparentSize, hardlink::HardlinkIgnorant, @@ -85,7 +86,7 @@ fn json_output() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let expected = builder diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 56fc257b..6ad608ab 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -24,6 +24,7 @@ use command_extra::CommandExtra; use parallel_disk_usage::{ bytes_format::BytesFormat, data_tree::DataTree, + device::DeviceBoundary, fs_tree_builder::FsTreeBuilder, get_size::GetApparentSize, hardlink::HardlinkIgnorant, @@ -43,35 +44,35 @@ use std::{ }; use which::which; -/// When all files reside on a single filesystem, `one_file_system: true` should produce -/// the same tree as `one_file_system: false`. +/// When all files reside on a single filesystem, [`DeviceBoundary::Stay`] should produce +/// the same tree as [`DeviceBoundary::Cross`]. #[test] fn same_device_on_sample_workspace() { let workspace = SampleWorkspace::default(); - let build_tree = |one_file_system: bool| -> DataTree { + let build_tree = |device_boundary: DeviceBoundary| -> DataTree { FsTreeBuilder { root: workspace.to_path_buf(), size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system, + device_boundary, max_depth: 10, } .into() }; - let without_1fs = build_tree(false) + let cross = build_tree(DeviceBoundary::Cross) .into_par_sorted(|left, right| left.name().cmp(right.name())) .into_reflection(); - let with_1fs = build_tree(true) + let stay = build_tree(DeviceBoundary::Stay) .into_par_sorted(|left, right| left.name().cmp(right.name())) .into_reflection(); assert_eq!( - sanitize_tree_reflection(without_1fs), - sanitize_tree_reflection(with_1fs), - "one_file_system should not change the result when all files are on the same device", + sanitize_tree_reflection(cross), + sanitize_tree_reflection(stay), + "DeviceBoundary should not change the result when all files are on the same device", ); } @@ -232,13 +233,13 @@ fn cross_device_excludes_mount() { "FUSE mount at {mount_point:?} not ready after {retries} retries" ); - let build_expected_tree = |one_file_system: bool| -> String { + let build_expected_tree = |device_boundary: DeviceBoundary| -> String { let builder = FsTreeBuilder { root: workspace.clone(), size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system, + device_boundary, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -272,7 +273,7 @@ fn cross_device_excludes_mount() { // Run pdu WITHOUT --one-file-system — should see both files let actual = run_pdu(false); - let expected = build_expected_tree(false); + let expected = build_expected_tree(DeviceBoundary::Cross); eprintln!("WITHOUT --one-file-system:\nACTUAL:\n{actual}\n\nEXPECTED:\n{expected}\n"); assert_eq!(actual, expected); assert!( @@ -286,7 +287,7 @@ fn cross_device_excludes_mount() { // Run pdu WITH --one-file-system — should only see outside.txt let actual = run_pdu(true); - let expected = build_expected_tree(true); + let expected = build_expected_tree(DeviceBoundary::Stay); eprintln!("WITH --one-file-system:\nACTUAL:\n{actual}\n\nEXPECTED:\n{expected}\n"); assert_eq!(actual, expected); assert!( diff --git a/tests/usual_cli.rs b/tests/usual_cli.rs index 661dd2ac..b91b9841 100644 --- a/tests/usual_cli.rs +++ b/tests/usual_cli.rs @@ -7,6 +7,7 @@ use command_extra::CommandExtra; use parallel_disk_usage::{ bytes_format::BytesFormat, data_tree::DataTree, + device::DeviceBoundary, fs_tree_builder::FsTreeBuilder, get_size::GetApparentSize, hardlink::HardlinkIgnorant, @@ -45,7 +46,7 @@ fn total_width() { size_getter: DEFAULT_GET_SIZE, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -85,7 +86,7 @@ fn column_width() { size_getter: DEFAULT_GET_SIZE, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -125,7 +126,7 @@ fn min_ratio_0() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -164,7 +165,7 @@ fn min_ratio() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -204,7 +205,7 @@ fn max_depth_2() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 2, }; let mut data_tree: DataTree = builder.into(); @@ -244,7 +245,7 @@ fn max_depth_1() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 1, }; let mut data_tree: DataTree = builder.into(); @@ -283,7 +284,7 @@ fn top_down() { size_getter: DEFAULT_GET_SIZE, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -322,7 +323,7 @@ fn align_right() { size_getter: DEFAULT_GET_SIZE, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -361,7 +362,7 @@ fn quantity_apparent_size() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -401,7 +402,7 @@ fn quantity_block_size() { size_getter: GetBlockSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -441,7 +442,7 @@ fn quantity_block_count() { size_getter: GetBlockCount, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -481,7 +482,7 @@ fn bytes_format_plain() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -521,7 +522,7 @@ fn bytes_format_metric() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -561,7 +562,7 @@ fn bytes_format_binary() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -600,7 +601,7 @@ fn path_to_workspace() { size_getter: DEFAULT_GET_SIZE, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -644,7 +645,7 @@ fn multiple_names() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); @@ -710,7 +711,7 @@ fn multiple_names_max_depth_2() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 1, }; let mut data_tree: DataTree = builder.into(); @@ -771,7 +772,7 @@ fn multiple_names_max_depth_1() { size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), - one_file_system: false, + device_boundary: DeviceBoundary::Cross, max_depth: 10, }; let mut data_tree: DataTree = builder.into(); From 4863a600332ee9930833b4695f52b61b462380de Mon Sep 17 00:00:00 2001 From: khai96_ Date: Fri, 27 Mar 2026 22:34:25 +0700 Subject: [PATCH 53/65] refactor: arguments should stay together --- tests/one_file_system.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 6ad608ab..99738295 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -261,11 +261,11 @@ fn cross_device_excludes_mount() { .with_arg("--quantity=apparent-size") .with_arg("--total-width=100") .with_arg("--bytes-format=plain") + .with_args(one_file_system.then_some("--one-file-system")) + .with_arg(&workspace) .with_stdin(Stdio::null()) .with_stdout(Stdio::piped()) .with_stderr(Stdio::piped()) - .with_args(one_file_system.then_some("--one-file-system")) - .with_arg(&workspace) .output() .expect("run pdu") .pipe(stdout_text) From 6c6cfe429a444f8c0ee7cf6efc5bb44cf8e3b3bd Mon Sep 17 00:00:00 2001 From: khai96_ Date: Fri, 27 Mar 2026 22:36:41 +0700 Subject: [PATCH 54/65] refactor: stop taking `bool` --- tests/one_file_system.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 99738295..56b11000 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -256,12 +256,12 @@ fn cross_device_excludes_mount() { expected.trim_end().to_string() }; - let run_pdu = |one_file_system: bool| -> String { + let run_pdu = |extra_arg: Option<&'static str>| -> String { Command::new(PDU) .with_arg("--quantity=apparent-size") .with_arg("--total-width=100") .with_arg("--bytes-format=plain") - .with_args(one_file_system.then_some("--one-file-system")) + .with_args(extra_arg) .with_arg(&workspace) .with_stdin(Stdio::null()) .with_stdout(Stdio::piped()) @@ -272,7 +272,7 @@ fn cross_device_excludes_mount() { }; // Run pdu WITHOUT --one-file-system — should see both files - let actual = run_pdu(false); + let actual = run_pdu(None); let expected = build_expected_tree(DeviceBoundary::Cross); eprintln!("WITHOUT --one-file-system:\nACTUAL:\n{actual}\n\nEXPECTED:\n{expected}\n"); assert_eq!(actual, expected); @@ -286,7 +286,7 @@ fn cross_device_excludes_mount() { ); // Run pdu WITH --one-file-system — should only see outside.txt - let actual = run_pdu(true); + let actual = run_pdu(Some("--one-file-system")); let expected = build_expected_tree(DeviceBoundary::Stay); eprintln!("WITH --one-file-system:\nACTUAL:\n{actual}\n\nEXPECTED:\n{expected}\n"); assert_eq!(actual, expected); From b48921b0a11ae118ffcdb72e92212f7090306074 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Fri, 27 Mar 2026 22:38:17 +0700 Subject: [PATCH 55/65] refactor: use `pipe` --- tests/one_file_system.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 56b11000..6a8ddad5 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -62,10 +62,12 @@ fn same_device_on_sample_workspace() { .into() }; - let cross = build_tree(DeviceBoundary::Cross) + let cross = DeviceBoundary::Cross + .pipe(build_tree) .into_par_sorted(|left, right| left.name().cmp(right.name())) .into_reflection(); - let stay = build_tree(DeviceBoundary::Stay) + let stay = DeviceBoundary::Stay + .pipe(build_tree) .into_par_sorted(|left, right| left.name().cmp(right.name())) .into_reflection(); From 15768f6b63d624fd9c4e5acef5b175ae150658f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 16:21:29 +0000 Subject: [PATCH 56/65] refactor(args): move `one_file_system` between `deduplicate_hardlinks` and `top_down` https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- USAGE.md | 14 +++++++------- exports/completion.bash | 2 +- exports/completion.elv | 4 ++-- exports/completion.fish | 2 +- exports/completion.ps1 | 4 ++-- exports/completion.zsh | 4 ++-- exports/long.help | 6 +++--- exports/short.help | 4 ++-- src/app.rs | 2 +- src/args.rs | 10 +++++----- 10 files changed, 26 insertions(+), 26 deletions(-) diff --git a/USAGE.md b/USAGE.md index 98c4dd6d..683aad2a 100644 --- a/USAGE.md +++ b/USAGE.md @@ -39,6 +39,13 @@ How to display the numbers of bytes. Detect and subtract the sizes of hardlinks from their parent directory totals. + +### `--one-file-system` + +* _Aliases:_ `-x`. + +Skip directories on different filesystems. + ### `--top-down` @@ -101,13 +108,6 @@ Do not sort the branches in the tree. Prevent filesystem error messages from appearing in stderr. - -### `--one-file-system` - -* _Aliases:_ `-x`. - -Skip directories on different filesystems. - ### `--progress` diff --git a/exports/completion.bash b/exports/completion.bash index 609bf197..7b03e2c5 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 -x -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 --one-file-system --progress --threads --omit-json-shared-details --omit-json-shared-summary --help --version [FILES]..." + opts="-b -H -x -q -d -w -m -s -p -h -V --json-input --json-output --bytes-format --detect-links --dedupe-links --deduplicate-hardlinks --one-file-system --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]..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 diff --git a/exports/completion.elv b/exports/completion.elv index d4db1e14..3889da99 100644 --- a/exports/completion.elv +++ b/exports/completion.elv @@ -38,14 +38,14 @@ set edit:completion:arg-completer[pdu] = {|@words| cand --deduplicate-hardlinks 'Detect and subtract the sizes of hardlinks from their parent directory totals' cand --detect-links 'Detect and subtract the sizes of hardlinks from their parent directory totals' cand --dedupe-links 'Detect and subtract the sizes of hardlinks from their parent directory totals' + cand -x 'Skip directories on different filesystems' + cand --one-file-system 'Skip directories on different filesystems' cand --top-down 'Print the tree top-down instead of bottom-up' cand --align-right 'Set the root of the bars to the right' cand --no-sort 'Do not sort the branches in the tree' cand -s 'Prevent filesystem error messages from appearing in stderr' cand --silent-errors 'Prevent filesystem error messages from appearing in stderr' cand --no-errors 'Prevent filesystem error messages from appearing in stderr' - cand -x 'Skip directories on different filesystems' - cand --one-file-system 'Skip directories on different filesystems' cand -p 'Report progress being made at the expense of performance' cand --progress 'Report progress being made at the expense of performance' cand --omit-json-shared-details 'Do not output `.shared.details` in the JSON output' diff --git a/exports/completion.fish b/exports/completion.fish index 60812b89..0431ae03 100644 --- a/exports/completion.fish +++ b/exports/completion.fish @@ -12,11 +12,11 @@ complete -c pdu -l threads -d 'Set the maximum number of threads to spawn. Could 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' +complete -c pdu -s x -l one-file-system -d 'Skip directories on different filesystems' complete -c pdu -l top-down -d 'Print the tree top-down instead of bottom-up' complete -c pdu -l align-right -d 'Set the root of the bars to the right' complete -c pdu -l no-sort -d 'Do not sort the branches in the tree' complete -c pdu -s s -l silent-errors -l no-errors -d 'Prevent filesystem error messages from appearing in stderr' -complete -c pdu -s x -l one-file-system -d 'Skip directories on different filesystems' complete -c pdu -s p -l progress -d 'Report progress being made at the expense of performance' complete -c pdu -l omit-json-shared-details -d 'Do not output `.shared.details` in the JSON output' complete -c pdu -l omit-json-shared-summary -d 'Do not output `.shared.summary` in the JSON output' diff --git a/exports/completion.ps1 b/exports/completion.ps1 index 8a5802f5..547404c0 100644 --- a/exports/completion.ps1 +++ b/exports/completion.ps1 @@ -41,14 +41,14 @@ Register-ArgumentCompleter -Native -CommandName 'pdu' -ScriptBlock { [CompletionResult]::new('--deduplicate-hardlinks', '--deduplicate-hardlinks', [CompletionResultType]::ParameterName, 'Detect and subtract the sizes of hardlinks from their parent directory totals') [CompletionResult]::new('--detect-links', '--detect-links', [CompletionResultType]::ParameterName, 'Detect and subtract the sizes of hardlinks from their parent directory totals') [CompletionResult]::new('--dedupe-links', '--dedupe-links', [CompletionResultType]::ParameterName, 'Detect and subtract the sizes of hardlinks from their parent directory totals') + [CompletionResult]::new('-x', '-x', [CompletionResultType]::ParameterName, 'Skip directories on different filesystems') + [CompletionResult]::new('--one-file-system', '--one-file-system', [CompletionResultType]::ParameterName, 'Skip directories on different filesystems') [CompletionResult]::new('--top-down', '--top-down', [CompletionResultType]::ParameterName, 'Print the tree top-down instead of bottom-up') [CompletionResult]::new('--align-right', '--align-right', [CompletionResultType]::ParameterName, 'Set the root of the bars to the right') [CompletionResult]::new('--no-sort', '--no-sort', [CompletionResultType]::ParameterName, 'Do not sort the branches in the tree') [CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'Prevent filesystem error messages from appearing in stderr') [CompletionResult]::new('--silent-errors', '--silent-errors', [CompletionResultType]::ParameterName, 'Prevent filesystem error messages from appearing in stderr') [CompletionResult]::new('--no-errors', '--no-errors', [CompletionResultType]::ParameterName, 'Prevent filesystem error messages from appearing in stderr') - [CompletionResult]::new('-x', '-x', [CompletionResultType]::ParameterName, 'Skip directories on different filesystems') - [CompletionResult]::new('--one-file-system', '--one-file-system', [CompletionResultType]::ParameterName, 'Skip directories on different filesystems') [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Report progress being made at the expense of performance') [CompletionResult]::new('--progress', '--progress', [CompletionResultType]::ParameterName, 'Report progress being made at the expense of performance') [CompletionResult]::new('--omit-json-shared-details', '--omit-json-shared-details', [CompletionResultType]::ParameterName, 'Do not output `.shared.details` in the JSON output') diff --git a/exports/completion.zsh b/exports/completion.zsh index d8b86cf1..d0bb2d24 100644 --- a/exports/completion.zsh +++ b/exports/completion.zsh @@ -43,14 +43,14 @@ block-count\:"Count numbers of blocks"))' \ '--deduplicate-hardlinks[Detect and subtract the sizes of hardlinks from their parent directory totals]' \ '--detect-links[Detect and subtract the sizes of hardlinks from their parent directory totals]' \ '--dedupe-links[Detect and subtract the sizes of hardlinks from their parent directory totals]' \ +'-x[Skip directories on different filesystems]' \ +'--one-file-system[Skip directories on different filesystems]' \ '--top-down[Print the tree top-down instead of bottom-up]' \ '--align-right[Set the root of the bars to the right]' \ '--no-sort[Do not sort the branches in the tree]' \ '-s[Prevent filesystem error messages from appearing in stderr]' \ '--silent-errors[Prevent filesystem error messages from appearing in stderr]' \ '--no-errors[Prevent filesystem error messages from appearing in stderr]' \ -'-x[Skip directories on different filesystems]' \ -'--one-file-system[Skip directories on different filesystems]' \ '-p[Report progress being made at the expense of performance]' \ '--progress[Report progress being made at the expense of performance]' \ '--omit-json-shared-details[Do not output \`.shared.details\` in the JSON output]' \ diff --git a/exports/long.help b/exports/long.help index 01d52dcf..47624bb3 100644 --- a/exports/long.help +++ b/exports/long.help @@ -31,6 +31,9 @@ Options: [aliases: --detect-links, --dedupe-links] + -x, --one-file-system + Skip directories on different filesystems + --top-down Print the tree top-down instead of bottom-up @@ -74,9 +77,6 @@ Options: [aliases: --no-errors] - -x, --one-file-system - Skip directories on different filesystems - -p, --progress Report progress being made at the expense of performance diff --git a/exports/short.help b/exports/short.help index 6e5666ed..51a1a8de 100644 --- a/exports/short.help +++ b/exports/short.help @@ -14,6 +14,8 @@ Options: How to display the numbers of bytes [default: metric] [possible values: plain, metric, binary] -H, --deduplicate-hardlinks Detect and subtract the sizes of hardlinks from their parent directory totals [aliases: --detect-links, --dedupe-links] + -x, --one-file-system + Skip directories on different filesystems --top-down Print the tree top-down instead of bottom-up --align-right @@ -32,8 +34,6 @@ Options: Do not sort the branches in the tree -s, --silent-errors Prevent filesystem error messages from appearing in stderr [aliases: --no-errors] - -x, --one-file-system - Skip directories on different filesystems -p, --progress Report progress being made at the expense of performance --threads diff --git a/src/app.rs b/src/app.rs index 3394a8bd..1148614b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -291,6 +291,7 @@ impl App { progress: $progress, #[cfg(unix)] deduplicate_hardlinks: $hardlinks, #[cfg(not(unix))] deduplicate_hardlinks: _, + one_file_system, files, json_output, bytes_format, @@ -299,7 +300,6 @@ impl App { max_depth, min_ratio, no_sort, - one_file_system, omit_json_shared_details, omit_json_shared_summary, .. diff --git a/src/args.rs b/src/args.rs index 8631f644..b3c09a96 100644 --- a/src/args.rs +++ b/src/args.rs @@ -112,6 +112,11 @@ pub struct Args { #[cfg_attr(not(unix), clap(hide = true))] pub deduplicate_hardlinks: bool, + /// Skip directories on different filesystems. + #[clap(long, short = 'x')] + #[cfg_attr(not(unix), clap(hide = true))] + pub one_file_system: bool, + /// Print the tree top-down instead of bottom-up. #[clap(long)] pub top_down: bool, @@ -155,11 +160,6 @@ pub struct Args { #[clap(long, short, visible_alias = "no-errors")] pub silent_errors: bool, - /// Skip directories on different filesystems. - #[clap(long, short = 'x')] - #[cfg_attr(not(unix), clap(hide = true))] - pub one_file_system: bool, - /// Report progress being made at the expense of performance. #[clap(long, short)] pub progress: bool, From 47449054bae4c278935c46f548e0eef7ff8809cc Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 00:50:06 +0700 Subject: [PATCH 57/65] feat(cli): set conflict --- src/args.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/args.rs b/src/args.rs index b3c09a96..03e14ea9 100644 --- a/src/args.rs +++ b/src/args.rs @@ -94,7 +94,7 @@ pub struct Args { /// Read JSON data from stdin. #[clap( long, - conflicts_with_all = ["quantity", "deduplicate_hardlinks"] + conflicts_with_all = ["quantity", "deduplicate_hardlinks", "one_file_system"] )] pub json_input: bool, From 27c740cb4822b41dd8b1366c731053001339e113 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 00:55:23 +0700 Subject: [PATCH 58/65] fix: completions --- exports/completion.zsh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exports/completion.zsh b/exports/completion.zsh index d0bb2d24..66cfe345 100644 --- a/exports/completion.zsh +++ b/exports/completion.zsh @@ -37,7 +37,7 @@ 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' \ -'(-q --quantity -H --deduplicate-hardlinks)--json-input[Read JSON data from stdin]' \ +'(-q --quantity -H --deduplicate-hardlinks -x --one-file-system)--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]' \ '--deduplicate-hardlinks[Detect and subtract the sizes of hardlinks from their parent directory totals]' \ From 077cbf09d6dad8202d023cbf33a2b0b29d7dff67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kh=E1=BA=A3i?= Date: Sat, 28 Mar 2026 00:57:11 +0700 Subject: [PATCH 59/65] test: set `--min-ratio` Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/one_file_system.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 6a8ddad5..cb3796d6 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -263,6 +263,7 @@ fn cross_device_excludes_mount() { .with_arg("--quantity=apparent-size") .with_arg("--total-width=100") .with_arg("--bytes-format=plain") + .with_arg("--min-ratio=0.01") .with_args(extra_arg) .with_arg(&workspace) .with_stdin(Stdio::null()) From 4e6f08498037ee14a7494cd1854440334158a3d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 18:05:45 +0000 Subject: [PATCH 60/65] refactor(device): rename `from_1fs` to `from_one_file_system` Align with the existing naming convention used by `Direction::from_top_down` and `BarAlignment::from_align_right`. https://claude.ai/code/session_01LfpnUZrgq93MVZgA3KVqE6 --- src/app.rs | 2 +- src/device.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1148614b..a685df54 100644 --- a/src/app.rs +++ b/src/app.rs @@ -308,7 +308,7 @@ impl App { bar_alignment: BarAlignment::from_align_right(align_right), size_getter: <$size_getter as GetSizeUtils>::INSTANCE, hardlinks_handler: <$size_getter as CreateHardlinksHandler<{ cfg!(unix) && $hardlinks }, $progress>>::create_hardlinks_handler(), - device_boundary: DeviceBoundary::from_1fs(one_file_system), + device_boundary: DeviceBoundary::from_one_file_system(one_file_system), reporter: <$size_getter as CreateReporter<$progress>>::create_reporter(report_error), bytes_format: <$size_getter as GetSizeUtils>::formatter(bytes_format), files, diff --git a/src/device.rs b/src/device.rs index 62eb0a88..1ef5a12b 100644 --- a/src/device.rs +++ b/src/device.rs @@ -8,7 +8,7 @@ pub enum DeviceBoundary { impl DeviceBoundary { /// Derive device boundary from `--one-file-system`. #[cfg(feature = "cli")] - pub(crate) fn from_1fs(one_file_system: bool) -> Self { + pub(crate) fn from_one_file_system(one_file_system: bool) -> Self { match one_file_system { false => DeviceBoundary::Cross, true => DeviceBoundary::Stay, From 8b06f9ec02fb882948eb835de7ea4425b3696937 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 01:23:35 +0700 Subject: [PATCH 61/65] refactor: reduce verbosity --- tests/one_file_system.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index cb3796d6..c7a791da 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -50,16 +50,15 @@ use which::which; fn same_device_on_sample_workspace() { let workspace = SampleWorkspace::default(); - let build_tree = |device_boundary: DeviceBoundary| -> DataTree { - FsTreeBuilder { + let build_tree = |device_boundary: DeviceBoundary| { + DataTree::from(FsTreeBuilder { root: workspace.to_path_buf(), size_getter: GetApparentSize, hardlinks_recorder: &HardlinkIgnorant, reporter: &ErrorOnlyReporter::new(ErrorReport::SILENT), device_boundary, max_depth: 10, - } - .into() + }) }; let cross = DeviceBoundary::Cross From 88c7d8de6c1a5ae823ecd7260452e1bee0031b6e Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 01:25:46 +0700 Subject: [PATCH 62/65] refactor: use pipe --- tests/one_file_system.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index c7a791da..f86ebdd7 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -64,15 +64,16 @@ fn same_device_on_sample_workspace() { let cross = DeviceBoundary::Cross .pipe(build_tree) .into_par_sorted(|left, right| left.name().cmp(right.name())) - .into_reflection(); + .into_reflection() + .pipe(sanitize_tree_reflection); let stay = DeviceBoundary::Stay .pipe(build_tree) .into_par_sorted(|left, right| left.name().cmp(right.name())) - .into_reflection(); + .into_reflection() + .pipe(sanitize_tree_reflection); assert_eq!( - sanitize_tree_reflection(cross), - sanitize_tree_reflection(stay), + cross, stay, "DeviceBoundary should not change the result when all files are on the same device", ); } From 30f37bc16b184da31b300e27029e7703a5f1e063 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 01:27:04 +0700 Subject: [PATCH 63/65] refactor: rename some variables --- tests/one_file_system.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index f86ebdd7..79befe57 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -61,19 +61,19 @@ fn same_device_on_sample_workspace() { }) }; - let cross = DeviceBoundary::Cross + let crossing = DeviceBoundary::Cross .pipe(build_tree) .into_par_sorted(|left, right| left.name().cmp(right.name())) .into_reflection() .pipe(sanitize_tree_reflection); - let stay = DeviceBoundary::Stay + let staying = DeviceBoundary::Stay .pipe(build_tree) .into_par_sorted(|left, right| left.name().cmp(right.name())) .into_reflection() .pipe(sanitize_tree_reflection); assert_eq!( - cross, stay, + crossing, staying, "DeviceBoundary should not change the result when all files are on the same device", ); } From 52d95d7d4d1bf916640db82ae66463622a5a32b8 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 01:27:59 +0700 Subject: [PATCH 64/65] docs: remove the obvious error message --- tests/one_file_system.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index 79befe57..e00e281d 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -72,10 +72,7 @@ fn same_device_on_sample_workspace() { .into_reflection() .pipe(sanitize_tree_reflection); - assert_eq!( - crossing, staying, - "DeviceBoundary should not change the result when all files are on the same device", - ); + assert_eq!(crossing, staying); } /// Information about the available FUSE tools, discovered by [`fuse_probe`]. From cfddd52ff87e98275e5420653af899aa3c1970f0 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 01:43:08 +0700 Subject: [PATCH 65/65] style: add an empty line --- tests/one_file_system.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/one_file_system.rs b/tests/one_file_system.rs index e00e281d..e96a06d2 100644 --- a/tests/one_file_system.rs +++ b/tests/one_file_system.rs @@ -66,6 +66,7 @@ fn same_device_on_sample_workspace() { .into_par_sorted(|left, right| left.name().cmp(right.name())) .into_reflection() .pipe(sanitize_tree_reflection); + let staying = DeviceBoundary::Stay .pipe(build_tree) .into_par_sorted(|left, right| left.name().cmp(right.name()))