pub fn check(kind: &str, value: &str) -> Result<(), String> {
let v = value.trim();
let outcome = match kind {
"dateTime" => date_time(v),
"date" => date(v),
"time" => time(v),
"gYear" => g_year(v),
"gMonth" => g_month(v),
"gDay" => g_day(v),
"duration" => duration(v),
_ => return Ok(()),
};
outcome.map_err(|why| format!("`{value}` is not a valid xs:{kind}: {why}"))
}
type Res = Result<(), &'static str>;
fn split_timezone(s: &str) -> Result<&str, &'static str> {
if let Some(rest) = s.strip_suffix('Z') {
return Ok(rest);
}
let b = s.as_bytes();
if b.len() >= 6 {
let at = b.len() - 6;
if (b[at] == b'+' || b[at] == b'-') && b[at + 3] == b':' {
let (hh, mm) = (&s[at + 1..at + 3], &s[at + 4..]);
let (Some(h), Some(m)) = (digits(hh), digits(mm)) else {
return Err("malformed timezone");
};
if h > 14 || m > 59 || (h == 14 && m != 0) {
return Err("timezone out of range (at most 14:00)");
}
return Ok(&s[..at]);
}
}
Ok(s)
}
fn digits(s: &str) -> Option<u64> {
if s.is_empty() || !s.bytes().all(|c| c.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
fn is_leap(year: u64) -> bool {
year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
}
fn days_in_month(year: u64, month: u64) -> u64 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ => {
if is_leap(year) {
29
} else {
28
}
}
}
}
fn year(s: &str) -> Result<u64, &'static str> {
let digits_part = s.strip_prefix('-').unwrap_or(s);
if digits_part.len() < 4 || !digits_part.bytes().all(|c| c.is_ascii_digit()) {
return Err("the year needs at least four digits");
}
if digits_part.len() > 4 && digits_part.starts_with('0') {
return Err("a year of more than four digits has no leading zero");
}
let y: u64 = digits_part.parse().map_err(|_| "the year is too large")?;
if y == 0 {
return Err("there is no year 0000");
}
Ok(y)
}
fn date_part(s: &str) -> Res {
let b = s.as_bytes();
if b.len() < 10 || b[b.len() - 3] != b'-' || b[b.len() - 6] != b'-' {
return Err("expected YYYY-MM-DD");
}
let y = year(&s[..s.len() - 6])?;
let m = digits(&s[s.len() - 5..s.len() - 3]).ok_or("the month is not two digits")?;
let d = digits(&s[s.len() - 2..]).ok_or("the day is not two digits")?;
if !(1..=12).contains(&m) {
return Err("the month is not 01 to 12");
}
if d < 1 || d > days_in_month(y, m) {
return Err("the day does not exist in that month");
}
Ok(())
}
fn time_part(s: &str) -> Res {
let (whole, fraction) = match s.split_once('.') {
Some((w, f)) => (w, Some(f)),
None => (s, None),
};
if let Some(f) = fraction
&& (f.is_empty() || !f.bytes().all(|c| c.is_ascii_digit()))
{
return Err("malformed fraction of a second");
}
let parts: Vec<&str> = whole.split(':').collect();
if parts.len() != 3 || parts.iter().any(|p| p.len() != 2) {
return Err("expected hh:mm:ss");
}
let (Some(h), Some(m), Some(sec)) = (digits(parts[0]), digits(parts[1]), digits(parts[2])) else {
return Err("expected hh:mm:ss");
};
if h == 24 {
let zero_fraction = fraction.is_none_or(|f| f.bytes().all(|c| c == b'0'));
return if m == 0 && sec == 0 && zero_fraction {
Ok(())
} else {
Err("hour 24 is only valid as 24:00:00")
};
}
if h > 23 {
return Err("the hour is not 00 to 23");
}
if m > 59 {
return Err("the minute is not 00 to 59");
}
if sec > 59 {
return Err("the second is not 00 to 59");
}
Ok(())
}
fn date_time(s: &str) -> Res {
let body = split_timezone(s)?;
let (d, t) = body
.split_once('T')
.ok_or("expected the `T` between date and time")?;
date_part(d)?;
time_part(t)
}
fn date(s: &str) -> Res {
date_part(split_timezone(s)?)
}
fn time(s: &str) -> Res {
time_part(split_timezone(s)?)
}
fn g_year(s: &str) -> Res {
year(split_timezone(s)?).map(|_| ())
}
fn g_month(s: &str) -> Res {
let body = split_timezone(s)?;
let body = body.strip_suffix("--").unwrap_or(body);
let m = body
.strip_prefix("--")
.and_then(digits)
.filter(|_| body.len() == 4);
match m {
Some(1..=12) => Ok(()),
_ => Err("expected --MM with a month 01 to 12"),
}
}
fn g_day(s: &str) -> Res {
let body = split_timezone(s)?;
let d = body
.strip_prefix("---")
.and_then(digits)
.filter(|_| body.len() == 5);
match d {
Some(1..=31) => Ok(()),
_ => Err("expected ---DD with a day 01 to 31"),
}
}
fn duration(s: &str) -> Res {
let body = s.strip_prefix('-').unwrap_or(s);
let Some(rest) = body.strip_prefix('P') else {
return Err("a duration starts with P");
};
let (date_side, time_side) = match rest.split_once('T') {
Some((d, t)) => (d, Some(t)),
None => (rest, None),
};
let mut components = 0;
let mut after = 0u8; let mut num = String::new();
for c in date_side.chars() {
if c.is_ascii_digit() {
num.push(c);
continue;
}
let rank = match c {
'Y' => 1,
'M' => 2,
'D' => 3,
_ => return Err("unexpected character in the date part of the duration"),
};
if num.is_empty() || rank <= after {
return Err("components are out of order or lack a number");
}
after = rank;
num.clear();
components += 1;
}
if !num.is_empty() {
return Err("a number without a designator");
}
if let Some(t) = time_side {
let mut after = 0u8;
let mut seen = 0;
let mut num = String::new();
let mut fraction = false;
for c in t.chars() {
if c.is_ascii_digit() {
num.push(c);
continue;
}
if c == '.' && !fraction && !num.is_empty() {
fraction = true;
num.push(c);
continue;
}
let rank = match c {
'H' => 1,
'M' => 2,
'S' => 3,
_ => return Err("unexpected character in the time part of the duration"),
};
if num.is_empty() || num.ends_with('.') || rank <= after || (fraction && rank != 3) {
return Err("components are out of order or malformed");
}
after = rank;
num.clear();
fraction = false;
seen += 1;
}
if !num.is_empty() {
return Err("a number without a designator");
}
if seen == 0 {
return Err("a `T` must be followed by at least one time component");
}
components += seen;
}
if components == 0 {
return Err("a duration needs at least one component");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::check;
fn ok(kind: &str, v: &str) {
assert!(check(kind, v).is_ok(), "{kind} {v}: {:?}", check(kind, v));
}
fn bad(kind: &str, v: &str) {
assert!(check(kind, v).is_err(), "{kind} {v} should be refused");
}
#[test]
fn date_time_follows_the_built_in() {
ok("dateTime", "2026-10-04T12:30:00Z");
ok("dateTime", "2026-10-04T12:30:00.123+02:00");
ok("dateTime", "2026-10-04T12:30:00");
ok("dateTime", "2026-10-04T24:00:00");
ok("dateTime", "2024-02-29T00:00:00Z");
ok("dateTime", "-0044-03-15T00:00:00Z");
bad("dateTime", "2024-13-45T99:99:99Z");
bad("dateTime", "2024-13-01T00:00:00Z");
bad("dateTime", "2023-02-29T00:00:00Z");
bad("dateTime", "2026-04-31T00:00:00Z");
bad("dateTime", "2026-10-04T24:00:01Z");
bad("dateTime", "2026-10-04T25:00:00Z");
bad("dateTime", "2026-10-04T12:60:00Z");
bad("dateTime", "2026-10-04T12:30:60Z");
bad("dateTime", "2026-10-04 12:30:00Z");
bad("dateTime", "2026-10-04T12:30:00+15:00");
bad("dateTime", "2026-10-04T12:30:00+14:30");
bad("dateTime", "0000-01-01T00:00:00Z");
bad("dateTime", "26-10-04T00:00:00Z");
bad("dateTime", "yesterday");
}
#[test]
fn date_time_gregorian_and_gyear() {
ok("date", "2026-10-04");
ok("date", "2026-10-04Z");
ok("date", "2026-10-04-05:00");
bad("date", "2026-10-4");
bad("date", "2026-02-30");
bad("date", "2026-10-04T00:00:00Z");
ok("time", "12:30:00");
ok("time", "12:30:00.5Z");
bad("time", "12:30");
ok("gYear", "2026");
ok("gYear", "12026");
bad("gYear", "26");
ok("gMonth", "--10");
bad("gMonth", "--13");
ok("gDay", "---31");
bad("gDay", "---32");
}
#[test]
fn duration_needs_a_component() {
ok("duration", "P1Y2M3DT4H5M6.5S");
ok("duration", "PT0S");
ok("duration", "-P1D");
bad("duration", "P");
bad("duration", "PT");
bad("duration", "P1D1Y");
bad("duration", "1D");
bad("duration", "P1.5D");
}
#[test]
fn an_unknown_kind_is_not_judged() {
ok("hexBinary", "anything");
}
}