///|
/// Timestamp formatting for rotated log file names.
///
/// Only the calendar/clock fields that make sense in a file name are
/// supported. Formatting is offset-aware so a daemon can render file names
/// in the host's local time while keeping the rest of its arithmetic in
/// Unix seconds.
///|
/// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm).
pub fn days_from_civil(year : Int, month : Int, day : Int) -> Int {
let y = if month <= 2 { year - 1 } else { year }
let era = (if y >= 0 { y } else { y - 399 }) / 400
let yoe = y - era * 400
let mp = if month > 2 { month - 3 } else { month + 9 }
let doy = (153 * mp + 2) / 5 + day - 1
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
era * 146097 + doe - 719468
}
///|
/// Civil date `(year, month, day)` for a number of days since 1970-01-01.
pub fn civil_from_days(days : Int) -> (Int, Int, Int) {
let z = days + 719468
let era = (if z >= 0 { z } else { z - 146096 }) / 146097
let doe = z - era * 146097
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
let y = yoe + era * 400
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
let mp = (5 * doy + 2) / 153
let d = doy - (153 * mp + 2) / 5 + 1
let m = if mp < 10 { mp + 3 } else { mp - 9 }
(if m <= 2 { y + 1 } else { y }, m, d)
}
///|
/// Day of week for a number of days since 1970-01-01 (0 = Sunday).
pub fn weekday_from_days(days : Int) -> Int {
(days % 7 + 4 + 7) % 7
}
///|
/// Split a local Unix second into its calendar/clock fields.
pub fn split_timestamp(
local_secs : Int64,
) -> (Int, Int, Int, Int, Int, Int, Int) {
let days = (local_secs / 86400L).to_int()
let sod = (local_secs % 86400L).to_int()
let (y, mo, d) = civil_from_days(days)
let hh = sod / 3600
let mi = sod % 3600 / 60
let ss = sod % 60
(y, mo, d, hh, mi, ss, days)
}
///|
/// Try to render a single format token at `pos`. Returns the rendered text
/// and the token length, or `None` when no token starts here.
fn render_token(
fmt : StringView,
pos : Int,
year : Int,
month : Int,
day : Int,
hour : Int,
minute : Int,
second : Int,
weekday : Int,
offset_secs : Int,
) -> (String, Int)? {
let rest = fmt.view(start_offset=pos, end_offset=fmt.length())
let abs_offset = if offset_secs < 0 { -offset_secs } else { offset_secs }
let offset_sign = if offset_secs < 0 { "-" } else { "+" }
let offset_hh = pad_left((abs_offset / 3600).to_string(), 2, '0')
let offset_mm = pad_left((abs_offset % 3600 / 60).to_string(), 2, '0')
// Longest tokens first so that YYYY wins over YY, MM over M, ...
let tokens : Array[(String, String)] = [
("YYYY", pad_left(year.to_string(), 4, '0')),
("MMM", month_names[month - 1]),
("ddd", weekday_names[weekday]),
("YY", pad_left((year % 100).to_string(), 2, '0')),
("MM", pad_left(month.to_string(), 2, '0')),
("DD", pad_left(day.to_string(), 2, '0')),
("HH", pad_left(hour.to_string(), 2, '0')),
("mm", pad_left(minute.to_string(), 2, '0')),
("ss", pad_left(second.to_string(), 2, '0')),
("ZZ", offset_sign + offset_hh + offset_mm),
("Z", offset_sign + offset_hh + ":" + offset_mm),
("M", month.to_string()),
("D", day.to_string()),
("H", hour.to_string()),
("m", minute.to_string()),
("s", second.to_string()),
]
for token in tokens {
let (pattern, value) = token
if rest.has_prefix(pattern) {
return Some((value, pattern.length()))
}
}
None
}
///|
/// Short weekday names, indexed by day of week (0 = Sunday).
let weekday_names : Array[String] = [
"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat",
]
///|
/// Short month names, indexed by month number minus one.
let month_names : Array[String] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct",
"Nov", "Dec",
]
///|
/// Format a Unix second using a subset of the moment.js-style tokens:
/// `YYYY`, `YY`, `MMM`, `MM`, `M`, `DD`, `D`, `ddd`, `HH`, `H`, `mm`, `m`,
/// `ss`, `s`, `Z` (±HH:mm) and `ZZ` (±HHmm). `[literal]` escapes text, as in
/// dayjs.
pub fn format_timestamp(
epoch_secs : Int64,
offset_secs : Int,
fmt : String,
) -> String {
let local_secs = epoch_secs + offset_secs.to_int64()
let (year, month, day, hour, minute, second, days) = split_timestamp(local_secs)
let weekday = weekday_from_days(days)
let out = StringBuilder()
let mut i = 0
while i < fmt.length() {
// `[literal]` escapes text, the way dayjs does, so a format can carry
// words like `GMT` without their letters being read as tokens.
if fmt.get_char(i) == Some('[') {
let mut j = i + 1
while j < fmt.length() && fmt.get_char(j) != Some(']') {
guard fmt.get_char(j) is Some(c) else { break }
out.write_char(c)
j = j + 1
}
i = if j < fmt.length() { j + 1 } else { j }
continue
}
match render_token(
fmt, i, year, month, day, hour, minute, second, weekday, offset_secs,
) {
Some((text, len)) => {
out.write_string(text)
i = i + len
}
None => {
guard fmt.get_char(i) is Some(c) else { break }
out.write_char(c)
i = i + 1
}
}
}
out.to_string()
}