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
103 lines
2.7 KiB
Elixir
103 lines
2.7 KiB
Elixir
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
|