1use std::fmt;
17
18use time::{Date, Month, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23pub enum DtmFormat {
24 Ccyymmdd,
26 Ccyymmddhhmm,
28 Period,
30 Hours,
32}
33
34impl DtmFormat {
35 #[must_use]
37 pub fn from_code(code: &str) -> Option<Self> {
38 match code {
39 "102" => Some(Self::Ccyymmdd),
40 "203" => Some(Self::Ccyymmddhhmm),
41 "719" => Some(Self::Period),
42 "805" => Some(Self::Hours),
43 _ => None,
44 }
45 }
46
47 #[must_use]
49 pub fn as_code(self) -> &'static str {
50 match self {
51 Self::Ccyymmdd => "102",
52 Self::Ccyymmddhhmm => "203",
53 Self::Period => "719",
54 Self::Hours => "805",
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65pub struct DvgwPeriod {
66 pub start: OffsetDateTime,
68 pub end: OffsetDateTime,
70}
71
72impl DvgwPeriod {
73 #[must_use]
78 pub fn is_forward(&self) -> bool {
79 self.start < self.end
80 }
81
82 #[must_use]
84 pub fn duration(&self) -> time::Duration {
85 self.end - self.start
86 }
87}
88
89impl fmt::Display for DvgwPeriod {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 write!(f, "{} .. {}", self.start, self.end)
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
98#[cfg_attr(feature = "serde", serde(rename_all = "camelCase", tag = "kind"))]
99pub enum DtmValue {
100 Instant(OffsetDateTime),
102 Period(DvgwPeriod),
104 Hours(i8),
106}
107
108impl DtmValue {
109 #[must_use]
111 pub fn as_instant(self) -> Option<OffsetDateTime> {
112 match self {
113 Self::Instant(t) => Some(t),
114 _ => None,
115 }
116 }
117
118 #[must_use]
120 pub fn as_period(self) -> Option<DvgwPeriod> {
121 match self {
122 Self::Period(p) => Some(p),
123 _ => None,
124 }
125 }
126
127 #[must_use]
129 pub fn as_hours(self) -> Option<i8> {
130 match self {
131 Self::Hours(h) => Some(h),
132 _ => None,
133 }
134 }
135}
136
137#[must_use]
146pub fn decode(value: &str, format: DtmFormat, offset: UtcOffset) -> Option<DtmValue> {
147 match format {
148 DtmFormat::Ccyymmdd => (value.len() == 8)
154 .then(|| parse_datetime(value, offset))
155 .flatten()
156 .map(DtmValue::Instant),
157 DtmFormat::Ccyymmddhhmm => (value.len() == 12)
158 .then(|| parse_datetime(value, offset))
159 .flatten()
160 .map(DtmValue::Instant),
161 DtmFormat::Period => {
162 if value.len() != 24 || !value.is_ascii() {
164 return None;
165 }
166 let start = parse_datetime(&value[..12], offset)?;
167 let end = parse_datetime(&value[12..], offset)?;
168 Some(DtmValue::Period(DvgwPeriod { start, end }))
169 }
170 DtmFormat::Hours => value.parse::<i8>().ok().map(DtmValue::Hours),
171 }
172}
173
174fn parse_datetime(s: &str, offset: UtcOffset) -> Option<OffsetDateTime> {
179 if !s.is_ascii() || !s.bytes().all(|b| b.is_ascii_digit()) {
180 return None;
181 }
182 let (date_part, time_part) = match s.len() {
183 8 => (s, "0000"),
184 12 => (&s[..8], &s[8..]),
185 _ => return None,
186 };
187 let year: i32 = date_part[..4].parse().ok()?;
188 let month = Month::try_from(date_part[4..6].parse::<u8>().ok()?).ok()?;
189 let day: u8 = date_part[6..8].parse().ok()?;
190 let hour: u8 = time_part[..2].parse().ok()?;
191 let minute: u8 = time_part[2..].parse().ok()?;
192
193 let date = Date::from_calendar_date(year, month, day).ok()?;
194 let (date, hour) = if hour == 24 && minute == 0 {
197 (date.next_day()?, 0)
198 } else {
199 (date, hour)
200 };
201 let clock = Time::from_hms(hour, minute, 0).ok()?;
202 Some(PrimitiveDateTime::new(date, clock).assume_offset(offset))
203}
204
205#[must_use]
207pub fn format_instant(value: OffsetDateTime, offset: UtcOffset) -> String {
208 let v = value.to_offset(offset);
209 format!(
210 "{:04}{:02}{:02}{:02}{:02}",
211 v.year(),
212 v.month() as u8,
213 v.day(),
214 v.hour(),
215 v.minute()
216 )
217}
218
219#[must_use]
221pub fn format_period(period: DvgwPeriod, offset: UtcOffset) -> String {
222 let mut s = format_instant(period.start, offset);
223 s.push_str(&format_instant(period.end, offset));
224 s
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use time::macros::datetime;
231
232 #[test]
233 fn decodes_the_gas_day_period_from_a_real_alocat() {
234 let v = decode(
235 "201801010500201801020500",
236 DtmFormat::Period,
237 UtcOffset::UTC,
238 )
239 .expect("format 719 must decode");
240 let p = v.as_period().unwrap();
241 assert_eq!(p.start, datetime!(2018-01-01 05:00 UTC));
242 assert_eq!(p.end, datetime!(2018-01-02 05:00 UTC));
243 assert!(p.is_forward());
244 assert_eq!(p.duration(), time::Duration::hours(24));
245 }
246
247 #[test]
248 fn decodes_the_message_timestamp() {
249 let v = decode("201801011200", DtmFormat::Ccyymmddhhmm, UtcOffset::UTC).unwrap();
250 assert_eq!(v.as_instant().unwrap(), datetime!(2018-01-01 12:00 UTC));
251 }
252
253 #[test]
254 fn decodes_the_timezone_declaration() {
255 assert_eq!(
256 decode("0", DtmFormat::Hours, UtcOffset::UTC)
257 .unwrap()
258 .as_hours(),
259 Some(0)
260 );
261 assert_eq!(
262 decode("1", DtmFormat::Hours, UtcOffset::UTC)
263 .unwrap()
264 .as_hours(),
265 Some(1)
266 );
267 }
268
269 #[test]
272 fn refuses_values_that_do_not_match_their_format_code() {
273 assert_eq!(
274 decode("2018-01-01", DtmFormat::Ccyymmdd, UtcOffset::UTC),
275 None
276 );
277 assert_eq!(
278 decode("201801011200", DtmFormat::Period, UtcOffset::UTC),
279 None
280 );
281 assert_eq!(
282 decode("20180132", DtmFormat::Ccyymmdd, UtcOffset::UTC),
283 None
284 );
285 assert_eq!(decode("", DtmFormat::Ccyymmddhhmm, UtcOffset::UTC), None);
286 }
287
288 #[test]
290 fn non_ascii_values_are_rejected_without_panicking() {
291 assert_eq!(
292 decode("2018ü101", DtmFormat::Ccyymmdd, UtcOffset::UTC),
293 None
294 );
295 let twelve_wide = "ü".repeat(12);
296 assert_eq!(
297 decode(&twelve_wide, DtmFormat::Ccyymmddhhmm, UtcOffset::UTC),
298 None
299 );
300 let twentyfour_wide = "ü".repeat(12);
301 assert_eq!(
302 decode(&twentyfour_wide, DtmFormat::Period, UtcOffset::UTC),
303 None
304 );
305 }
306
307 #[test]
308 fn hour_24_is_the_next_midnight() {
309 let v = decode("201801012400", DtmFormat::Ccyymmddhhmm, UtcOffset::UTC).unwrap();
310 assert_eq!(v.as_instant().unwrap(), datetime!(2018-01-02 00:00 UTC));
311 }
312
313 #[test]
314 fn rendering_round_trips_through_decoding() {
315 let period = DvgwPeriod {
316 start: datetime!(2026-03-01 05:00 UTC),
317 end: datetime!(2026-03-02 05:00 UTC),
318 };
319 let wire = format_period(period, UtcOffset::UTC);
320 assert_eq!(wire, "202603010500202603020500");
321 assert_eq!(
322 decode(&wire, DtmFormat::Period, UtcOffset::UTC)
323 .unwrap()
324 .as_period(),
325 Some(period)
326 );
327 }
328
329 #[test]
330 fn a_declared_offset_is_applied_not_assumed() {
331 let plus_one = UtcOffset::from_hms(1, 0, 0).unwrap();
332 let v = decode("202603010600", DtmFormat::Ccyymmddhhmm, plus_one).unwrap();
333 assert_eq!(v.as_instant().unwrap(), datetime!(2026-03-01 05:00 UTC));
334 }
335}