use std::str::FromStr;
use serde::Serialize;
use serde_with::DeserializeFromStr;
use time::{UtcOffset, format_description::FormatItem, macros::format_description};
use tracing::warn;
pub trait UtcOffsetExt {
fn local_or_utc() -> UtcOffset;
fn resolve_spec(spec: impl AsRef<str>) -> Result<UtcOffset, TimezoneDecodingError>;
}
impl UtcOffsetExt for UtcOffset {
fn local_or_utc() -> UtcOffset {
UtcOffset::current_local_offset().unwrap_or_else(|e| {
warn!("could not determine local UTC offset, falling back to UTC: {e}");
UtcOffset::UTC
})
}
fn resolve_spec(spec: impl AsRef<str>) -> Result<UtcOffset, TimezoneDecodingError> {
let spec = spec.as_ref().to_lowercase();
if matches!(spec.as_str(), "l" | "local") {
return Ok(UtcOffset::current_local_offset()?);
}
if matches!(spec.as_str(), "0" | "utc") {
return Ok(UtcOffset::UTC);
}
Ok(UtcOffset::parse(&spec, OFFSET_FMT)?)
}
}
#[derive(
Clone,
Copy,
Debug,
Eq,
PartialEq,
DeserializeFromStr,
Serialize,
derive_more::Display,
derive_more::From,
derive_more::Into,
)]
#[display("{_0}")]
pub struct UtcOffsetSpec(pub UtcOffset);
impl FromStr for UtcOffsetSpec {
type Err = TimezoneDecodingError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(UtcOffset::resolve_spec(s)?.into())
}
}
static OFFSET_FMT: &[FormatItem<'_>] = format_description!(
"[offset_hour sign:mandatory padding:none][optional [:[offset_minute padding:none][optional [:[offset_second padding:none]]]]]"
);
#[derive(Debug, Clone, Copy, thiserror::Error)]
pub enum TimezoneDecodingError {
#[error("failed to query local timezone {_0} ")]
IndeterminateOffset(#[from] time::error::IndeterminateOffset),
#[error("invalid timezone format: {_0}")]
InvalidTimezone(#[from] time::error::Parse),
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case::no_sign("09:30")]
#[case::garbage("not-a-timezone")]
fn resolve_spec_rejects_invalid(#[case] spec: &str) {
assert!(UtcOffset::resolve_spec(spec).is_err());
assert!(UtcOffsetSpec::from_str(spec).is_err());
}
#[test]
fn spec_wraps_the_resolved_offset() {
let spec = UtcOffsetSpec::from_str("+09:30").unwrap();
assert_eq!(spec.0.as_hms(), (9, 30, 0));
let offset: UtcOffset = spec.into();
assert_eq!(UtcOffsetSpec::from(offset), spec);
}
#[rstest]
#[case::utc("utc", 0, 0, 0)]
#[case::zero("0", 0, 0, 0)]
#[case::plus("+09:30", 9, 30, 0)]
#[case::minus("-2:30", -2, -30, 0)]
#[case::with_seconds("+01:23:45", 1, 23, 45)]
#[case::uppercase("UTC", 0, 0, 0)]
fn resolve_spec_returns_the_offset(
#[case] spec: &str,
#[case] h: i8,
#[case] m: i8,
#[case] s: i8,
) {
assert_eq!(UtcOffset::resolve_spec(spec).unwrap().as_hms(), (h, m, s));
}
#[test]
fn resolve_spec_accepts_anything_stringlike() {
assert!(UtcOffset::resolve_spec("utc").is_ok());
assert!(UtcOffset::resolve_spec(String::from("utc")).is_ok());
}
#[test]
fn resolve_spec_local_queries_the_system() {
assert!(UtcOffset::resolve_spec("local").is_ok());
}
}