///|
/// Parse digits from a character array between start (inclusive) and end (exclusive).
pub fn parse_date_digits(chars : Array[Char], start : Int, end : Int) -> Int {
let mut result = 0
for i in start..= '0' && c <= '9' {
result = result * 10 + (c.to_int() - '0'.to_int())
}
}
}
result
}
///|
/// Calculate the number of days since the Unix epoch (1970-01-01).
pub fn days_since_epoch(year : Int, month : Int, day : Int) -> Int {
let mut days = 0
for y in 1970.. Bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
///|
/// Parse a leading integer from a string, stopping at the first non-digit.
pub fn parse_int_from_string(s : String) -> Int {
let mut result = 0
let mut found = false
for c in s {
if c >= '0' && c <= '9' {
result = result * 10 + (c.to_int() - '0'.to_int())
found = true
} else {
break
}
}
if found {
result
} else {
0
}
}
///|
/// Convert a month name (e.g. "jan", "february") to its number (1-12).
pub fn month_name_to_number(name : String) -> Int? {
let lower = name.to_lower()
if lower == "jan" || lower == "january" {
Some(1)
} else if lower == "feb" || lower == "february" {
Some(2)
} else if lower == "mar" || lower == "march" {
Some(3)
} else if lower == "apr" || lower == "april" {
Some(4)
} else if lower == "may" {
Some(5)
} else if lower == "jun" || lower == "june" {
Some(6)
} else if lower == "jul" || lower == "july" {
Some(7)
} else if lower == "aug" || lower == "august" {
Some(8)
} else if lower == "sep" || lower == "september" {
Some(9)
} else if lower == "oct" || lower == "october" {
Some(10)
} else if lower == "nov" || lower == "november" {
Some(11)
} else if lower == "dec" || lower == "december" {
Some(12)
} else {
None
}
}
///|
/// Parse an ISO 8601 date/time string (e.g. "2024-01-15T10:30:00").
/// Returns 0 if the string is too short or if the resulting calendar
/// fields are out of range (would index `month_days[]` out of bounds
/// inside `days_since_epoch`).
pub fn parse_approxidate_iso8601(s : String) -> Int64 {
let chars = s.to_array()
if chars.length() < 10 {
return 0L
}
let year = parse_date_digits(chars, 0, 4)
let month = parse_date_digits(chars, 5, 7)
let day = parse_date_digits(chars, 8, 10)
if year < 1970 || month < 1 || month > 12 || day < 1 || day > 31 {
return 0L
}
let mut hour = 0
let mut minute = 0
let mut second = 0
if chars.length() >= 16 {
hour = parse_date_digits(chars, 11, 13)
minute = parse_date_digits(chars, 14, 16)
if chars.length() >= 19 && chars[16] == ':' {
second = parse_date_digits(chars, 17, 19)
}
}
let days = days_since_epoch(year, month, day)
days.to_int64() * 86400L +
hour.to_int64() * 3600L +
minute.to_int64() * 60L +
second.to_int64()
}
///|
/// Parse a YYYY-MM-DD date string, optionally with time (YYYY-MM-DD HH:MM:SS or YYYY-MM-DDTHH:MM:SS).
pub fn parse_approxidate_iso_date(s : String) -> Int64? {
let chars = s.to_array()
if chars.length() < 10 {
return None
}
guard chars[4] == '-' && chars[7] == '-' else { return None }
let year = parse_date_digits(chars, 0, 4)
let month = parse_date_digits(chars, 5, 7)
let day = parse_date_digits(chars, 8, 10)
if year < 1970 || month < 1 || month > 12 || day < 1 || day > 31 {
return None
}
let mut hour = 0
let mut minute = 0
let mut second = 0
// Accept both "HH:MM" (length >= 16) and "HH:MM:SS" (length >= 19); git
// permits a reflog date without an explicit seconds field.
if chars.length() >= 16 && (chars[10] == ' ' || chars[10] == 'T') {
hour = parse_date_digits(chars, 11, 13)
minute = parse_date_digits(chars, 14, 16)
if chars.length() >= 19 && chars[16] == ':' {
second = parse_date_digits(chars, 17, 19)
}
}
let days = days_since_epoch(year, month, day)
Some(
days.to_int64() * 86400L +
hour.to_int64() * 3600L +
minute.to_int64() * 60L +
second.to_int64(),
)
}
///|
/// Parse a human-readable date string like "Jan 15 2024" or "January 15 2024 10:30:00".
pub fn parse_approxidate_human(s : String) -> Int64? {
let parts : Array[String] = []
for p in s.split(" ") {
let ps = p.to_owned()
if ps.length() > 0 {
parts.push(ps)
}
}
if parts.length() < 3 {
return None
}
match month_name_to_number(parts[0]) {
Some(month) => {
let day = parse_int_from_string(parts[1])
let year = parse_int_from_string(parts[2])
if day <= 0 || year < 1970 {
return None
}
let mut hour = 0
let mut minute = 0
let mut second = 0
if parts.length() >= 4 {
let time_parts : Array[String] = []
for tp in parts[3].split(":") {
time_parts.push(tp.to_owned())
}
if time_parts.length() >= 1 {
hour = parse_int_from_string(time_parts[0])
}
if time_parts.length() >= 2 {
minute = parse_int_from_string(time_parts[1])
}
if time_parts.length() >= 3 {
second = parse_int_from_string(time_parts[2])
}
}
let days = days_since_epoch(year, month, day)
Some(
days.to_int64() * 86400L +
hour.to_int64() * 3600L +
minute.to_int64() * 60L +
second.to_int64(),
)
}
None => None
}
}
///|
/// Parse a relative date string like "2 weeks ago", "yesterday", "now".
/// Takes the current timestamp as a parameter for purity.
pub fn parse_approxidate_relative(s : String, now : Int64) -> Int64? {
let lower = s.to_lower()
if lower == "now" {
return Some(now)
}
if lower == "yesterday" {
return Some(now - 86400L)
}
// Split by spaces and dots (git treats both as separators)
let normalized = lower.replace_all(old=".", new=" ")
let parts : Array[String] = []
for p in normalized.split(" ") {
let ps = p.to_owned()
if ps.length() > 0 {
parts.push(ps)
}
}
if parts.length() >= 3 && parts[parts.length() - 1] == "ago" {
let n = parse_int_from_string(parts[0])
if n <= 0 {
return None
}
let unit = parts[1]
let seconds_per_unit : Int64 = if unit == "second" ||
unit == "seconds" ||
unit == "sec" ||
unit == "secs" {
1L
} else if unit == "minute" ||
unit == "minutes" ||
unit == "min" ||
unit == "mins" {
60L
} else if unit == "hour" || unit == "hours" {
3600L
} else if unit == "day" || unit == "days" {
86400L
} else if unit == "week" || unit == "weeks" {
604800L
} else if unit == "month" || unit == "months" {
2592000L
} else if unit == "year" || unit == "years" {
31536000L
} else {
return None
}
return Some(now - n.to_int64() * seconds_per_unit)
}
None
}
///|
/// Parse git's raw epoch date form `@`, optionally followed by a
/// timezone such as ` +0000`. The timezone offset does not change the epoch
/// value git reports, so it is accepted but ignored. Returns None when the
/// string is not in this form.
pub fn parse_approxidate_epoch(s : String) -> Int64? {
let chars = s.to_array()
if chars.length() < 2 || chars[0] != '@' {
return None
}
let mut i = 1
let mut result = 0L
let mut found = false
while i < chars.length() && chars[i] >= '0' && chars[i] <= '9' {
result = result * 10L + (chars[i].to_int() - '0'.to_int()).to_int64()
found = true
i += 1
}
if !found {
return None
}
// Skip whitespace before an optional timezone, then ensure the only trailing
// content is a +HHMM / -HHMM offset.
while i < chars.length() && (chars[i] == ' ' || chars[i] == '\t') {
i += 1
}
if i < chars.length() && chars[i] != '+' && chars[i] != '-' {
return None
}
Some(result)
}
///|
/// Parse a date string trying multiple formats: ISO 8601, relative, human-readable, YYYY-MM-DD.
/// Takes the current timestamp for relative date resolution.
pub fn parse_approxidate(date_str : String, now : Int64) -> Int64? {
let s = date_str
match parse_approxidate_epoch(s) {
Some(ts) => return Some(ts)
None => ()
}
if s.find("T") is Some(_) {
let ts = parse_approxidate_iso8601(s)
if ts > 0L {
return Some(ts)
}
}
match parse_approxidate_relative(s, now) {
Some(ts) => return Some(ts)
None => ()
}
match parse_approxidate_human(s) {
Some(ts) => return Some(ts)
None => ()
}
match parse_approxidate_iso_date(s) {
Some(ts) => return Some(ts)
None => ()
}
None
}