Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ SPDX-License-Identifier: EUPL-1.2
-->
# Changelog

## [Unreleased]

### Features

- Add `--since` flag to filter files by recency (modified or created time within a duration)

## [0.23.4] - 2025-10-03

### Bug Fixes
Expand Down
5 changes: 4 additions & 1 deletion Cargo.lock

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

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ serde = { version = "1.0.219", features = ["derive"] }
dirs = "6.0.0"
serde_norway = "0.9"
backtrace = "0.3"
humantime = "2.1"

[dependencies.git2]
version = "0.20"
Expand All @@ -134,6 +135,8 @@ chrono = { version = "0.4.40", default-features = false, features = ["clock"] }
[dev-dependencies]
criterion = { version = "0.5.1", features = ["html_reports"] }
trycmd = "0.15"
tempfile = "3.13"
filetime = "0.2"

[features]
default = ["git"]
Expand Down
3 changes: 3 additions & 0 deletions man/eza.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ Sort fields starting with a capital letter will sort uppercase before lowercase:
`-I`, `--ignore-glob=GLOBS`
: Glob patterns, pipe-separated, of files to ignore.

`--since=DURATION`
: Show only files modified or created within the specified duration from now. Duration can be specified in human-readable format such as '10m' (10 minutes), '1h' (1 hour), '2d' (2 days), '1w' (1 week), or with spaces like '5 minutes', '2 hours'. Files without timestamps are excluded.

`--git-ignore` [if eza was built with git support]
: Do not list files that are ignored by Git.

Expand Down
58 changes: 58 additions & 0 deletions src/fs/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ pub struct FileFilter {

/// Whether to explicitly show symlinks
pub show_symlinks: bool,

/// Duration to filter files by recency (modified or created time).
/// Files older than (now - duration) are filtered out.
pub since_duration: Option<std::time::Duration>,
}

impl FileFilter {
Expand Down Expand Up @@ -117,6 +121,11 @@ impl FileFilter {
_ => true,
}
});

// Apply since duration filter if specified
if let Some(duration) = self.since_duration {
files.retain(|f| self.is_recent_file(f, duration));
}
}

/// Remove every file in the given vector that does *not* pass the
Expand All @@ -130,6 +139,55 @@ impl FileFilter {
/// from the glob, even though the globbing is done by the shell!
pub fn filter_argument_files(&self, files: &mut Vec<File<'_>>) {
files.retain(|f| !self.ignore_patterns.is_ignored(&f.name));

// Apply since duration filter if specified
if let Some(duration) = self.since_duration {
files.retain(|f| self.is_recent_file(f, duration));
}
}

/// Check if a file is recent enough based on the `since_duration`.
/// A file is considered recent if its most recent timestamp (modified or created)
/// is within the duration window from now.
fn is_recent_file(&self, file: &File<'_>, duration: std::time::Duration) -> bool {
use std::time::SystemTime;

let now = SystemTime::now();
let Some(cutoff) = now.checked_sub(duration) else {
return true; // If we can't calculate cutoff, include the file
};

// Get the modified timestamp
let modified = Self::naive_datetime_to_systemtime(file.modified_time());

// Get the created timestamp
let created = Self::naive_datetime_to_systemtime(file.created_time());

// Use the maximum of modified and created timestamps.
// This ensures that a file qualifies if EITHER timestamp is recent enough,
// which is useful because:
// - A newly created file will have a recent created time
// - A recently modified file will have a recent modified time
// - Using max() captures both cases for the most intuitive behavior
let timestamp_to_check = match (modified, created) {
(Some(m), Some(c)) => Some(if m > c { m } else { c }),
(Some(m), None) => Some(m),
(None, Some(c)) => Some(c),
(None, None) => None,
};

// If we have a timestamp, check if it's recent enough
// If no timestamp is available, conservatively exclude the file
timestamp_to_check.map_or(false, |timestamp| timestamp >= cutoff)
}

/// Convert a `NaiveDateTime` to `SystemTime` for comparison
fn naive_datetime_to_systemtime(dt: Option<chrono::NaiveDateTime>) -> Option<std::time::SystemTime> {
dt.and_then(|dt| {
dt.and_utc().timestamp().try_into().ok().and_then(|secs: u64| {
std::time::SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
})
})
}
Comment on lines +184 to 191

Choose a reason for hiding this comment

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

🟡 MEDIUM - Private helper lacks edge case documentation
Agent: rust

Category: docs

Description:
The naive_datetime_to_systemtime() helper uses try_into() from i64 to u64 without documenting that negative timestamps (pre-1970) will return None. The is_recent_file() method has partial documentation but doesn't fully explain this edge case behavior.

Suggestion:
Add a brief doc comment to naive_datetime_to_systemtime() explaining that negative Unix timestamps are treated as unavailable (returns None).

Why this matters: Explicit contracts prevent undefined behavior and misuse.

Confidence: 65%
Rule: rs_document_safety_panics_and_errors
Review ID: 0b482805-d6db-4856-9263-a5b31a9b9b5a
Rate it 👍 or 👎 to improve future reviews | Powered by diffray


/// Sort the files in the given vector based on the sort field option.
Expand Down
17 changes: 17 additions & 0 deletions src/options/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,25 @@ impl FileFilter {
dot_filter: DotFilter::deduce(matches)?,
ignore_patterns: IgnorePatterns::deduce(matches)?,
git_ignore: GitIgnore::deduce(matches)?,
since_duration: Self::deduce_since_duration(matches)?,
});
}

/// Parse the --since duration argument
fn deduce_since_duration(matches: &MatchedFlags<'_>) -> Result<Option<std::time::Duration>, OptionsError> {
let Some(duration_str) = matches.get(&flags::SINCE)? else {
return Ok(None);
};

let Some(duration_str) = duration_str.to_str() else {
return Err(OptionsError::BadArgument(&flags::SINCE, duration_str.into()));
};

match humantime::parse_duration(duration_str) {
Ok(duration) => Ok(Some(duration)),
Err(_) => Err(OptionsError::BadArgument(&flags::SINCE, duration_str.into())),
}
}
}

impl SortField {
Expand Down
3 changes: 2 additions & 1 deletion src/options/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub static ONLY_DIRS: Arg = Arg { short: Some(b'D'), long: "only-dirs"
pub static ONLY_FILES: Arg = Arg { short: Some(b'f'), long: "only-files", takes_value: TakesValue::Forbidden };
pub static NO_SYMLINKS: Arg = Arg { short: None, long: "no-symlinks", takes_value: TakesValue::Forbidden };
pub static SHOW_SYMLINKS: Arg = Arg { short: None, long: "show-symlinks", takes_value: TakesValue::Forbidden };
pub static SINCE: Arg = Arg { short: None, long: "since", takes_value: TakesValue::Necessary(None) };

const SORTS: Values = &[ "name", "Name", "size", "extension",
"Extension", "modified", "changed", "accessed",
Expand Down Expand Up @@ -106,7 +107,7 @@ pub static ALL_ARGS: Args = Args(&[
&WIDTH, &NO_QUOTES, &ABSOLUTE,

&ALL, &ALMOST_ALL, &TREAT_DIRS_AS_FILES, &LIST_DIRS, &LEVEL, &REVERSE, &SORT, &DIRS_FIRST, &DIRS_LAST,
&IGNORE_GLOB, &GIT_IGNORE, &ONLY_DIRS, &ONLY_FILES,
&IGNORE_GLOB, &GIT_IGNORE, &ONLY_DIRS, &ONLY_FILES, &SINCE,

&BINARY, &BYTES, &GROUP, &NUMERIC, &HEADER, &ICONS, &INODE, &LINKS, &MODIFIED, &CHANGED,
&BLOCKSIZE, &TOTAL_SIZE, &TIME, &ACCESSED, &CREATED, &TIME_STYLE, &HYPERLINK, &MOUNTS,
Expand Down
4 changes: 3 additions & 1 deletion src/options/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ FILTERING AND SORTING OPTIONS
-s, --sort SORT_FIELD which field to sort by
--group-directories-first list directories before other files
--group-directories-last list directories after other files
-I, --ignore-glob GLOBS glob patterns (pipe-separated) of files to ignore";
-I, --ignore-glob GLOBS glob patterns (pipe-separated) of files to ignore
--since DURATION show only files modified/created within DURATION
(e.g., '10m', '1h', '2d', '1w')";

static GIT_FILTER_HELP: &str = " \
--git-ignore ignore files mentioned in '.gitignore'";
Expand Down
188 changes: 188 additions & 0 deletions tests/since_filter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, SystemTime};
use filetime::FileTime;
use tempfile::TempDir;

fn get_eza_binary() -> PathBuf {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("target");
path.push("debug");
path.push("eza");
path
}

// Helper to create files with specific modified times.
// Note: On most filesystems, created/birth time is set at creation and cannot
// be modified backwards. The filter uses max(modified, created), so files
// created during test execution will have recent created times even if their
// modified time is set to be old. Tests account for this behavior.
fn create_file_with_mtime(dir: &TempDir, filename: &str, age_secs: u64) -> PathBuf {
let file_path = dir.path().join(filename);
fs::File::create(&file_path).expect("Failed to create file");

let now = SystemTime::now();
let file_time = now
.checked_sub(Duration::from_secs(age_secs))
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| FileTime::from_unix_time(d.as_secs() as i64, 0))
.expect("Failed to calculate file time");

filetime::set_file_times(&file_path, file_time, file_time).expect("Failed to set times");
file_path
}

#[test]
fn test_since_filter_shows_recent_files() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");

create_file_with_mtime(&temp_dir, "file1.txt", 30);
create_file_with_mtime(&temp_dir, "file2.txt", 60);

std::thread::sleep(Duration::from_millis(100));

let eza = get_eza_binary();

let output = Command::new(&eza)
.arg("--since")
.arg("5m")
.arg(temp_dir.path())
.output()
.expect("Failed to execute eza");

let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("file1.txt"), "Should show file1.txt");
assert!(stdout.contains("file2.txt"), "Should show file2.txt");
}

#[test]
fn test_since_filter_with_short_window() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");

create_file_with_mtime(&temp_dir, "test.txt", 60);

std::thread::sleep(Duration::from_millis(150));

let eza = get_eza_binary();

let output = Command::new(&eza)
.arg("--since")
.arg("50ms")
.arg(temp_dir.path())
.output()
.expect("Failed to execute eza");

let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!stdout.contains("test.txt"), "Should not show with very short window after delay");
}
Comment on lines +60 to +78

Choose a reason for hiding this comment

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

🟡 MEDIUM - Test name 'test_since_filter_with_short_window' is ambiguous
Agent: testing

Category: quality

Description:
Test name doesn't clarify the expected outcome (file exclusion). The test creates a 60-second-old file and uses a 50ms window, expecting the file to NOT be shown.

Suggestion:
Rename to 'test_since_filter_excludes_files_older_than_window' to clarify the boundary and expected behavior.

Why this matters: Descriptive test names serve as documentation and help debug failures.

Confidence: 65%
Rule: test_generic_test_names_rust
Review ID: 0b482805-d6db-4856-9263-a5b31a9b9b5a
Rate it 👍 or 👎 to improve future reviews | Powered by diffray


#[test]
fn test_since_filter_long_view() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");

create_file_with_mtime(&temp_dir, "file.txt", 30);

let eza = get_eza_binary();

let output = Command::new(&eza)
.arg("--since")
.arg("1m")
.arg("-l")
.arg(temp_dir.path())
.output()
.expect("Failed to execute eza");

let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("file.txt"), "Should show in long view");
}

#[test]
fn test_since_filter_tree_view() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");

let subdir = temp_dir.path().join("subdir");
fs::create_dir(&subdir).expect("Failed to create subdir");

create_file_with_mtime(&temp_dir, "root_file.txt", 30);

let sub_file = subdir.join("sub_file.txt");
fs::File::create(&sub_file).expect("Failed to create sub file");

let eza = get_eza_binary();

let output = Command::new(&eza)
.arg("--since")
.arg("1m")
.arg("--tree")
.arg(temp_dir.path())
.output()
.expect("Failed to execute eza");

let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("root_file.txt"), "Should show root file in tree view");
assert!(stdout.contains("subdir"), "Should show subdir");
assert!(stdout.contains("sub_file.txt"), "Should show sub file in tree view");
}

#[test]
fn test_since_filter_invalid_duration() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
create_file_with_mtime(&temp_dir, "test.txt", 60);

let eza = get_eza_binary();

let output = Command::new(&eza)
.arg("--since")
.arg("invalid")
.arg(temp_dir.path())
.output()
.expect("Failed to execute eza");

assert!(!output.status.success(), "Should fail with invalid duration");
}
Comment on lines +129 to +143

Choose a reason for hiding this comment

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

🟡 MEDIUM - Test name doesn't describe expected behavior (failure)
Agent: testing

Category: quality

Description:
Test name 'test_since_filter_invalid_duration' describes the input but doesn't indicate the expected outcome (command should fail).

Suggestion:
Rename to 'test_since_filter_rejects_invalid_duration' to clearly indicate the expected failure behavior.

Why this matters: Descriptive test names serve as documentation and help debug failures.

Confidence: 65%
Rule: test_generic_test_names_rust
Review ID: 0b482805-d6db-4856-9263-a5b31a9b9b5a
Rate it 👍 or 👎 to improve future reviews | Powered by diffray


#[test]
fn test_since_filter_one_line_view() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");

create_file_with_mtime(&temp_dir, "file1.txt", 10);
create_file_with_mtime(&temp_dir, "file2.txt", 20);

let eza = get_eza_binary();

let output = Command::new(&eza)
.arg("--since")
.arg("1m")
.arg("-1")
.arg(temp_dir.path())
.output()
.expect("Failed to execute eza");

let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("file1.txt"), "Should show file1.txt in one-line view");
assert!(stdout.contains("file2.txt"), "Should show file2.txt in one-line view");
}

#[test]
fn test_since_filter_grid_view() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");

create_file_with_mtime(&temp_dir, "a.txt", 15);
create_file_with_mtime(&temp_dir, "b.txt", 25);
create_file_with_mtime(&temp_dir, "c.txt", 35);

let eza = get_eza_binary();

let output = Command::new(&eza)
.arg("--since")
.arg("2m")
.arg(temp_dir.path())
.output()
.expect("Failed to execute eza");

let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("a.txt"), "Should show a.txt");
assert!(stdout.contains("b.txt"), "Should show b.txt");
assert!(stdout.contains("c.txt"), "Should show c.txt");
}
Loading