diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d291b2f2..45a5a5c4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index b2ffe0c18..e4b3e86bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -418,8 +418,10 @@ dependencies = [ "chrono", "criterion", "dirs", + "filetime", "git2", "glob", + "humantime", "libc", "locale", "log", @@ -434,6 +436,7 @@ dependencies = [ "rayon", "serde", "serde_norway", + "tempfile", "terminal_size", "timeago", "trycmd", diff --git a/Cargo.toml b/Cargo.toml index 860425354..05d339434 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" @@ -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"] diff --git a/man/eza.1.md b/man/eza.1.md index 4daeac48b..b14313a72 100644 --- a/man/eza.1.md +++ b/man/eza.1.md @@ -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. diff --git a/src/fs/filter.rs b/src/fs/filter.rs index 9a2965dbd..1e40bdfef 100644 --- a/src/fs/filter.rs +++ b/src/fs/filter.rs @@ -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, } impl FileFilter { @@ -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 @@ -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>) { 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) -> Option { + 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)) + }) + }) } /// Sort the files in the given vector based on the sort field option. diff --git a/src/options/filter.rs b/src/options/filter.rs index dbea01f20..bf2cc0a86 100644 --- a/src/options/filter.rs +++ b/src/options/filter.rs @@ -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, 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 { diff --git a/src/options/flags.rs b/src/options/flags.rs index 53eef54cb..158879be5 100644 --- a/src/options/flags.rs +++ b/src/options/flags.rs @@ -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", @@ -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, diff --git a/src/options/help.rs b/src/options/help.rs index 69070dbaf..1e5860002 100644 --- a/src/options/help.rs +++ b/src/options/help.rs @@ -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'"; diff --git a/tests/since_filter.rs b/tests/since_filter.rs new file mode 100644 index 000000000..6b795b0d6 --- /dev/null +++ b/tests/since_filter.rs @@ -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"); +} + +#[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"); +} + +#[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"); +}