Skip to content

Commit 45e3583

Browse files
authored
fix(date): implement -r flag for file modification time (#1141)
## Summary - Implement `date -r <file>` / `--reference=<file>` to display file modification time from VFS - Return proper error message for nonexistent files (`cannot stat '...': No such file or directory`) - Add 5 tests: mtime retrieval, file-not-found, format string, long flag, UTC mode ## Test plan - [x] `cargo test -p bashkit --lib -- date::` — all 47 tests pass (5 new) - [x] `cargo fmt` — no changes needed - [x] `cargo clippy` — no new warnings Closes #1113
1 parent 3f9c35f commit 45e3583

File tree

1 file changed

+143
-6
lines changed

1 file changed

+143
-6
lines changed

crates/bashkit/src/builtins/date.rs

Lines changed: 143 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//! caught and return graceful errors.
77
88
use std::fmt::Write;
9+
use std::path::Path;
910

1011
use async_trait::async_trait;
1112
use chrono::format::{Item, StrftimeItems};
@@ -17,13 +18,14 @@ use crate::interpreter::ExecResult;
1718

1819
/// The date builtin - display or set date and time.
1920
///
20-
/// Usage: date [+FORMAT] [-u] [-R] [-I[TIMESPEC]]
21+
/// Usage: date [+FORMAT] [-u] [-R] [-I[TIMESPEC]] [-r FILE]
2122
///
2223
/// Options:
2324
/// +FORMAT Output date according to FORMAT
2425
/// -u Display UTC time instead of local time
2526
/// -R Output RFC 2822 formatted date
2627
/// -I[FMT] Output ISO 8601 formatted date (FMT: date, hours, minutes, seconds)
28+
/// -r FILE Display the last modification time of FILE
2729
///
2830
/// FORMAT specifiers:
2931
/// %Y Year with century (e.g., 2024)
@@ -327,6 +329,7 @@ impl Builtin for Date {
327329
let mut utc = false;
328330
let mut format_arg: Option<String> = None;
329331
let mut date_str: Option<String> = None;
332+
let mut ref_file: Option<String> = None;
330333
let mut rfc2822 = false;
331334
let mut iso8601: Option<String> = None;
332335

@@ -343,6 +346,15 @@ impl Builtin for Date {
343346
if let Some(val) = p.positional() {
344347
date_str = Some(val.to_string());
345348
}
349+
} else if let Some(val) = p.current().and_then(|s| s.strip_prefix("--reference=")) {
350+
ref_file = Some(val.to_string());
351+
p.advance();
352+
} else if let Some(val) = p.flag_value_opt("-r") {
353+
ref_file = Some(val.to_string());
354+
} else if p.flag("--reference") {
355+
if let Some(val) = p.positional() {
356+
ref_file = Some(val.to_string());
357+
}
346358
} else if p.flag_any(&["-R", "--rfc-2822", "--rfc-email"]) {
347359
rfc2822 = true;
348360
} else if let Some(val) = p.current().and_then(|s| s.strip_prefix("--iso-8601=")) {
@@ -364,14 +376,34 @@ impl Builtin for Date {
364376
// Get the datetime to format
365377
// THREAT[TM-INF-018]: Use virtual time if configured
366378
let now = self.now();
367-
let epoch_input = date_str.as_deref().is_some_and(uses_epoch_input);
368-
let dt_utc = if let Some(ref ds) = date_str {
369-
match parse_date_string(ds, now) {
379+
380+
// Resolve the datetime: -r (file mtime) > -d (date string) > now
381+
let epoch_input;
382+
let dt_utc;
383+
if let Some(ref file) = ref_file {
384+
// -r / --reference: stat file to get modification time
385+
let path = Path::new(file);
386+
match ctx.fs.stat(path).await {
387+
Ok(meta) => {
388+
dt_utc = meta.modified.into();
389+
epoch_input = false;
390+
}
391+
Err(_) => {
392+
return Ok(ExecResult::err(
393+
format!("date: cannot stat '{}': No such file or directory\n", file),
394+
1,
395+
));
396+
}
397+
}
398+
} else if let Some(ref ds) = date_str {
399+
epoch_input = uses_epoch_input(ds);
400+
dt_utc = match parse_date_string(ds, now) {
370401
Ok(dt) => dt,
371402
Err(e) => return Ok(ExecResult::err(format!("{}\n", e), 1)),
372-
}
403+
};
373404
} else {
374-
now
405+
epoch_input = false;
406+
dt_utc = now;
375407
};
376408

377409
// Handle -R (RFC 2822) output
@@ -857,4 +889,109 @@ mod tests {
857889
// We only expand single %N, not %%N
858890
assert_eq!(expand_nanoseconds("%%N", 123), "%%N");
859891
}
892+
893+
// Helper to run date with a pre-configured filesystem
894+
async fn run_date_with_fs(args: &[&str], fs: Arc<InMemoryFs>) -> ExecResult {
895+
let mut variables = HashMap::new();
896+
let env = HashMap::new();
897+
let mut cwd = PathBuf::from("/");
898+
899+
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
900+
let ctx = Context {
901+
args: &args,
902+
env: &env,
903+
variables: &mut variables,
904+
cwd: &mut cwd,
905+
fs,
906+
stdin: None,
907+
#[cfg(feature = "http_client")]
908+
http_client: None,
909+
#[cfg(feature = "git")]
910+
git_client: None,
911+
#[cfg(feature = "ssh")]
912+
ssh_client: None,
913+
shell: None,
914+
};
915+
916+
Date::new().execute(ctx).await.unwrap()
917+
}
918+
919+
// === -r / --reference (file mtime) tests ===
920+
921+
#[tokio::test]
922+
async fn test_date_r_file_mtime() {
923+
use crate::fs::FileSystem;
924+
925+
let fs = Arc::new(InMemoryFs::new());
926+
fs.mkdir(std::path::Path::new("/tmp"), true).await.unwrap();
927+
fs.write_file(std::path::Path::new("/tmp/test.txt"), b"hello")
928+
.await
929+
.unwrap();
930+
931+
// -r should return the file's mtime, not an error
932+
let result = run_date_with_fs(&["-r", "/tmp/test.txt", "+%Y-%m-%d"], fs).await;
933+
assert_eq!(result.exit_code, 0);
934+
let date = result.stdout.trim();
935+
// Should be a valid date (YYYY-MM-DD)
936+
assert_eq!(date.len(), 10);
937+
assert!(date.contains('-'));
938+
}
939+
940+
#[tokio::test]
941+
async fn test_date_r_file_not_found() {
942+
let fs = Arc::new(InMemoryFs::new());
943+
let result = run_date_with_fs(&["-r", "/nonexistent.txt"], fs).await;
944+
assert_eq!(result.exit_code, 1);
945+
assert!(result.stderr.contains("cannot stat"));
946+
assert!(result.stderr.contains("/nonexistent.txt"));
947+
}
948+
949+
#[tokio::test]
950+
async fn test_date_r_with_format() {
951+
use crate::fs::FileSystem;
952+
953+
let fs = Arc::new(InMemoryFs::new());
954+
fs.mkdir(std::path::Path::new("/tmp"), true).await.unwrap();
955+
fs.write_file(std::path::Path::new("/tmp/test.txt"), b"content")
956+
.await
957+
.unwrap();
958+
959+
let result = run_date_with_fs(&["-r", "/tmp/test.txt", "+%B"], fs).await;
960+
assert_eq!(result.exit_code, 0);
961+
// Should be a month name, non-empty
962+
let month = result.stdout.trim();
963+
assert!(!month.is_empty());
964+
}
965+
966+
#[tokio::test]
967+
async fn test_date_reference_long_flag() {
968+
use crate::fs::FileSystem;
969+
970+
let fs = Arc::new(InMemoryFs::new());
971+
fs.mkdir(std::path::Path::new("/tmp"), true).await.unwrap();
972+
fs.write_file(std::path::Path::new("/tmp/test.txt"), b"content")
973+
.await
974+
.unwrap();
975+
976+
let result = run_date_with_fs(&["--reference=/tmp/test.txt", "+%Y"], fs).await;
977+
assert_eq!(result.exit_code, 0);
978+
let year = result.stdout.trim();
979+
assert_eq!(year.len(), 4);
980+
}
981+
982+
#[tokio::test]
983+
async fn test_date_r_with_utc() {
984+
use crate::fs::FileSystem;
985+
986+
let fs = Arc::new(InMemoryFs::new());
987+
fs.mkdir(std::path::Path::new("/tmp"), true).await.unwrap();
988+
fs.write_file(std::path::Path::new("/tmp/test.txt"), b"content")
989+
.await
990+
.unwrap();
991+
992+
let result = run_date_with_fs(&["-u", "-r", "/tmp/test.txt", "+%Z"], fs).await;
993+
assert_eq!(result.exit_code, 0);
994+
let tz = result.stdout.trim();
995+
assert!(tz.contains("UTC") || tz == "+0000" || tz == "+00:00");
996+
}
860997
}

0 commit comments

Comments
 (0)