mirror of
https://github.com/uutils/parse_datetime.git
synced 2026-10-16 03:25:04 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
618dda7263 | ||
|
|
9f2a054284 | ||
|
|
390c747003 | ||
|
|
1c7fdea358 | ||
|
|
e42c4f0e2e | ||
|
|
4cf4db33f0 | ||
|
|
46fb737541 | ||
|
|
0562eec95b | ||
|
|
d435b3727a | ||
|
|
01efbda22a | ||
|
|
0ded151ae6 | ||
|
|
75aaf908c0 |
No files matched your search
@@ -31,7 +31,7 @@ jobs:
|
||||
run: cargo codspeed build
|
||||
|
||||
- name: Run the benchmarks
|
||||
uses: CodSpeedHQ/action@v4
|
||||
uses: CodSpeedHQ/action@v5
|
||||
with:
|
||||
mode: simulation
|
||||
run: cargo codspeed run
|
||||
Generated
+21
-10
@@ -231,9 +231,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
@@ -419,11 +419,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.31"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634"
|
||||
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"jiff-tzdb-platform",
|
||||
"log",
|
||||
@@ -434,11 +435,21 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.31"
|
||||
name = "jiff-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2"
|
||||
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
|
||||
dependencies = [
|
||||
"jiff-core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
@@ -524,7 +535,7 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "parse_datetime"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"codspeed-criterion-compat",
|
||||
"jiff",
|
||||
@@ -1026,9 +1037,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.3"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "parse_datetime"
|
||||
description = "parsing human-readable time strings and converting them to a Zoned datetime"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/uutils/parse_datetime"
|
||||
|
||||
Generated
+16
-5
@@ -114,11 +114,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.31"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634"
|
||||
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"jiff-tzdb-platform",
|
||||
"log",
|
||||
@@ -129,11 +130,21 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.31"
|
||||
name = "jiff-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2"
|
||||
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
|
||||
dependencies = [
|
||||
"jiff-core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
|
||||
+42
-6
@@ -20,6 +20,10 @@ pub(crate) struct DateTimeBuilder {
|
||||
time: Option<time::Time>,
|
||||
weekday: Option<weekday::Weekday>,
|
||||
offset: Option<offset::Offset>,
|
||||
/// Whether the military timezone letter `j` was given. It is a timezone
|
||||
/// item, so it excludes any other one, but it means local time and so
|
||||
/// contributes no offset of its own.
|
||||
local_zone: bool,
|
||||
timezone: Option<jiff::tz::TimeZone>,
|
||||
relative: Vec<relative::Relative>,
|
||||
}
|
||||
@@ -59,6 +63,7 @@ impl DateTimeBuilder {
|
||||
|| self.time.is_some()
|
||||
|| self.weekday.is_some()
|
||||
|| self.offset.is_some()
|
||||
|| self.local_zone
|
||||
|| !self.relative.is_empty()
|
||||
{
|
||||
return Err("timestamp cannot be combined with other date/time items");
|
||||
@@ -84,7 +89,7 @@ impl DateTimeBuilder {
|
||||
return Err("timestamp cannot be combined with other date/time items");
|
||||
} else if self.time.is_some() {
|
||||
return Err("time cannot appear more than once");
|
||||
} else if self.offset.is_some() && time.offset.is_some() {
|
||||
} else if (self.offset.is_some() || self.local_zone) && time.offset.is_some() {
|
||||
return Err("time offset and timezone are mutually exclusive");
|
||||
}
|
||||
|
||||
@@ -107,6 +112,7 @@ impl DateTimeBuilder {
|
||||
if self.timestamp.is_some() {
|
||||
return Err("timestamp cannot be combined with other date/time items");
|
||||
} else if self.offset.is_some()
|
||||
|| self.local_zone
|
||||
|| self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some()
|
||||
{
|
||||
return Err("time offset cannot appear more than once");
|
||||
@@ -116,6 +122,24 @@ impl DateTimeBuilder {
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Record the military timezone letter `j` (local time).
|
||||
///
|
||||
/// It occupies the same slot as a numeric offset, so GNU rejects `8j utc`,
|
||||
/// `8 utc j` and `8 j j` as a repeated timezone, and so do we.
|
||||
fn set_local_zone(mut self) -> Result<Self, &'static str> {
|
||||
if self.timestamp.is_some() {
|
||||
return Err("timestamp cannot be combined with other date/time items");
|
||||
} else if self.offset.is_some()
|
||||
|| self.local_zone
|
||||
|| self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some()
|
||||
{
|
||||
return Err("time offset cannot appear more than once");
|
||||
}
|
||||
|
||||
self.local_zone = true;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
fn push_relative(mut self, relative: relative::Relative) -> Result<Self, &'static str> {
|
||||
if self.timestamp.is_some() {
|
||||
return Err("timestamp cannot be combined with other date/time items");
|
||||
@@ -240,12 +264,15 @@ impl DateTimeBuilder {
|
||||
return Ok(ts.to_zoned(base.offset().to_time_zone()));
|
||||
}
|
||||
|
||||
// 3. Determine whether to truncate the time of day.
|
||||
// 3. Determine whether to truncate the time of day. Like GNU, a
|
||||
// relative item on its own keeps the current time of day, while a date,
|
||||
// a weekday, or a timezone item without any relative item resets it to
|
||||
// midnight.
|
||||
let zone_seen = self.offset.is_some() || self.local_zone || has_timezone;
|
||||
let need_midnight = self.date.is_some()
|
||||
|| self.time.is_some()
|
||||
|| self.weekday.is_some()
|
||||
|| self.offset.is_some()
|
||||
|| has_timezone;
|
||||
|| (zone_seen && self.relative.is_empty());
|
||||
|
||||
let mut dt = if need_midnight {
|
||||
base.with().time(civil::time(0, 0, 0, 0)).build()?
|
||||
@@ -362,6 +389,7 @@ impl DateTimeBuilder {
|
||||
time,
|
||||
weekday,
|
||||
offset,
|
||||
local_zone,
|
||||
timezone,
|
||||
relative,
|
||||
} = self;
|
||||
@@ -375,11 +403,13 @@ impl DateTimeBuilder {
|
||||
};
|
||||
let rule_tz = base.time_zone().clone();
|
||||
|
||||
// See `build_in_range` for why a relative item alone keeps the
|
||||
// current time of day.
|
||||
let zone_seen = offset.is_some() || local_zone || has_timezone;
|
||||
let need_midnight = date.is_some()
|
||||
|| time.is_some()
|
||||
|| weekday.is_some()
|
||||
|| offset.is_some()
|
||||
|| has_timezone;
|
||||
|| (zone_seen && relative.is_empty());
|
||||
let mut dt = ExtendedDateTime::new(
|
||||
DateParts {
|
||||
year: u32::try_from(base.year()).map_err(|_| "year must be non-negative")?,
|
||||
@@ -528,6 +558,7 @@ impl TryFrom<Vec<Item>> for DateTimeBuilder {
|
||||
Item::Time(t) => builder.set_time(t)?,
|
||||
Item::Weekday(weekday) => builder.set_weekday(weekday)?,
|
||||
Item::Offset(offset) => builder.set_offset(offset)?,
|
||||
Item::LocalZone => builder.set_local_zone()?,
|
||||
Item::Relative(rel) => builder.push_relative(rel)?,
|
||||
Item::TimeZone(tz) => builder.set_timezone(tz)?,
|
||||
Item::Pure(pure) => builder.set_pure(pure)?,
|
||||
@@ -681,6 +712,7 @@ mod tests {
|
||||
DateTimeBuilder::new().set_time(time()).unwrap(),
|
||||
DateTimeBuilder::new().set_weekday(weekday()).unwrap(),
|
||||
DateTimeBuilder::new().set_offset(offset()).unwrap(),
|
||||
DateTimeBuilder::new().set_local_zone().unwrap(),
|
||||
DateTimeBuilder::new()
|
||||
.push_relative(relative_day())
|
||||
.unwrap(),
|
||||
@@ -715,6 +747,10 @@ mod tests {
|
||||
ts_builder().set_offset(offset()).unwrap_err(),
|
||||
"timestamp cannot be combined with other date/time items"
|
||||
);
|
||||
assert_eq!(
|
||||
ts_builder().set_local_zone().unwrap_err(),
|
||||
"timestamp cannot be combined with other date/time items"
|
||||
);
|
||||
assert_eq!(
|
||||
ts_builder().set_pure("2023".to_string()).unwrap_err(),
|
||||
"timestamp cannot be combined with other date/time items"
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@
|
||||
//! > ‘September’.
|
||||
|
||||
use winnow::{
|
||||
ascii::{alpha1, multispace1},
|
||||
ascii::alpha1,
|
||||
combinator::{alt, eof, opt, preceded, terminated},
|
||||
error::ErrMode,
|
||||
stream::AsChar,
|
||||
@@ -36,7 +36,7 @@ use winnow::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
primitive::{ctx_err, dec_uint, s},
|
||||
primitive::{ctx_err, dec_uint, multispace1, s},
|
||||
year::{year_from_str, year_str},
|
||||
};
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ enum Item {
|
||||
Weekday(weekday::Weekday),
|
||||
Relative(relative::Relative),
|
||||
Offset(offset::Offset),
|
||||
/// The military timezone letter `j`, which GNU `date` defines as local
|
||||
/// time. It is a timezone item like [`Item::Offset`], but carries no
|
||||
/// offset, so it cannot be represented as one.
|
||||
LocalZone,
|
||||
TimeZone(jiff::tz::TimeZone),
|
||||
Pure(String),
|
||||
}
|
||||
@@ -260,6 +264,7 @@ fn parse_item(input: &mut &str) -> ModalResult<Item> {
|
||||
relative::parse.map(Item::Relative),
|
||||
weekday::parse.map(Item::Weekday),
|
||||
offset::parse.map(Item::Offset),
|
||||
offset::parse_local.map(|()| Item::LocalZone),
|
||||
pure::parse.map(Item::Pure),
|
||||
)),
|
||||
)
|
||||
@@ -391,6 +396,12 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only() {
|
||||
let result = parse(&mut "\x0B \x0C");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid() {
|
||||
let result = parse(&mut "2025-05-19 2024-05-20 06:14:49");
|
||||
@@ -779,4 +790,30 @@ mod tests {
|
||||
assert_eq!(parse_build(input), expected, "{input}");
|
||||
}
|
||||
}
|
||||
/// `j` is a time zone item, so it participates in the same duplicate check
|
||||
/// as a numeric offset. GNU rejects all three of these as a repeated zone.
|
||||
#[test]
|
||||
fn military_j_is_a_timezone_item() {
|
||||
// Parses on its own, attached or spaced, in either case.
|
||||
for input in ["j", "8j", "8 j", "8J", "j 8"] {
|
||||
assert!(parse(&mut &*input).is_ok(), "`{input}` should parse");
|
||||
}
|
||||
|
||||
for input in ["8j utc", "8 utc j", "8 j j"] {
|
||||
let result = parse(&mut &*input);
|
||||
assert!(result.is_err(), "`{input}` should be rejected");
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("time offset cannot appear more than once"),
|
||||
"`{input}` should report a repeated time offset"
|
||||
);
|
||||
}
|
||||
|
||||
// A bare numeric offset is not an item on its own (the grammar requires
|
||||
// a named zone), so this is rejected by the parser rather than by the
|
||||
// duplicate check. GNU rejects it too.
|
||||
assert!(parse(&mut "j +05:00").is_err());
|
||||
}
|
||||
}
|
||||
+56
-3
@@ -168,10 +168,29 @@ impl Display for Offset {
|
||||
}
|
||||
}
|
||||
|
||||
/// Timezone abbreviations are assumed to be no longer than 6 characters.
|
||||
const MAX_TZ_SIZE: usize = 6;
|
||||
|
||||
pub(super) fn parse(input: &mut &str) -> ModalResult<Offset> {
|
||||
timezone_name_offset.parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse the military timezone letter `j`.
|
||||
///
|
||||
/// Every other military letter denotes a fixed offset from UTC, but GNU `date`
|
||||
/// defines `j` as local time, which cannot be expressed as an [`Offset`] at
|
||||
/// all. It is therefore reported to the builder as its own item.
|
||||
///
|
||||
/// The whole alphabetic word is consumed before comparing, so `jan` is left to
|
||||
/// the date parser rather than being read as `j` followed by `an`, and `jj` is
|
||||
/// rejected rather than matching a `j` prefix.
|
||||
pub(super) fn parse_local(input: &mut &str) -> ModalResult<()> {
|
||||
s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha))
|
||||
.verify(|word: &str| word.eq_ignore_ascii_case("j"))
|
||||
.void()
|
||||
.parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse a timezone starting with `+` or `-`.
|
||||
pub(super) fn timezone_offset(input: &mut &str) -> ModalResult<Offset> {
|
||||
// Strings like "+8 years" are ambiguous, they can either be parsed as a
|
||||
@@ -188,9 +207,6 @@ pub(super) fn timezone_offset(input: &mut &str) -> ModalResult<Offset> {
|
||||
|
||||
/// Parse a timezone by name, with an optional numeric offset appended.
|
||||
fn timezone_name_offset(input: &mut &str) -> ModalResult<Offset> {
|
||||
/// I'm assuming there are no timezone abbreviations with more
|
||||
/// than 6 charactres
|
||||
const MAX_TZ_SIZE: usize = 6;
|
||||
let nextword = s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input)?;
|
||||
let tz = timezone_name_to_offset(nextword)?;
|
||||
|
||||
@@ -302,9 +318,13 @@ fn timezone_name_to_offset(input: &str) -> ModalResult<Offset> {
|
||||
"mst" => Ok("-7"),
|
||||
"msk" => Ok("+3"),
|
||||
"msd" => Ok("+4"),
|
||||
"mez" => Ok("+1"),
|
||||
"mesz" => Ok("+2"),
|
||||
"mest" => Ok("+2"),
|
||||
"mdt" => Ok("-6"),
|
||||
"m" => Ok("+12"),
|
||||
"l" => Ok("+11"),
|
||||
"kst" => Ok("+9"),
|
||||
"k" => Ok("+10"),
|
||||
"jst" => Ok("+9"),
|
||||
"ist" => Ok("+5:30"),
|
||||
@@ -431,6 +451,12 @@ mod tests {
|
||||
("cst", off(true, 6, 0)), // negative offset
|
||||
("ist", off(false, 5, 30)), // positive offset with non-zero minutes
|
||||
("nst", off(true, 3, 30)), // negative offset with non-zero minutes
|
||||
// Accepted by GNU date but previously missing here, which made
|
||||
// `date -d "2024-01-15 12:00 MEZ"` fail (uutils/coreutils#13865).
|
||||
("mez", off(false, 1, 0)),
|
||||
("mesz", off(false, 2, 0)),
|
||||
("mest", off(false, 2, 0)),
|
||||
("kst", off(false, 9, 0)),
|
||||
("z123", off(false, 0, 0)), // space separator can be ignored if immediately followed by digits (GNU date behavior)
|
||||
] {
|
||||
let mut s = input;
|
||||
@@ -484,4 +510,31 @@ mod tests {
|
||||
assert_eq!(off(true, 5, 30).total_seconds(), -19_800);
|
||||
assert_eq!(off(false, 24, 0).total_seconds(), 86_400);
|
||||
}
|
||||
/// `j` is the one military letter that is not an offset, so it is parsed
|
||||
/// separately. The whole alphabetic word must be consumed before matching,
|
||||
/// otherwise `jan` would be read as `j` followed by `an`.
|
||||
#[test]
|
||||
fn military_letter_j_local() {
|
||||
for input in ["j", "J", " j"] {
|
||||
let mut s = input;
|
||||
assert!(
|
||||
parse_local(&mut s).is_ok(),
|
||||
"`{input}` should parse as local time"
|
||||
);
|
||||
assert!(s.is_empty(), "`{input}` should be fully consumed");
|
||||
}
|
||||
|
||||
for input in ["jj", "jan", "jst", "z", "a", ""] {
|
||||
let mut s = input;
|
||||
assert!(
|
||||
parse_local(&mut s).is_err(),
|
||||
"`{input}` should not parse as local time"
|
||||
);
|
||||
}
|
||||
|
||||
// `j` is absent from the offset table, so the offset parser must not
|
||||
// claim it.
|
||||
let mut s = "j";
|
||||
assert!(timezone_name_offset(&mut s).is_err());
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -6,7 +6,7 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use winnow::{
|
||||
ascii::{digit1, multispace0, Uint},
|
||||
ascii::{digit1, Uint},
|
||||
combinator::{alt, delimited, not, opt, peek, preceded, repeat, separated},
|
||||
error::{ContextError, ParserError, StrContext, StrContextValue},
|
||||
stream::AsChar,
|
||||
@@ -26,6 +26,23 @@ where
|
||||
preceded(space, p)
|
||||
}
|
||||
|
||||
/// Same as winnow's `multispace0`, but also accepting `\v` and `\f`, like
|
||||
/// the whitespace GNU `date` accepts between tokens
|
||||
fn multispace0<'a, E>(input: &mut &'a str) -> winnow::Result<&'a str, E>
|
||||
where
|
||||
E: ParserError<&'a str>,
|
||||
{
|
||||
take_while(0.., (' ', '\t', '\n', '\x0B', '\x0C', '\r')).parse_next(input)
|
||||
}
|
||||
|
||||
/// Same as [`multispace0`], but requiring at least one character
|
||||
pub(super) fn multispace1<'a, E>(input: &mut &'a str) -> winnow::Result<&'a str, E>
|
||||
where
|
||||
E: ParserError<&'a str>,
|
||||
{
|
||||
take_while(1.., (' ', '\t', '\n', '\x0B', '\x0C', '\r')).parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse the space in-between tokens
|
||||
///
|
||||
/// You probably want to use the [`s`] combinator instead.
|
||||
|
||||
+121
@@ -75,6 +75,18 @@ fn test_absolute_date_numeric(#[case] input: &str, #[case] expected: &str) {
|
||||
check_absolute(input, expected);
|
||||
}
|
||||
|
||||
// Any run of ASCII whitespace may separate items, like in GNU date. The
|
||||
// expected values were verified with GNU date (`date -d 'input'`).
|
||||
#[rstest]
|
||||
#[case::vertical_tab_between_date_and_year("Jan 23\x0B2026 1:00AM", "2026-01-23 01:00:00+00:00")]
|
||||
#[case::form_feed_between_date_and_year("Jan 23\x0C2026 1:00AM", "2026-01-23 01:00:00+00:00")]
|
||||
#[case::vertical_tab_after_comma("Jan 23,\x0B2026", "2026-01-23 00:00:00+00:00")]
|
||||
#[case::vertical_tab_before_time("14 nov 2026\x0B1:00AM", "2026-11-14 01:00:00+00:00")]
|
||||
#[case::vertical_tab_as_iso_separator("2026-01-23\x0B01:00:00", "2026-01-23 01:00:00+00:00")]
|
||||
fn test_whitespace_between_items(#[case] input: &str, #[case] expected: &str) {
|
||||
check_absolute(input, expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::us_style("11/14", 2022, "2022-11-14 00:00:00+00:00")]
|
||||
#[case::alphabetical_full_month_in_front("november 14", 2022, "2022-11-14 00:00:00+00:00")]
|
||||
@@ -313,3 +325,112 @@ fn test_utc_keyword_plus_relative_seconds_across_dst() {
|
||||
.expect_in_range();
|
||||
assert_eq!(parsed.timestamp().as_second(), seconds);
|
||||
}
|
||||
|
||||
// The military time zone letter `j`.
|
||||
//
|
||||
// Every other military letter (`a`-`i`, `k`-`y`, and `z` for UTC) denotes a
|
||||
// fixed offset from UTC. GNU `date` defines `j` as *local* time instead, so it
|
||||
// follows the base zone's DST rules rather than pinning an offset:
|
||||
//
|
||||
// $ TZ=America/New_York date -d '8j' # 2026-08-25 08:00 -0400
|
||||
// $ TZ=America/New_York date -d '2026-01-01 j' # 2026-01-01 00:00 -0500
|
||||
// $ TZ=America/New_York date -d '8z' # 2026-08-25 04:00 -0400
|
||||
//
|
||||
// Verified against GNU coreutils 9.4.
|
||||
#[rstest]
|
||||
// Winter: New York is on EST (UTC-5).
|
||||
#[case::est("2026-01-15 12:00:00", -5 * 3600)]
|
||||
// Summer: the same zone is on EDT (UTC-4). A fixed offset could not do this.
|
||||
#[case::edt("2026-07-15 12:00:00", -4 * 3600)]
|
||||
fn test_military_j_is_local_time(#[case] base: &str, #[case] expected_offset: i32) {
|
||||
let base = base
|
||||
.parse::<DateTime>()
|
||||
.unwrap()
|
||||
.to_zoned(TimeZone::get("America/New_York").unwrap())
|
||||
.unwrap();
|
||||
|
||||
let parsed = parse_datetime::parse_datetime_at_date(base.clone(), "8j")
|
||||
.unwrap()
|
||||
.expect_in_range();
|
||||
|
||||
assert_eq!(parsed.hour(), 8, "`8j` should be 08:00 local");
|
||||
assert_eq!(
|
||||
parsed.offset().seconds(),
|
||||
expected_offset,
|
||||
"`8j` should take the base zone's offset"
|
||||
);
|
||||
|
||||
// `z` is UTC, so it must *not* follow the base zone. This is what
|
||||
// distinguishes `j` from every other military letter.
|
||||
let utc = parse_datetime::parse_datetime_at_date(base, "8z")
|
||||
.unwrap()
|
||||
.expect_in_range();
|
||||
assert_eq!(utc.offset().seconds(), 0, "`8z` should be UTC");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::bare("j")]
|
||||
#[case::attached("8j")]
|
||||
#[case::uppercase("8J")]
|
||||
#[case::spaced("8 j")]
|
||||
#[case::leading("j 8")]
|
||||
fn test_military_j_accepted(#[case] input: &str) {
|
||||
assert!(
|
||||
parse_datetime::parse_datetime(input).is_ok(),
|
||||
"`{input}` should parse"
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
// `j` is a time zone item, so a second one is a repeated zone, as in GNU.
|
||||
#[case::j_then_utc("8j utc")]
|
||||
#[case::utc_then_j("8 utc j")]
|
||||
#[case::j_twice("8 j j")]
|
||||
#[case::j_then_numeric("j +05:00")]
|
||||
// The whole alphabetic word is matched, so `j` never steals a prefix.
|
||||
#[case::doubled_letter("jj")]
|
||||
#[case::doubled_letter_after_number("8 jj")]
|
||||
fn test_military_j_rejected(#[case] input: &str) {
|
||||
assert!(
|
||||
parse_datetime::parse_datetime(input).is_err(),
|
||||
"`{input}` should be rejected, as GNU date does"
|
||||
);
|
||||
}
|
||||
|
||||
// A relative item keeps the base time of day, even when combined with a time
|
||||
// zone item. Only a date, a weekday, or a lone zone item resets it to
|
||||
// midnight:
|
||||
//
|
||||
// $ TZ=America/New_York date -d 'j 1 day' # tomorrow, current time of day
|
||||
// $ TZ=America/New_York date -d 'utc 1 day' # ditto, in UTC
|
||||
// $ TZ=America/New_York date -d 'j' # today at 00:00
|
||||
// $ TZ=America/New_York date -d 'j monday' # next monday at 00:00
|
||||
//
|
||||
// Verified against GNU coreutils 9.7.
|
||||
#[rstest]
|
||||
#[case::local_zone_and_relative("j 1 day", "2026-08-30 11:30:45")]
|
||||
#[case::relative_then_local_zone("5 minutes j", "2026-08-29 11:35:45")]
|
||||
#[case::offset_and_relative("utc 1 day", "2026-08-30 11:30:45")]
|
||||
#[case::relative_then_offset("5 minutes utc", "2026-08-29 11:35:45")]
|
||||
#[case::timezone_rule_and_relative("TZ=\"Europe/Paris\" 1 day", "2026-08-30 17:30:45")]
|
||||
// Without a relative item, a zone item still truncates to midnight.
|
||||
#[case::local_zone_alone("j", "2026-08-29 00:00:00")]
|
||||
#[case::local_zone_and_weekday("j monday", "2026-08-31 00:00:00")]
|
||||
#[case::offset_alone("utc", "2026-08-29 00:00:00")]
|
||||
fn test_zone_item_keeps_time_of_day_with_relative(#[case] input: &str, #[case] expected: &str) {
|
||||
let base = "2026-08-29 11:30:45"
|
||||
.parse::<DateTime>()
|
||||
.unwrap()
|
||||
.to_zoned(TimeZone::get("America/New_York").unwrap())
|
||||
.unwrap();
|
||||
|
||||
let parsed = parse_datetime::parse_datetime_at_date(base, input)
|
||||
.unwrap()
|
||||
.expect_in_range();
|
||||
|
||||
assert_eq!(
|
||||
parsed.datetime().to_string(),
|
||||
expected.parse::<DateTime>().unwrap().to_string(),
|
||||
"`{input}` should resolve to {expected} in its own zone"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user