forked from Crockan/MercuryToolbox
chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "recent"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
readme.workspace = true
|
||||
publish.workspace = true
|
||||
description = "List recently changed files with gitignore-aware filtering."
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common", default-features = false }
|
||||
humantime.workspace = true
|
||||
ignore.workspace = true
|
||||
lexopt.workspace = true
|
||||
regex-lite.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd.workspace = true
|
||||
filetime.workspace = true
|
||||
predicates.workspace = true
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,278 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use common::{
|
||||
CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice,
|
||||
parse_input_format, print_quick_help_error,
|
||||
};
|
||||
use lexopt::prelude::{Long, Short, Value};
|
||||
|
||||
use super::{Cli, KindFilter};
|
||||
|
||||
const HELP: &str = "\
|
||||
List recently changed files with gitignore-aware filtering.
|
||||
|
||||
Usage:
|
||||
recent [OPTIONS]
|
||||
|
||||
Options:
|
||||
--format <FORMAT> Structured output format: text, json, toon
|
||||
--json Shortcut for --format json
|
||||
--toon Shortcut for --format toon
|
||||
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
|
||||
--color <WHEN> Control ANSI color output: auto, never
|
||||
--quiet Suppress non-essential status output
|
||||
--since <DURATION> Maximum accepted age such as 15m or 2h
|
||||
--ext <EXT[,EXT...]> Comma-delimited list of file extensions to keep
|
||||
--name <REGEX> Regular expression applied to the basename
|
||||
--kind <KIND> Filesystem kinds to report: file, dir, any
|
||||
--limit <COUNT> Maximum number of entries to print
|
||||
--root <PATH> Explicit root directory to scan
|
||||
-h, --help Show this help text
|
||||
-V, --version Show the command version
|
||||
|
||||
Examples:
|
||||
recent --root . --since 2h --ext rs --name '^(lib|main)$'
|
||||
recent --root . --kind dir --limit 10
|
||||
recent --root . --since 30d --ext cs --name 'Player|Trainer|Plugin' --limit 40
|
||||
'.' | recent --json | ConvertFrom-Json
|
||||
|
||||
Notes:
|
||||
On large repos, start with --since plus at least one of --ext, --name, or --limit to keep output high-signal
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ParseOutcome {
|
||||
Help,
|
||||
Version,
|
||||
Run,
|
||||
}
|
||||
|
||||
/// Parses CLI arguments and returns a process exit code.
|
||||
#[must_use]
|
||||
pub fn main_entry() -> i32 {
|
||||
match parse_cli_from(std::env::args_os()) {
|
||||
Ok((ParseOutcome::Help, _)) => {
|
||||
print!("{HELP}");
|
||||
ExitCode::Success.as_i32()
|
||||
}
|
||||
Ok((ParseOutcome::Version, _)) => {
|
||||
println!("recent {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::Success.as_i32()
|
||||
}
|
||||
Ok((ParseOutcome::Run, cli)) => match super::run(cli) {
|
||||
Ok(code) => code.as_i32(),
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString>,
|
||||
{
|
||||
let mut parser = lexopt::Parser::from_iter(args);
|
||||
let mut cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
since: None,
|
||||
ext: Vec::new(),
|
||||
name: None,
|
||||
kind: KindFilter::File,
|
||||
limit: None,
|
||||
roots: Vec::new(),
|
||||
};
|
||||
|
||||
while let Some(argument) = parser
|
||||
.next()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?
|
||||
{
|
||||
match argument {
|
||||
Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)),
|
||||
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
|
||||
Long("json") => cli.common.set_render_mode(RenderMode::Json),
|
||||
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
|
||||
Long("format") => {
|
||||
let value = parser_value_string(&mut parser, "--format")?;
|
||||
cli.common.set_render_mode(parse_format_choice(&value)?);
|
||||
}
|
||||
Long("input-format") => {
|
||||
let value = parser_value_string(&mut parser, "--input-format")?;
|
||||
cli.common.input_format = parse_input_format(&value)?;
|
||||
}
|
||||
Long("color") => {
|
||||
let value = parser_value_string(&mut parser, "--color")?;
|
||||
cli.common.color = parse_color_choice(&value)?;
|
||||
}
|
||||
Long("quiet") => cli.common.quiet = true,
|
||||
Long("since") => {
|
||||
cli.since = Some(parser_value_string(&mut parser, "--since")?);
|
||||
}
|
||||
Long("ext") => {
|
||||
cli.ext.extend(split_csv_values(&parser_value_string(
|
||||
&mut parser,
|
||||
"--ext",
|
||||
)?));
|
||||
}
|
||||
Long("name") => {
|
||||
cli.name = Some(parser_value_string(&mut parser, "--name")?);
|
||||
}
|
||||
Long("kind") => {
|
||||
cli.kind = parse_kind_filter(&parser_value_string(&mut parser, "--kind")?)?;
|
||||
}
|
||||
Long("limit") => {
|
||||
cli.limit = Some(parse_usize_flag(
|
||||
"--limit",
|
||||
&parser_value_string(&mut parser, "--limit")?,
|
||||
)?);
|
||||
}
|
||||
Long("root") => {
|
||||
cli.roots.push(PathBuf::from(
|
||||
parser
|
||||
.value()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?,
|
||||
));
|
||||
}
|
||||
Value(unexpected) => {
|
||||
return Err(CliError::usage(format!(
|
||||
"unexpected positional argument '{}'; recent only accepts --root or stdin input",
|
||||
unexpected.to_string_lossy()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"unsupported argument; use --help to see available options",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((ParseOutcome::Run, cli))
|
||||
}
|
||||
|
||||
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
|
||||
let value = parser
|
||||
.value()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?;
|
||||
value.into_string().map_err(|invalid| {
|
||||
CliError::usage(format!(
|
||||
"{flag} expects UTF-8 text, got '{}'",
|
||||
invalid.to_string_lossy()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn split_csv_values(raw: &str) -> Vec<String> {
|
||||
raw.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn parse_kind_filter(value: &str) -> Result<KindFilter, CliError> {
|
||||
match value {
|
||||
"file" => Ok(KindFilter::File),
|
||||
"dir" => Ok(KindFilter::Dir),
|
||||
"any" => Ok(KindFilter::Any),
|
||||
other => Err(CliError::usage(format!(
|
||||
"invalid --kind value '{other}'; expected file, dir, or any"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use common::{ColorChoice, InputFormat};
|
||||
|
||||
#[test]
|
||||
fn parse_cli_accepts_common_and_recent_specific_flags() {
|
||||
let (_, cli) = parse_cli_from([
|
||||
"recent",
|
||||
"--json",
|
||||
"--input-format",
|
||||
"lines",
|
||||
"--color",
|
||||
"never",
|
||||
"--quiet",
|
||||
"--since",
|
||||
"2h",
|
||||
"--ext",
|
||||
"rs,toml",
|
||||
"--name",
|
||||
"^(lib|main)$",
|
||||
"--kind",
|
||||
"dir",
|
||||
"--limit",
|
||||
"5",
|
||||
"--root",
|
||||
".",
|
||||
])
|
||||
.expect("cli");
|
||||
|
||||
assert!(cli.common.json);
|
||||
assert_eq!(cli.common.input_format, InputFormat::Lines);
|
||||
assert_eq!(cli.common.color, ColorChoice::Never);
|
||||
assert!(cli.common.quiet);
|
||||
assert_eq!(cli.since.as_deref(), Some("2h"));
|
||||
assert_eq!(cli.ext, vec!["rs", "toml"]);
|
||||
assert_eq!(cli.name.as_deref(), Some("^(lib|main)$"));
|
||||
assert_eq!(cli.kind, KindFilter::Dir);
|
||||
assert_eq!(cli.limit, Some(5));
|
||||
assert_eq!(cli.roots, vec![PathBuf::from(".")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_supports_help_and_version_outcomes() {
|
||||
let (outcome, cli) = parse_cli_from(["recent", "--help"]).expect("help");
|
||||
assert_eq!(outcome, ParseOutcome::Help);
|
||||
assert!(cli.roots.is_empty());
|
||||
|
||||
let (outcome, cli) = parse_cli_from(["recent", "-V"]).expect("version");
|
||||
assert_eq!(outcome, ParseOutcome::Version);
|
||||
assert_eq!(cli.kind, KindFilter::File);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_rejects_unexpected_positionals_and_invalid_values() {
|
||||
assert!(matches!(
|
||||
parse_cli_from(["recent", "demo"]),
|
||||
Err(CliError::Usage(message))
|
||||
if message.contains("unexpected positional argument")
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_cli_from(["recent", "--kind", "socket"]),
|
||||
Err(CliError::Usage(message))
|
||||
if message.contains("invalid --kind value 'socket'")
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_cli_from(["recent", "--limit", "many"]),
|
||||
Err(CliError::Usage(message))
|
||||
if message.contains("invalid --limit value 'many'")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_parsers_cover_csv_and_kind_paths() {
|
||||
assert_eq!(
|
||||
split_csv_values("rs, toml, , json "),
|
||||
vec!["rs", "toml", "json"]
|
||||
);
|
||||
assert_eq!(parse_kind_filter("file").expect("file"), KindFilter::File);
|
||||
assert_eq!(parse_kind_filter("any").expect("any"), KindFilter::Any);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
//! The `recent` command lists recently modified files or directories.
|
||||
|
||||
use std::cmp::{Ordering, Reverse};
|
||||
use std::collections::BinaryHeap;
|
||||
use std::io::{self, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use common::{
|
||||
CliError, ExitCode, RenderMode, print_json, print_structured, read_existing_stdin_path_records,
|
||||
should_read_stdin,
|
||||
};
|
||||
use ignore::WalkBuilder;
|
||||
use regex_lite::Regex;
|
||||
use serde::Serialize;
|
||||
|
||||
const MAX_SCANNED_ENTRIES: usize = 250_000;
|
||||
|
||||
/// Hidden CLI entry helpers for the `recent` binary.
|
||||
#[doc(hidden)]
|
||||
pub mod cli;
|
||||
|
||||
/// Selects which filesystem node kinds should be reported.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum KindFilter {
|
||||
/// Report only regular files.
|
||||
File,
|
||||
/// Report only directories.
|
||||
Dir,
|
||||
/// Report both files and directories.
|
||||
Any,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Cli {
|
||||
common: common::CommonArgs,
|
||||
since: Option<String>,
|
||||
ext: Vec<String>,
|
||||
name: Option<String>,
|
||||
kind: KindFilter,
|
||||
limit: Option<usize>,
|
||||
roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Collected runtime options for recent-file scans.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecentOptions {
|
||||
/// Root directories to traverse.
|
||||
pub roots: Vec<PathBuf>,
|
||||
/// Optional maximum age for returned entries.
|
||||
pub since: Option<Duration>,
|
||||
/// File extensions to keep, without a leading dot.
|
||||
pub exts: Vec<String>,
|
||||
/// Filesystem object kinds to include.
|
||||
pub kind: KindFilter,
|
||||
/// Maximum number of entries to return.
|
||||
pub limit: Option<usize>,
|
||||
/// Optional basename filter applied with a regular expression.
|
||||
pub name_pattern: Option<Regex>,
|
||||
}
|
||||
|
||||
/// A single filesystem entry returned by `recent`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RecentEntry {
|
||||
/// Filesystem path for the matched entry.
|
||||
pub path: PathBuf,
|
||||
/// Whether the entry is a file or directory.
|
||||
pub kind: KindFilter,
|
||||
/// RFC 3339 timestamp rendered for CLI and JSON output.
|
||||
pub modified_rfc3339: String,
|
||||
#[serde(skip_serializing)]
|
||||
modified: SystemTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RankedEntry(RecentEntry);
|
||||
|
||||
/// Scans the configured roots and returns matching entries ordered by freshness.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Runtime`] when directory traversal or metadata access fails.
|
||||
pub fn collect_recent(
|
||||
options: &RecentOptions,
|
||||
now: SystemTime,
|
||||
) -> Result<Vec<RecentEntry>, CliError> {
|
||||
collect_recent_with_entry_cap(options, now, MAX_SCANNED_ENTRIES)
|
||||
}
|
||||
|
||||
fn collect_recent_with_entry_cap(
|
||||
options: &RecentOptions,
|
||||
now: SystemTime,
|
||||
max_scanned_entries: usize,
|
||||
) -> Result<Vec<RecentEntry>, CliError> {
|
||||
if options.limit == Some(0) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let cutoff = options.since.map(|duration| now - duration);
|
||||
let exts = options
|
||||
.exts
|
||||
.iter()
|
||||
.map(|ext| ext.trim_start_matches('.').to_ascii_lowercase())
|
||||
.collect::<Vec<_>>();
|
||||
let mut entries = Vec::new();
|
||||
let mut limited_entries = options
|
||||
.limit
|
||||
.map_or_else(BinaryHeap::new, BinaryHeap::with_capacity);
|
||||
let mut scanned = 0_usize;
|
||||
for root in &options.roots {
|
||||
let walker = WalkBuilder::new(root)
|
||||
.standard_filters(true)
|
||||
.require_git(false)
|
||||
.build();
|
||||
|
||||
for candidate in walker {
|
||||
scanned += 1;
|
||||
if scanned > max_scanned_entries {
|
||||
return Err(CliError::runtime(format!(
|
||||
"recent scan exceeded {max_scanned_entries} filesystem entries; narrow the scan with --root, --since, --ext, or --name"
|
||||
)));
|
||||
}
|
||||
let candidate =
|
||||
candidate.map_err(|error| CliError::runtime(format!("walk failed: {error}")))?;
|
||||
let path = candidate.path();
|
||||
if path == root {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_type = candidate.file_type().ok_or_else(|| {
|
||||
CliError::runtime(format!(
|
||||
"failed to determine file type for {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let is_file = file_type.is_file();
|
||||
let is_dir = file_type.is_dir();
|
||||
|
||||
if !matches_kind(options.kind, is_file, is_dir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !matches_extension(&exts, path, is_dir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !matches_basename(options.name_pattern.as_ref(), path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = candidate.metadata().map_err(|error| {
|
||||
CliError::runtime(format!(
|
||||
"failed to read metadata for {}: {error}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let modified = metadata.modified().map_err(|error| {
|
||||
CliError::runtime(format!(
|
||||
"failed to read modified time for {}: {error}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
if cutoff.is_some_and(|instant| modified < instant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry = RecentEntry {
|
||||
path: path.to_path_buf(),
|
||||
kind: if is_dir {
|
||||
KindFilter::Dir
|
||||
} else {
|
||||
KindFilter::File
|
||||
},
|
||||
modified_rfc3339: humantime::format_rfc3339_seconds(modified).to_string(),
|
||||
modified,
|
||||
};
|
||||
|
||||
if let Some(limit) = options.limit {
|
||||
push_limited_entry(&mut limited_entries, limit, entry);
|
||||
} else {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if options.limit.is_some() {
|
||||
entries = finalize_limited_entries(limited_entries);
|
||||
} else {
|
||||
sort_recent_entries(&mut entries);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub use cli::main_entry;
|
||||
|
||||
/// Executes the `recent` command with the provided arguments.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] for invalid filters and [`CliError::Runtime`] for I/O failures.
|
||||
fn run(cli: Cli) -> Result<ExitCode, CliError> {
|
||||
let options = RecentOptions {
|
||||
roots: collect_roots(&cli)?,
|
||||
since: parse_since(cli.since.as_deref())?,
|
||||
exts: cli.ext,
|
||||
name_pattern: parse_name_pattern(cli.name.as_deref())?,
|
||||
kind: cli.kind,
|
||||
limit: cli.limit,
|
||||
};
|
||||
|
||||
let entries = collect_recent(&options, SystemTime::now())?;
|
||||
match cli.common.render_mode() {
|
||||
RenderMode::Json => print_json(&entries)?,
|
||||
RenderMode::Toon => print_structured(&entries, RenderMode::Toon)?,
|
||||
RenderMode::Text => {
|
||||
if entries.is_empty() {
|
||||
if !cli.common.quiet {
|
||||
println!("no_matches=true");
|
||||
}
|
||||
} else {
|
||||
for entry in &entries {
|
||||
println!(
|
||||
"{} {} {}",
|
||||
entry.modified_rfc3339,
|
||||
kind_label(entry.kind),
|
||||
entry.path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(if entries.is_empty() {
|
||||
ExitCode::NoResults
|
||||
} else {
|
||||
ExitCode::Success
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_roots(cli: &Cli) -> Result<Vec<PathBuf>, CliError> {
|
||||
if should_read_stdin(!cli.roots.is_empty(), cli.common.stdin_is_terminal()) {
|
||||
let mut buffer = String::new();
|
||||
io::stdin()
|
||||
.read_to_string(&mut buffer)
|
||||
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
|
||||
let stdin_roots = parse_roots_from_string(&buffer, cli.common.input_format)?;
|
||||
if !stdin_roots.is_empty() {
|
||||
return Ok(stdin_roots);
|
||||
}
|
||||
}
|
||||
|
||||
if !cli.roots.is_empty() {
|
||||
return common::expand_input_patterns(&cli.roots, "recent");
|
||||
}
|
||||
|
||||
std::env::current_dir()
|
||||
.map(|path| vec![path])
|
||||
.map_err(|error| CliError::runtime(format!("failed to resolve current directory: {error}")))
|
||||
}
|
||||
|
||||
fn parse_since(raw: Option<&str>) -> Result<Option<Duration>, CliError> {
|
||||
raw.map(humantime::parse_duration)
|
||||
.transpose()
|
||||
.map_err(|error| CliError::usage(format!("invalid --since value: {error}")))
|
||||
}
|
||||
|
||||
fn parse_roots_from_string(
|
||||
buffer: &str,
|
||||
input_format: common::InputFormat,
|
||||
) -> Result<Vec<PathBuf>, CliError> {
|
||||
Ok(read_existing_stdin_path_records(buffer, input_format, "recent")?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn parse_name_pattern(raw: Option<&str>) -> Result<Option<Regex>, CliError> {
|
||||
raw.map(Regex::new)
|
||||
.transpose()
|
||||
.map_err(|error| CliError::usage(format!("invalid --name regex: {error}")))
|
||||
}
|
||||
|
||||
const fn matches_kind(filter: KindFilter, is_file: bool, is_dir: bool) -> bool {
|
||||
match filter {
|
||||
KindFilter::File => is_file,
|
||||
KindFilter::Dir => is_dir,
|
||||
KindFilter::Any => is_file || is_dir,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_extension(exts: &[String], path: &Path, is_dir: bool) -> bool {
|
||||
if exts.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if is_dir {
|
||||
return false;
|
||||
}
|
||||
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| {
|
||||
exts.iter()
|
||||
.any(|candidate| candidate.eq_ignore_ascii_case(ext))
|
||||
})
|
||||
}
|
||||
|
||||
fn matches_basename(pattern: Option<&Regex>, path: &Path) -> bool {
|
||||
pattern.is_none_or(|regex| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| regex.is_match(name))
|
||||
|| path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.is_some_and(|stem| regex.is_match(stem))
|
||||
})
|
||||
}
|
||||
|
||||
const fn kind_label(kind: KindFilter) -> &'static str {
|
||||
match kind {
|
||||
KindFilter::File => "file",
|
||||
KindFilter::Dir => "dir",
|
||||
KindFilter::Any => "any",
|
||||
}
|
||||
}
|
||||
|
||||
fn push_limited_entry(
|
||||
entries: &mut BinaryHeap<Reverse<RankedEntry>>,
|
||||
limit: usize,
|
||||
entry: RecentEntry,
|
||||
) {
|
||||
let ranked = RankedEntry(entry);
|
||||
if entries.len() < limit {
|
||||
entries.push(Reverse(ranked));
|
||||
return;
|
||||
}
|
||||
|
||||
if entries
|
||||
.peek()
|
||||
.is_some_and(|oldest_retained| ranked > oldest_retained.0)
|
||||
{
|
||||
let _ = entries.pop();
|
||||
entries.push(Reverse(ranked));
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_limited_entries(entries: BinaryHeap<Reverse<RankedEntry>>) -> Vec<RecentEntry> {
|
||||
let mut results = entries
|
||||
.into_sorted_vec()
|
||||
.into_iter()
|
||||
.map(|Reverse(entry)| entry.0)
|
||||
.collect::<Vec<_>>();
|
||||
sort_recent_entries(&mut results);
|
||||
results
|
||||
}
|
||||
|
||||
fn sort_recent_entries(entries: &mut [RecentEntry]) {
|
||||
entries.sort_unstable_by(|left, right| {
|
||||
right
|
||||
.modified
|
||||
.cmp(&left.modified)
|
||||
.then_with(|| left.path.cmp(&right.path))
|
||||
});
|
||||
}
|
||||
|
||||
impl PartialEq for RankedEntry {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0.modified == other.0.modified && self.0.path == other.0.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for RankedEntry {}
|
||||
|
||||
impl PartialOrd for RankedEntry {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for RankedEntry {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.0
|
||||
.modified
|
||||
.cmp(&other.0.modified)
|
||||
.then_with(|| self.0.path.cmp(&other.0.path))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use common::CommonArgs;
|
||||
use filetime::{FileTime, set_file_mtime};
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn touch_with_age(path: &Path, age: Duration) {
|
||||
fs::write(path, path.display().to_string()).expect("write fixture");
|
||||
let timestamp = FileTime::from_system_time(SystemTime::now() - age);
|
||||
set_file_mtime(path, timestamp).expect("set file mtime");
|
||||
}
|
||||
|
||||
fn common_args(json: bool) -> CommonArgs {
|
||||
CommonArgs {
|
||||
json,
|
||||
format: None,
|
||||
input_format: common::InputFormat::Auto,
|
||||
color: common::ColorChoice::Never,
|
||||
quiet: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_since_accepts_none_and_reports_invalid_values() {
|
||||
assert_eq!(parse_since(None).expect("no since"), None);
|
||||
assert_eq!(
|
||||
parse_since(Some("15m")).expect("duration"),
|
||||
Some(Duration::from_secs(15 * 60))
|
||||
);
|
||||
|
||||
let error = parse_since(Some("nonsense")).expect_err("invalid duration should fail");
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Usage(message)
|
||||
if message.contains("invalid --since value")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_roots_from_string_discards_blank_lines() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
let first = root.path().join("logs");
|
||||
let second = root.path().join("src");
|
||||
fs::write(&first, "").expect("first");
|
||||
fs::write(&second, "").expect("second");
|
||||
assert_eq!(
|
||||
parse_roots_from_string(
|
||||
&format!(" \n{}\n\n{} \n", first.display(), second.display()),
|
||||
common::InputFormat::Lines
|
||||
)
|
||||
.expect("roots"),
|
||||
vec![first, second]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_name_pattern_accepts_none_and_reports_invalid_values() {
|
||||
assert!(parse_name_pattern(None).expect("none").is_none());
|
||||
assert!(
|
||||
parse_name_pattern(Some("^(lib|main)$"))
|
||||
.expect("valid regex")
|
||||
.is_some()
|
||||
);
|
||||
|
||||
let error = parse_name_pattern(Some("(")).expect_err("invalid regex should fail");
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Usage(message)
|
||||
if message.contains("invalid --name regex")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_and_extension_helpers_cover_all_variants() {
|
||||
assert!(matches_kind(KindFilter::File, true, false));
|
||||
assert!(matches_kind(KindFilter::Dir, false, true));
|
||||
assert!(matches_kind(KindFilter::Any, true, false));
|
||||
assert!(matches_kind(KindFilter::Any, false, true));
|
||||
assert!(!matches_kind(KindFilter::File, false, true));
|
||||
|
||||
let path = Path::new("demo.RS");
|
||||
assert!(matches_extension(&[], path, false));
|
||||
assert!(matches_extension(&["rs".into()], path, false));
|
||||
assert!(!matches_extension(&["txt".into()], path, false));
|
||||
assert!(!matches_extension(&["rs".into()], path, true));
|
||||
assert!(matches_basename(
|
||||
parse_name_pattern(Some("^demo$")).expect("regex").as_ref(),
|
||||
Path::new("demo.rs")
|
||||
));
|
||||
assert!(!matches_basename(
|
||||
parse_name_pattern(Some("^other$")).expect("regex").as_ref(),
|
||||
Path::new("demo.rs")
|
||||
));
|
||||
|
||||
assert_eq!(kind_label(KindFilter::File), "file");
|
||||
assert_eq!(kind_label(KindFilter::Dir), "dir");
|
||||
assert_eq!(kind_label(KindFilter::Any), "any");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_roots_prefers_explicit_roots_and_defaults_to_current_dir() {
|
||||
let explicit_root = PathBuf::from("C:\\jade-explicit-root");
|
||||
let explicit = collect_roots(&Cli {
|
||||
common: common_args(false),
|
||||
since: None,
|
||||
ext: Vec::new(),
|
||||
name: None,
|
||||
kind: KindFilter::File,
|
||||
limit: None,
|
||||
roots: vec![explicit_root.clone()],
|
||||
})
|
||||
.expect("explicit roots");
|
||||
assert_eq!(explicit, vec![explicit_root]);
|
||||
|
||||
let fallback = collect_roots(&Cli {
|
||||
common: common_args(false),
|
||||
since: None,
|
||||
ext: Vec::new(),
|
||||
name: None,
|
||||
kind: KindFilter::File,
|
||||
limit: None,
|
||||
roots: Vec::new(),
|
||||
})
|
||||
.expect("current dir root");
|
||||
assert_eq!(fallback.len(), 1);
|
||||
assert_eq!(
|
||||
fallback[0],
|
||||
std::env::current_dir().expect("current directory")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_recent_filters_extensions_since_and_limit() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
let old_rs = root.path().join("old.rs");
|
||||
let new_rs = root.path().join("new.rs");
|
||||
let new_txt = root.path().join("new.txt");
|
||||
touch_with_age(&old_rs, Duration::from_secs(60 * 60 * 2));
|
||||
touch_with_age(&new_rs, Duration::from_secs(30));
|
||||
touch_with_age(&new_txt, Duration::from_secs(10));
|
||||
|
||||
let entries = collect_recent(
|
||||
&RecentOptions {
|
||||
roots: vec![root.path().to_path_buf()],
|
||||
since: Some(Duration::from_secs(60 * 60)),
|
||||
exts: vec!["rs".into()],
|
||||
name_pattern: parse_name_pattern(Some("^new$")).expect("regex"),
|
||||
kind: KindFilter::File,
|
||||
limit: Some(1),
|
||||
},
|
||||
SystemTime::now(),
|
||||
)
|
||||
.expect("recent entries");
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].kind, KindFilter::File);
|
||||
assert!(entries[0].path.ends_with("new.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_recent_can_return_directories() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
let directory = root.path().join("plugins");
|
||||
fs::create_dir_all(&directory).expect("directory");
|
||||
|
||||
let entries = collect_recent(
|
||||
&RecentOptions {
|
||||
roots: vec![root.path().to_path_buf()],
|
||||
since: None,
|
||||
exts: Vec::new(),
|
||||
name_pattern: None,
|
||||
kind: KindFilter::Dir,
|
||||
limit: None,
|
||||
},
|
||||
SystemTime::now(),
|
||||
)
|
||||
.expect("directory entries");
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].kind, KindFilter::Dir);
|
||||
assert_eq!(entries[0].path, directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_recent_rejects_unbounded_large_tree_scans() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
let max_scanned_entries = 8;
|
||||
for index in 0..=max_scanned_entries {
|
||||
touch_with_age(
|
||||
&root.path().join(format!("file-{index}.txt")),
|
||||
Duration::from_secs(1),
|
||||
);
|
||||
}
|
||||
|
||||
let error = collect_recent_with_entry_cap(
|
||||
&RecentOptions {
|
||||
roots: vec![root.path().to_path_buf()],
|
||||
since: None,
|
||||
exts: Vec::new(),
|
||||
name_pattern: None,
|
||||
kind: KindFilter::File,
|
||||
limit: None,
|
||||
},
|
||||
SystemTime::now(),
|
||||
max_scanned_entries,
|
||||
)
|
||||
.expect_err("large unbounded scan should fail");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Runtime(message) if message.contains("recent scan exceeded")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_maps_empty_and_non_empty_results_to_exit_codes() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
let recent_file = root.path().join("active.rs");
|
||||
touch_with_age(&recent_file, Duration::from_secs(5));
|
||||
|
||||
let success = run(Cli {
|
||||
common: common_args(true),
|
||||
since: Some("1h".into()),
|
||||
ext: vec!["rs".into()],
|
||||
name: Some("^active$".into()),
|
||||
kind: KindFilter::File,
|
||||
limit: Some(5),
|
||||
roots: vec![root.path().to_path_buf()],
|
||||
})
|
||||
.expect("recent run");
|
||||
assert_eq!(success, ExitCode::Success);
|
||||
|
||||
let no_results = run(Cli {
|
||||
common: common_args(false),
|
||||
since: Some("1s".into()),
|
||||
ext: vec!["txt".into()],
|
||||
name: None,
|
||||
kind: KindFilter::File,
|
||||
limit: Some(5),
|
||||
roots: vec![root.path().to_path_buf()],
|
||||
})
|
||||
.expect("empty run");
|
||||
assert_eq!(no_results, ExitCode::NoResults);
|
||||
|
||||
let text_success = run(Cli {
|
||||
common: common_args(false),
|
||||
since: Some("1h".into()),
|
||||
ext: vec!["rs".into()],
|
||||
name: Some("^active$".into()),
|
||||
kind: KindFilter::File,
|
||||
limit: Some(5),
|
||||
roots: vec![root.path().to_path_buf()],
|
||||
})
|
||||
.expect("text run");
|
||||
assert_eq!(text_success, ExitCode::Success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Binary entry point for `recent`.
|
||||
|
||||
fn main() {
|
||||
std::process::exit(recent::main_entry());
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Integration tests for the `recent` command.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use assert_cmd::Command;
|
||||
use filetime::{FileTime, set_file_mtime};
|
||||
use predicates::prelude::*;
|
||||
use recent::{KindFilter, RecentOptions, collect_recent};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn cargo_command() -> Command {
|
||||
Command::cargo_bin("recent").expect("binary")
|
||||
}
|
||||
|
||||
fn pwsh_command(script: impl AsRef<str>) -> Command {
|
||||
let mut command = Command::new("pwsh");
|
||||
command
|
||||
.arg("-NoProfile")
|
||||
.arg("-Command")
|
||||
.arg(script.as_ref());
|
||||
command
|
||||
}
|
||||
|
||||
fn ps_quote(value: impl std::fmt::Display) -> String {
|
||||
format!("'{}'", value.to_string().replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn touch(path: &std::path::Path, age: Duration) {
|
||||
fs::write(path, path.display().to_string()).expect("write fixture");
|
||||
let timestamp = FileTime::from_system_time(SystemTime::now() - age);
|
||||
set_file_mtime(path, timestamp).expect("set mtime");
|
||||
}
|
||||
|
||||
fn file_recent_options(root: PathBuf) -> RecentOptions {
|
||||
RecentOptions {
|
||||
roots: vec![root],
|
||||
since: None,
|
||||
exts: Vec::new(),
|
||||
name_pattern: None,
|
||||
kind: KindFilter::File,
|
||||
limit: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_recent_respects_gitignore() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
fs::write(root.path().join(".gitignore"), "ignored.log\n").expect("gitignore");
|
||||
touch(&root.path().join("visible.log"), Duration::from_secs(30));
|
||||
touch(&root.path().join("ignored.log"), Duration::from_secs(10));
|
||||
|
||||
let entries = collect_recent(
|
||||
&file_recent_options(root.path().to_path_buf()),
|
||||
SystemTime::now(),
|
||||
)
|
||||
.expect("recent entries");
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(entries[0].path.ends_with("visible.log"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_since_and_extension_in_json_output() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
touch(
|
||||
&root.path().join("old.rs"),
|
||||
Duration::from_secs(60 * 60 * 24),
|
||||
);
|
||||
touch(&root.path().join("new.rs"), Duration::from_secs(30));
|
||||
touch(&root.path().join("new.txt"), Duration::from_secs(30));
|
||||
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--root")
|
||||
.arg(root.path())
|
||||
.arg("--since")
|
||||
.arg("1h")
|
||||
.arg("--ext")
|
||||
.arg("rs")
|
||||
.arg("--json")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"path\"").and(predicate::str::contains("new.rs")))
|
||||
.stdout(predicate::str::contains("old.rs").not())
|
||||
.stdout(predicate::str::contains("new.txt").not());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_powershell_root_pipeline() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
touch(&root.path().join("one.rs"), Duration::from_secs(10));
|
||||
touch(&root.path().join("two.rs"), Duration::from_secs(20));
|
||||
|
||||
let binary = assert_cmd::cargo::cargo_bin("recent");
|
||||
let script = format!(
|
||||
"{} | & {} --limit 1",
|
||||
ps_quote(root.path().display()),
|
||||
ps_quote(binary.display())
|
||||
);
|
||||
|
||||
let mut command = pwsh_command(script);
|
||||
command
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("one.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_by_basename_regex() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
touch(&root.path().join("alpha.rs"), Duration::from_secs(10));
|
||||
touch(&root.path().join("beta.rs"), Duration::from_secs(20));
|
||||
touch(&root.path().join("gamma.txt"), Duration::from_secs(5));
|
||||
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--root")
|
||||
.arg(root.path())
|
||||
.arg("--name")
|
||||
.arg("^(alpha|gamma)$")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("alpha.rs"))
|
||||
.stdout(predicate::str::contains("gamma.txt"))
|
||||
.stdout(predicate::str::contains("beta.rs").not())
|
||||
.stdout(predicate::str::contains("delta").not());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_includes_name_filter_examples() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--help")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("--name"))
|
||||
.stdout(predicate::str::contains(
|
||||
"recent --root . --since 2h --ext rs --name",
|
||||
))
|
||||
.stdout(predicate::str::contains("ConvertFrom-Json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basename_filter_keeps_directory_suffixes() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
let dotted_dir = root.path().join("plugins.v1");
|
||||
fs::create_dir_all(&dotted_dir).expect("directory");
|
||||
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--root")
|
||||
.arg(root.path())
|
||||
.arg("--kind")
|
||||
.arg("dir")
|
||||
.arg("--name")
|
||||
.arg("^plugins\\.v1$")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("plugins.v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_mode_emits_no_matches_hint() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
touch(&root.path().join("alpha.rs"), Duration::from_secs(10));
|
||||
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--root")
|
||||
.arg(root.path())
|
||||
.arg("--name")
|
||||
.arg("^beta$")
|
||||
.assert()
|
||||
.code(1)
|
||||
.stdout(predicate::str::contains("no_matches=true"));
|
||||
}
|
||||
Reference in New Issue
Block a user