towerops/lib/towerops_web/changelog_parser.ex
Graham McIntire efaf5558ff refactor: convert 6 Gleam modules to idiomatic Elixir with TDD (#196)
Phase 1: Foundation Types (100% complete)
- query_helpers: SQL LIKE sanitization with pipe operators
- numeric: Integer parsing with pattern matching guards
- result: Pure Elixir Result monad (map, and_then, unwrap_or)

Phase 2: Ecto Domain Types (100% complete)
- ip_address: IPv4/IPv6 validation using :inet directly
- mac_address: Multi-format MAC parsing (colon/hyphen/dot/compact)
- snmp_oid: OID parsing/manipulation with recursive pattern matching

All 198 tests passing across converted modules.
API changed from Gleam-style {:some/:none to idiomatic {:ok/:error.
Refactored parse_numeric_oid to use with statement, reducing nesting depth.

Reviewed-on: graham/towerops-web#196
2026-03-28 09:52:07 -05:00

103 lines
2.7 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule ToweropsWeb.ChangelogParser do
@moduledoc """
Reads and parses priv/static/changelog.txt into structured entries.
Parses changelog entries with format:
- "YYYY-MM-DD — Title" or "YYYY-MM-DD Title" (with em/en dash)
- "YYYY-MM-DD" (date only, no title)
- "* Item text" (bullet items belonging to the most recent date)
"""
@doc "Returns a list of %{date: Date.t(), title: String.t() | nil, items: [String.t()]}"
def parse do
path = Application.app_dir(:towerops, "priv/static/changelog.txt")
case File.read(path) do
{:ok, content} ->
content
|> parse_content()
|> Enum.map(&convert_entry/1)
{:error, _} ->
[]
end
end
@doc """
Parse changelog content string into a list of entries.
Returns tuples in Gleam format: {:changelog_entry, date, title_opt, items}
"""
def parse_content(content) when is_binary(content) do
content
|> String.split("\n")
|> Enum.reduce([], fn line, acc -> parse_line(line, acc) end)
|> Enum.reverse()
|> Enum.map(fn entry ->
{:changelog_entry, entry.date, entry.title, Enum.reverse(entry.items)}
end)
end
# Parse a single line and update the accumulator
defp parse_line(line, acc) do
# Check for date with title: "YYYY-MM-DD [—–] Title"
case Regex.run(~r/^(\d{4}-\d{2}-\d{2})\s+(—|)\s+(.+)/u, line) do
[_, date_str, _dash, title] ->
add_date_entry(acc, date_str, {:some, String.trim(title)})
nil ->
parse_line_no_title(line, acc)
end
end
defp parse_line_no_title(line, acc) do
cond do
# Check for date only: "YYYY-MM-DD"
Regex.match?(~r/^\d{4}-\d{2}-\d{2}\s*$/, line) ->
add_date_entry(acc, line, :none)
# Check for bullet item: "* Item text"
String.starts_with?(String.trim(line), "* ") ->
add_item_to_current_entry(acc, line)
# Non-matching line - ignore
true ->
acc
end
end
defp add_date_entry(acc, date_str, title) do
[
%{
date: String.trim(date_str),
title: title,
items: []
}
| acc
]
end
defp add_item_to_current_entry(acc, line) do
trimmed = String.trim(line)
item = String.slice(trimmed, 2..-1//1)
case acc do
[current | rest] ->
[%{current | items: [item | current.items]} | rest]
[] ->
# Orphan item before any date header - ignore
[]
end
end
defp convert_entry({:changelog_entry, date_str, title_opt, items}) do
%{
date: Date.from_iso8601!(date_str),
title: unwrap_option(title_opt),
items: items
}
end
defp unwrap_option({:some, value}), do: value
defp unwrap_option(:none), do: nil
end