Skip to content

Instantly share code, notes, and snippets.

@decatur
Last active May 6, 2025 05:27
Show Gist options
  • Save decatur/7e627cf7f53f4e92186a084f83813e0a to your computer and use it in GitHub Desktop.
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.
// 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