use super::{Invalid, TargetVersion};
pub(super) fn guid(value: &str, version: TargetVersion) -> Result<(), Invalid> {
let hex = |c: u8| match version {
TargetVersion::V2_1 => c.is_ascii_hexdigit(),
TargetVersion::V3_0 => c.is_ascii_digit() || (b'a'..=b'f').contains(&c),
};
let b = value.as_bytes();
let shaped = b.len() == 36
&& b.iter().enumerate().all(|(i, &c)| match i {
8 | 13 | 18 | 23 => c == b'-',
_ => hex(c),
});
if shaped {
Ok(())
} else {
Err(Invalid::Guid {
value: value.to_string(),
})
}
}
pub(super) fn ifc_guid(value: &str) -> Result<(), Invalid> {
let b = value.as_bytes();
let alphabet = |c: &u8| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'$';
if b.len() == 22 && b.iter().all(alphabet) && (b'0'..=b'3').contains(&b[0]) {
Ok(())
} else {
Err(Invalid::IfcGuid {
value: value.to_string(),
})
}
}
pub(super) fn text(value: &str) -> Result<(), Invalid> {
if value.trim().is_empty() {
return Err(Invalid::Blank);
}
if value.trim() != value {
return Err(Invalid::SurroundingWhitespace {
value: value.to_string(),
});
}
if let Some(ch) = value.chars().find(|&c| !xml_char(c)) {
return Err(Invalid::ForbiddenCharacter { ch });
}
Ok(())
}
fn xml_char(c: char) -> bool {
matches!(c, '\t' | '\n' | '\r') || (c >= ' ' && c != '\u{FFFE}' && c != '\u{FFFF}')
}
pub(super) fn date_time(value: &str) -> Result<(), Invalid> {
if parse_date_time(value).is_some() {
Ok(())
} else {
Err(Invalid::DateTime {
value: value.to_string(),
})
}
}
fn parse_date_time(value: &str) -> Option<()> {
let s = value.strip_prefix('-').unwrap_or(value);
let (date, rest) = s.split_once('T')?;
let mut parts = date.rsplitn(3, '-');
let (day, month, year) = (parts.next()?, parts.next()?, parts.next()?);
if year.len() < 4 || (year.len() > 4 && year.starts_with('0')) || !digits(year) {
return None;
}
if year.bytes().all(|b| b == b'0') {
return None;
}
let month = two_digits(month)?;
let day = two_digits(day)?;
if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
return None;
}
let (time, zone) = if let Some(t) = rest.strip_suffix('Z') {
(t, None)
} else if rest.len() > 6 && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-') {
let (t, z) = rest.split_at(rest.len() - 6);
(t, Some(&z[1..]))
} else {
(rest, None)
};
if let Some(z) = zone {
let (h, m) = z.split_once(':')?;
let (h, m) = (two_digits(h)?, two_digits(m)?);
if m > 59 || h > 14 || (h == 14 && m != 0) {
return None;
}
}
let (clock, fraction) = match time.split_once('.') {
Some((c, f)) if !f.is_empty() && digits(f) => (c, Some(f)),
Some(_) => return None,
None => (time, None),
};
let mut hms = clock.split(':');
let (h, m, sec) = (
two_digits(hms.next()?)?,
two_digits(hms.next()?)?,
two_digits(hms.next()?)?,
);
if hms.next().is_some() {
return None;
}
let end_of_day =
h == 24 && m == 0 && sec == 0 && fraction.is_none_or(|f| f.bytes().all(|b| b == b'0'));
if !end_of_day && (h > 23 || m > 59 || sec > 59) {
return None;
}
Some(())
}
fn digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
fn two_digits(s: &str) -> Option<u32> {
(s.len() == 2 && digits(s))
.then(|| s.parse().ok())
.flatten()
}
fn days_in_month(year: &str, month: u32) -> u32 {
match month {
4 | 6 | 9 | 11 => 30,
2 => {
let y: u32 = year[year.len() - 4..].parse().unwrap_or(1);
if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 {
29
} else {
28
}
}
_ => 31,
}
}
pub(super) fn finite(value: f64) -> Result<(), Invalid> {
if value.is_finite() {
Ok(())
} else {
Err(Invalid::Number {
value,
expected: "a finite number",
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn guids_follow_the_version_pattern() {
let lower = "3f2504e0-4f89-41d3-9a0c-0305e82c3301";
let upper = "3F2504E0-4F89-41D3-9A0C-0305E82C3301";
assert!(guid(lower, TargetVersion::V2_1).is_ok());
assert!(guid(lower, TargetVersion::V3_0).is_ok());
assert!(guid(upper, TargetVersion::V2_1).is_ok());
assert!(
guid(upper, TargetVersion::V3_0).is_err(),
"3.0 is lowercase only"
);
for bad in [
"",
"3f2504e0-4f89-41d3-9a0c-0305e82c330",
"3f2504e04f8941d39a0c0305e82c3301",
"{3f2504e0-4f89-41d3-9a0c-0305e82c3301}",
"3f2504e0-4f89-41d3-9a0c-0305e82c330g",
"3f2504e0_4f89-41d3-9a0c-0305e82c3301",
] {
assert!(guid(bad, TargetVersion::V2_1).is_err(), "{bad:?}");
}
}
#[test]
fn ifc_guids_are_22_chars_of_ifc_base64() {
assert!(ifc_guid("0fXw$sQh19ixbI4tZgfkXu").is_ok());
assert!(ifc_guid("3cUkl32yn9qRSPvBJVyWY_").is_ok());
for bad in [
"",
"0fXw$sQh19ixbI4tZgfkX", "0fXw$sQh19ixbI4tZgfkXuu", "0fXw$sQh19ixbI4tZgfk,u", "0fXw-sQh19ixbI4tZgfkXu", "4fXw$sQh19ixbI4tZgfkXu", ] {
assert!(ifc_guid(bad).is_err(), "{bad:?}");
}
}
#[test]
fn text_refuses_what_would_not_round_trip() {
assert!(text("Kollision Lüftung & <Rohr>").is_ok());
assert!(text("line one\r\nline two").is_ok());
assert_eq!(text(""), Err(Invalid::Blank));
assert_eq!(text(" \t\n"), Err(Invalid::Blank));
assert!(matches!(
text(" x"),
Err(Invalid::SurroundingWhitespace { .. })
));
assert!(matches!(
text("x\n"),
Err(Invalid::SurroundingWhitespace { .. })
));
assert_eq!(
text("a\u{0}b"),
Err(Invalid::ForbiddenCharacter { ch: '\u{0}' })
);
assert_eq!(
text("a\u{1b}b"),
Err(Invalid::ForbiddenCharacter { ch: '\u{1b}' })
);
assert_eq!(
text("a\u{FFFF}"),
Err(Invalid::ForbiddenCharacter { ch: '\u{FFFF}' })
);
}
#[test]
fn date_times_accept_the_xsd_lexical_space() {
for ok in [
"2026-09-26T10:00:00Z",
"2026-09-26T10:00:00",
"2026-09-26T10:00:00.123456+02:00",
"2026-09-26T10:00:00-14:00",
"2024-02-29T00:00:00Z",
"2000-02-29T00:00:00Z",
"2026-12-31T24:00:00Z",
"-0044-03-15T12:00:00Z",
"12026-01-01T00:00:00Z",
] {
assert!(date_time(ok).is_ok(), "{ok}");
}
for bad in [
"",
"2026-09-26",
"2026-09-26 10:00:00",
"2026-9-26T10:00:00Z",
"26-09-26T10:00:00Z",
"0000-01-01T00:00:00Z",
"02026-01-01T00:00:00Z",
"2026-13-01T00:00:00Z",
"2026-02-29T00:00:00Z",
"1900-02-29T00:00:00Z",
"2026-04-31T00:00:00Z",
"2026-09-26T24:00:01Z",
"2026-09-26T10:60:00Z",
"2026-09-26T10:00:60Z",
"2026-09-26T10:00Z",
"2026-09-26T10:00:00.Z",
"2026-09-26T10:00:00+15:00",
"2026-09-26T10:00:00+14:30",
"2026-09-26T10:00:00+0200",
"2026-09-26T10:00:00z",
] {
assert!(date_time(bad).is_err(), "{bad}");
}
}
#[test]
fn non_finite_numbers_are_refused() {
assert!(finite(0.0).is_ok());
assert!(finite(f64::NAN).is_err());
assert!(finite(f64::INFINITY).is_err());
}
}