From 149529a494ecab26ffad4b1cba957d8b02553879 Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Tue, 29 Jul 2025 02:51:04 +0800 Subject: [PATCH 1/5] pr: Switch to uucore::time formatting --- Cargo.lock | 1 - src/uu/pr/Cargo.toml | 3 +-- src/uu/pr/src/pr.rs | 41 ++++++++++++++++++++++------------------- 3 files changed, 23 insertions(+), 22 deletions(-) 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/src/pr.rs b/src/uu/pr/src/pr.rs index 2ea418647ab..453c9042336 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,12 +13,14 @@ 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::translate; +use uucore::time::{FormatSystemTimeFallback, format_system_time}; const TAB: char = '\t'; const LINES_PER_PAGE: usize = 66; @@ -487,11 +488,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 mut v = Vec::new(); + 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| { + format_system_time( + &mut v, + time, + DATE_TIME_FORMAT, + FormatSystemTimeFallback::Integer, + ) + .ok() + }) + .map(|()| String::from_utf8_lossy(&v).to_string()) + .unwrap_or_default() }; // +page option is less priority than --pages @@ -1126,19 +1142,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 { From 4878e65ea7d50c9152c3378cea5c148de8b39887 Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Tue, 29 Jul 2025 12:54:22 +0800 Subject: [PATCH 2/5] pr: Set date format depending on environment. Based on GNU manual. --- src/uu/pr/src/pr.rs | 19 +++++++++++--- tests/by-util/test_pr.rs | 56 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/uu/pr/src/pr.rs b/src/uu/pr/src/pr.rs index 453c9042336..8f0c31489e3 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -20,7 +20,7 @@ use uucore::display::Quotable; use uucore::error::UResult; use uucore::format_usage; use uucore::translate; -use uucore::time::{FormatSystemTimeFallback, format_system_time}; +use uucore::time::{FormatSystemTimeFallback, format, format_system_time}; const TAB: char = '\t'; const LINES_PER_PAGE: usize = 66; @@ -33,7 +33,6 @@ 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"; @@ -402,6 +401,20 @@ fn parse_usize(matches: &ArgMatches, opt: &str) -> Option .map(from_parse_error_to_pr_error) } +fn get_date_format() -> String { + // 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, @@ -501,7 +514,7 @@ fn build_options( format_system_time( &mut v, time, - DATE_TIME_FORMAT, + &get_date_format(), FormatSystemTimeFallback::Integer, ) .ok() diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index a8d8bd3c8b2..00a4fa1ad5f 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,50 @@ fn test_with_offset_space_option() { .stdout_is_templated_fixture(expected_test_file_path, &[("{last_modified_time}", &value)]); } +#[test] +fn test_with_date_format() { + 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![ From d35a60e5e2f6a66451a79e9fbfa8a83fca72cbf2 Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Tue, 29 Jul 2025 15:30:30 +0800 Subject: [PATCH 3/5] pr: Add support for -D/--date-format parameter --- src/uu/pr/locales/en-US.ftl | 2 ++ src/uu/pr/locales/fr-FR.ftl | 2 ++ src/uu/pr/src/pr.rs | 37 +++++++++++++++++++++---------- tests/by-util/test_pr.rs | 44 +++++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 12 deletions(-) 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 8f0c31489e3..10e90808071 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -19,8 +19,8 @@ use thiserror::Error; use uucore::display::Quotable; use uucore::error::UResult; use uucore::format_usage; -use uucore::translate; use uucore::time::{FormatSystemTimeFallback, format, format_system_time}; +use uucore::translate; const TAB: char = '\t'; const LINES_PER_PAGE: usize = 66; @@ -36,6 +36,7 @@ const FF: u8 = 0x0C_u8; 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,16 +409,21 @@ fn parse_usize(matches: &ArgMatches, opt: &str) -> Option .map(from_parse_error_to_pr_error) } -fn get_date_format() -> String { - // 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 +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() } @@ -514,7 +527,7 @@ fn build_options( format_system_time( &mut v, time, - &get_date_format(), + &get_date_format(matches), FormatSystemTimeFallback::Integer, ) .ok() diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index 00a4fa1ad5f..06405bd07c6 100644 --- a/tests/by-util/test_pr.rs +++ b/tests/by-util/test_pr.rs @@ -404,6 +404,50 @@ fn test_with_offset_space_option() { #[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. + let mut scenario = new_ucmd!(); + scenario + .args(&[test_file_path, "-D", "Hello!"]) + .succeeds() + .stdout_is_templated_fixture( + expected_test_file_path, + &[("{last_modified_time}", "Hello!")], + ); + + // Long option also works + let mut scenario = new_ucmd!(); + scenario + .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 + let mut scenario = new_ucmd!(); + scenario + .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 From bdedd24e06c833272f15c3c82d67c3cbafd19340 Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Thu, 31 Jul 2025 22:05:35 +0800 Subject: [PATCH 4/5] pr: Move vector creation Co-authored-by: Daniel Hofstetter --- src/uu/pr/src/pr.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uu/pr/src/pr.rs b/src/uu/pr/src/pr.rs index 10e90808071..fbad8c93adc 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -515,7 +515,6 @@ fn build_options( let line_separator = "\n".to_string(); let last_modified_time = { - let mut v = Vec::new(); let time = if is_merge_mode || paths[0].eq(FILE_STDIN) { Some(SystemTime::now()) } else { @@ -524,6 +523,7 @@ fn build_options( .and_then(|i| i.modified().ok()) }; time.and_then(|time| { + let mut v = Vec::new(); format_system_time( &mut v, time, @@ -531,8 +531,8 @@ fn build_options( FormatSystemTimeFallback::Integer, ) .ok() + .map(|()| String::from_utf8_lossy(&v).to_string()) }) - .map(|()| String::from_utf8_lossy(&v).to_string()) .unwrap_or_default() }; From 82cb88ac0df4e7c92ade95bcde5f56c308e3141e Mon Sep 17 00:00:00 2001 From: Nicolas Boichat Date: Thu, 31 Jul 2025 22:07:59 +0800 Subject: [PATCH 5/5] test_pr: Use new_ucmd!() directly --- tests/by-util/test_pr.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index 06405bd07c6..c2f9e84fb17 100644 --- a/tests/by-util/test_pr.rs +++ b/tests/by-util/test_pr.rs @@ -414,8 +414,7 @@ fn test_with_date_format() { .stdout_is_templated_fixture(expected_test_file_path, &[("{last_modified_time}", &value)]); // "Format" doesn't need to contain any replaceable token. - let mut scenario = new_ucmd!(); - scenario + new_ucmd!() .args(&[test_file_path, "-D", "Hello!"]) .succeeds() .stdout_is_templated_fixture( @@ -424,8 +423,7 @@ fn test_with_date_format() { ); // Long option also works - let mut scenario = new_ucmd!(); - scenario + new_ucmd!() .args(&[test_file_path, "--date-format=Hello!"]) .succeeds() .stdout_is_templated_fixture( @@ -434,8 +432,7 @@ fn test_with_date_format() { ); // Option takes precedence over environment variables - let mut scenario = new_ucmd!(); - scenario + new_ucmd!() .env("POSIXLY_CORRECT", "1") .env("LC_TIME", "POSIX") .args(&[test_file_path, "-D", "Hello!"])