Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2f5bd83
feat: Add --sort arg to CLI parsing
DeflateAwning Apr 20, 2026
6511a2f
chore: Add sort_key to Config struct
DeflateAwning Apr 20, 2026
bbca886
feat: Connect --sort CLI arg to work with -x
DeflateAwning Apr 20, 2026
d1364d1
chore(deps): Add rand dev dependency for shuffling in tests
DeflateAwning Apr 20, 2026
b30e1b4
test: Add test_sort_by_path and _with_exec
DeflateAwning Apr 20, 2026
3663a3c
docs: Add CHANGELOG entry
DeflateAwning Apr 20, 2026
5196835
fix: Clippy suggestions
DeflateAwning Apr 20, 2026
bf2435c
refactor: Move SortKey enum to config.rs (review comment)
DeflateAwning Apr 22, 2026
216b771
refactor,fix,test: Allow TestEnv configured to validate output order
DeflateAwning Apr 22, 2026
ba9cb06
perf,fix: Avoid collecting sorted results in batch if no sort required
DeflateAwning Apr 22, 2026
ab55454
Review suggestion - WorkerResult matching in sort - src/walk.rs
DeflateAwning Apr 22, 2026
f1553f4
fix: sort_worker_results error handling cases
DeflateAwning Apr 22, 2026
676be36
perf: Optimize sort_worker_results for cached key lookup (fn and orde…
DeflateAwning Apr 22, 2026
b62eb09
refactor: Use --sort mechanism with printing (until buffer full)
DeflateAwning Apr 22, 2026
61bd00a
refactor: Set max output buffer size in Config
DeflateAwning Apr 22, 2026
c861293
test: Create --sort=size test cases
DeflateAwning Apr 22, 2026
90e6539
docs: Explain --sort CLI arg better with warning
DeflateAwning Apr 22, 2026
86506ce
test: Add test_sort_by_size_with_exec_batch
DeflateAwning Apr 22, 2026
fee946e
fix: Clippy suggestions
DeflateAwning Apr 22, 2026
88ca1a6
fix: Directory file size shows differently across platforms
DeflateAwning Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
39 changes: 39 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 19 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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;
Expand Down Expand Up @@ -546,9 +547,26 @@ 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<Duration>,

/// Sort search results by the given key before printing or executing commands via `--exec`/`--exec-batch`.
///
/// 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",
value_enum,
hide_short_help = true,
help = "Sort results by: path, size, created, or modified",
long_help,
conflicts_with_all(&["list_details", "max_buffer_time"])
)]
pub sort: Option<SortKey>,

///Limit the number of search results to 'count' and quit immediately.
#[arg(
long,
Expand Down
19 changes: 19 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{path::PathBuf, sync::Arc, time::Duration};

use clap::ValueEnum;
use lscolors::LsColors;
use regex::bytes::RegexSet;

Expand Down Expand Up @@ -70,6 +71,9 @@ pub struct Config {
/// `max_buffer_time`.
pub max_buffer_time: Option<Duration>,

/// 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<LsColors>,
Expand Down Expand Up @@ -133,6 +137,9 @@ pub struct Config {

/// Names that should stop traversal down their parent. (e.g. https://bford.info/cachedir/).
pub ignore_contain: Vec<String>,

/// The key to sort results by
pub sort_key: Option<SortKey>,
}

impl Config {
Expand All @@ -141,3 +148,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,
}
9 changes: 9 additions & 0 deletions src/filesystem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ pub fn is_empty(entry: &dir_entry::DirEntry) -> bool {
}
}

pub fn file_size(entry: &dir_entry::DirEntry) -> Option<u64> {
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()
Expand Down
14 changes: 13 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -33,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
Expand Down Expand Up @@ -273,7 +275,16 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result<Config
min_depth: opts.min_depth(),
prune: opts.prune,
threads: opts.threads().get(),
max_buffer_time: opts.max_buffer_time,
max_buffer_time: match opts.sort {
// If sorting is enabled, then set max_buffer_time to practically infinity.
Some(_) => Some(Duration::from_secs(3600 * 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,
Expand Down Expand Up @@ -336,6 +347,7 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result<Config
max_results: opts.max_results(),
strip_cwd_prefix: opts.strip_cwd_prefix(|| !(opts.null_separator || has_command)),
ignore_contain: opts.ignore_contain,
sort_key: opts.sort,
})
}

Expand Down
86 changes: 75 additions & 11 deletions src/walk.rs

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation requires sorting to be handled by every output path. That adds complexity and makes it harder to maintain.

It would be better if there is some way we could avoid needing separate sorting logic for --exec, --exec-batch, and printing.

Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -15,7 +15,7 @@ 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;
Expand All @@ -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.
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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()?;
}
}
Expand Down Expand Up @@ -241,6 +242,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();
}
}
Expand Down Expand Up @@ -280,9 +282,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 {
Expand Down Expand Up @@ -407,12 +422,23 @@ impl WorkerState {
/// threads (for --exec).
fn receive(&self, rx: Receiver<Batch>) -> ExitCode {
let config = &self.config;

// This will be set to `Some` if the `--exec` argument was supplied.
if let Some(ref cmd) = config.command {
Comment thread
DeflateAwning marked this conversation as resolved.
if cmd.in_batch_mode() {
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<WorkerResult> = rx.into_iter().flatten().collect();
sort_worker_results(&mut results, sort_key);
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;
Expand Down Expand Up @@ -663,6 +689,44 @@ impl WorkerState {
}
}

type SortKeyValueFn = Box<dyn Fn(&DirEntry) -> Option<SortKeyValue>>;

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| filesystem::file_size(e).map(SortKeyValue::Size)),
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) {
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.
/// None sorts last via Option<T: Ord>'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<SystemTime>),
}

fn search_str_for_entry<'a>(
entry_path: &'a std::path::Path,
full_path_base: Option<&std::path::Path>,
Expand Down
Loading