66//! caught and return graceful errors.
77
88use std:: fmt:: Write ;
9+ use std:: path:: Path ;
910
1011use async_trait:: async_trait;
1112use 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