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    pub fn new(year: u32, month: u32, day: u32) -> Self {
43        match Self::try_new(year, month, day) {
44            Ok(x) => x,
45            Err(e) => panic!("{}", e),
46        }
47    }
48
49    /// Create a date, using YYYY-MM-DD format (ISO 8601).
50    ///
51    /// # Errors
52    /// Returns an error if the date is obviously invalid.
53    /// However, this validation may have false negatives.
54    /// See [`Self::new`] for a version that panics instead.
55    pub fn try_new(year: u32, month: u32, day: u32) -> Result<Self, DateValidationError> {
56        if year < 1 {
57            return Err(DateValidationError {
58                field: InvalidDateField::Year,
59                value: year,
60            })
61        }
62        if month < 1 || month > 12 {
63            return Err(DateValidationError {
64                field: InvalidDateField::Month,
65                value: month,
66            })
67        }
68        if day < 1 || day > max_days_of_month(month) {
69            return Err(DateValidationError {
70                field: InvalidDateField::DayOfMonth {
71                    month
72                },
73                value: day,
74            })
75        }
76        Ok(Date {
77            month: month as u8,
78            day: day as u8,
79            year,
80        })
81    }
82
83    /// Check if this date is later than or equal to the specified start.
84    ///
85    /// Equivalent to `self >= start`, but potentially clearer.
86    ///
87    /// # Example
88    /// ```
89    /// # use rustversion_detect::Date;;
90    /// assert!(Date::new(2024, 11, 16).is_since(Date::new(2024, 7, 28)));
91    /// ```
92    #[inline]
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    pub fn is_before(&self, end: Date) -> bool {
110        *self < end
111    }
112
113    /// The year (AD/CE), in the range `1..`
114    #[inline]
115    pub fn year(&self) -> u32 {
116        self.year
117    }
118
119    /// The month of the year, in the range `1..=12`.
120    #[inline]
121    pub fn month(&self) -> u32 {
122        self.month as u32
123    }
124
125    /// The day of the year, in the range `1..=31`.
126    #[inline]
127    pub fn day(&self) -> u32 {
128        self.day as u32
129    }
130}
131/// Displays the date consistent with the ISO 8601 standard.
132impl Display for Date {
133    fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
134        write!(
135            formatter,
136            "{:04}-{:02}-{:02}",
137            self.year, self.month, self.day,
138        )
139    }
140}
141impl FromStr for Date {
142    type Err = DateParseError;
143    fn from_str(full_text: &str) -> Result<Self, Self::Err> {
144        fn do_parse(full_text: &str) -> Result<Date, ParseErrorReason> {
145            let mut raw_parts = full_text.split('-');
146            let mut parts: [Option<u32>; 3] = [None; 3];
147            for part in &mut parts {
148                let raw_part = raw_parts.next()
149                    .ok_or(ParseErrorReason::MalformedSyntax)?;
150                *part = Some(raw_part.parse().map_err(|cause| {
151                    ParseErrorReason::NumberParseFailure {
152                        text: raw_part.into(),
153                        cause
154                    }
155                })?);
156            }
157            if raw_parts.next().is_some() {
158                return Err(ParseErrorReason::MalformedSyntax);
159            }
160            Date::try_new(
161                parts[0].unwrap(),
162                parts[1].unwrap(),
163                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}
176/// An error that occurs parsing a [`Date`].
177#[derive(Debug)]
178pub struct DateParseError {
179    full_text: String,
180    reason: ParseErrorReason,
181}
182impl std::error::Error for DateParseError {
183    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
184        match self.reason {
185            ParseErrorReason::MalformedSyntax => None,
186            ParseErrorReason::NumberParseFailure { ref cause, .. } => Some(cause),
187            ParseErrorReason::ValidationFailure(ref cause) => Some(cause),
188        }
189    }
190}
191#[derive(Debug)]
192enum ParseErrorReason {
193    MalformedSyntax,
194    NumberParseFailure {
195        text: String,
196        cause: std::num::ParseIntError,
197    },
198    ValidationFailure(DateValidationError),
199}
200impl Display for DateParseError {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        write!(f, "Failed to parse `{:?}` as a date: ", self.full_text)?;
203        match self.reason {
204            ParseErrorReason::MalformedSyntax => {
205                write!(f, "Not in ISO 8601 format (example: 2025-12-31)")
206            },
207            ParseErrorReason::NumberParseFailure { ref text, ref cause } => {
208                write!(f, "Failed to parse `{}` as number ({})", text, cause)
209            },
210            ParseErrorReason::ValidationFailure(ref cause) => {
211                Display::fmt(cause, f)
212            }
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 {
241        month: u32,
242    }
243}
244impl Display for DateValidationError {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        let field_name = match self.field {
247            InvalidDateField::Year => "year",
248            InvalidDateField::Month => "month",
249            InvalidDateField::DayOfMonth { .. } => "day of month"
250        };
251        write!(f, "Invalid {} `{}`", field_name, self.value)?;
252        match self.field {
253            InvalidDateField::Year | InvalidDateField::Month => {},
254            InvalidDateField::DayOfMonth { month } => {
255                write!(f, " for month {}", month)?;
256            }
257        }
258        Ok(())
259    }
260}
261
262#[cfg(test)]
263mod test {
264    use super::*;
265
266    // (before, after)
267    fn test_dates() -> Vec<(Date, Date)> {
268        vec![
269            (Date::new(2018, 12, 14), Date::new(2022, 8, 16)),
270            (Date::new(2024, 11, 14), Date::new(2024, 12, 7)),
271            (Date::new(2024, 11, 14), Date::new(2024, 11, 17)),
272        ]
273    }
274
275    #[test]
276    fn days_of_month() {
277        assert_eq!(max_days_of_month(1), 31);
278        assert_eq!(max_days_of_month(12), 31);
279        assert_eq!(max_days_of_month(2), 29);
280        assert_eq!(max_days_of_month(10), 31);
281    }
282
283    #[test]
284    fn before_after() {
285        for (before, after) in test_dates() {
286            assert!(before.is_before(after), "{} & {}", before, after);
287            assert!(after.is_since(before), "{} & {}", before, after);
288            // check equal dates
289            for &date in [before, after].iter() {
290                assert!(date.is_since(date), "{}", date);
291                assert!(!date.is_before(date), "{}", date);
292            }
293        }
294    }
295
296    #[test]
297    #[should_panic(expected = "Invalid year")]
298    fn invalid_year() {
299        Date::new(0, 7, 18);
300    }
301
302    #[test]
303    #[should_panic(expected = "Invalid month")]
304    fn invalid_month() {
305        Date::new(2014, 13, 18);
306    }
307
308    #[test]
309    #[should_panic(expected = "Invalid day of month")]
310    fn invalid_date() {
311        Date::new(2014, 7, 36);
312    }
313
314
315    #[test]
316    #[should_panic(expected = "Invalid day of month")]
317    fn contextually_invalid_date() {
318        Date::new(2014, 2, 30);
319    }
320}