Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 179 additions & 15 deletions src/uu/date/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.

// spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz
// spell-checker:ignore strtime rsplit ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz

mod format_modifiers;
mod locale;
Expand Down Expand Up @@ -280,6 +280,60 @@ fn parse_military_timezone_with_offset(s: &str) -> Option<(i32, DayDelta)> {
Some((hours_from_midnight, day_delta))
}

/// Parse a positional argument with format `MMDDhhmm[[CC]YY][.ss]`
fn parse_positional_set_datetime(s: &str, utc: bool) -> Option<Zoned> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you please add some unit tests for this function

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, working on it right now

Copy link
Copy Markdown
Contributor Author

@aguimaraes aguimaraes Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, I think that works, do you need me to rebase?
The fuzzer found a crash but I think it was pre-existing and I saw some PRs to solve it.

let (base, ss) = if let Some((before, after)) = s.rsplit_once('.') {
if after.len() != 2 || !after.chars().all(|c| c.is_ascii_digit()) {
return None;
}
(before, Some(after))
} else {
(s, None)
};

if !base.chars().all(|c| c.is_ascii_digit()) {
return None;
}

let tz = if utc {
TimeZone::UTC
} else {
TimeZone::system()
};

let rearranged = match base.len() {
8 => {
let year = Timestamp::now().to_zoned(tz.clone()).year();
format!("{year}{base}")
}
10 => {
let yy: u32 = base[8..10].parse().ok()?;
let century = if yy > 68 { 19 } else { 20 };
format!("{century}{}{}", &base[8..], &base[..8])
}
12 => format!("{}{}", &base[8..], &base[..8]),
_ => return None,
};

let with_seconds = if let Some(seconds) = ss {
format!("{rearranged}.{seconds}")
} else {
rearranged
};

let parse_format = if ss.is_none() {
"%Y%m%d%H%M"
} else {
"%Y%m%d%H%M.%S"
};

let dt = strtime::parse(parse_format, &with_seconds)
.and_then(|parsed| parsed.to_datetime())
.ok()?;

tz.to_ambiguous_zoned(dt).unambiguous().ok()
}

#[uucore::main]
#[allow(clippy::cognitive_complexity)]
pub fn uumain(args: impl uucore::Args) -> UResult<()> {
Expand Down Expand Up @@ -317,25 +371,34 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
}
}

let utc = matches.get_flag(OPT_UNIVERSAL);

let mut positional_set_to: Option<Zoned> = None;

let format = if let Some(form) = matches.get_one::<String>(OPT_FORMAT) {
if !form.starts_with('+') {
// if an optional Format String was found but the user has not provided an input date
// GNU prints an invalid date Error
if !matches!(date_source, DateSource::Human(_)) {
return Err(USimpleError::new(
1,
translate!("date-error-invalid-date", "date" => form),
));
}
// If the user did provide an input date with the --date flag and the Format String is
// not starting with '+' GNU prints the missing '+' error message
if let Some(stripped) = form.strip_prefix('+') {
Format::Custom(stripped.to_string())
} else if matches!(date_source, DateSource::Human(_)) {
// -d was given, positional must be +FORMAT
return Err(USimpleError::new(
1,
translate!("date-error-format-missing-plus", "arg" => form),
));
} else if matches.get_one::<String>(OPT_SET).is_some() {
// -s flag is present: positional must be +FORMAT
return Err(USimpleError::new(
1,
translate!("date-error-invalid-date", "date" => form),
));
} else if let Some(zoned) = parse_positional_set_datetime(form, utc) {
positional_set_to = Some(zoned);
Format::Default
} else {
return Err(USimpleError::new(
1,
translate!("date-error-invalid-date", "date" => form),
));
}
let form = form[1..].to_string();
Format::Custom(form)
} else if let Some(fmt) = matches
.get_many::<String>(OPT_ISO_8601)
.map(|mut iter| iter.next().unwrap_or(&DATE.to_string()).as_str().into())
Expand All @@ -354,7 +417,6 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
Format::Default
};

let utc = matches.get_flag(OPT_UNIVERSAL);
let debug_mode = matches.get_flag(OPT_DEBUG);

// Get the current time, either in the local time zone or UTC.
Expand All @@ -378,6 +440,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
Some(Ok(date)) => Some(date),
};

// positional_set_to and set_to are mutually exclusive:
// MMDDhhmm parsing is only attempted when -s is absent.
let set_to = positional_set_to.or(set_to);

let settings = Settings {
utc,
format,
Expand Down Expand Up @@ -1167,4 +1233,102 @@ mod tests {
assert_eq!(strip_parenthesized_comments("a(b(c)d"), "a"); // Nested unbalanced
assert_eq!(strip_parenthesized_comments("a(b)c(d)e(f"), "ace"); // Multiple groups, last unmatched
}

#[test]
fn test_parse_positional_12_digits() {
let result = parse_positional_set_datetime("010112002025", true).unwrap();
assert_eq!(result.year(), 2025);
assert_eq!(result.month(), 1);
assert_eq!(result.day(), 1);
assert_eq!(result.hour(), 12);
assert_eq!(result.minute(), 0);
assert_eq!(result.second(), 0);
}

#[test]
fn test_parse_positional_8_digits() {
let result = parse_positional_set_datetime("01011200", true).unwrap();
let current_year = Timestamp::now().to_zoned(TimeZone::UTC).year();
assert_eq!(result.year(), current_year);
assert_eq!(result.month(), 1);
assert_eq!(result.day(), 1);
assert_eq!(result.hour(), 12);
assert_eq!(result.minute(), 0);
}

#[test]
fn test_parse_positional_10_digits_century_rule() {
// 25 -> 2025
let result = parse_positional_set_datetime("0101120025", true).unwrap();
assert_eq!(result.year(), 2025);

// 68 -> 2068 (boundary)
let result = parse_positional_set_datetime("0101120068", true).unwrap();
assert_eq!(result.year(), 2068);

// 69 -> 1969 (boundary)
let result = parse_positional_set_datetime("0101120069", true).unwrap();
assert_eq!(result.year(), 1969);

// 99 -> 1999
let result = parse_positional_set_datetime("0101120099", true).unwrap();
assert_eq!(result.year(), 1999);
}

#[test]
fn test_parse_positional_with_seconds() {
// 12 digits + seconds
let result = parse_positional_set_datetime("010112002025.45", true).unwrap();
assert_eq!(result.year(), 2025);
assert_eq!(result.hour(), 12);
assert_eq!(result.second(), 45);

// 10 digits + seconds
let result = parse_positional_set_datetime("0101120025.30", true).unwrap();
assert_eq!(result.year(), 2025);
assert_eq!(result.second(), 30);

// 8 digits + seconds
let result = parse_positional_set_datetime("01011200.59", true).unwrap();
assert_eq!(result.hour(), 12);
assert_eq!(result.second(), 59);

// Seconds boundary: .00
let result = parse_positional_set_datetime("010112002025.00", true).unwrap();
assert_eq!(result.second(), 0);
}

#[test]
fn test_parse_positional_local_timezone() {
let result = parse_positional_set_datetime("010112002025", false).unwrap();
assert_eq!(result.year(), 2025);
assert_eq!(result.month(), 1);
assert_eq!(result.day(), 1);
assert_eq!(result.hour(), 12);
assert_eq!(result.minute(), 0);
}

#[test]
fn test_parse_positional_invalid_inputs() {
// Invalid month
assert!(parse_positional_set_datetime("13011200", true).is_none());
// Invalid day
assert!(parse_positional_set_datetime("01321200", true).is_none());
// Invalid hour
assert!(parse_positional_set_datetime("01012500", true).is_none());
// Invalid minute
assert!(parse_positional_set_datetime("01011261", true).is_none());
// Feb 30
assert!(parse_positional_set_datetime("02301200", true).is_none());
// Wrong length
assert!(parse_positional_set_datetime("0101120", true).is_none());
// Non-digits
assert!(parse_positional_set_datetime("0101120a", true).is_none());
// Empty string
assert!(parse_positional_set_datetime("", true).is_none());
// Bad seconds suffix
assert!(parse_positional_set_datetime("01011200.1", true).is_none());
assert!(parse_positional_set_datetime("01011200.abc", true).is_none());
assert!(parse_positional_set_datetime("01011200.123", true).is_none());
}
}
Loading
Loading