log_parser.ry

module log_parser
  purpose: Parse server log lines and count the errors in each hour.

import std.console
import std.filesystem exposing Path, FileError

public type Timestamp is Text
  where value.matches(raw "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$")
  purpose: An ISO 8601 timestamp to the second, such as 2024-05-01T13:45:00.

public type Level is one of
  purpose: Severity of a log entry.
  Debug
  Info
  Warning
  Error
end

public type Entry
  purpose: One parsed log line.
  has timestamp: Timestamp
  has level: Level
  has message: Text
end

public type ParseError is one of
  purpose: Why a line could not be parsed.
  Malformed(line: Text)
  UnknownLevel(word: Text)
end

public function parse_level(word: Text) returns Level or fails with ParseError
  purpose: The level named by the capitalized word used in log lines.
  example: parse_level("ERROR") is Error
  example: parse_level("TRACE") fails with UnknownLevel(word: "TRACE")

  match word
    when "DEBUG" then return Debug
    when "INFO" then return Info
    when "WARN" then return Warning
    when "ERROR" then return Error
    otherwise fail with UnknownLevel(word: word)
  end
end

public function parse_line(line: Text) returns Entry or fails with ParseError
  purpose: Split a line of the form "timestamp LEVEL message" into an entry.
  tags: logs, parsing
  example: parse_line("2024-05-01T13:45:00 INFO server started")
    is Entry(timestamp: Timestamp("2024-05-01T13:45:00"), level: Info, message: "server started")
  example: parse_line("garbage") fails with Malformed(line: "garbage")

  let words be line.split(" ")
  if words.length() is less than 3 then fail with Malformed(line: line) end
  let first_word be words.at(0) otherwise fail with Malformed(line: line)
  let timestamp be Timestamp(first_word) otherwise fail with Malformed(line: line)
  let level_word be words.at(1) otherwise fail with Malformed(line: line)
  let level be parse_level(level_word) otherwise fail
  return Entry(timestamp: timestamp, level: level, message: words.drop(2).join(" "))
end

public function hour_of(timestamp: Timestamp) returns Text
  purpose: The date and hour of a timestamp, used as the bucket key.
  example: hour_of(Timestamp("2024-05-01T13:45:00")) is "2024-05-01T13"

  return timestamp.take(13)
end

public function errors_per_hour(entries: List of Entry) returns Map of Text to Integer
  purpose: How many error entries fall into each hour.

  let errors be for each entry in entries where entry.level is Error collect entry
  let grouped be for each entry in errors group by hour_of(entry.timestamp)
  let mutable totals: Map of Text to Integer be {}
  for each hour, bucket in grouped
    change totals to totals.set(key: hour, value: bucket.length())
  end
  return totals
end

public function main() or fails with FileError or ParseError needs console, filesystem.read
  purpose: Print the number of errors in each hour of the sample log, earliest hour first.

  let text be filesystem.read_text(Path("data/server.log")) otherwise fail
  let entries be
    for each line in text.lines()
    where line.trim() is not ""
    collect parse_line(line) otherwise fail
  for each hour, total in errors_per_hour(entries) sorted by hour
    console.print("{hour}: {total} errors")
  end
end