html_datetime_local/
lib.rs

1//! # html-datetime-local
2//!
3//! [![GitHub license](https://img.shields.io/github/license/tomsik68/html-datetime-local?style=for-the-badge)](https://github.com/tomsik68/html-datetime-local/blob/master/LICENSE)
4//! [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/tomsik68/html-datetime-local/rust.yml?branch=master&style=for-the-badge)](https://github.com/tomsik68/html-datetime-local/actions/workflows/rust.yml)
5//! [![Crates.io](https://img.shields.io/crates/v/html-datetime-local?style=for-the-badge)](https://crates.io/crates/html-datetime-local)
6//! [![Crates.io (latest)](https://img.shields.io/crates/dv/html-datetime-local?style=for-the-badge)](https://crates.io/crates/html-datetime-local)
7//!
8//! ## Overview
9//!
10//! `html-datetime-local` is a Rust library for parsing local date and time strings based on the [WHATWG HTML Living Standard](https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#local-dates-and-times).
11//!
12//! This may be helpful for server-side code that deals with values from `<input type="datetime-local" />`.
13//!
14//! ## Usage
15//!
16//! Add this to your `Cargo.toml`:
17//!
18//! ```toml
19//! [dependencies]
20//! html-datetime-local = "0.1"
21//! ```
22//!
23//! Then, in your Rust code:
24//! ```rust
25//! use html_datetime_local::Datetime;
26//! use std::str::FromStr;
27//!
28//! let input = "2023-12-31T23:59:59";
29//! match Datetime::from_str(input) {
30//!     Ok(datetime) => println!("Parsed datetime: {:?}", datetime),
31//!     Err(err) => eprintln!("Error parsing datetime: {}", err),
32//! }
33//! ```
34//!
35//! # Contributing
36//!
37//! Pull requests and bug reports are welcome! If you have any questions or suggestions, feel free to open an issue.
38//!
39//! # License
40//!
41//! This project is licensed under the MIT License - see the LICENSE file for details.
42//!
43//! # Acknowledgments
44//!
45//! Special thanks to [ChatGPT](https://www.openai.com/gpt), an AI language model by OpenAI, for providing invaluable assistance during the development of this project. ChatGPT helped with code suggestions, problem-solving, and provided guidance throughout the development process.
46
47use anyhow::Error;
48use std::convert::TryFrom;
49use std::str::FromStr;
50use thiserror::Error;
51
52#[cfg(test)]
53mod tests;
54
55#[derive(Debug, PartialEq, Clone)]
56pub struct Datetime {
57    pub date: YearMonthDay,
58    pub time: HourMinuteSecond,
59}
60
61impl FromStr for Datetime {
62    type Err = DateTimeParseError;
63
64    fn from_str(s: &str) -> Result<Self, Self::Err> {
65        let mut parts = s.split('T');
66
67        let date = YearMonthDay::from_str(parts.next().ok_or_else(|| DateTimeParseError {
68            component: Component::Date,
69            found: "".to_string(),
70            kind: DateTimeParseErrorKind::ValueMissing,
71        })?)?;
72
73        let time = HourMinuteSecond::from_str(parts.next().ok_or_else(|| DateTimeParseError {
74            component: Component::Time,
75            found: "".to_string(),
76            kind: DateTimeParseErrorKind::ValueMissing,
77        })?)?;
78
79        Ok(Datetime { date, time })
80    }
81}
82
83#[derive(Debug, Error)]
84#[error("Failed to parse {component}'s value `{found}`: {kind}")]
85pub struct DateTimeParseError {
86    component: Component,
87    found: String,
88    kind: DateTimeParseErrorKind,
89}
90
91#[derive(Debug, Error)]
92pub enum DateTimeParseErrorKind {
93    #[error(transparent)]
94    InvalidNumber(Error),
95    #[error("The value is missing")]
96    ValueMissing,
97    #[error("The value must be at least {min} and at most {max}")]
98    OutOfRange { min: i32, max: i32 },
99}
100
101#[derive(Debug, PartialEq, Clone, strum::Display)]
102pub enum Component {
103    Year,
104    Month,
105    Day,
106    Hour,
107    Minute,
108    Second,
109
110    Date,
111    Time,
112}
113
114#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
115pub struct Year(i32);
116
117#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
118pub struct Month(u8);
119
120#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
121pub struct Day(u8);
122
123#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
124pub struct Hour(u8);
125
126#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
127pub struct Minute(u8);
128
129#[derive(Debug, PartialEq, PartialOrd, Clone, Copy)]
130pub struct Second(f32);
131
132#[derive(Debug, PartialEq, Clone)]
133pub struct YearMonthDay {
134    year: Year,
135    month: Month,
136    day: Day,
137}
138
139#[derive(Debug, PartialEq, Clone)]
140pub struct HourMinuteSecond {
141    hour: Hour,
142    minute: Minute,
143    second: Second,
144}
145
146macro_rules! impl_parse_numeric {
147    ($component:tt, $inner:ty, $min:expr, $max:expr) => {
148        impl TryFrom<$inner> for $component {
149            type Error = DateTimeParseError;
150
151            fn try_from(value: $inner) -> Result<Self, Self::Error> {
152                if !(($min as $inner)..=($max as $inner)).contains(&value) {
153                    return Err(DateTimeParseError {
154                        component: Component::$component,
155                        found: value.to_string(),
156                        kind: DateTimeParseErrorKind::OutOfRange {
157                            min: $min,
158                            max: ($max - 1),
159                        },
160                    });
161                }
162
163                Ok(Self(value))
164            }
165        }
166
167        impl FromStr for $component {
168            type Err = DateTimeParseError;
169
170            fn from_str(value: &str) -> Result<Self, Self::Err> {
171                let inner =
172                    <$inner as FromStr>::from_str(value).map_err(|source| DateTimeParseError {
173                        component: Component::$component,
174                        found: value.to_string(),
175                        kind: DateTimeParseErrorKind::InvalidNumber(source.into()),
176                    })?;
177
178                Self::try_from(inner)
179            }
180        }
181    };
182}
183
184impl_parse_numeric!(Year, i32, i32::MIN, i32::MAX);
185impl_parse_numeric!(Month, u8, 1, 13);
186impl_parse_numeric!(Day, u8, 1, 32);
187impl_parse_numeric!(Hour, u8, 0, 24);
188impl_parse_numeric!(Minute, u8, 0, 60);
189impl_parse_numeric!(Second, f32, 0, 60);
190
191impl FromStr for YearMonthDay {
192    type Err = DateTimeParseError;
193
194    fn from_str(value: &str) -> Result<Self, Self::Err> {
195        let parts: Vec<&str> = value.split('-').collect();
196
197        let year = parts.first().ok_or_else(|| DateTimeParseError {
198            found: "".to_string(),
199            component: Component::Year,
200            kind: DateTimeParseErrorKind::ValueMissing,
201        })?;
202        let month = parts.get(1).ok_or_else(|| DateTimeParseError {
203            found: "".to_string(),
204            component: Component::Month,
205            kind: DateTimeParseErrorKind::ValueMissing,
206        })?;
207        let day = parts.get(2).ok_or_else(|| DateTimeParseError {
208            found: "".to_string(),
209            component: Component::Day,
210            kind: DateTimeParseErrorKind::ValueMissing,
211        })?;
212
213        let year = Year::from_str(year)?;
214        let month = Month::from_str(month)?;
215        let day = Day::from_str(day)?;
216
217        Self::from_components(year, month, day)
218    }
219}
220
221impl YearMonthDay {
222    pub fn from_components(year: Year, month: Month, day: Day) -> Result<Self, DateTimeParseError> {
223        if !is_valid_day(year, month, day) {
224            return Err(DateTimeParseError {
225                kind: DateTimeParseErrorKind::OutOfRange {
226                    min: 1,
227                    max: day_in_month(year, month) as i32,
228                },
229                found: day.0.to_string(),
230                component: Component::Day,
231            });
232        }
233
234        Ok(YearMonthDay { year, month, day })
235    }
236}
237
238// Helper function to check if the given day is valid for the given year and month.
239fn is_valid_day(year: Year, month: Month, day: Day) -> bool {
240    day.0 <= day_in_month(year, month)
241}
242
243// Helper function to determine the number of days in a month.
244fn day_in_month(year: Year, month: Month) -> u8 {
245    match month.0 {
246        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
247        4 | 6 | 9 | 11 => 30,
248        2 if is_leap_year(year.0) => 29,
249        2 => 28,
250        _ => unreachable!("The Month type guards against values that aren't in range (1..=12)"),
251    }
252}
253
254// Helper function to check if a year is a leap year.
255fn is_leap_year(year: i32) -> bool {
256    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
257}
258
259impl FromStr for HourMinuteSecond {
260    type Err = DateTimeParseError;
261
262    fn from_str(value: &str) -> Result<Self, Self::Err> {
263        let parts: Vec<&str> = value.split(':').collect();
264
265        let hour = parts.first().ok_or_else(|| DateTimeParseError {
266            component: Component::Hour,
267            found: value.to_string(),
268            kind: DateTimeParseErrorKind::ValueMissing,
269        })?;
270        let minute = parts.get(1).ok_or_else(|| DateTimeParseError {
271            component: Component::Minute,
272            found: value.to_string(),
273            kind: DateTimeParseErrorKind::ValueMissing,
274        })?;
275
276        let second = parts.get(2).unwrap_or(&"0");
277
278        Ok(HourMinuteSecond {
279            hour: Hour::from_str(hour)?,
280            minute: Minute::from_str(minute)?,
281            second: Second::from_str(second)?,
282        })
283    }
284}