Merge pull request #97 from jfinkels/military-time-zones

Add support for military time zones
This commit is contained in:
Sylvestre Ledru
2025-01-09 07:51:39 +01:00
committed by GitHub
+102 -12
View File
@@ -7,11 +7,75 @@ mod time_only_formats {
pub const TWELVEHOUR: &str = "%r";
}
/// Convert a military time zone string to a time zone offset.
///
/// Military time zones are the letters A through Z except J. They are
/// described in RFC 5322.
fn to_offset(tz: &str) -> Option<FixedOffset> {
let hour = match tz {
"A" => 1,
"B" => 2,
"C" => 3,
"D" => 4,
"E" => 5,
"F" => 6,
"G" => 7,
"H" => 8,
"I" => 9,
"K" => 10,
"L" => 11,
"M" => 12,
"N" => -1,
"O" => -2,
"P" => -3,
"Q" => -4,
"R" => -5,
"S" => -6,
"T" => -7,
"U" => -8,
"V" => -9,
"W" => -10,
"X" => -11,
"Y" => -12,
"Z" => 0,
_ => return None,
};
let offset_in_sec = hour * 3600;
FixedOffset::east_opt(offset_in_sec)
}
/// Parse a time string without an offset and apply an offset to it.
///
/// Multiple formats are attempted when parsing the string.
fn parse_time_with_offset_multi(
date: DateTime<Local>,
offset: FixedOffset,
s: &str,
) -> Option<DateTime<FixedOffset>> {
for fmt in [
time_only_formats::HH_MM,
time_only_formats::HH_MM_SS,
time_only_formats::TWELVEHOUR,
] {
let parsed = match NaiveTime::parse_from_str(s, fmt) {
Ok(t) => t,
Err(_) => continue,
};
let parsed_dt = date.date_naive().and_time(parsed);
match offset.from_local_datetime(&parsed_dt).single() {
Some(dt) => return Some(dt),
None => continue,
}
}
None
}
pub(crate) fn parse_time_only(date: DateTime<Local>, s: &str) -> Option<DateTime<FixedOffset>> {
let re =
Regex::new(r"^(?<time>.*?)(?:(?<sign>\+|-)(?<h>[0-9]{1,2}):?(?<m>[0-9]{0,2}))?$").unwrap();
let captures = re.captures(s)?;
// Parse the sign, hour, and minute to get a `FixedOffset`, if possible.
let parsed_offset = match captures.name("h") {
Some(hours) if !(hours.as_str().is_empty()) => {
let mut offset_in_sec = hours.as_str().parse::<i32>().unwrap() * 3600;
@@ -27,18 +91,33 @@ pub(crate) fn parse_time_only(date: DateTime<Local>, s: &str) -> Option<DateTime
_ => None,
};
for fmt in [
time_only_formats::HH_MM,
time_only_formats::HH_MM_SS,
time_only_formats::TWELVEHOUR,
] {
if let Ok(parsed) = NaiveTime::parse_from_str(captures["time"].trim(), fmt) {
let parsed_dt = date.date_naive().and_time(parsed);
let offset = match parsed_offset {
Some(offset) => offset,
None => *date.offset(),
};
return offset.from_local_datetime(&parsed_dt).single();
// Parse the time and apply the parsed offset.
let s = captures["time"].trim();
let offset = match parsed_offset {
Some(offset) => offset,
None => *date.offset(),
};
if let Some(result) = parse_time_with_offset_multi(date, offset, s) {
return Some(result);
}
// Military time zones are specified in RFC 5322, Section 4.3
// "Obsolete Date and Time".
// <https://datatracker.ietf.org/doc/html/rfc5322>
//
// We let the parsing above handle "5:00 AM" so at this point we
// should be guaranteed that we don't have an AM/PM suffix. That
// way, we can safely parse "5:00M" here without interference.
let re = Regex::new(r"(?<time>.*?)(?<tz>[A-IKLMN-YZ])").unwrap();
let captures = re.captures(s)?;
if let Some(tz) = captures.name("tz") {
let s = captures["time"].trim();
let offset = match to_offset(tz.as_str()) {
Some(offset) => offset,
None => *date.offset(),
};
if let Some(result) = parse_time_with_offset_multi(date, offset, s) {
return Some(result);
}
}
@@ -64,6 +143,17 @@ mod tests {
assert_eq!(parsed_time, 1709499840)
}
#[test]
fn test_military_time_zones() {
env::set_var("TZ", "UTC");
let date = get_test_date();
let actual = parse_time_only(date, "05:00C").unwrap().timestamp();
// Computed via `date -u -d "2024-03-03 05:00:00C" +%s`, using a
// version of GNU date after v8.32 (earlier versions had a bug).
let expected = 1709431200;
assert_eq!(actual, expected);
}
#[test]
fn test_time_with_offset() {
env::set_var("TZ", "UTC");