Skip to main content

src/fixdate.gleam

//// Format and parse HTTP-date timestamps (RFC 9110, section 5.6.7), the
//// format used by HTTP headers such as `Date`, `Last-Modified`, and
//// `Expires`.

import gleam/int
import gleam/list
import gleam/result
import gleam/string
import gleam/time/calendar
import gleam/time/timestamp.{type Timestamp}

/// Render a `Timestamp` as an RFC 9110 IMF-fixdate string.
///
/// ```gleam
/// to_string(timestamp.from_unix_seconds(1_782_697_748))
/// // -> "Mon, 29 Jun 2026 01:49:08 GMT"
/// ```
pub fn to_string(timestamp: Timestamp) -> String {
  let #(date, time) = timestamp.to_calendar(timestamp, calendar.utc_offset)
  let calendar.Date(year:, month:, day:) = date
  let calendar.TimeOfDay(hours:, minutes:, seconds:, ..) = time

  weekday_name(day_of_week(year, month, day))
  <> ", "
  <> pad2(day)
  <> " "
  <> month_name(month)
  <> " "
  <> pad4(year)
  <> " "
  <> pad2(hours)
  <> ":"
  <> pad2(minutes)
  <> ":"
  <> pad2(seconds)
  <> " GMT"
}

fn pad2(n: Int) -> String {
  string.pad_start(int.to_string(n), to: 2, with: "0")
}

fn pad4(n: Int) -> String {
  string.pad_start(int.to_string(n), to: 4, with: "0")
}

fn month_name(month: calendar.Month) -> String {
  case month {
    calendar.January -> "Jan"
    calendar.February -> "Feb"
    calendar.March -> "Mar"
    calendar.April -> "Apr"
    calendar.May -> "May"
    calendar.June -> "Jun"
    calendar.July -> "Jul"
    calendar.August -> "Aug"
    calendar.September -> "Sep"
    calendar.October -> "Oct"
    calendar.November -> "Nov"
    calendar.December -> "Dec"
  }
}

fn weekday_name(dow: Int) -> String {
  case dow {
    0 -> "Sun"
    1 -> "Mon"
    2 -> "Tue"
    3 -> "Wed"
    4 -> "Thu"
    5 -> "Fri"
    _ -> "Sat"
  }
}

// Sakamoto's algorithm. Returns 0 = Sunday .. 6 = Saturday
fn day_of_week(year: Int, month: calendar.Month, day: Int) -> Int {
  let y = case month {
    calendar.January | calendar.February -> year - 1
    _ -> year
  }
  let t = case month {
    calendar.January -> 0
    calendar.February -> 3
    calendar.March -> 2
    calendar.April -> 5
    calendar.May -> 0
    calendar.June -> 3
    calendar.July -> 5
    calendar.August -> 1
    calendar.September -> 4
    calendar.October -> 6
    calendar.November -> 2
    calendar.December -> 4
  }

  { y + y / 4 - y / 100 + y / 400 + t + day } % 7
}

/// Parse an HTTP-date into a `Timestamp`. Accepts all three formats in
/// RFC 9110: IMF-fixdate, the obsolete RFC 850 (2-digit year), and
/// asctime. Returns `Error(Nil)` on malformed or out-of-range input.
///
/// ```gleam
/// parse("Mon, 29 Jun 2026 01:49:08 GMT")
/// // -> Ok(timestamp.from_unix_seconds(1_782_697_748))
/// ```
pub fn parse(input: String) -> Result(Timestamp, Nil) {
  let tokens =
    input
    |> string.split(" ")
    |> list.filter(fn(token) { token != "" })
  case tokens {
    // IMF-fixdate: "Mon, 29 Jun 2026 01:49:08 GMT"
    [_dow, day, month, year, time, "GMT"] ->
      parse_fields(day:, month:, year:, time:)
    // RFC 850: "Monday, 29-Jun-26 01:49:08 GMT"
    [_dow, date, time, "GMT"] -> parse_rfc850(date, time)
    // asctime: "Mon Jun 29 01:49:08 2026"
    [_day, month, day, time, year] -> parse_fields(day:, month:, year:, time:)
    _ -> Error(Nil)
  }
}

fn parse_fields(
  day day: String,
  month month: String,
  year year: String,
  time time: String,
) -> Result(Timestamp, Nil) {
  use year <- result.try(int.parse(year))
  build_timestamp(day:, month:, year:, time:)
}

fn build_timestamp(
  day day: String,
  month month: String,
  year year: Int,
  time time: String,
) -> Result(Timestamp, Nil) {
  use day <- result.try(int.parse(day))
  use month <- result.try(month_from_name(month))
  use #(hours, minutes, seconds) <- result.try(parse_time(time))
  let date = calendar.Date(year:, month:, day:)

  case calendar.is_valid_date(date) && valid_time(hours, minutes, seconds) {
    True -> {
      let time_of_day =
        calendar.TimeOfDay(hours:, minutes:, seconds:, nanoseconds: 0)
      Ok(timestamp.from_calendar(date, time_of_day, calendar.utc_offset))
    }
    False -> Error(Nil)
  }
}

fn parse_rfc850(date: String, time: String) -> Result(Timestamp, Nil) {
  case string.split(date, "-") {
    [day, month, two_digit_year] -> {
      use year <- result.try(expand_two_digit_year(two_digit_year))
      build_timestamp(day:, month:, year:, time:)
    }
    _ -> Error(Nil)
  }
}

fn expand_two_digit_year(two_digit_year: String) -> Result(Int, Nil) {
  case int.parse(two_digit_year) {
    Ok(year) if year >= 0 && year <= 69 -> Ok(2000 + year)
    Ok(year) if year >= 70 && year <= 99 -> Ok(1900 + year)
    _ -> Error(Nil)
  }
}

fn month_from_name(name: String) -> Result(calendar.Month, Nil) {
  case name {
    "Jan" -> Ok(calendar.January)
    "Feb" -> Ok(calendar.February)
    "Mar" -> Ok(calendar.March)
    "Apr" -> Ok(calendar.April)
    "May" -> Ok(calendar.May)
    "Jun" -> Ok(calendar.June)
    "Jul" -> Ok(calendar.July)
    "Aug" -> Ok(calendar.August)
    "Sep" -> Ok(calendar.September)
    "Oct" -> Ok(calendar.October)
    "Nov" -> Ok(calendar.November)
    "Dec" -> Ok(calendar.December)
    _ -> Error(Nil)
  }
}

fn parse_time(time: String) -> Result(#(Int, Int, Int), Nil) {
  case string.split(time, ":") {
    [hours, minutes, seconds] -> {
      use hours <- result.try(int.parse(hours))
      use minutes <- result.try(int.parse(minutes))
      use seconds <- result.try(int.parse(seconds))
      Ok(#(hours, minutes, seconds))
    }
    _ -> Error(Nil)
  }
}

// Accepts the RFC 9110 leap-second value 60
fn valid_time(hours: Int, minutes: Int, seconds: Int) -> Bool {
  hours >= 0
  && hours <= 23
  && minutes >= 0
  && minutes <= 59
  && seconds >= 0
  && seconds <= 60
}