Skip to main content

rustversion_detect/
date.rs

1//! Contains a basic [`Date`] type used for differentiating nightly versions of rust.
2//!
3//! Intentionally ignores timezone information, making it much simpler than the [`time` crate]
4//!
5//! [`time` crate]: https://github.com/time-rs/time
6
7use core::fmt::{self, Display};
8use std::str::FromStr;
9
10/// Indicates the date.
11///
12/// Used for the nightly versions of rust.
13///
14/// The timezone is not explicitly specified here,
15/// and matches whatever one the rust team uses for nightly releases.
16#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
17pub struct Date {
18    /// The year (AD/CE)
19    year: u32,
20    /// The month (1..=12)
21    month: u8,
22    /// The day of the month.
23    day: u8,
24}
25impl Date {
26    /// Create a date, using YYYY-MM-DD format (ISO 8601).
27    ///
28    /// # Panics
29    /// May panic if the date is invalid.
30    /// See [`Self::try_new`] for a version that returns an error instead.
31    ///
32    /// # Examples
33    /// ```
34    /// # use rustversion_detect::Date;
35    /// let x = Date::new(2018, 10, 21);
36    /// let y = Date::new(2017, 11, 30);
37    /// assert!(x.is_since(y));
38    /// assert!(x.is_since(x));
39    /// ```
40    ///
41    #[inline]
42    #[must_use]
43    pub fn new(year: u32, month: u32, day: u32) -> Self {
44        match Self::try_new(year, month, day) {
45            Ok(x) => x,
46            Err(e) => panic!("{}", e),
47        }
48    }
49
50    /// Create a date, using YYYY-MM-DD format (ISO 8601).
51    ///
52    /// # Errors
53    /// Returns an error if the date is obviously invalid.
54    /// However, this validation may have false negatives.
55    /// See [`Self::new`] for a version that panics instead.
56    pub fn try_new(year: u32, month: u32, day: u32) -> Result<Self, DateValidationError> {
57        if year < 1 {
58            return Err(DateValidationError {
59                field: InvalidDateField::Year,
60                value: year,
61            });
62        }
63        if month < 1 || month > 12 {
64            return Err(DateValidationError {
65                field: InvalidDateField::Month,
66                value: month,
67            });
68        }
69        if day < 1 || day > max_days_of_month(month) {
70            return Err(DateValidationError {
71                field: InvalidDateField::DayOfMonth { month },
72                value: day,
73            });
74        }
75        Ok(Date {
76            month: month as u8,
77            day: day as u8,
78            year,
79        })
80    }
81
82    /// Check if this date is later than or equal to the specified start.
83    ///
84    /// Equivalent to `self >= start`, but potentially clearer.
85    ///
86    /// # Example
87    /// ```
88    /// # use rustversion_detect::Date;;
89    /// assert!(Date::new(2024, 11, 16).is_since(Date::new(2024, 7, 28)));
90    /// ```
91    #[inline]
92    #[must_use]
93    pub fn is_since(&self, start: Date) -> bool {
94        *self >= start
95    }
96
97    /// Check if this date is before the specified end.
98    ///
99    /// Equivalent to `self < end`, but potentially clearer.
100    ///
101    /// # Example
102    /// ```
103    /// # use rustversion_detect::Date;
104    /// assert!(Date::new(2018, 12, 14).is_before(Date::new(2022, 8, 16)));
105    /// assert!(Date::new(2024, 11, 14).is_before(Date::new(2024, 12, 7)));
106    /// assert!(Date::new(2024, 11, 14).is_before(Date::new(2024, 11, 17)));
107    /// ```
108    #[inline]
109    #[must_use]
110    pub fn is_before(&self, end: Date) -> bool {
111        *self < end
112    }
113
114    /// The year (AD/CE), in the range `1..`
115    #[inline]
116    #[must_use]
117    pub fn year(&self) -> u32 {
118        self.year
119    }
120
121    /// The month of the year, in the range `1..=12`.
122    #[inline]
123    #[must_use]
124    pub fn month(&self) -> u32 {
125        self.month as u32
126    }
127
128    /// The day of the year, in the range `1..=31`.
129    #[inline]
130    #[must_use]
131    pub fn day(&self) -> u32 {
132        self.day as u32
133    }
134}
135/// Displays the date consistent with the ISO 8601 standard.
136impl Display for Date {
137    fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
138        write!(
139            formatter,
140            "{:04}-{:02}-{:02}",
141            self.year, self.month, self.day,
142        )
143    }
144}
145impl FromStr for Date {
146    type Err = DateParseError;
147    fn from_str(full_text: &str) -> Result<Self, Self::Err> {
148        fn do_parse(full_text: &str) -> Result<Date, ParseErrorReason> {
149            let mut raw_parts = full_text.split('-');
150            let mut parts: [Option<u32>; 3] = [None; 3];
151            for part in &mut parts {
152                let raw_part = raw_parts.next().ok_or(ParseErrorReason::MalformedSyntax)?;
153                *part = Some(raw_part.parse().map_err(|cause| {
154                    ParseErrorReason::NumberParseFailure {
155                        text: raw_part.into(),
156                        cause,
157                    }
158                })?);
159            }
160            if raw_parts.next().is_some() {
161                return Err(ParseErrorReason::MalformedSyntax);
162            }
163            Date::try_new(parts[0].unwrap(), parts[1].unwrap(), parts[2].unwrap())
164                .map_err(ParseErrorReason::ValidationFailure)
165        }
166        match do_parse(full_text) {
167            Ok(res) => Ok(res),
168            Err(reason) => Err(DateParseError {
169                full_text: full_text.into(),
170                reason,
171            }),
172        }
173    }
174}
175/// An error that occurs parsing a [`Date`].
176#[derive(Debug)]
177pub struct DateParseError {
178    full_text: String,
179    reason: ParseErrorReason,
180}
181impl std::error::Error for DateParseError {
182    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
183        match self.reason {
184            ParseErrorReason::MalformedSyntax => None,
185            ParseErrorReason::NumberParseFailure { ref cause, .. } => Some(cause),
186            ParseErrorReason::ValidationFailure(ref cause) => Some(cause),
187        }
188    }
189}
190#[derive(Debug)]
191enum ParseErrorReason {
192    MalformedSyntax,
193    NumberParseFailure {
194        text: String,
195        cause: std::num::ParseIntError,
196    },
197    ValidationFailure(DateValidationError),
198}
199impl Display for DateParseError {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        write!(f, "Failed to parse `{:?}` as a date: ", self.full_text)?;
202        match self.reason {
203            ParseErrorReason::MalformedSyntax => {
204                write!(f, "Not in ISO 8601 format (example: 2025-12-31)")
205            }
206            ParseErrorReason::NumberParseFailure {
207                ref text,
208                ref cause,
209            } => {
210                write!(f, "Failed to parse `{}` as number ({})", text, cause)
211            }
212            ParseErrorReason::ValidationFailure(ref cause) => Display::fmt(cause, f),
213        }
214    }
215}
216
217/// Return the maximum numbers of days in the specified month,
218/// assuming that the Gregorian calendar is being used.
219///
220/// Does not take into account leap years.
221#[inline]
222fn max_days_of_month(x: u32) -> u32 {
223    match x {
224        1 => 31,
225        2 => 29,
226        _ => 30 + ((x + 1) % 2),
227    }
228}
229/// An error that occurs in [`Date::try_new`] when an invalid date is encountered.
230#[derive(Debug)]
231pub struct DateValidationError {
232    field: InvalidDateField,
233    value: u32,
234}
235impl std::error::Error for DateValidationError {}
236#[derive(Debug)]
237enum InvalidDateField {
238    Year,
239    Month,
240    DayOfMonth { month: u32 },
241}
242impl Display for DateValidationError {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        let field_name = match self.field {
245            InvalidDateField::Year => "year",
246            InvalidDateField::Month => "month",
247            InvalidDateField::DayOfMonth { .. } => "day of month",
248        };
249        write!(f, "Invalid {} `{}`", field_name, self.value)?;
250        match self.field {
251            InvalidDateField::Year | InvalidDateField::Month => {}
252            InvalidDateField::DayOfMonth { month } => {
253                write!(f, " for month {}", month)?;
254            }
255        }
256        Ok(())
257    }
258}
259
260#[cfg(test)]
261mod test {
262    use super::*;
263
264    // (before, after)
265    fn test_dates() -> Vec<(Date, Date)> {
266        vec![
267            (Date::new(2018, 12, 14), Date::new(2022, 8, 16)),
268            (Date::new(2024, 11, 14), Date::new(2024, 12, 7)),
269            (Date::new(2024, 11, 14), Date::new(2024, 11, 17)),
270        ]
271    }
272
273    #[test]
274    fn days_of_month() {
275        assert_eq!(max_days_of_month(1), 31);
276        assert_eq!(max_days_of_month(12), 31);
277        assert_eq!(max_days_of_month(2), 29);
278        assert_eq!(max_days_of_month(10), 31);
279    }
280
281    #[test]
282    fn before_after() {
283        for (before, after) in test_dates() {
284            assert!(before.is_before(after), "{} & {}", before, after);
285            assert!(after.is_since(before), "{} & {}", before, after);
286            // check equal dates
287            for &date in &[before, after] {
288                assert!(date.is_since(date), "{}", date);
289                assert!(!date.is_before(date), "{}", date);
290            }
291        }
292    }
293
294    #[test]
295    #[should_panic(expected = "Invalid year")]
296    fn invalid_year() {
297        let _ = Date::new(0, 7, 18);
298    }
299
300    #[test]
301    #[should_panic(expected = "Invalid month")]
302    fn invalid_month() {
303        let _ = Date::new(2014, 13, 18);
304    }
305
306    #[test]
307    #[should_panic(expected = "Invalid day of month")]
308    fn invalid_date() {
309        let _ = Date::new(2014, 7, 36);
310    }
311
312    #[test]
313    #[should_panic(expected = "Invalid day of month")]
314    fn contextually_invalid_date() {
315        let _ = Date::new(2014, 2, 30);
316    }
317}