diff --git a/Cargo.lock b/Cargo.lock index b8c738ac596..b854e8ab804 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3592,7 +3592,6 @@ dependencies = [ name = "uu_pr" version = "0.1.0" dependencies = [ - "chrono", "clap", "fluent", "itertools 0.14.0", diff --git a/src/uu/pr/Cargo.toml b/src/uu/pr/Cargo.toml index 3fb12813340..d8b5b279117 100644 --- a/src/uu/pr/Cargo.toml +++ b/src/uu/pr/Cargo.toml @@ -19,10 +19,9 @@ path = "src/pr.rs" [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["entries"] } +uucore = { workspace = true, features = ["entries", "time"] } itertools = { workspace = true } regex = { workspace = true } -chrono = { workspace = true } thiserror = { workspace = true } fluent = { workspace = true } diff --git a/src/uu/pr/locales/en-US.ftl b/src/uu/pr/locales/en-US.ftl index c53c62a873e..b4c0e10f286 100644 --- a/src/uu/pr/locales/en-US.ftl +++ b/src/uu/pr/locales/en-US.ftl @@ -13,6 +13,8 @@ pr-help-pages = Begin and stop printing with page FIRST_PAGE[:LAST_PAGE] pr-help-header = Use the string header to replace the file name in the header line. +pr-help-date-format = + Use 'date'-style FORMAT for the header date. pr-help-double-space = Produce output that is double spaced. An extra character is output following every found in the input. diff --git a/src/uu/pr/locales/fr-FR.ftl b/src/uu/pr/locales/fr-FR.ftl index 54b134d0010..af596787235 100644 --- a/src/uu/pr/locales/fr-FR.ftl +++ b/src/uu/pr/locales/fr-FR.ftl @@ -13,6 +13,8 @@ pr-help-pages = Commencer et arrêter l'impression à la page PREMIÈRE_PAGE[:DE pr-help-header = Utiliser la chaîne d'en-tête pour remplacer le nom de fichier dans la ligne d'en-tête. +pr-help-date-format = + Utiliser le FORMAT de style 'date' pour la date dans la ligne d'en-tête. pr-help-double-space = Produire une sortie avec double espacement. Un caractère supplémentaire est affiché après chaque trouvé dans l'entrée. diff --git a/src/uu/pr/src/pr.rs b/src/uu/pr/src/pr.rs index 2ea418647ab..fbad8c93adc 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -6,7 +6,6 @@ // spell-checker:ignore (ToDO) adFfmprt, kmerge -use chrono::{DateTime, Local}; use clap::{Arg, ArgAction, ArgMatches, Command}; use itertools::Itertools; use regex::Regex; @@ -14,11 +13,13 @@ use std::fs::{File, metadata}; use std::io::{BufRead, BufReader, Lines, Read, Write, stdin, stdout}; #[cfg(unix)] use std::os::unix::fs::FileTypeExt; +use std::time::SystemTime; use thiserror::Error; use uucore::display::Quotable; use uucore::error::UResult; use uucore::format_usage; +use uucore::time::{FormatSystemTimeFallback, format, format_system_time}; use uucore::translate; const TAB: char = '\t'; @@ -32,10 +33,10 @@ const DEFAULT_COLUMN_WIDTH: usize = 72; const DEFAULT_COLUMN_WIDTH_WITH_S_OPTION: usize = 512; const DEFAULT_COLUMN_SEPARATOR: &char = &TAB; const FF: u8 = 0x0C_u8; -const DATE_TIME_FORMAT: &str = "%b %d %H:%M %Y"; mod options { pub const HEADER: &str = "header"; + pub const DATE_FORMAT: &str = "date-format"; pub const DOUBLE_SPACE: &str = "double-space"; pub const NUMBER_LINES: &str = "number-lines"; pub const FIRST_LINE_NUMBER: &str = "first-line-number"; @@ -176,6 +177,13 @@ pub fn uu_app() -> Command { .help(translate!("pr-help-header")) .value_name("STRING"), ) + .arg( + Arg::new(options::DATE_FORMAT) + .short('D') + .long(options::DATE_FORMAT) + .value_name("FORMAT") + .help(translate!("pr-help-date-format")), + ) .arg( Arg::new(options::DOUBLE_SPACE) .short('d') @@ -401,6 +409,25 @@ fn parse_usize(matches: &ArgMatches, opt: &str) -> Option .map(from_parse_error_to_pr_error) } +fn get_date_format(matches: &ArgMatches) -> String { + match matches.get_one::(options::DATE_FORMAT) { + Some(format) => format, + None => { + // Replicate behavior from GNU manual. + if std::env::var("POSIXLY_CORRECT").is_ok() + // TODO: This needs to be moved to uucore and handled by icu? + && (std::env::var("LC_TIME").unwrap_or_default() == "POSIX" + || std::env::var("LC_ALL").unwrap_or_default() == "POSIX") + { + "%b %e %H:%M %Y" + } else { + format::LONG_ISO + } + } + } + .to_string() +} + #[allow(clippy::cognitive_complexity)] fn build_options( matches: &ArgMatches, @@ -487,11 +514,26 @@ fn build_options( let line_separator = "\n".to_string(); - let last_modified_time = if is_merge_mode || paths[0].eq(FILE_STDIN) { - let date_time = Local::now(); - date_time.format(DATE_TIME_FORMAT).to_string() - } else { - file_last_modified_time(paths.first().unwrap()) + let last_modified_time = { + let time = if is_merge_mode || paths[0].eq(FILE_STDIN) { + Some(SystemTime::now()) + } else { + metadata(paths.first().unwrap()) + .ok() + .and_then(|i| i.modified().ok()) + }; + time.and_then(|time| { + let mut v = Vec::new(); + format_system_time( + &mut v, + time, + &get_date_format(matches), + FormatSystemTimeFallback::Integer, + ) + .ok() + .map(|()| String::from_utf8_lossy(&v).to_string()) + }) + .unwrap_or_default() }; // +page option is less priority than --pages @@ -1126,19 +1168,6 @@ fn header_content(options: &OutputOptions, page: usize) -> Vec { } } -fn file_last_modified_time(path: &str) -> String { - metadata(path) - .map(|i| { - i.modified() - .map(|x| { - let date_time: DateTime = x.into(); - date_time.format(DATE_TIME_FORMAT).to_string() - }) - .unwrap_or_default() - }) - .unwrap_or_default() -} - /// Returns five empty lines as trailer content if displaying trailer /// is not disabled by using `NO_HEADER_TRAILER_OPTION`option. fn trailer_content(options: &OutputOptions) -> Vec { diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index a8d8bd3c8b2..c2f9e84fb17 100644 --- a/tests/by-util/test_pr.rs +++ b/tests/by-util/test_pr.rs @@ -9,9 +9,9 @@ use std::fs::metadata; use uutests::new_ucmd; use uutests::util::UCommand; -const DATE_TIME_FORMAT: &str = "%b %d %H:%M %Y"; +const DATE_TIME_FORMAT_DEFAULT: &str = "%Y-%m-%d %H:%M"; -fn file_last_modified_time(ucmd: &UCommand, path: &str) -> String { +fn file_last_modified_time_format(ucmd: &UCommand, path: &str, format: &str) -> String { let tmp_dir_path = ucmd.get_full_fixture_path(path); let file_metadata = metadata(tmp_dir_path); file_metadata @@ -19,19 +19,23 @@ fn file_last_modified_time(ucmd: &UCommand, path: &str) -> String { i.modified() .map(|x| { let date_time: DateTime = x.into(); - date_time.format(DATE_TIME_FORMAT).to_string() + date_time.format(format).to_string() }) .unwrap_or_default() }) .unwrap_or_default() } +fn file_last_modified_time(ucmd: &UCommand, path: &str) -> String { + file_last_modified_time_format(ucmd, path, DATE_TIME_FORMAT_DEFAULT) +} + fn all_minutes(from: DateTime, to: DateTime) -> Vec { let to = to + Duration::try_minutes(1).unwrap(); let mut vec = vec![]; let mut current = from; while current < to { - vec.push(current.format(DATE_TIME_FORMAT).to_string()); + vec.push(current.format(DATE_TIME_FORMAT_DEFAULT).to_string()); current += Duration::try_minutes(1).unwrap(); } vec @@ -398,6 +402,91 @@ fn test_with_offset_space_option() { .stdout_is_templated_fixture(expected_test_file_path, &[("{last_modified_time}", &value)]); } +#[test] +fn test_with_date_format() { + let test_file_path = "test_one_page.log"; + let expected_test_file_path = "test_one_page.log.expected"; + let mut scenario = new_ucmd!(); + let value = file_last_modified_time_format(&scenario, test_file_path, "%Y__%s"); + scenario + .args(&[test_file_path, "-D", "%Y__%s"]) + .succeeds() + .stdout_is_templated_fixture(expected_test_file_path, &[("{last_modified_time}", &value)]); + + // "Format" doesn't need to contain any replaceable token. + new_ucmd!() + .args(&[test_file_path, "-D", "Hello!"]) + .succeeds() + .stdout_is_templated_fixture( + expected_test_file_path, + &[("{last_modified_time}", "Hello!")], + ); + + // Long option also works + new_ucmd!() + .args(&[test_file_path, "--date-format=Hello!"]) + .succeeds() + .stdout_is_templated_fixture( + expected_test_file_path, + &[("{last_modified_time}", "Hello!")], + ); + + // Option takes precedence over environment variables + new_ucmd!() + .env("POSIXLY_CORRECT", "1") + .env("LC_TIME", "POSIX") + .args(&[test_file_path, "-D", "Hello!"]) + .succeeds() + .stdout_is_templated_fixture( + expected_test_file_path, + &[("{last_modified_time}", "Hello!")], + ); +} + +#[test] +fn test_with_date_format_env() { + const POSIXLY_FORMAT: &str = "%b %e %H:%M %Y"; + + // POSIXLY_CORRECT + LC_ALL/TIME=POSIX uses "%b %e %H:%M %Y" date format + let test_file_path = "test_one_page.log"; + let expected_test_file_path = "test_one_page.log.expected"; + let mut scenario = new_ucmd!(); + let value = file_last_modified_time_format(&scenario, test_file_path, POSIXLY_FORMAT); + scenario + .env("POSIXLY_CORRECT", "1") + .env("LC_ALL", "POSIX") + .args(&[test_file_path]) + .succeeds() + .stdout_is_templated_fixture(expected_test_file_path, &[("{last_modified_time}", &value)]); + + let mut scenario = new_ucmd!(); + let value = file_last_modified_time_format(&scenario, test_file_path, POSIXLY_FORMAT); + scenario + .env("POSIXLY_CORRECT", "1") + .env("LC_TIME", "POSIX") + .args(&[test_file_path]) + .succeeds() + .stdout_is_templated_fixture(expected_test_file_path, &[("{last_modified_time}", &value)]); + + // But not if POSIXLY_CORRECT/LC_ALL is something else. + let mut scenario = new_ucmd!(); + let value = file_last_modified_time_format(&scenario, test_file_path, DATE_TIME_FORMAT_DEFAULT); + scenario + .env("LC_TIME", "POSIX") + .args(&[test_file_path]) + .succeeds() + .stdout_is_templated_fixture(expected_test_file_path, &[("{last_modified_time}", &value)]); + + let mut scenario = new_ucmd!(); + let value = file_last_modified_time_format(&scenario, test_file_path, DATE_TIME_FORMAT_DEFAULT); + scenario + .env("POSIXLY_CORRECT", "1") + .env("LC_TIME", "C") + .args(&[test_file_path]) + .succeeds() + .stdout_is_templated_fixture(expected_test_file_path, &[("{last_modified_time}", &value)]); +} + #[test] fn test_with_pr_core_utils_tests() { let test_cases = vec![