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 > 31 {
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/// An error that occurs in [`Date::try_new`] when an invalid date is encountered.
218#[derive(Debug)]
219pub struct DateValidationError {
220    field: InvalidDateField,
221    value: u32,
222}
223impl std::error::Error for DateValidationError {}
224#[derive(Debug)]
225enum InvalidDateField {
226    Year,
227    Month,
228    DayOfMonth { month: u32 },
229}
230impl Display for DateValidationError {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        let field_name = match self.field {
233            InvalidDateField::Year => "year",
234            InvalidDateField::Month => "month",
235            InvalidDateField::DayOfMonth { .. } => "day of month",
236        };
237        write!(f, "Invalid {} `{}`", field_name, self.value)?;
238        match self.field {
239            InvalidDateField::Year | InvalidDateField::Month => {}
240            InvalidDateField::DayOfMonth { month } => {
241                write!(f, " for month {}", month)?;
242            }
243        }
244        Ok(())
245    }
246}
247
248#[cfg(test)]
249mod test {
250    use super::*;
251
252    #[test]
253    fn parse_valid() {
254        macro_rules! check_valid {
255            ($($text:literal => $expected:expr),+ $(,)?) => {
256                $(
257                    assert_eq!(
258                        $text.parse::<Date>().expect(concat!("Date `", $text, "` is invalid")),
259                        $expected,
260                    );
261                )*
262            }
263        }
264        check_valid! {
265            "2026-07-31" => Date::new(2026, 7, 31),
266        }
267    }
268
269    // (before, after)
270    fn test_dates() -> Vec<(Date, Date)> {
271        vec![
272            (Date::new(2018, 12, 14), Date::new(2022, 8, 16)),
273            (Date::new(2024, 11, 14), Date::new(2024, 12, 7)),
274            (Date::new(2024, 11, 14), Date::new(2024, 11, 17)),
275        ]
276    }
277
278    #[test]
279    fn before_after() {
280        for (before, after) in test_dates() {
281            assert!(before.is_before(after), "{} & {}", before, after);
282            assert!(after.is_since(before), "{} & {}", before, after);
283            // check equal dates
284            for &date in &[before, after] {
285                assert!(date.is_since(date), "{}", date);
286                assert!(!date.is_before(date), "{}", date);
287            }
288        }
289    }
290
291    #[test]
292    #[should_panic(expected = "Invalid year")]
293    fn invalid_year() {
294        let _ = Date::new(0, 7, 18);
295    }
296
297    #[test]
298    #[should_panic(expected = "Invalid month")]
299    fn invalid_month() {
300        let _ = Date::new(2014, 13, 18);
301    }
302
303    #[test]
304    #[should_panic(expected = "Invalid day of month")]
305    fn invalid_date() {
306        let _ = Date::new(2014, 7, 36);
307    }
308}