Last active
May 6, 2025 05:27
-
-
Save decatur/7e627cf7f53f4e92186a084f83813e0a to your computer and use it in GitHub Desktop.
Parse ISO8601 date/times and validates against the proleptic Gregorian calendar with no dependencies and no_std compliant.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Parse ISO8601 date/times and validates against the proleptic Gregorian calendar with no dependencies and no_std compliant. | |
// | |
// If you want to convert a parsed date into a unix timestamp, I recommend [tz-rs="0.7.0"](https://docs.rs/tz-rs/0.7.0) , which also has no dependencies. | |
// | |
// See also | |
// * https://docs.rs/iso8601/0.6.2/iso8601/ | |
// * https://github.com/BurntSushi/jiff | |
/// Parses ISO8601 dates and validates against the proleptic Gregorian calendar. | |
/// Usage: | |
/// let parts = parse_iso8601("2025-01-24T23:00:00.232999936Z")?; | |
pub fn parse_iso8601(ts: &str) -> Result<(u16, u8, u8, u8, u8, u8, u32, i16), &str> { | |
if ts.len() > 30 { | |
return Err("Date string too long or non-ascii char in date string"); | |
}; | |
let ts = ts.as_bytes(); | |
#[derive(Debug)] | |
enum State { | |
Year, | |
Month, | |
Day, | |
Hour, | |
Minute, | |
Second, | |
Subsecond, | |
Z, | |
HourOffset, | |
MinuteOffset, | |
} | |
let mut state = State::Year; | |
let mut year = 0; | |
let mut month = 0; | |
let mut day = 0; | |
let mut hour = 0; | |
let mut minute = 0; | |
let mut second = 0; | |
let mut subsecond = 0; | |
let mut offset_sign = 0i16; | |
let mut offset_hour = 0u8; | |
let mut offset_minute = 0u8; | |
let mut n_digits = 0; | |
for ch in ts { | |
// println!("{state:?} {ch}"); | |
match (&state, ch) { | |
(State::Year, 48..=57 /* 0..=9 */) => { | |
n_digits += 1; | |
if n_digits > 4 { | |
return Err("Year has more than 4 digits"); | |
} | |
year = 10 * year + (ch - 48) as u16 | |
} | |
(State::Year, 45 /* - */) => { | |
n_digits = 0; | |
state = State::Month; | |
} | |
(State::Year, _) => return Err("Invalid character in year"), | |
(State::Month, 48..=57 /* 0..=9 */) => { | |
n_digits += 1; | |
if n_digits > 2 { | |
return Err("Month has more than 2 digits"); | |
} | |
month = 10 * month + ch - 48; | |
} | |
(State::Month, 45 /* - */) => { | |
if month == 0 || month > 12 { | |
return Err("Month out of range"); | |
} | |
n_digits = 0; | |
state = State::Day; | |
} | |
(State::Month, _) => return Err("Invalid character in month"), | |
(State::Day, 48..=57 /* 0..=9 */) => { | |
n_digits += 1; | |
if n_digits > 2 { | |
return Err("Day has more than 2 digits"); | |
} | |
day = 10 * day + ch - 48; | |
} | |
(State::Day, 84 /* T */) => { | |
let daycount_february = if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) { | |
29 // https://en.wikipedia.org/wiki/Leap_year | |
} else { | |
28 | |
}; | |
let day_ranges = [ | |
31, | |
daycount_february, | |
31, | |
30, | |
31, | |
30, | |
31, | |
31, | |
30, | |
31, | |
30, | |
31, | |
]; | |
if day == 0 || day > day_ranges[(month - 1) as usize] { | |
return Err("Day out of range"); | |
} | |
n_digits = 0; | |
state = State::Hour | |
} | |
(State::Day, _) => return Err("Invalid character in day"), | |
(State::Hour, 48..=57 /* 0..=9 */) => { | |
n_digits += 1; | |
if n_digits > 2 { | |
return Err("Hour has more than 2 digits"); | |
} | |
hour = 10 * hour + ch - 48; | |
} | |
(State::Hour, 58 /* : */) => { | |
if hour > 24 { | |
return Err("Hour out of range"); | |
} | |
n_digits = 0; | |
state = State::Minute | |
} | |
(State::Hour, _) => return Err("Invalid character in hour"), | |
(State::Minute, 48..=57 /* 0..=9 */) => { | |
n_digits += 1; | |
if n_digits > 2 { | |
return Err("Minute has more than 2 digits"); | |
} | |
minute = 10 * minute + ch - 48; | |
} | |
(State::Minute, 58 /* : */) => { | |
if minute > 60 { | |
return Err("Minute out of range"); | |
} | |
n_digits = 0; | |
state = State::Second | |
} | |
(State::Minute, _) => return Err("Invalid character in minute"), | |
(State::Second, 48..=57 /* 0..=9 */) => { | |
n_digits += 1; | |
if n_digits > 2 { | |
return Err("Second has more than 2 digits"); | |
} | |
second = 10 * second + ch - 48; | |
} | |
(State::Second, 46 /* . */) => { | |
if second > 60 { | |
return Err("Second out of range"); | |
} | |
n_digits = 0; | |
state = State::Subsecond; | |
} | |
(State::Second, 90 /* Z */) => { | |
if second > 60 { | |
return Err("Second out of range"); | |
} | |
n_digits = 0; | |
state = State::Z; | |
} | |
(State::Second | State::Subsecond, 43 /* + */) => { | |
if second > 60 { | |
return Err("Second out of range"); | |
} | |
n_digits = 0; | |
offset_sign = 1; | |
state = State::HourOffset; | |
} | |
(State::Second | State::Subsecond, 45 /* - */) => { | |
if second > 60 { | |
return Err("Second out of range"); | |
} | |
n_digits = 0; | |
offset_sign = -1; | |
state = State::HourOffset; | |
} | |
(State::Second, _) => return Err("Invalid character in second"), | |
(State::Subsecond, 48..=57) => { | |
n_digits += 1; | |
if n_digits > 9 { | |
return Err("Subsecond has more than 9 digits"); | |
} | |
subsecond = 10 * subsecond + (ch - 48) as u32; | |
} | |
(State::Subsecond, 90 /* Z */) => { | |
state = State::Z; | |
} | |
(State::Subsecond, _) => return Err("Invalid character in subsecond"), | |
(State::Z, _) => return Err("Invalid character in timezone"), | |
(State::HourOffset, 48..=57 /* 0..=9 */) => { | |
n_digits += 1; | |
if n_digits > 2 { | |
return Err("Hour offset has more than 2 digits"); | |
} | |
offset_hour = 10 * offset_hour + (ch - 48); | |
} | |
(State::HourOffset, 58 /* : */) => { | |
if minute > 60 { | |
return Err("Minute out of range"); | |
} | |
n_digits = 0; | |
state = State::MinuteOffset | |
} | |
(State::HourOffset, _) => return Err("Invalid character in hour offset"), | |
(State::MinuteOffset, 48..=57 /* 0..=9 */) => { | |
n_digits += 1; | |
if n_digits > 2 { | |
return Err("Minute offset has more than 2 digits"); | |
} | |
offset_minute = 10 * offset_minute + (ch - 48); | |
} | |
(State::MinuteOffset, _) => return Err("Invalid character in minute offset"), | |
} | |
} | |
return Ok(( | |
year, | |
month, | |
day, | |
hour, | |
minute, | |
second, | |
subsecond, | |
offset_sign * (offset_hour as i16 * 60 + offset_minute as i16), | |
)); | |
} | |
#[cfg(test)] | |
mod iso8601_tests { | |
use super::*; | |
#[test] | |
fn test_parse() { | |
let r = parse_iso8601("2025-01-24T23:59:56.232999936Z"); | |
assert_eq!(r.unwrap(), (2025, 1, 24, 23, 59, 56, 232999936, 0)); | |
let r = parse_iso8601("2000-02-29T23:59:56.232999Z"); | |
assert_eq!(r.unwrap(), (2000, 2, 29, 23, 59, 56, 232999, 0)); | |
let r = parse_iso8601("2025-02-28T23:00:56.232Z"); | |
assert_eq!(r.unwrap(), (2025, 2, 28, 23, 00, 56, 232, 0)); | |
let r = parse_iso8601("2025-02-28T23:00:56Z"); | |
assert_eq!(r.unwrap(), (2025, 2, 28, 23, 00, 56, 0, 0)); | |
let r = parse_iso8601("202๐-01-24T23:30:56.232999936Z"); | |
assert_eq!( | |
r.unwrap_err(), | |
"Date string too long or non-ascii char in date string" | |
); | |
let r = parse_iso8601("202๐-01-24T23:30:56.232Z"); | |
assert_eq!(r.unwrap_err(), "Invalid character in year"); | |
let r = parse_iso8601("202@-01-24T23:00:56.232999936Z"); | |
assert_eq!(r.unwrap_err(), "Invalid character in year"); | |
let r = parse_iso8601("2025-13-24T23:01:56.232999936Z"); | |
assert_eq!(r.unwrap_err(), "Month out of range"); | |
let r = parse_iso8601("20250-12-24T23:01:56Z"); | |
assert_eq!(r.unwrap_err(), "Year has more than 4 digits"); | |
let r = parse_iso8601("2025-321-243T23:01:56Z"); | |
assert_eq!(r.unwrap_err(), "Month has more than 2 digits"); | |
let r = parse_iso8601("2025-12-243T23:01:56Z"); | |
assert_eq!(r.unwrap_err(), "Day has more than 2 digits"); | |
let r = parse_iso8601("2025-12-24T321:01:56Z"); | |
assert_eq!(r.unwrap_err(), "Hour has more than 2 digits"); | |
let r = parse_iso8601("2025-12-24T18:321:56Z"); | |
assert_eq!(r.unwrap_err(), "Minute has more than 2 digits"); | |
let r = parse_iso8601("2025-12-24T18:32:987Z"); | |
assert_eq!(r.unwrap_err(), "Second has more than 2 digits"); | |
let r = parse_iso8601("2025-02-28T23:00:56+01"); | |
assert_eq!(r.unwrap(), (2025, 2, 28, 23, 00, 56, 0, 60)); | |
let r = parse_iso8601("2025-02-28T23:00:56-01"); | |
assert_eq!(r.unwrap(), (2025, 2, 28, 23, 00, 56, 0, -60)); | |
let r = parse_iso8601("2025-02-28T23:00:56.0-01"); | |
assert_eq!(r.unwrap(), (2025, 2, 28, 23, 00, 56, 0, -60)); | |
let r = parse_iso8601("2025-02-28T23:00:56+01:00"); | |
assert_eq!(r.unwrap(), (2025, 2, 28, 23, 00, 56, 0, 60)); | |
let r = parse_iso8601("2025-02-28T23:00:56-01:00"); | |
assert_eq!(r.unwrap(), (2025, 2, 28, 23, 00, 56, 0, -60)); | |
let r = parse_iso8601("2025-02-28T23:00:56.8+01:00"); | |
assert_eq!(r.unwrap(), (2025, 2, 28, 23, 00, 56, 8, 60)); | |
let r = parse_iso8601("2025-02-28T23:00:56.8-01:30"); | |
assert_eq!(r.unwrap(), (2025, 2, 28, 23, 00, 56, 8, -90)); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment