Skip to main content

pinto/
timezone.rs

1//! User-facing timezone selection for human-readable timestamps.
2
3use chrono::{DateTime, FixedOffset, Local, Utc};
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use std::fmt;
6use std::str::FromStr;
7
8/// Timezone used only when rendering human-readable output.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum DisplayTimezone {
11    /// Use the operating system's local timezone.
12    #[default]
13    Local,
14    /// Render in UTC.
15    Utc,
16    /// Render using an explicit fixed offset from UTC.
17    Fixed(FixedOffset),
18}
19
20impl DisplayTimezone {
21    /// Format a UTC instant in this display timezone.
22    #[must_use]
23    pub fn format_datetime(&self, value: DateTime<Utc>, pattern: &str) -> String {
24        match self {
25            Self::Local => value.with_timezone(&Local).format(pattern).to_string(),
26            Self::Utc => value.format(pattern).to_string(),
27            Self::Fixed(offset) => value.with_timezone(offset).format(pattern).to_string(),
28        }
29    }
30}
31
32impl fmt::Display for DisplayTimezone {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Self::Local => f.write_str("local"),
36            Self::Utc => f.write_str("UTC"),
37            Self::Fixed(offset) => {
38                let seconds = offset.local_minus_utc();
39                let sign = if seconds < 0 { '-' } else { '+' };
40                let seconds = seconds.unsigned_abs();
41                write!(
42                    f,
43                    "{sign}{:02}:{:02}",
44                    seconds / 3_600,
45                    seconds % 3_600 / 60
46                )
47            }
48        }
49    }
50}
51
52/// Invalid configured display timezone.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct TimezoneParseError {
55    value: String,
56}
57
58impl fmt::Display for TimezoneParseError {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        write!(
61            f,
62            "invalid display timezone {:?}; use `local`, `UTC`, or a fixed offset `+HH:MM` / `-HH:MM`",
63            self.value
64        )
65    }
66}
67
68impl std::error::Error for TimezoneParseError {}
69
70impl FromStr for DisplayTimezone {
71    type Err = TimezoneParseError;
72
73    fn from_str(value: &str) -> Result<Self, Self::Err> {
74        let value = value.trim();
75        if value.eq_ignore_ascii_case("local") {
76            return Ok(Self::Local);
77        }
78        if value.eq_ignore_ascii_case("utc") || value.eq_ignore_ascii_case("z") {
79            return Ok(Self::Utc);
80        }
81
82        let offset = value
83            .get(..3)
84            .filter(|prefix| prefix.eq_ignore_ascii_case("utc"))
85            .map_or(value, |_| &value[3..]);
86        let valid_shape = offset.len() == 6
87            && matches!(offset.as_bytes().first(), Some(b'+' | b'-'))
88            && offset.as_bytes().get(3) == Some(&b':')
89            && offset[1..3].bytes().all(|byte| byte.is_ascii_digit())
90            && offset[4..].bytes().all(|byte| byte.is_ascii_digit());
91        if !valid_shape {
92            return Err(TimezoneParseError {
93                value: value.to_string(),
94            });
95        }
96        let hours = offset[1..3]
97            .parse::<i32>()
98            .map_err(|_| TimezoneParseError {
99                value: value.to_string(),
100            })?;
101        let minutes = offset[4..].parse::<i32>().map_err(|_| TimezoneParseError {
102            value: value.to_string(),
103        })?;
104        if hours > 23 || minutes > 59 {
105            return Err(TimezoneParseError {
106                value: value.to_string(),
107            });
108        }
109        let seconds =
110            (hours * 3_600 + minutes * 60) * if offset.as_bytes()[0] == b'-' { -1 } else { 1 };
111        let fixed = FixedOffset::east_opt(seconds).ok_or_else(|| TimezoneParseError {
112            value: value.to_string(),
113        })?;
114        Ok(Self::Fixed(fixed))
115    }
116}
117
118impl Serialize for DisplayTimezone {
119    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
120    where
121        S: Serializer,
122    {
123        serializer.serialize_str(&self.to_string())
124    }
125}
126
127impl<'de> Deserialize<'de> for DisplayTimezone {
128    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
129    where
130        D: Deserializer<'de>,
131    {
132        let value = String::deserialize(deserializer)?;
133        value.parse().map_err(serde::de::Error::custom)
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use chrono::{DateTime, Utc};
141
142    fn instant(seconds: i64) -> DateTime<Utc> {
143        DateTime::from_timestamp(seconds, 0).expect("valid timestamp")
144    }
145
146    #[test]
147    fn parses_local_utc_and_fixed_offsets_and_formats_date_boundaries() {
148        assert_eq!(
149            "local".parse::<DisplayTimezone>().expect("local"),
150            DisplayTimezone::Local
151        );
152        assert_eq!(
153            "UTC".parse::<DisplayTimezone>().expect("UTC"),
154            DisplayTimezone::Utc
155        );
156        let plus = "+09:00"
157            .parse::<DisplayTimezone>()
158            .expect("positive offset");
159        let minus = "UTC-01:00"
160            .parse::<DisplayTimezone>()
161            .expect("negative offset");
162
163        assert_eq!(
164            DisplayTimezone::Utc.format_datetime(instant(0), "%Y-%m-%d %H:%M"),
165            "1970-01-01 00:00"
166        );
167        assert_eq!(
168            plus.format_datetime(instant(0), "%Y-%m-%d %H:%M"),
169            "1970-01-01 09:00"
170        );
171        assert_eq!(
172            minus.format_datetime(instant(30 * 60), "%Y-%m-%d %H:%M"),
173            "1969-12-31 23:30"
174        );
175    }
176
177    #[test]
178    fn rejects_invalid_or_unavailable_timezone_values_with_guidance() {
179        for value in ["", "Mars/Base", "+24:00", "+09:60", "UTC+9"] {
180            let error = value
181                .parse::<DisplayTimezone>()
182                .expect_err("invalid timezone");
183            assert!(
184                error.to_string().contains("local")
185                    && error.to_string().contains("UTC")
186                    && error.to_string().contains("HH:MM"),
187                "guidance for {value:?}: {error}"
188            );
189        }
190    }
191}