///|
/// Parsed RFC 3339 timestamp used by STIX 2.1 datetime properties.
pub(all) struct Timestamp {
year : Int
month : Int
day : Int
hour : Int
minute : Int
second : Int
fraction : String
offset_minutes : Int
text : String
} derive(Eq, Debug)
///|
pub fn parse_timestamp(text : String) -> Result[Timestamp, String] {
if text.length() < 19 {
return Err("timestamp is too short")
}
if !char_eq(text, 4, '-') || !char_eq(text, 7, '-') {
return Err("timestamp date must use YYYY-MM-DD")
}
if !char_eq(text, 10, 'T') {
return Err("timestamp must separate date and time with 'T'")
}
if !char_eq(text, 13, ':') || !char_eq(text, 16, ':') {
return Err("timestamp time must use HH:MM:SS")
}
let year = match parse_digits(text, 0, 4) {
Some(value) => value
None => return Err("invalid year")
}
let month = match parse_digits(text, 5, 2) {
Some(value) => value
None => return Err("invalid month")
}
let day = match parse_digits(text, 8, 2) {
Some(value) => value
None => return Err("invalid day")
}
let hour = match parse_digits(text, 11, 2) {
Some(value) => value
None => return Err("invalid hour")
}
let minute = match parse_digits(text, 14, 2) {
Some(value) => value
None => return Err("invalid minute")
}
let second = match parse_digits(text, 17, 2) {
Some(value) => value
None => return Err("invalid second")
}
if month < 1 || month > 12 {
return Err("month out of range")
}
if day < 1 || day > days_in_month(year, month) {
return Err("day out of range")
}
if hour > 23 || minute > 59 || second > 59 {
return Err("time out of range")
}
let mut pos = 19
let mut fraction = ""
if char_eq(text, pos, '.') {
pos += 1
let start = pos
while pos < text.length() {
match char_at(text, pos) {
Some(ch) => if ch.is_ascii_digit() { pos += 1 } else { break }
None => break
}
}
if pos == start {
return Err("timestamp fraction has no digits")
}
fraction = slice_to(text, start, pos)
}
let offset_minutes = match parse_timezone(text, pos) {
Ok(value) => value
Err(err) => return Err(err)
}
Ok({ year, month, day, hour, minute, second, fraction, offset_minutes, text, })
}
///|
fn parse_digits(text : String, start : Int, width : Int) -> Int? {
if start + width > text.length() {
return None
}
let mut value = 0
for i = 0; i < width; i = i + 1 {
match char_at(text, start + i) {
Some(ch) => {
if !ch.is_ascii_digit() {
return None
}
value = value * 10 + (ch.to_int() - 48)
}
None => return None
}
}
Some(value)
}
///|
fn days_in_month(year : Int, month : Int) -> Int {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31
4 | 6 | 9 | 11 => 30
2 => if is_leap_year(year) { 29 } else { 28 }
_ => 0
}
}
///|
fn is_leap_year(year : Int) -> Bool {
year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
}
///|
fn parse_timezone(text : String, start : Int) -> Result[Int, String] {
match char_at(text, start) {
None => Err("timestamp is missing a timezone")
Some('Z') =>
if start + 1 != text.length() {
Err("timestamp has trailing characters after Z")
} else {
Ok(0)
}
Some(sign) => {
if sign != '+' && sign != '-' {
return Err("timestamp timezone must be Z or an offset")
}
if start + 6 != text.length() || !char_eq(text, start + 3, ':') {
return Err("timestamp offset must be HH:MM")
}
let hour = match parse_digits(text, start + 1, 2) {
Some(value) => value
None => return Err("invalid offset hour")
}
let minute = match parse_digits(text, start + 4, 2) {
Some(value) => value
None => return Err("invalid offset minute")
}
if hour > 23 || minute > 59 {
return Err("offset out of range")
}
let total = hour * 60 + minute
if sign == '-' {
Ok(0 - total)
} else {
Ok(total)
}
}
}
}
///|
pub fn timestamp_is_rfc3339(text : String) -> Bool {
match parse_timestamp(text) {
Ok(_) => true
Err(_) => false
}
}