chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:41:59 +08:00
commit e365e5df4d
508 changed files with 163373 additions and 0 deletions
+30
View File
@@ -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
+278
View File
@@ -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);
}
}
+648
View File
@@ -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);
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `recent`.
fn main() {
std::process::exit(recent::main_entry());
}
+178
View File
@@ -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"));
}