From 2f5bd834e46533fba980b2a61d45bfddc9ea91c2 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:02:23 -0600 Subject: [PATCH 01/20] feat: Add --sort arg to CLI parsing --- src/cli.rs | 16 +++++++++++++++- src/filter/mod.rs | 2 ++ src/filter/sort.rs | 13 +++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/filter/sort.rs diff --git a/src/cli.rs b/src/cli.rs index 9c54d7c2c..204c0d1a2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -16,7 +16,7 @@ use crate::exec::CommandSet; use crate::filesystem; #[cfg(unix)] use crate::filter::OwnerFilter; -use crate::filter::SizeFilter; +use crate::filter::{SizeFilter, SortKey}; #[derive(Parser)] #[command( @@ -549,6 +549,20 @@ pub struct Opts { #[arg(long, hide = true, value_parser = parse_millis)] pub max_buffer_time: Option, + /// Sort search results by the given key before printing or executing commands. + /// + /// Note: this buffers all results in memory before outputting. + #[arg( + long, + value_name = "key", + value_enum, + hide_short_help = true, + help = "Sort results by: path, size, created, or modified", + long_help, + conflicts_with("list_details") + )] + pub sort: Option, + ///Limit the number of search results to 'count' and quit immediately. #[arg( long, diff --git a/src/filter/mod.rs b/src/filter/mod.rs index 5e45d3b1c..5b73a6256 100644 --- a/src/filter/mod.rs +++ b/src/filter/mod.rs @@ -1,10 +1,12 @@ pub use self::size::SizeFilter; +pub use self::sort::SortKey; pub use self::time::TimeFilter; #[cfg(unix)] pub use self::owner::OwnerFilter; mod size; +mod sort; // Arguably not a "filter", but more of an augmentation on search results. mod time; #[cfg(unix)] diff --git a/src/filter/sort.rs b/src/filter/sort.rs new file mode 100644 index 000000000..affdd69c6 --- /dev/null +++ b/src/filter/sort.rs @@ -0,0 +1,13 @@ +use clap::ValueEnum; + +#[derive(Copy, Clone, PartialEq, Eq, Debug, ValueEnum)] +pub enum SortKey { + /// Sort by path + Path, + /// Sort by file size + Size, + /// Sort by creation time + Created, + /// Sort by modification time + Modified, +} From 6511a2fb9d61546018b26d2fbee2b53c913f9b18 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:17:58 -0600 Subject: [PATCH 02/20] chore: Add sort_key to Config struct --- src/config.rs | 5 ++++- src/main.rs | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index a027812a4..c0b8d480e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,7 +7,7 @@ use crate::exec::CommandSet; use crate::filetypes::FileTypes; #[cfg(unix)] use crate::filter::OwnerFilter; -use crate::filter::{SizeFilter, TimeFilter}; +use crate::filter::{SizeFilter, SortKey, TimeFilter}; use crate::fmt::FormatTemplate; /// Configuration options for *fd*. @@ -133,6 +133,9 @@ pub struct Config { /// Names that should stop traversal down their parent. (e.g. https://bford.info/cachedir/). pub ignore_contain: Vec, + + /// The key to sort results by + pub sort_key: Option, } impl Config { diff --git a/src/main.rs b/src/main.rs index 8b7bd936c..57ba4d70e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -336,6 +336,7 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result Date: Mon, 20 Apr 2026 17:29:09 -0600 Subject: [PATCH 03/20] feat: Connect --sort CLI arg to work with -x --- src/walk.rs | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/walk.rs b/src/walk.rs index 018ad2f70..bcf1f90ad 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -21,6 +21,7 @@ use crate::error::print_error; use crate::exec; use crate::exit_codes::{ExitCode, merge_exitcodes}; use crate::filesystem; +use crate::filter::SortKey; use crate::output; /// The receiver thread can either be buffering results or directly streaming to the console. @@ -411,7 +412,18 @@ impl WorkerState { // This will be set to `Some` if the `--exec` argument was supplied. if let Some(ref cmd) = config.command { if cmd.in_batch_mode() { - exec::batch(rx.into_iter().flatten(), cmd, config) + let mut results: Vec = rx.into_iter().flatten().collect(); + if let Some(sort_key) = config.sort_key { + sort_worker_results(&mut results, sort_key); + } + exec::batch(results.into_iter(), cmd, config) + } else if let Some(sort_key) = config.sort_key { + // With --sort, we must collect all results before dispatching, + // and run sequentially so the order is preserved. + + let mut results: Vec = rx.into_iter().flatten().collect(); + sort_worker_results(&mut results, sort_key); + return exec::job(results.into_iter(), cmd, config); } else { thread::scope(|scope| { // Each spawned job will store its thread handle in here. @@ -663,6 +675,35 @@ impl WorkerState { } } +fn sort_worker_results(results: &mut Vec, sort_key: SortKey) { + results.sort_by(|a, b| { + // Errors sort to the end; two errors are considered equal + let (WorkerResult::Entry(a), WorkerResult::Entry(b)) = (a, b) else { + return match (a, b) { + (WorkerResult::Error(_), WorkerResult::Entry(_)) => std::cmp::Ordering::Greater, + (WorkerResult::Entry(_), WorkerResult::Error(_)) => std::cmp::Ordering::Less, + _ => std::cmp::Ordering::Equal, + }; + }; + + match sort_key { + SortKey::Path => a.path().cmp(b.path()), + SortKey::Size => { + let size = |e: &DirEntry| e.metadata().map(|m| m.len()).unwrap_or(0); + size(a).cmp(&size(b)) + } + SortKey::Created => { + let time = |e: &DirEntry| e.metadata().and_then(|m| m.created().ok()); + time(a).cmp(&time(b)) + } + SortKey::Modified => { + let time = |e: &DirEntry| e.metadata().and_then(|m| m.modified().ok()); + time(a).cmp(&time(b)) + } + } + }); +} + fn search_str_for_entry<'a>( entry_path: &'a std::path::Path, full_path_base: Option<&std::path::Path>, From d1364d1168c37cf729ef05fb9c8d06b3a41c8fa2 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:43:35 -0600 Subject: [PATCH 04/20] chore(deps): Add rand dev dependency for shuffling in tests --- Cargo.lock | 39 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + 2 files changed, 40 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index fb070409e..336140872 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,6 +131,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "clap" version = "4.6.0" @@ -187,6 +198,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -316,6 +336,7 @@ dependencies = [ "nix 0.31.2", "normpath", "nu-ansi-term", + "rand", "regex", "regex-syntax", "tempfile", @@ -355,6 +376,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core", "wasip2", "wasip3", ] @@ -656,6 +678,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "redox_syscall" version = "0.7.3" diff --git a/Cargo.toml b/Cargo.toml index c85026188..418875965 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ diff = "0.1" tempfile = "3.27" filetime = "0.2" test-case = "3.3" +rand = "0.10.1" [profile.dev] debug = "line-tables-only" From b30e1b4f38bcbc24458b2f56bbb52b6723735728 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:44:00 -0600 Subject: [PATCH 05/20] test: Add test_sort_by_path and _with_exec --- tests/tests.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/tests.rs b/tests/tests.rs index c125d3a59..c17e60bbd 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -2023,6 +2023,49 @@ fn test_exec_batch_with_limit() { ); } +fn shuffle_files(files: &[&'static str], seed: u64) -> Vec<&'static str> { + use rand::SeedableRng as _; + use rand::seq::SliceRandom as _; + let mut files = files.to_vec(); + files.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed)); + + files +} + +#[test] +fn test_sort_by_path() { + let te = TestEnv::new(DEFAULT_DIRS, &shuffle_files(DEFAULT_FILES, 42)); + + // --sort=path should produce deterministic alphabetical output + te.assert_output( + &["--sort=path", "foo"], + "a.foo + one/b.foo + one/two/C.Foo2 + one/two/c.foo + one/two/three/d.foo + one/two/three/directory_foo/", + ); +} + +/// Shell script execution with --sort (--exec) +#[cfg(not(windows))] +#[test] +fn test_sort_by_path_with_exec() { + let te = TestEnv::new(DEFAULT_DIRS, &shuffle_files(DEFAULT_FILES, 42)); + + // --exec with --sort should produce output in sorted order + te.assert_output( + &["foo", "--sort=path", "--exec", "echo", "File: {}"], + "File: ./a.foo + File: ./one/b.foo + File: ./one/two/C.Foo2 + File: ./one/two/c.foo + File: ./one/two/three/d.foo + File: ./one/two/three/directory_foo", + ); +} + /// Shell script execution (--exec) with a custom --path-separator #[test] fn test_exec_with_separator() { From 3663a3ca92b5b2474bcdf01234ca3d26b2a7e187 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:48:27 -0600 Subject: [PATCH 06/20] docs: Add CHANGELOG entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b013bb4..50a127dc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Features - Add `--ignore-parent` option to override `--no-ignore-parent`, see #1958 (@tmchow) +- Add `--sort` arg to CLI to sort by path/size/dates, see #1875 and #1982 (@DeflateAwning) ## Bugfixes - Handle invalid working directories gracefully when using `--full-path`, see #1900 (@Xavrir). From 51968356a234ddbe4bd4dc3dba0f699f9d0b46fc Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:50:29 -0600 Subject: [PATCH 07/20] fix: Clippy suggestions --- src/walk.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/walk.rs b/src/walk.rs index bcf1f90ad..30263111a 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -416,14 +416,14 @@ impl WorkerState { if let Some(sort_key) = config.sort_key { sort_worker_results(&mut results, sort_key); } - exec::batch(results.into_iter(), cmd, config) + exec::batch(results, cmd, config) } else if let Some(sort_key) = config.sort_key { // With --sort, we must collect all results before dispatching, // and run sequentially so the order is preserved. let mut results: Vec = rx.into_iter().flatten().collect(); sort_worker_results(&mut results, sort_key); - return exec::job(results.into_iter(), cmd, config); + exec::job(results, cmd, config) } else { thread::scope(|scope| { // Each spawned job will store its thread handle in here. @@ -675,7 +675,7 @@ impl WorkerState { } } -fn sort_worker_results(results: &mut Vec, sort_key: SortKey) { +fn sort_worker_results(results: &mut [WorkerResult], sort_key: SortKey) { results.sort_by(|a, b| { // Errors sort to the end; two errors are considered equal let (WorkerResult::Entry(a), WorkerResult::Entry(b)) = (a, b) else { From bf2435c4c59f1cd5dfd7dc7211d8c1f910338709 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:17:48 -0600 Subject: [PATCH 08/20] refactor: Move SortKey enum to config.rs (review comment) --- src/cli.rs | 3 ++- src/config.rs | 15 ++++++++++++++- src/filter/mod.rs | 2 -- src/filter/sort.rs | 13 ------------- src/walk.rs | 3 +-- 5 files changed, 17 insertions(+), 19 deletions(-) delete mode 100644 src/filter/sort.rs diff --git a/src/cli.rs b/src/cli.rs index 204c0d1a2..55e9d566c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,12 +11,13 @@ use clap::{ use clap_complete::Shell; use normpath::PathExt; +use crate::config::SortKey; use crate::error::print_error; use crate::exec::CommandSet; use crate::filesystem; #[cfg(unix)] use crate::filter::OwnerFilter; -use crate::filter::{SizeFilter, SortKey}; +use crate::filter::SizeFilter; #[derive(Parser)] #[command( diff --git a/src/config.rs b/src/config.rs index c0b8d480e..86fe3d85c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ use std::{path::PathBuf, sync::Arc, time::Duration}; +use clap::ValueEnum; use lscolors::LsColors; use regex::bytes::RegexSet; @@ -7,7 +8,7 @@ use crate::exec::CommandSet; use crate::filetypes::FileTypes; #[cfg(unix)] use crate::filter::OwnerFilter; -use crate::filter::{SizeFilter, SortKey, TimeFilter}; +use crate::filter::{SizeFilter, TimeFilter}; use crate::fmt::FormatTemplate; /// Configuration options for *fd*. @@ -144,3 +145,15 @@ impl Config { self.command.is_none() } } + +#[derive(Copy, Clone, PartialEq, Eq, Debug, ValueEnum)] +pub enum SortKey { + /// Sort by path + Path, + /// Sort by file size + Size, + /// Sort by creation time + Created, + /// Sort by modification time + Modified, +} diff --git a/src/filter/mod.rs b/src/filter/mod.rs index 5b73a6256..5e45d3b1c 100644 --- a/src/filter/mod.rs +++ b/src/filter/mod.rs @@ -1,12 +1,10 @@ pub use self::size::SizeFilter; -pub use self::sort::SortKey; pub use self::time::TimeFilter; #[cfg(unix)] pub use self::owner::OwnerFilter; mod size; -mod sort; // Arguably not a "filter", but more of an augmentation on search results. mod time; #[cfg(unix)] diff --git a/src/filter/sort.rs b/src/filter/sort.rs deleted file mode 100644 index affdd69c6..000000000 --- a/src/filter/sort.rs +++ /dev/null @@ -1,13 +0,0 @@ -use clap::ValueEnum; - -#[derive(Copy, Clone, PartialEq, Eq, Debug, ValueEnum)] -pub enum SortKey { - /// Sort by path - Path, - /// Sort by file size - Size, - /// Sort by creation time - Created, - /// Sort by modification time - Modified, -} diff --git a/src/walk.rs b/src/walk.rs index 30263111a..722059493 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -15,13 +15,12 @@ use ignore::overrides::{Override, OverrideBuilder}; use ignore::{WalkBuilder, WalkParallel, WalkState}; use regex::bytes::Regex; -use crate::config::Config; +use crate::config::{Config, SortKey}; use crate::dir_entry::DirEntry; use crate::error::print_error; use crate::exec; use crate::exit_codes::{ExitCode, merge_exitcodes}; use crate::filesystem; -use crate::filter::SortKey; use crate::output; /// The receiver thread can either be buffering results or directly streaming to the console. From 216b7717754d9133f664c212fdfdc7606447d0e4 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:36:13 -0600 Subject: [PATCH 09/20] refactor,fix,test: Allow TestEnv configured to validate output order --- tests/testenv/mod.rs | 51 ++++++++++++++++++++++++++++++++++++++++---- tests/tests.rs | 6 ++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/tests/testenv/mod.rs b/tests/testenv/mod.rs index 541fa46ec..07d2fd227 100644 --- a/tests/testenv/mod.rs +++ b/tests/testenv/mod.rs @@ -18,9 +18,15 @@ pub struct TestEnv { /// Path to the *fd* executable. fd_exe: PathBuf, - /// Normalize each line by sorting the whitespace-separated words + /// Normalize each line by sorting the whitespace-separated words. normalize_line: bool, + /// When `true`, the order of lines in the result is allowed to be arbitrary + /// (i.e., a sort is performed before comparison). + /// + /// When `false`, the order of lines in the result is checked (e.g., when testing the `--sort` CLI option). + allow_random_result_order: bool, + /// Temporary directory for storing test config (global ignore file) config_dir: Option, } @@ -112,7 +118,13 @@ fn format_output_error(args: &[&str], expected: &str, actual: &str) -> String { } /// Normalize the output for comparison. -fn normalize_output(s: &str, trim_start: bool, normalize_line: bool) -> String { +/// +/// Args: +/// - `s`: The output string to normalize. +/// - `trim_start`: Whether to trim whitespace from the start of each line. +/// - `normalize_line`: Whether to sort the whitespace-separated words in each line. +/// - `sort_lines`: Whether to sort the lines alphabetically. +fn normalize_output(s: &str, trim_start: bool, normalize_line: bool, sort_lines: bool) -> String { // Split into lines and normalize separators. let mut lines = s .replace('\0', "NULL\n") @@ -129,7 +141,9 @@ fn normalize_output(s: &str, trim_start: bool, normalize_line: bool) -> String { }) .collect::>(); - lines.sort(); + if sort_lines { + lines.sort(); + } lines.join("\n") } @@ -153,15 +167,34 @@ impl TestEnv { temp_dir, fd_exe, normalize_line: false, + allow_random_result_order: true, config_dir: None, } } + /// Sets whether output lines should be normalized before comparison. + /// + /// Normalization sorts whitespace-separated elements in each line to ensure consistent comparison. pub fn normalize_line(self, normalize: bool) -> TestEnv { TestEnv { temp_dir: self.temp_dir, fd_exe: self.fd_exe, normalize_line: normalize, + allow_random_result_order: self.allow_random_result_order, + config_dir: self.config_dir, + } + } + + /// Sets whether test results are allowed to appear in any order. + /// + /// When `true`, assertions will pass regardless of result ordering, + /// useful for tests where output order is non-deterministic. + pub fn allow_random_result_order(self, allow_random_result_order: bool) -> TestEnv { + TestEnv { + temp_dir: self.temp_dir, + fd_exe: self.fd_exe, + normalize_line: self.normalize_line, + allow_random_result_order, config_dir: self.config_dir, } } @@ -241,10 +274,13 @@ impl TestEnv { &String::from_utf8_lossy(&output.stdout), false, self.normalize_line, + self.allow_random_result_order, ) } /// Assert that calling *fd* with the specified arguments produces the expected output. + /// + /// Does not compare ordering. pub fn assert_output(&self, args: &[&str], expected: &str) { self.assert_output_subdirectory(".", args, expected) } @@ -259,6 +295,8 @@ impl TestEnv { /// Assert that calling *fd* in the specified path under the root working directory, /// and with the specified arguments produces the expected output. + /// + /// Performs normalization to pub fn assert_output_subdirectory>( &self, path: P, @@ -266,7 +304,12 @@ impl TestEnv { expected: &str, ) { // Normalize both expected and actual output. - let expected = normalize_output(expected, true, self.normalize_line); + let expected = normalize_output( + expected, + true, + self.normalize_line, + self.allow_random_result_order, + ); let actual = self.assert_success_and_get_normalized_output(path, args); // Compare actual output to expected output. diff --git a/tests/tests.rs b/tests/tests.rs index c17e60bbd..b606c2fde 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -2034,7 +2034,8 @@ fn shuffle_files(files: &[&'static str], seed: u64) -> Vec<&'static str> { #[test] fn test_sort_by_path() { - let te = TestEnv::new(DEFAULT_DIRS, &shuffle_files(DEFAULT_FILES, 42)); + let te = TestEnv::new(DEFAULT_DIRS, &shuffle_files(DEFAULT_FILES, 42)) + .allow_random_result_order(false); // --sort=path should produce deterministic alphabetical output te.assert_output( @@ -2052,7 +2053,8 @@ fn test_sort_by_path() { #[cfg(not(windows))] #[test] fn test_sort_by_path_with_exec() { - let te = TestEnv::new(DEFAULT_DIRS, &shuffle_files(DEFAULT_FILES, 42)); + let te = TestEnv::new(DEFAULT_DIRS, &shuffle_files(DEFAULT_FILES, 42)) + .allow_random_result_order(false); // --exec with --sort should produce output in sorted order te.assert_output( From ba9cb0627baf9419790ef170d5d835155b2d5a66 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:10:11 -0600 Subject: [PATCH 10/20] perf,fix: Avoid collecting sorted results in batch if no sort required --- src/walk.rs | 20 ++++++++++---------- tests/tests.rs | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/walk.rs b/src/walk.rs index 722059493..6f5c3bf1e 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -407,23 +407,23 @@ impl WorkerState { /// threads (for --exec). fn receive(&self, rx: Receiver) -> ExitCode { let config = &self.config; - // This will be set to `Some` if the `--exec` argument was supplied. if let Some(ref cmd) = config.command { - if cmd.in_batch_mode() { - let mut results: Vec = rx.into_iter().flatten().collect(); - if let Some(sort_key) = config.sort_key { - sort_worker_results(&mut results, sort_key); - } - exec::batch(results, cmd, config) - } else if let Some(sort_key) = config.sort_key { + if let Some(sort_key) = config.sort_key { // With --sort, we must collect all results before dispatching, // and run sequentially so the order is preserved. - let mut results: Vec = rx.into_iter().flatten().collect(); sort_worker_results(&mut results, sort_key); - exec::job(results, cmd, config) + if cmd.in_batch_mode() { + exec::batch(results, cmd, config) + } else { + exec::job(results, cmd, config) + } + } else if cmd.in_batch_mode() { + // Batch mode without sorting. + exec::batch(rx.into_iter().flatten(), cmd, config) } else { + // No sort. Not Batch mode. Dispatch jobs across a thread pool as results stream in. thread::scope(|scope| { // Each spawned job will store its thread handle in here. let threads = config.threads; diff --git a/tests/tests.rs b/tests/tests.rs index b606c2fde..0eba90332 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -2056,7 +2056,7 @@ fn test_sort_by_path_with_exec() { let te = TestEnv::new(DEFAULT_DIRS, &shuffle_files(DEFAULT_FILES, 42)) .allow_random_result_order(false); - // --exec with --sort should produce output in sorted order + // --exec with --sort should produce output in sorted order. te.assert_output( &["foo", "--sort=path", "--exec", "echo", "File: {}"], "File: ./a.foo From ab554540f33be73d36c0d3d3cef32865c23935a9 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:12:05 -0600 Subject: [PATCH 11/20] Review suggestion - WorkerResult matching in sort - src/walk.rs Co-authored-by: Thayne McCombs --- src/walk.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/walk.rs b/src/walk.rs index 6f5c3bf1e..70d287d25 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -677,14 +677,14 @@ impl WorkerState { fn sort_worker_results(results: &mut [WorkerResult], sort_key: SortKey) { results.sort_by(|a, b| { // Errors sort to the end; two errors are considered equal - let (WorkerResult::Entry(a), WorkerResult::Entry(b)) = (a, b) else { - return match (a, b) { - (WorkerResult::Error(_), WorkerResult::Entry(_)) => std::cmp::Ordering::Greater, - (WorkerResult::Entry(_), WorkerResult::Error(_)) => std::cmp::Ordering::Less, - _ => std::cmp::Ordering::Equal, - }; + let (a, b) = match (a, b) { + (WorkerResult::Entry(a), WorkerResult::Entry(b)) => (a, b) + (WorkerResult::Error(_), WorkerResult::Entry(_)) => return std::cmp::Ordering::Greater, + (WorkerResult::Entry(_), WorkerResult::Error(_)) => return std::cmp::Ordering::Less, + _ => return std::cmp::Ordering::Equal, }; + match sort_key { SortKey::Path => a.path().cmp(b.path()), SortKey::Size => { From f1553f4ec7e2634447a750b9060fab12a26a0572 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:15:31 -0600 Subject: [PATCH 12/20] fix: sort_worker_results error handling cases --- src/walk.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/walk.rs b/src/walk.rs index 70d287d25..b1806f90b 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -676,15 +676,20 @@ impl WorkerState { fn sort_worker_results(results: &mut [WorkerResult], sort_key: SortKey) { results.sort_by(|a, b| { - // Errors sort to the end; two errors are considered equal + // Errors sort to the end; two errors are considered equal. let (a, b) = match (a, b) { - (WorkerResult::Entry(a), WorkerResult::Entry(b)) => (a, b) - (WorkerResult::Error(_), WorkerResult::Entry(_)) => return std::cmp::Ordering::Greater, - (WorkerResult::Entry(_), WorkerResult::Error(_)) => return std::cmp::Ordering::Less, - _ => return std::cmp::Ordering::Equal, + (WorkerResult::Entry(a), WorkerResult::Entry(b)) => (a, b), + (WorkerResult::Error(_), WorkerResult::Entry(_)) => { + return std::cmp::Ordering::Greater; + } + (WorkerResult::Entry(_), WorkerResult::Error(_)) => { + return std::cmp::Ordering::Less; + } + (WorkerResult::Error(_), WorkerResult::Error(_)) => { + return std::cmp::Ordering::Equal; + } }; - match sort_key { SortKey::Path => a.path().cmp(b.path()), SortKey::Size => { From 676be3673a6565e1bebb8be67f1527e2f49791bb Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:31:03 -0600 Subject: [PATCH 13/20] perf: Optimize sort_worker_results for cached key lookup (fn and ordering) - review comment --- src/walk.rs | 70 +++++++++++++++++++++++++++++------------------------ 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/src/walk.rs b/src/walk.rs index b1806f90b..b80095925 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use std::thread; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime}; use anyhow::{Result, anyhow}; use crossbeam_channel::{Receiver, RecvTimeoutError, SendError, Sender, bounded}; @@ -675,37 +675,45 @@ impl WorkerState { } fn sort_worker_results(results: &mut [WorkerResult], sort_key: SortKey) { - results.sort_by(|a, b| { - // Errors sort to the end; two errors are considered equal. - let (a, b) = match (a, b) { - (WorkerResult::Entry(a), WorkerResult::Entry(b)) => (a, b), - (WorkerResult::Error(_), WorkerResult::Entry(_)) => { - return std::cmp::Ordering::Greater; - } - (WorkerResult::Entry(_), WorkerResult::Error(_)) => { - return std::cmp::Ordering::Less; - } - (WorkerResult::Error(_), WorkerResult::Error(_)) => { - return std::cmp::Ordering::Equal; - } - }; + // Build the key extractor once, based on sort_key. + // Returns None for errors, pushing them to the end naturally via Ord on Option. + let key_fn: Box Option> = match sort_key { + SortKey::Path => Box::new(|r| match r { + WorkerResult::Entry(e) => Some(SortKeyValue::Path(e.path().to_path_buf())), + WorkerResult::Error(_) => None, + }), + SortKey::Size => Box::new(|r| match r { + WorkerResult::Entry(e) => Some(SortKeyValue::Size( + e.metadata().map(|m| m.len()).unwrap_or(0), + )), + WorkerResult::Error(_) => None, + }), + SortKey::Created => Box::new(|r| match r { + WorkerResult::Entry(e) => Some(SortKeyValue::Time( + e.metadata().and_then(|m| m.created().ok()), + )), + WorkerResult::Error(_) => None, + }), + SortKey::Modified => Box::new(|r| match r { + WorkerResult::Entry(e) => Some(SortKeyValue::Time( + e.metadata().and_then(|m| m.modified().ok()), + )), + WorkerResult::Error(_) => None, + }), + }; + + // Use sort_by_cached_key to avoid recomputing the key for each comparison. + results.sort_by_cached_key(|r| key_fn(r)); +} - match sort_key { - SortKey::Path => a.path().cmp(b.path()), - SortKey::Size => { - let size = |e: &DirEntry| e.metadata().map(|m| m.len()).unwrap_or(0); - size(a).cmp(&size(b)) - } - SortKey::Created => { - let time = |e: &DirEntry| e.metadata().and_then(|m| m.created().ok()); - time(a).cmp(&time(b)) - } - SortKey::Modified => { - let time = |e: &DirEntry| e.metadata().and_then(|m| m.modified().ok()); - time(a).cmp(&time(b)) - } - } - }); +/// Comparable key values, one variant per SortKey. +/// None sorts last via Option's natural ordering (None > Some). +/// Only like enum variants will be compared (e.g., Path < Size will never be compared). +#[derive(Eq, PartialEq, Ord, PartialOrd)] +enum SortKeyValue { + Path(PathBuf), + Size(u64), + Time(Option), } fn search_str_for_entry<'a>( From b62eb09a82786f094d3be0cffec970f5700658f6 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:09:29 -0600 Subject: [PATCH 14/20] refactor: Use --sort mechanism with printing (until buffer full) --- src/cli.rs | 4 +-- src/main.rs | 7 ++++- src/walk.rs | 74 +++++++++++++++++++++++++++++------------------------ 3 files changed, 49 insertions(+), 36 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 55e9d566c..11e065b20 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -547,7 +547,7 @@ pub struct Opts { /// /// Amount of time in milliseconds to buffer, before streaming the search /// results to the console. - #[arg(long, hide = true, value_parser = parse_millis)] + #[arg(long, hide = true, value_parser = parse_millis, conflicts_with("sort"))] pub max_buffer_time: Option, /// Sort search results by the given key before printing or executing commands. @@ -560,7 +560,7 @@ pub struct Opts { hide_short_help = true, help = "Sort results by: path, size, created, or modified", long_help, - conflicts_with("list_details") + conflicts_with_all(&["list_details", "max_buffer_time"]) )] pub sort: Option, diff --git a/src/main.rs b/src/main.rs index 57ba4d70e..9047dd3e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ use std::env; use std::io::IsTerminal; use std::path::Path; use std::sync::Arc; +use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use clap::{CommandFactory, Parser}; @@ -273,7 +274,11 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result Some(Duration::from_secs(u64::MAX / 2)), + None => opts.max_buffer_time, + }, ls_colors, hyperlink, interactive_terminal, diff --git a/src/walk.rs b/src/walk.rs index b80095925..edc26c633 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -24,7 +24,7 @@ use crate::filesystem; use crate::output; /// The receiver thread can either be buffering results or directly streaming to the console. -#[derive(PartialEq)] +#[derive(Copy, Clone, PartialEq)] enum ReceiverMode { /// Receiver is still buffering in order to sort the results, if the search finishes fast /// enough. @@ -241,6 +241,7 @@ impl<'a, W: Write> ReceiverBuffer<'a, W> { self.stream()?; } Err(RecvTimeoutError::Disconnected) => { + // Note: This branch is called when all the results are walked/exhausted. return self.stop(); } } @@ -280,9 +281,22 @@ impl<'a, W: Write> ReceiverBuffer<'a, W> { /// Stop looping. fn stop(&mut self) -> Result<(), ExitCode> { - if self.mode == ReceiverMode::Buffering { - self.buffer.sort(); - self.stream()?; + match (self.mode, self.config.sort_key) { + (ReceiverMode::Buffering, None) => { + self.buffer.sort(); + self.stream()?; + } + (ReceiverMode::Buffering, Some(sort_key)) => { + sort_dir_entry_results(&mut self.buffer, sort_key); + self.stream()?; + } + (ReceiverMode::Streaming, None) => {} + (ReceiverMode::Streaming, Some(_)) => { + // We force Buffering mode in Config construction if --sort is set by setting the timeout to almost infinity. + unreachable!( + "--sort cannot work in Streaming mode. Buffering mode is forced in Config construction." + ); + } } if self.config.quiet { @@ -674,36 +688,30 @@ impl WorkerState { } } +fn dir_entry_key_fn(sort_key: SortKey) -> Box Option> { + match sort_key { + SortKey::Path => Box::new(|e| Some(SortKeyValue::Path(e.path().to_path_buf()))), + SortKey::Size => Box::new(|e| e.metadata().map(|m| SortKeyValue::Size(m.len()))), + SortKey::Created => { + Box::new(|e| e.metadata().map(|m| SortKeyValue::Time(m.created().ok()))) + } + SortKey::Modified => { + Box::new(|e| e.metadata().map(|m| SortKeyValue::Time(m.modified().ok()))) + } + } +} + +fn sort_dir_entry_results(results: &mut [DirEntry], sort_key: SortKey) { + let key_fn = dir_entry_key_fn(sort_key); + results.sort_by_cached_key(|e| key_fn(e)); +} + fn sort_worker_results(results: &mut [WorkerResult], sort_key: SortKey) { - // Build the key extractor once, based on sort_key. - // Returns None for errors, pushing them to the end naturally via Ord on Option. - let key_fn: Box Option> = match sort_key { - SortKey::Path => Box::new(|r| match r { - WorkerResult::Entry(e) => Some(SortKeyValue::Path(e.path().to_path_buf())), - WorkerResult::Error(_) => None, - }), - SortKey::Size => Box::new(|r| match r { - WorkerResult::Entry(e) => Some(SortKeyValue::Size( - e.metadata().map(|m| m.len()).unwrap_or(0), - )), - WorkerResult::Error(_) => None, - }), - SortKey::Created => Box::new(|r| match r { - WorkerResult::Entry(e) => Some(SortKeyValue::Time( - e.metadata().and_then(|m| m.created().ok()), - )), - WorkerResult::Error(_) => None, - }), - SortKey::Modified => Box::new(|r| match r { - WorkerResult::Entry(e) => Some(SortKeyValue::Time( - e.metadata().and_then(|m| m.modified().ok()), - )), - WorkerResult::Error(_) => None, - }), - }; - - // Use sort_by_cached_key to avoid recomputing the key for each comparison. - results.sort_by_cached_key(|r| key_fn(r)); + let key_fn = dir_entry_key_fn(sort_key); + results.sort_by_cached_key(|r| match r { + WorkerResult::Entry(e) => key_fn(e), + WorkerResult::Error(_) => None, + }); } /// Comparable key values, one variant per SortKey. From 61bd00a4496c0d86dc9c2e4ffc5f317fd439a6c5 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:23:52 -0600 Subject: [PATCH 15/20] refactor: Set max output buffer size in Config --- src/config.rs | 3 +++ src/main.rs | 10 ++++++++-- src/walk.rs | 7 ++++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/config.rs b/src/config.rs index 86fe3d85c..e1eea3092 100644 --- a/src/config.rs +++ b/src/config.rs @@ -71,6 +71,9 @@ pub struct Config { /// `max_buffer_time`. pub max_buffer_time: Option, + /// Maximum size of the output buffer before flushing results to the console. + pub max_buffer_size: usize, + /// `None` if the output should not be colorized. Otherwise, a `LsColors` instance that defines /// how to style different filetypes. pub ls_colors: Option, diff --git a/src/main.rs b/src/main.rs index 9047dd3e9..4a6fd3744 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,7 @@ use crate::filetypes::FileTypes; use crate::filter::OwnerFilter; use crate::filter::TimeFilter; use crate::regex_helper::{pattern_has_uppercase_char, pattern_matches_strings_with_leading_dot}; +use crate::walk::DEFAULT_MAX_BUFFER_LENGTH; // We use jemalloc for performance reasons, see https://github.com/sharkdp/fd/pull/481 // FIXME: re-enable jemalloc on macOS, see comment in Cargo.toml file for more infos @@ -275,10 +276,15 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result Some(Duration::from_secs(u64::MAX / 2)), + // If sorting is enabled, then set max_buffer_time to practically infinity. + Some(_) => Some(Duration::from_hours(24 * 365 * 10)), // 10 years - arbitrarily-large. None => opts.max_buffer_time, }, + max_buffer_size: match opts.sort { + // If sorting is enabled, allow a practically infinite buffer size. + Some(_) => usize::MAX, + None => DEFAULT_MAX_BUFFER_LENGTH, + }, ls_colors, hyperlink, interactive_terminal, diff --git a/src/walk.rs b/src/walk.rs index edc26c633..9883bd204 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -122,7 +122,8 @@ impl BatchSender { } /// Maximum size of the output buffer before flushing results to the console -const MAX_BUFFER_LENGTH: usize = 1000; +pub const DEFAULT_MAX_BUFFER_LENGTH: usize = 1000; + /// Default duration until output buffering switches to streaming. const DEFAULT_MAX_BUFFER_TIME: Duration = Duration::from_millis(100); @@ -165,7 +166,7 @@ impl<'a, W: Write> ReceiverBuffer<'a, W> { stdout, mode: ReceiverMode::Buffering, deadline, - buffer: Vec::with_capacity(MAX_BUFFER_LENGTH), + buffer: Vec::with_capacity(config.max_buffer_size.min(10_000)), num_results: 0, } } @@ -208,7 +209,7 @@ impl<'a, W: Write> ReceiverBuffer<'a, W> { match self.mode { ReceiverMode::Buffering => { self.buffer.push(dir_entry); - if self.buffer.len() > MAX_BUFFER_LENGTH { + if self.buffer.len() > self.config.max_buffer_size { self.stream()?; } } From c8612934afacbe307865cce5b5a8560bad9b5647 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:47:30 -0600 Subject: [PATCH 16/20] test: Create --sort=size test cases --- tests/testenv/mod.rs | 58 +++++++++++++++++++++++++++++++++++++++++ tests/tests.rs | 62 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/tests/testenv/mod.rs b/tests/testenv/mod.rs index 07d2fd227..0194ec765 100644 --- a/tests/testenv/mod.rs +++ b/tests/testenv/mod.rs @@ -68,6 +68,46 @@ fn create_working_directory( Ok(temp_dir) } +/// Create the working directory and the test files. +fn create_test_directory_with_sized_files( + directories: &[&'static str], + files: &[(&'static str, usize)], +) -> Result { + let temp_dir = tempfile::Builder::new().prefix("fd-tests").tempdir()?; + + { + let root = temp_dir.path(); + + // Pretend that this is a Git repository in order for `.gitignore` files to be respected + fs::create_dir_all(root.join(".git"))?; + + for directory in directories { + fs::create_dir_all(root.join(directory))?; + } + + for (file, size) in files { + let fp = fs::File::create(root.join(file))?; + + // Create a file with the specified size. + fp.set_len(*size as u64)?; + } + + #[cfg(unix)] + unix::fs::symlink(root.join("one/two"), root.join("symlink"))?; + + // Note: creating symlinks on Windows requires the `SeCreateSymbolicLinkPrivilege` which + // is by default only granted for administrators. + #[cfg(windows)] + windows::fs::symlink_dir(root.join("one/two"), root.join("symlink"))?; + + fs::File::create(root.join(".fdignore"))?.write_all(b"fdignored.foo")?; + + fs::File::create(root.join(".gitignore"))?.write_all(b"gitignored.foo")?; + } + + Ok(temp_dir) +} + fn create_config_directory_with_global_ignore(ignore_file_content: &str) -> io::Result { let config_dir = tempfile::Builder::new().prefix("fd-config").tempdir()?; let fd_dir = config_dir.path().join("fd"); @@ -159,6 +199,7 @@ fn trim_lines(s: &str) -> String { } impl TestEnv { + /// Create a test environment with a temporary folder, empty files, directories, and symlinks. pub fn new(directories: &[&'static str], files: &[&'static str]) -> TestEnv { let temp_dir = create_working_directory(directories, files).expect("working directory"); let fd_exe = find_fd_exe(); @@ -172,6 +213,23 @@ impl TestEnv { } } + pub fn new_with_sized_files( + directories: &[&'static str], + files: &[(&'static str, usize)], + ) -> TestEnv { + let temp_dir = + create_test_directory_with_sized_files(directories, files).expect("working directory"); + let fd_exe = find_fd_exe(); + + TestEnv { + temp_dir, + fd_exe, + normalize_line: false, + allow_random_result_order: true, + config_dir: None, + } + } + /// Sets whether output lines should be normalized before comparison. /// /// Normalization sorts whitespace-separated elements in each line to ensure consistent comparison. diff --git a/tests/tests.rs b/tests/tests.rs index 0eba90332..7216d912f 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -28,6 +28,18 @@ static DEFAULT_FILES: &[&str] = &[ "e1 e2", ]; +static DEFAULT_FILES_WITH_SIZES: &[(&str, usize)] = &[ + ("a.foo", 437), + ("one/b.foo", 823), + ("one/two/c.foo", 91), + ("one/two/C.Foo2", 614), + ("one/two/three/d.foo", 259), + ("fdignored.foo", 748), + ("gitignored.foo", 183), + (".hidden.foo", 502), + ("e1 e2", 376), +]; + #[allow(clippy::let_and_return)] fn get_absolute_root_path(env: &TestEnv) -> String { let path = env @@ -2049,6 +2061,23 @@ fn test_sort_by_path() { ); } +#[test] +fn test_sort_by_size() { + let te = TestEnv::new_with_sized_files(DEFAULT_DIRS, DEFAULT_FILES_WITH_SIZES) + .allow_random_result_order(false); + + // --exec with --sort should produce output in sorted order. + te.assert_output( + &["foo", "--sort=size"], + "one/two/three/directory_foo/ + one/two/c.foo + one/two/three/d.foo + a.foo + one/two/C.Foo2 + one/b.foo", + ); +} + /// Shell script execution with --sort (--exec) #[cfg(not(windows))] #[test] @@ -2058,13 +2087,32 @@ fn test_sort_by_path_with_exec() { // --exec with --sort should produce output in sorted order. te.assert_output( - &["foo", "--sort=path", "--exec", "echo", "File: {}"], - "File: ./a.foo - File: ./one/b.foo - File: ./one/two/C.Foo2 - File: ./one/two/c.foo - File: ./one/two/three/d.foo - File: ./one/two/three/directory_foo", + &["foo", "--sort=path", "--exec", "echo", "Item: {}"], + "Item: ./a.foo + Item: ./one/b.foo + Item: ./one/two/C.Foo2 + Item: ./one/two/c.foo + Item: ./one/two/three/d.foo + Item: ./one/two/three/directory_foo", + ); +} + +/// Shell script execution with --sort (--exec) +#[cfg(not(windows))] +#[test] +fn test_sort_by_size_with_exec() { + let te = TestEnv::new_with_sized_files(DEFAULT_DIRS, DEFAULT_FILES_WITH_SIZES) + .allow_random_result_order(false); + + // --exec with --sort should produce output in sorted order. + te.assert_output( + &["foo", "--sort=size", "--exec", "echo", "Item: {}"], + "Item: ./one/two/three/directory_foo + Item: ./one/two/c.foo + Item: ./one/two/three/d.foo + Item: ./a.foo + Item: ./one/two/C.Foo2 + Item: ./one/b.foo", ); } From 90e653923ceabb80e2491d051bfd69705b2d21e6 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:52:28 -0600 Subject: [PATCH 17/20] docs: Explain --sort CLI arg better with warning --- src/cli.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 11e065b20..79cf35ea8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -550,9 +550,12 @@ pub struct Opts { #[arg(long, hide = true, value_parser = parse_millis, conflicts_with("sort"))] pub max_buffer_time: Option, - /// Sort search results by the given key before printing or executing commands. + /// Sort search results by the given key before printing or executing commands via `--exec`/`--exec-batch`. /// - /// Note: this buffers all results in memory before outputting. + /// This option results in slower execution as parallel execution is effectively disabled. + /// + /// Warning: This option significantly increases memory usage. + /// All results are buffered in memory before outputting. #[arg( long, value_name = "key", From 86506ce774e0dcc34295405fa4e76d580f9a3685 Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:55:59 -0600 Subject: [PATCH 18/20] test: Add test_sort_by_size_with_exec_batch --- tests/tests.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/tests.rs b/tests/tests.rs index 7216d912f..01989a2cd 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -2116,6 +2116,21 @@ fn test_sort_by_size_with_exec() { ); } +/// Shell script execution with --sort (--exec-batch) +#[cfg(not(windows))] +#[test] +fn test_sort_by_size_with_exec_batch() { + let te = TestEnv::new_with_sized_files(DEFAULT_DIRS, DEFAULT_FILES_WITH_SIZES) + .normalize_line(false) // Assert order exactly as input. + .allow_random_result_order(false); + + // --exec-batch with --sort should maintain the sorted order in its arguments. + te.assert_output( + &["foo", "--sort=size", "--exec-batch", "echo"], + "./one/two/three/directory_foo ./one/two/c.foo ./one/two/three/d.foo ./a.foo ./one/two/C.Foo2 ./one/b.foo", + ); +} + /// Shell script execution (--exec) with a custom --path-separator #[test] fn test_exec_with_separator() { From fee946e4185dce361a0d17a4dd1911f33719381e Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:58:40 -0600 Subject: [PATCH 19/20] fix: Clippy suggestions --- src/main.rs | 2 +- src/walk.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4a6fd3744..2d40d5cc6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -277,7 +277,7 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result Some(Duration::from_hours(24 * 365 * 10)), // 10 years - arbitrarily-large. + Some(_) => Some(Duration::from_secs(3600 * 24 * 365 * 10)), // 10 years - arbitrarily-large. None => opts.max_buffer_time, }, max_buffer_size: match opts.sort { diff --git a/src/walk.rs b/src/walk.rs index 9883bd204..2e0aeacce 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -689,7 +689,9 @@ impl WorkerState { } } -fn dir_entry_key_fn(sort_key: SortKey) -> Box Option> { +type SortKeyValueFn = Box Option>; + +fn dir_entry_key_fn(sort_key: SortKey) -> SortKeyValueFn { match sort_key { SortKey::Path => Box::new(|e| Some(SortKeyValue::Path(e.path().to_path_buf()))), SortKey::Size => Box::new(|e| e.metadata().map(|m| SortKeyValue::Size(m.len()))), From 88ca1a63303df4606e17f5a9fb5baeb99a8cbfbb Mon Sep 17 00:00:00 2001 From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:08:34 -0600 Subject: [PATCH 20/20] fix: Directory file size shows differently across platforms --- src/filesystem.rs | 9 +++++++++ src/walk.rs | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/filesystem.rs b/src/filesystem.rs index 4a04f9d52..29499f1a6 100644 --- a/src/filesystem.rs +++ b/src/filesystem.rs @@ -59,6 +59,15 @@ pub fn is_empty(entry: &dir_entry::DirEntry) -> bool { } } +pub fn file_size(entry: &dir_entry::DirEntry) -> Option { + let file_type = entry.file_type()?; + if file_type.is_dir() { + None + } else { + entry.metadata().map(|m| m.len()) + } +} + #[cfg(any(unix, target_os = "redox"))] pub fn is_block_device(ft: fs::FileType) -> bool { ft.is_block_device() diff --git a/src/walk.rs b/src/walk.rs index 2e0aeacce..45a6e2b5a 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -694,7 +694,7 @@ type SortKeyValueFn = Box Option>; fn dir_entry_key_fn(sort_key: SortKey) -> SortKeyValueFn { match sort_key { SortKey::Path => Box::new(|e| Some(SortKeyValue::Path(e.path().to_path_buf()))), - SortKey::Size => Box::new(|e| e.metadata().map(|m| SortKeyValue::Size(m.len()))), + SortKey::Size => Box::new(|e| filesystem::file_size(e).map(SortKeyValue::Size)), SortKey::Created => { Box::new(|e| e.metadata().map(|m| SortKeyValue::Time(m.created().ok()))) }