use thiserror::Error;
use time::{Time, UtcOffset};
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum OffsetTimeError {
#[error("not a valid time of day: {value:?}")]
InvalidTime {
value: String,
},
#[error("not a valid UTC offset: {value:?}")]
InvalidOffset {
value: String,
},
}
pub fn parse(s: &str) -> Result<(Time, Option<UtcOffset>), OffsetTimeError> {
let s = s.trim();
let split = s
.char_indices()
.skip(1)
.find(|&(_, c)| c == 'Z' || c == 'z' || c == '+' || c == '-')
.map(|(i, _)| i);
let (time_part, offset_part) = match split {
Some(i) => (&s[..i], Some(&s[i..])),
None => (s, None),
};
let time = parse_time_of_day(time_part).ok_or_else(|| OffsetTimeError::InvalidTime {
value: time_part.to_owned(),
})?;
let offset = match offset_part {
None => None,
Some(raw) => Some(
parse_offset(raw).ok_or_else(|| OffsetTimeError::InvalidOffset {
value: raw.to_owned(),
})?,
),
};
Ok((time, offset))
}
fn parse_time_of_day(s: &str) -> Option<Time> {
let mut parts = s.split(':');
let hour: u8 = two_digits(parts.next()?)?;
let minute: u8 = two_digits(parts.next()?)?;
let (second, nanos) = match parts.next() {
None => (0, 0),
Some(sec) => {
let (whole, frac) = match sec.split_once(['.', ',']) {
Some((w, f)) => (w, Some(f)),
None => (sec, None),
};
let second = two_digits(whole)?;
let nanos = match frac {
None => 0,
Some(f) => {
if f.is_empty() || !f.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let digits: String = f.chars().take(9).collect();
let scale = 10u32.pow(9 - u32::try_from(digits.len()).ok()?);
digits.parse::<u32>().ok()? * scale
}
};
(second, nanos)
}
};
if parts.next().is_some() {
return None;
}
Time::from_hms_nano(hour, minute, second, nanos).ok()
}
fn parse_offset(s: &str) -> Option<UtcOffset> {
if s.eq_ignore_ascii_case("Z") {
return Some(UtcOffset::UTC);
}
let (sign, rest) = match s.as_bytes().first()? {
b'+' => (1i8, &s[1..]),
b'-' => (-1i8, &s[1..]),
_ => return None,
};
let mut parts = rest.split(':');
let hours = two_digits(parts.next()?)?;
let minutes = parts.next().map_or(Some(0), two_digits)?;
let seconds = parts.next().map_or(Some(0), two_digits)?;
if parts.next().is_some() {
return None;
}
UtcOffset::from_hms(
sign * i8::try_from(hours).ok()?,
sign * i8::try_from(minutes).ok()?,
sign * i8::try_from(seconds).ok()?,
)
.ok()
}
#[must_use]
pub fn format(time: Time, offset: Option<UtcOffset>) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(24);
let _ = write!(
out,
"{:02}:{:02}:{:02}",
time.hour(),
time.minute(),
time.second()
);
let nanos = time.nanosecond();
if nanos != 0 {
out.push('.');
out.push_str(std::format!("{nanos:09}").trim_end_matches('0'));
}
match offset {
None => {}
Some(o) if o.is_utc() => out.push('Z'),
Some(o) => {
let (h, m, s) = o.as_hms();
out.push(if o.is_negative() { '-' } else { '+' });
let _ = write!(out, "{:02}:{:02}", h.unsigned_abs(), m.unsigned_abs());
if s != 0 {
let _ = write!(out, ":{:02}", s.unsigned_abs());
}
}
}
out
}
fn two_digits(s: &str) -> Option<u8> {
if s.is_empty() || s.len() > 2 || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::{offset, time};
#[test]
fn parses_the_bo4e_examples() {
assert_eq!(
parse("18:00:00+01:00"),
Ok((time!(18:00:00), Some(offset!(+1))))
);
assert_eq!(
parse("19:00:00+01:00"),
Ok((time!(19:00:00), Some(offset!(+1))))
);
}
#[test]
fn the_offset_is_optional_and_z_is_utc() {
assert_eq!(parse("06:30"), Ok((time!(06:30:00), None)));
assert_eq!(parse("06:30:15"), Ok((time!(06:30:15), None)));
assert_eq!(
parse("06:30:00Z"),
Ok((time!(06:30:00), Some(offset!(UTC))))
);
assert_eq!(
parse("06:30:00z"),
Ok((time!(06:30:00), Some(offset!(UTC))))
);
}
#[test]
fn a_missing_offset_is_not_utc() {
assert_eq!(parse("18:00:00").unwrap().1, None);
assert_ne!(parse("18:00:00").unwrap().1, Some(offset!(UTC)));
}
#[test]
fn parses_every_offset_shape() {
assert_eq!(parse("00:00:00+02").unwrap().1, Some(offset!(+2)));
assert_eq!(parse("00:00:00-05:30").unwrap().1, Some(offset!(-5:30)));
assert_eq!(parse("00:00:00+05:45").unwrap().1, Some(offset!(+5:45)));
let (_, o) = parse("00:00:00-01:30").unwrap();
assert_eq!(o.unwrap().whole_minutes(), -90);
}
#[test]
fn parses_fractional_seconds() {
assert_eq!(parse("12:00:00.5").unwrap().0, time!(12:00:00.500));
assert_eq!(parse("12:00:00,5").unwrap().0, time!(12:00:00.500));
assert_eq!(
parse("12:00:00.123456789").unwrap().0,
time!(12:00:00.123456789)
);
assert_eq!(
parse("12:00:00.1234567891").unwrap().0,
time!(12:00:00.123456789)
);
}
#[test]
fn rejects_what_is_not_a_time() {
for bad in [
"",
"18",
"18:",
":00",
"18:60",
"25:00",
"18:00:60",
"18:00:00:00",
"6:3:0:1",
"abc",
"18:00:00+",
"18:00:00+99:00",
"18:00:00±01",
"-01:00",
"18:00:00.",
"18:00:00.abc",
"1_8:00",
] {
assert!(
parse(bad).is_err(),
"{bad:?} must not parse, got {:?}",
parse(bad)
);
}
}
#[test]
fn rejects_end_of_day_24_00() {
assert!(parse("24:00:00").is_err());
}
#[test]
fn never_panics_on_arbitrary_input() {
for s in ["+", "-", "Z", "::", "18:00:00+", "ä:öü", "18:00:00+ä"] {
let _ = parse(s);
}
}
}