edtf/
lib.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4//
5// Copyright © 2021 Corporation for Digital Scholarship
6
7#![allow(dead_code)]
8#![cfg_attr(docsrs, feature(doc_cfg))]
9#![doc = include_str!("../README.md")]
10// use `bacon docs-rs` to review missing documentation
11#![cfg_attr(docsrs, warn(missing_docs))]
12
13#[cfg(all(doc, feature = "chrono"))]
14use chrono::NaiveDate;
15
16pub(crate) mod common;
17pub(crate) mod helpers;
18mod level0;
19#[allow(missing_docs)]
20mod level2;
21pub mod level_1;
22use common::{UnvalidatedTime, UnvalidatedTz};
23pub use level0::api as level_0;
24#[doc(hidden)]
25pub use level2::api as level_2;
26
27#[cfg(feature = "chrono")]
28#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
29mod chrono_interop;
30
31#[cfg(feature = "serde")]
32#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
33mod serde_interop;
34
35use core::convert::TryInto;
36use core::num::NonZeroU8;
37
38/// The error type for all the `parse` methods in this crate.
39#[derive(Debug, Copy, Clone, PartialEq, Eq)]
40#[non_exhaustive]
41pub enum ParseError {
42    /// A field is out of the permitted range.
43    OutOfRange,
44
45    /// The input string has some invalid character sequence.
46    Invalid,
47}
48
49impl std::error::Error for ParseError {}
50
51use core::fmt;
52impl fmt::Display for ParseError {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        write!(f, "{:?}", self)
55    }
56}
57
58#[allow(rustdoc::broken_intra_doc_links)]
59/// A DateTime object.
60///
61/// This has minimal introspection methods. It is not an attempt to build a complete DateTime API.
62/// Prefer to use its implementation of [chrono::Datelike] and [chrono::Timelike] or simply the
63/// [DateTime::to_chrono] method to use a specific [chrono::TimeZone], all available with `features
64/// = ["chrono"]`.
65///
66/// Also, its Display implementation is geared towards lossless EDTF parse-format roundtrips. It
67/// does not always produce valid RFC3339 timestamps, in particular [TzOffset::Hours] is rendered
68/// as `+04` instead of `+04:00`. This is best considered a problem with the EDTF specification for
69/// allowing a useless extra timestamp format.
70#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
71pub struct DateTime {
72    pub(crate) date: DateComplete,
73    pub(crate) time: Time,
74}
75
76#[cfg(feature = "chrono")]
77fn chrono_tz_datetime<Tz: chrono::TimeZone>(
78    tz: &Tz,
79    date: &DateComplete,
80    time: &Time,
81) -> chrono::DateTime<Tz> {
82    tz.ymd(date.year, date.month.get() as u32, date.day.get() as u32)
83        .and_hms(time.hh as u32, time.mm as u32, time.ss as u32)
84}
85
86impl DateTime {
87    /// Gets the date portion
88    pub fn date(&self) -> DateComplete {
89        self.date
90    }
91    /// Gets the time portion
92    pub fn time(&self) -> Time {
93        self.time
94    }
95
96    #[cfg_attr(not(feature = "chrono"), allow(rustdoc::broken_intra_doc_links))]
97    /// Get the `TzOffset`. If `None` is returned, this represents a timestamp which did not
98    /// specify a timezone.
99    ///
100    /// If using the `chrono` interop, None means you should attempt to convert to a [chrono::NaiveDate]
101    pub fn offset(&self) -> TzOffset {
102        self.time.offset()
103    }
104
105    #[cfg(feature = "chrono")]
106    #[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
107    #[cfg_attr(not(feature = "chrono"), allow(rustdoc::broken_intra_doc_links))]
108    /// Convert to a [chrono::NaiveDate]
109    pub fn to_chrono_naive(&self) -> chrono::NaiveDateTime {
110        let date = self.date.to_chrono();
111        let time = self.time.to_chrono_naive();
112        date.and_time(time)
113    }
114
115    /// ```
116    /// use edtf::level_1::Edtf;
117    /// use chrono::TimeZone;
118    ///
119    /// let utc = chrono::Utc;
120    /// assert_eq!(
121    ///     Edtf::parse("2004-02-29T01:47:00+05:00")
122    ///         .unwrap()
123    ///         .as_datetime()
124    ///         .unwrap()
125    ///         .to_chrono(&utc),
126    ///     utc.ymd(2004, 02, 28).and_hms(20, 47, 00)
127    /// );
128    /// ```
129    #[cfg(feature = "chrono")]
130    #[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
131    pub fn to_chrono<Tz>(&self, tz: &Tz) -> chrono::DateTime<Tz>
132    where
133        Tz: chrono::TimeZone,
134    {
135        let DateTime { date, time } = self;
136        match time.tz {
137            TzOffset::Unspecified => chrono_tz_datetime(tz, date, time),
138            TzOffset::Utc => {
139                let utc = chrono_tz_datetime(&chrono::Utc, date, time);
140                utc.with_timezone(tz)
141            }
142            TzOffset::Hours(hours) => {
143                let fixed_zone = chrono::FixedOffset::east_opt(hours * 3600)
144                    .expect("time zone offset out of bounds");
145                let fixed_dt = chrono_tz_datetime(&fixed_zone, date, time);
146                fixed_dt.with_timezone(tz)
147            }
148            TzOffset::Minutes(signed_min) => {
149                let fixed_zone = chrono::FixedOffset::east_opt(signed_min * 60)
150                    .expect("time zone offset out of bounds");
151                let fixed_dt = chrono_tz_datetime(&fixed_zone, date, time);
152                fixed_dt.with_timezone(tz)
153            }
154        }
155    }
156}
157
158/// A structure to hold the date portion of a [DateTime]. It contains a valid date in the proleptic
159/// Gregorian calendar.
160#[derive(Copy, Clone, PartialEq, Eq, Hash)]
161pub struct DateComplete {
162    pub(crate) year: i32,
163    pub(crate) month: NonZeroU8,
164    pub(crate) day: NonZeroU8,
165}
166
167impl fmt::Debug for DateComplete {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        <Self as fmt::Display>::fmt(self, f)
170    }
171}
172impl fmt::Display for DateComplete {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        let DateComplete { year, month, day } = *self;
175        let sign = helpers::sign_str_if_neg(year);
176        let year = year.abs();
177        write!(f, "{}{:04}", sign, year)?;
178        write!(f, "-{:02}", month)?;
179        write!(f, "-{:02}", day)?;
180        Ok(())
181    }
182}
183
184impl DateComplete {
185    /// Create a complete date. Panics if the date is invalid.
186    pub fn from_ymd(year: i32, month: u32, day: u32) -> Self {
187        Self::from_ymd_opt(year, month, day).expect("invalid complete date")
188    }
189    /// Create a complete date. Returns None if the date is invalid. The only way a date can be
190    /// invalid is if it isn't a real date. There are otherwise no limitations on the range of
191    /// acceptable years.
192    pub fn from_ymd_opt(year: i32, month: u32, day: u32) -> Option<Self> {
193        let month = month.try_into().ok().map(NonZeroU8::new)??;
194        let day = day.try_into().ok().map(NonZeroU8::new)??;
195        Self { year, month, day }.validate().ok()
196    }
197
198    /// Gets the year
199    pub fn year(&self) -> i32 {
200        self.year
201    }
202    /// Gets the month
203    pub fn month(&self) -> u32 {
204        self.month.get() as u32
205    }
206    /// Gets the day
207    pub fn day(&self) -> u32 {
208        self.day.get() as u32
209    }
210}
211
212/// The time portion of a [DateTime].
213#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
214pub struct Time {
215    pub(crate) hh: u8,
216    pub(crate) mm: u8,
217    pub(crate) ss: u8,
218    pub(crate) tz: TzOffset,
219}
220
221impl Time {
222    /// Create a time. Panics if it's invalid.
223    pub fn from_hmsz(hh: u32, mm: u32, ss: u32, tz: TzOffset) -> Self {
224        Self::from_hmsz_opt(hh, mm, ss, tz).expect("out of range in Time::from_hmsz")
225    }
226    /// Create a time. Returns None if it's invalid for any reason.
227    pub fn from_hmsz_opt(hh: u32, mm: u32, ss: u32, tz: TzOffset) -> Option<Self> {
228        let unval = UnvalidatedTime {
229            hh: hh.try_into().ok()?,
230            mm: mm.try_into().ok()?,
231            ss: ss.try_into().ok()?,
232            tz: UnvalidatedTz::Unspecified,
233        };
234        let mut time = unval.validate().ok()?;
235        let tz = match tz {
236            TzOffset::Unspecified => tz,
237            TzOffset::Hours(x) if x.abs() < 24 => tz,
238            TzOffset::Minutes(x) if x.abs() < 24 * 60 => tz,
239            TzOffset::Utc => tz,
240            _ => return None,
241        };
242        time.tz = tz;
243        Some(time)
244    }
245    /// 0..=23
246    pub fn hour(&self) -> u32 {
247        self.hh as u32
248    }
249    /// 0..=59
250    pub fn minute(&self) -> u32 {
251        self.mm as u32
252    }
253    /// 0..=60 (because of leap seconds; only if hour==23 and minute=59)
254    pub fn second(&self) -> u32 {
255        self.ss as u32
256    }
257    #[cfg_attr(not(feature = "chrono"), allow(rustdoc::broken_intra_doc_links))]
258    /// Get the `TzOffset`. If `None` is returned, this represents a timestamp which did not
259    /// specify a timezone.
260    ///
261    /// If using the `chrono` interop, None means you should attempt to convert to a [chrono::NaiveDate]
262    pub fn offset(&self) -> TzOffset {
263        self.tz
264    }
265    #[cfg(feature = "chrono")]
266    #[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
267    /// Strips out the timezone and returns a [chrono::NaiveTime].
268    pub fn to_chrono_naive(&self) -> chrono::NaiveTime {
269        chrono::NaiveTime::from_hms(self.hour(), self.minute(), self.second())
270    }
271}
272
273#[cfg_attr(not(feature = "chrono"), allow(rustdoc::broken_intra_doc_links))]
274/// A parsed EDTF timezone.
275///
276/// If `features = ["chrono"]` is enabled, then this can act as a [chrono::TimeZone]. This can be
277/// used to preserve the level of TZ brevity i.e. `TzOffset::Hours(_)` ends up as `+04` instead of
278/// `+04:00`.
279#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
280pub enum TzOffset {
281    #[cfg_attr(not(feature = "chrono"), allow(rustdoc::broken_intra_doc_links))]
282    /// An EDTF with no timezone information at all. Not RFC3339-compliant. Not rendered at all
283    /// when formatting an Edtf.
284    ///
285    /// If this is used as a [chrono::TimeZone], then it may be coerced into Utc when combined with
286    /// other tz implementations, like [chrono::offset::FixedOffset].
287    Unspecified,
288    /// `Z`
289    Utc,
290    /// `+04`
291    /// A number of hours only. Not RFC3339-compliant.
292    ///
293    /// In order to provide lossless parse-format roundtrips,  this will be formatted without the
294    /// `:00`, so if you want timestamps to be RFC3339-compliant, do not use this. Because of this,
295    /// you may wish to use the `chrono` interop to format an RFC3339 timestamp instead of the
296    /// Display implementation on [DateTime].
297    Hours(i32),
298    /// `+04:30`
299    /// A number of minutes offset from UTC.
300    Minutes(i32),
301}
302
303#[cfg_attr(not(feature = "chrono"), allow(rustdoc::broken_intra_doc_links))]
304/// A helper trait for getting timezone information from some value. (Especially [chrono::DateTime]
305/// or [chrono::NaiveDateTime].)
306///
307/// Implementations for the `chrono` types are included with `feature = ["chrono"]`.
308///
309/// Not implemented on [DateTime] because this is only used as a bound on `impl<T> From<T> for DateTime`
310/// implementations.
311pub trait GetTimezone {
312    /// Return the number of seconds difference from UTC.
313    ///
314    /// - `TzOffset::None` represents NO timezone information on the EDTF timestamp.
315    /// - `TzOffset::Utc` represents a `Z` timezone, i.e. UTC/Zulu time.
316    /// - `TzOffset::Hours(1)` represents `+01
317    /// - `TzOffset::Minutes(-16_200)` represents `-04:30`
318    fn tz_offset(&self) -> TzOffset;
319}