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
16 changes: 16 additions & 0 deletions src/datetime/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,22 @@ fn test_datetime_from_str() {
// no test for `DateTime<Local>`, we cannot verify that much.
}

#[test]
fn test_datetime_from_str_suboffset_roundtrip() {
// `Display` prints a sub-minute offset as `+HH:MM:SS`, so `FromStr` must read that
// back instead of choking on the trailing `:SS`.
for secs in [30, -30, 71608, -71608, 86399, -86399] {
let offset = FixedOffset::east_opt(secs).unwrap();
let dt = offset
.from_local_datetime(
&NaiveDate::from_ymd_opt(2015, 2, 18).unwrap().and_hms_opt(23, 16, 9).unwrap(),
)
.unwrap();
let printed = dt.to_string();
assert_eq!(printed.parse::<DateTime<FixedOffset>>(), Ok(dt), "{printed}");
}
}

#[test]
fn test_parse_datetime_utc() {
// valid cases
Expand Down
10 changes: 8 additions & 2 deletions src/format/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,8 @@ pub(crate) fn parse_rfc3339(mut s: &str) -> ParseResult<DateTime<FixedOffset>> {
.ok_or(OUT_OF_RANGE)?;

// Max for the hours field is `23`, and for the minutes field `59`.
let offset = try_consume!(scan::timezone_offset(s, |s| scan::char(s, b':'), true, false, true));
let offset =
try_consume!(scan::timezone_offset(s, |s| scan::char(s, b':'), true, false, true, false));
if !s.is_empty() {
return Err(TOO_LONG);
}
Expand Down Expand Up @@ -524,6 +525,7 @@ where
false,
false,
true,
false,
));
parsed.set_offset(i64::from(offset))?;
}
Expand All @@ -535,6 +537,7 @@ where
true,
false,
true,
false,
));
parsed.set_offset(i64::from(offset))?;
}
Expand All @@ -547,6 +550,7 @@ where
true,
true,
true,
false,
));
parsed.set_offset(i64::from(offset))?;
}
Expand Down Expand Up @@ -604,6 +608,8 @@ impl str::FromStr for DateTime<FixedOffset> {
/// `DateTime<Utc>`.
/// - There can be spaces between any of the components.
/// - The colon in the offset may be missing.
/// - The offset may carry a seconds component (e.g. `+00:00:30`), as printed by the
/// `Display`/`Debug` of `DateTime<FixedOffset>` for sub-minute offsets.
fn parse_rfc3339_relaxed<'a>(parsed: &mut Parsed, mut s: &'a str) -> ParseResult<(&'a str, ())> {
const DATE_ITEMS: &[Item<'static>] = &[
Item::Numeric(Numeric::Year, Pad::Zero),
Expand Down Expand Up @@ -639,7 +645,7 @@ fn parse_rfc3339_relaxed<'a>(parsed: &mut Parsed, mut s: &'a str) -> ParseResult
let (s, offset) = if s.len() >= 3 && "UTC".as_bytes().eq_ignore_ascii_case(&s.as_bytes()[..3]) {
(&s[3..], 0)
} else {
scan::timezone_offset(s, scan::colon_or_space, true, false, true)?
scan::timezone_offset(s, scan::colon_or_space, true, false, true, true)?
};
parsed.set_offset(i64::from(offset))?;
Ok((s, ()))
Expand Down
25 changes: 23 additions & 2 deletions src/format/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,13 +199,18 @@ pub(crate) fn colon_or_space(s: &str) -> ParseResult<&str> {
/// ASCII-compatible `-` HYPHEN-MINUS (U+2D).
/// This is part of [RFC 3339 & ISO 8601].
///
/// The `allow_seconds` flag allows a trailing seconds component such as the `:30`
/// in `+00:00:30`. `FixedOffset`'s `Display`/`Debug` print sub-minute offsets in
/// that form, so the parsers behind `FromStr` accept it for the value to round-trip.
///
/// [RFC 3339 & ISO 8601]: https://en.wikipedia.org/w/index.php?title=ISO_8601&oldid=1114309368#Time_offsets_from_UTC
pub(crate) fn timezone_offset<F>(
mut s: &str,
mut consume_colon: F,
allow_zulu: bool,
allow_missing_minutes: bool,
allow_tz_minus_sign: bool,
allow_seconds: bool,
) -> ParseResult<(&str, i32)>
where
F: FnMut(&str) -> ParseResult<&str>,
Expand Down Expand Up @@ -275,7 +280,23 @@ where
_ => return Err(TOO_SHORT),
};

let seconds = hours * 3600 + minutes * 60;
let mut seconds = hours * 3600 + minutes * 60;

// optional seconds (00--59), as emitted by `FixedOffset`'s `Display`/`Debug`
if allow_seconds {
let rest = consume_colon(s)?;
if let Ok(ds) = digits(rest) {
match ds {
(s1 @ b'0'..=b'5', s2 @ b'0'..=b'9') => {
seconds += i32::from((s1 - b'0') * 10 + (s2 - b'0'));
s = &rest[2..];
}
(b'6'..=b'9', b'0'..=b'9') => return Err(OUT_OF_RANGE),
_ => {}
}
}
}

Ok((s, if negative { -seconds } else { seconds }))
}

Expand Down Expand Up @@ -316,7 +337,7 @@ pub(super) fn timezone_offset_2822(s: &str) -> ParseResult<(&str, i32)> {
}
Err(INVALID)
} else {
timezone_offset(s, |s| Ok(s), false, false, false)
timezone_offset(s, |s| Ok(s), false, false, false, false)
}
}

Expand Down
14 changes: 13 additions & 1 deletion src/offset/fixed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ impl FixedOffset {
impl FromStr for FixedOffset {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (_, offset) = scan::timezone_offset(s, scan::colon_or_space, false, false, true)?;
let (_, offset) = scan::timezone_offset(s, scan::colon_or_space, false, false, true, true)?;
Self::east_opt(offset).ok_or(OUT_OF_RANGE)
}
}
Expand Down Expand Up @@ -243,6 +243,18 @@ mod tests {
assert_eq!(offset.local_minus_utc, (6 * 3600) + 1800);
}

#[test]
fn test_parse_offset_seconds() {
// A sub-minute offset is printed with a seconds component; parsing it must read the
// seconds back rather than silently dropping them.
for secs in [30, -30, 71608, -71608, 86399, -86399] {
let offset = FixedOffset::east_opt(secs).unwrap();
assert_eq!(FixedOffset::from_str(&offset.to_string()), Ok(offset));
}
assert_eq!(FixedOffset::from_str("+00:00:30").unwrap().local_minus_utc, 30);
assert_eq!(FixedOffset::from_str("-00:00:30").unwrap().local_minus_utc, -30);
}

#[test]
#[cfg(feature = "rkyv-validation")]
fn test_rkyv_validation() {
Expand Down
Loading