///|
/// A structured date/time value used for feed timestamps.
/// Supports RFC 822 (for RSS ``) and RFC 3339 (for Atom ``/``).
pub struct FeedDate {
year : Int
month : Int // 1-12
day : Int // 1-31
hour : Int // 0-23
minute : Int // 0-59
second : Int // 0-59
offset_minutes : Int // UTC offset in minutes; 0 = UTC, 540 = +09:00, -300 = -05:00
} derive(Debug, Eq, Compare)
///|
/// Create a `FeedDate` at UTC (offset = 0).
pub fn FeedDate::utc(
year : Int,
month : Int,
day : Int,
hour? : Int = 0,
minute? : Int = 0,
second? : Int = 0,
) -> FeedDate {
{ year, month, day, hour, minute, second, offset_minutes: 0 }
}
///|
/// Create a `FeedDate` with an explicit UTC offset in minutes.
pub fn FeedDate::new(
year : Int,
month : Int,
day : Int,
hour? : Int = 0,
minute? : Int = 0,
second? : Int = 0,
offset_minutes? : Int = 0,
) -> FeedDate {
{ year, month, day, hour, minute, second, offset_minutes }
}
// Day-of-week names and month abbreviations for RFC 822.
///|
let dow_names : Array[String] = [
"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat",
]
///|
let month_names : Array[String] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
///|
fn is_leap_year(year : Int) -> Bool {
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}
///|
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 date_validation_error(
year : Int,
month : Int,
day : Int,
hour : Int,
minute : Int,
second : Int,
offset_minutes : Int,
) -> String? {
if year < 1 || year > 9999 {
return Some("year out of range: \{year}")
}
if month < 1 || month > 12 {
return Some("month out of range: \{month}")
}
let max_day = days_in_month(year, month)
if day < 1 || day > max_day {
return Some("day out of range: \{day}")
}
if hour < 0 || hour > 23 {
return Some("hour out of range: \{hour}")
}
if minute < 0 || minute > 59 {
return Some("minute out of range: \{minute}")
}
if second < 0 || second > 59 {
return Some("second out of range: \{second}")
}
if offset_minutes < -23 * 60 - 59 || offset_minutes > 23 * 60 + 59 {
return Some("UTC offset out of range: \{offset_minutes}")
}
None
}
///|
fn FeedDate::validation_error(self : FeedDate, field : String) -> FeedError? {
match
date_validation_error(
self.year,
self.month,
self.day,
self.hour,
self.minute,
self.second,
self.offset_minutes,
) {
Some(msg) => Some(InvalidDate("\{field}: \{msg}"))
None => None
}
}
///|
fn FeedDate::is_valid(self : FeedDate) -> Bool {
self.validation_error("date") is None
}
///|
/// Zero-pad an integer to exactly 2 decimal digits.
fn pad2(n : Int) -> String {
if n < 10 {
"0" + n.to_string()
} else {
n.to_string()
}
}
///|
/// Compute the day-of-week using Sakamoto's algorithm.
/// Returns 0 = Sunday, 1 = Monday, ..., 6 = Saturday.
/// Uses the LOCAL date (as written, regardless of offset).
fn day_of_week(year : Int, month : Int, day : Int) -> Int {
let t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]
let y = if month < 3 { year - 1 } else { year }
(y + y / 4 - y / 100 + y / 400 + t[month - 1] + day) % 7
}
///|
/// Render as RFC 822 date string, e.g. `"Wed, 02 Oct 2002 13:00:00 GMT"`.
/// When `offset_minutes` is non-zero, uses numeric zone like `"+0900"`.
fn FeedDate::to_rfc822(self : FeedDate) -> String {
let dow = dow_names[day_of_week(self.year, self.month, self.day)]
let mon = month_names[self.month - 1]
let zone = if self.offset_minutes == 0 {
"GMT"
} else {
let abs_min = if self.offset_minutes < 0 {
-self.offset_minutes
} else {
self.offset_minutes
}
let sign = if self.offset_minutes < 0 { "-" } else { "+" }
let h = abs_min / 60
let m = abs_min % 60
sign + pad2(h) + pad2(m)
}
"\{dow}, \{pad2(self.day)} \{mon} \{self.year} \{pad2(self.hour)}:\{pad2(self.minute)}:\{pad2(self.second)} \{zone}"
}
///|
/// Render as RFC 3339 date string, e.g. `"2002-10-02T13:00:00Z"`.
/// When `offset_minutes` is non-zero, uses `"+HH:MM"` suffix.
fn FeedDate::to_rfc3339(self : FeedDate) -> String {
let date = "\{self.year}-\{pad2(self.month)}-\{pad2(self.day)}T\{pad2(self.hour)}:\{pad2(self.minute)}:\{pad2(self.second)}"
if self.offset_minutes == 0 {
date + "Z"
} else {
let abs_min = if self.offset_minutes < 0 {
-self.offset_minutes
} else {
self.offset_minutes
}
let sign = if self.offset_minutes < 0 { "-" } else { "+" }
let h = abs_min / 60
let m = abs_min % 60
date + sign + pad2(h) + ":" + pad2(m)
}
}
///|
fn FeedDate::to_rfc822_checked(self : FeedDate) -> String? {
if self.is_valid() {
Some(self.to_rfc822())
} else {
None
}
}
///|
fn FeedDate::to_rfc3339_checked(self : FeedDate) -> String? {
if self.is_valid() {
Some(self.to_rfc3339())
} else {
None
}
}
///|
fn optional_date_to_rfc822(date : FeedDate?) -> String? {
match date {
Some(d) => d.to_rfc822_checked()
None => None
}
}
///|
fn optional_date_to_rfc3339(date : FeedDate?) -> String? {
match date {
Some(d) => d.to_rfc3339_checked()
None => None
}
}