Skip to main content

dvgw_edi/
datetime.rs

1//! `DTM` values, interpreted through the format code that accompanies them.
2//!
3//! Every DVGW `DTM` is a triple — qualifier, value, **format code** — and the
4//! format code is what says how to read the value:
5//!
6//! ```text
7//! DTM+Z05:0:805'                                  ← timezone, value is an hour offset
8//! DTM+137:201801011200:203'                       ← CCYYMMDDHHMM
9//! DTM+Z01:201801010500201801020500:719'           ← a period: two CCYYMMDDHHMM back to back
10//! ```
11//!
12//! The types here decode against the format code and refuse a value that does
13//! not match it. `201801011200` is neither `YYYYMMDD` nor ISO 8601, so a reader
14//! that guesses the shape books the wrong gas day.
15
16use std::fmt;
17
18use time::{Date, Month, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset};
19
20/// The `DTM` C507 DE 2379 format codes DVGW uses.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23pub enum DtmFormat {
24    /// `102` — `CCYYMMDD`.
25    Ccyymmdd,
26    /// `203` — `CCYYMMDDHHMM`.
27    Ccyymmddhhmm,
28    /// `719` — `CCYYMMDDHHMMCCYYMMDDHHMM`, a start/end period.
29    Period,
30    /// `805` — a whole number of hours (used by `DTM+Z05` for the timezone).
31    Hours,
32}
33
34impl DtmFormat {
35    /// Parse a DE 2379 code.
36    #[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    /// The DE 2379 wire code.
48    #[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/// A half-open period `[start, end)` from a format-`719` value.
60///
61/// A DVGW gas day is `DTM+Z01:CCYYMMDD0500CCYYMMDD0500:719` — 05:00 UTC, which is
62/// the 06:00 CET gas-day boundary in winter.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65pub struct DvgwPeriod {
66    /// Inclusive start.
67    pub start: OffsetDateTime,
68    /// Exclusive end.
69    pub end: OffsetDateTime,
70}
71
72impl DvgwPeriod {
73    /// `true` when the period runs forwards.
74    ///
75    /// An inverted or empty period is a message defect, not something to
76    /// normalise away, so this is exposed rather than corrected.
77    #[must_use]
78    pub fn is_forward(&self) -> bool {
79        self.start < self.end
80    }
81
82    /// The period's length.
83    #[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/// A decoded `DTM` value.
96#[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    /// A point in time, from format `102` or `203`.
101    Instant(OffsetDateTime),
102    /// A period, from format `719`.
103    Period(DvgwPeriod),
104    /// A whole number of hours, from format `805`.
105    Hours(i8),
106}
107
108impl DtmValue {
109    /// The instant, when this value is one.
110    #[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    /// The period, when this value is one.
119    #[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    /// The hour count, when this value is one.
128    #[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/// Decode a `DTM` value against its format code, in the interchange's timezone.
138///
139/// `offset` is the zone declared by `DTM+Z05` (`0` = UTC). All DVGW timestamps
140/// are wall-clock readings in that zone, so it is applied rather than assumed.
141///
142/// Returns `None` when the value does not match the shape its own format code
143/// declares — a malformed message, reported as a validation finding rather than
144/// coerced into a plausible-looking timestamp.
145#[must_use]
146pub fn decode(value: &str, format: DtmFormat, offset: UtcOffset) -> Option<DtmValue> {
147    match format {
148        // Each format is held to its own width. Sharing a length-keyed reader
149        // let `DTM+137:20180101:203` decode as midnight, which is the failure
150        // this module exists to prevent: the value and its declared format
151        // disagree, and the message should say so rather than pick a plausible
152        // reading.
153        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            // Exactly two CCYYMMDDHHMM stamps, no separator.
163            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
174/// Parse `CCYYMMDD` or `CCYYMMDDHHMM` at `offset`.
175///
176/// ASCII is checked before any slicing: the value is untrusted wire data and a
177/// byte-index split through a multi-byte character would panic.
178fn 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    // Hour 24 is a legal EDIFACT end-of-day; `time` refuses it, so it is
195    // normalised to 00:00 of the following day — the same instant.
196    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/// Render an instant as a format-`203` value (`CCYYMMDDHHMM`) in `offset`.
206#[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/// Render a period as a format-`719` value in `offset`.
220#[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    /// The old reader guessed `YYYY-MM-DD` then `YYYYMMDD` and fell back to
270    /// today. Refusing is the whole point: a wrong gas day is silent corruption.
271    #[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    /// Untrusted wire bytes must never panic the parser on a byte-index split.
289    #[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}