towerops/lib/snmpkit/formatting.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

225 lines
5.6 KiB
Elixir

defmodule SnmpKit.Formatting do
@moduledoc """
Pure formatting functions for SnmpKit.
Provides number formatting, byte size formatting, rate formatting,
response time formatting, string truncation, and SNMP TimeTicks uptime.
"""
@doc """
Format a non-negative integer with comma thousand-separators.
## Examples
iex> SnmpKit.Formatting.format_number(1234567)
"1,234,567"
iex> SnmpKit.Formatting.format_number(-1234)
"-1,234"
"""
@spec format_number(integer()) :: String.t()
def format_number(number) when number < 0 do
"-" <> format_positive_number(-number)
end
def format_number(number) do
format_positive_number(number)
end
@doc """
Format bytes as a human-readable size string.
Uses binary units (1024-based): B, KB, MB, GB.
## Examples
iex> SnmpKit.Formatting.format_bytes(1024)
"1.0 KB"
iex> SnmpKit.Formatting.format_bytes(1_048_576)
"1.0 MB"
iex> SnmpKit.Formatting.format_bytes(500)
"500 B"
"""
@spec format_bytes(integer()) :: String.t()
def format_bytes(bytes) do
cond do
bytes >= 1_073_741_824 ->
format_float(bytes / 1_073_741_824.0, 1) <> " GB"
bytes >= 1_048_576 ->
format_float(bytes / 1_048_576.0, 1) <> " MB"
bytes >= 1024 ->
format_float(bytes / 1024.0, 1) <> " KB"
true ->
"#{bytes} B"
end
end
@doc """
Format a rate with SI-prefix units (K, M, G).
## Examples
iex> SnmpKit.Formatting.format_rate(1_500_000, "bps")
"1.5 Mbps"
iex> SnmpKit.Formatting.format_rate(1_000, "bps")
"1.0 Kbps"
"""
@spec format_rate(integer(), String.t()) :: String.t()
def format_rate(value, unit) do
cond do
value >= 1_000_000_000 ->
format_float(value / 1_000_000_000.0, 1) <> " G" <> unit
value >= 1_000_000 ->
format_float(value / 1_000_000.0, 1) <> " M" <> unit
value >= 1_000 ->
format_float(value / 1_000.0, 1) <> " K" <> unit
true ->
"#{value} #{unit}"
end
end
@doc """
Format microseconds as a human-readable response time.
## Examples
iex> SnmpKit.Formatting.format_response_time(1500)
"1.50ms"
iex> SnmpKit.Formatting.format_response_time(2_500_000)
"2.50s"
iex> SnmpKit.Formatting.format_response_time(500)
"500μs"
"""
@spec format_response_time(integer()) :: String.t()
def format_response_time(microseconds) do
cond do
microseconds >= 1_000_000 ->
format_float(microseconds / 1_000_000.0, 2) <> "s"
microseconds >= 1_000 ->
format_float(microseconds / 1_000.0, 2) <> "ms"
true ->
"#{microseconds}μs"
end
end
@doc """
Truncate a string to a maximum length, appending "..." if truncated.
For max_length <= 3, returns a plain slice with no ellipsis.
## Examples
iex> SnmpKit.Formatting.truncate_string("Hello, World!", 10)
"Hello, ..."
iex> SnmpKit.Formatting.truncate_string("Hi", 10)
"Hi"
iex> SnmpKit.Formatting.truncate_string("Hello", 2)
"He"
"""
@spec truncate_string(String.t(), non_neg_integer()) :: String.t()
def truncate_string(s, max_length) when max_length > 3 do
if String.length(s) <= max_length do
s
else
String.slice(s, 0, max_length - 3) <> "..."
end
end
def truncate_string(s, max_length) do
String.slice(s, 0, max(max_length, 0))
end
@doc """
Format SNMP TimeTicks (centiseconds) as a human-readable uptime string.
## Examples
iex> SnmpKit.Formatting.format_timeticks_uptime(9000)
"1 minute 30 seconds"
iex> SnmpKit.Formatting.format_timeticks_uptime(42)
"42 centiseconds"
iex> SnmpKit.Formatting.format_timeticks_uptime(0)
"0 centiseconds"
"""
@spec format_timeticks_uptime(non_neg_integer()) :: String.t()
def format_timeticks_uptime(centiseconds) do
total_seconds = div(centiseconds, 100)
remaining_cs = rem(centiseconds, 100)
case {total_seconds, remaining_cs} do
{0, 0} ->
"0 centiseconds"
{0, cs} ->
"#{cs} centiseconds"
{_, _} ->
build_time_parts(total_seconds, remaining_cs)
end
end
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
defp format_float(value, decimals) when is_number(value) and is_integer(decimals) do
:erlang.float_to_binary(value * 1.0, decimals: decimals)
end
defp format_positive_number(number) do
number
|> Integer.to_string()
|> String.graphemes()
|> Enum.reverse()
|> Enum.chunk_every(3)
|> Enum.map(fn chunk -> chunk |> Enum.reverse() |> Enum.join("") end)
|> Enum.reverse()
|> Enum.join(",")
end
defp build_time_parts(total_seconds, centiseconds) do
days = div(total_seconds, 86_400)
remaining = rem(total_seconds, 86_400)
hours = div(remaining, 3600)
remaining = rem(remaining, 3600)
minutes = div(remaining, 60)
seconds = rem(remaining, 60)
[]
|> append_if_positive(days, "day")
|> append_if_positive(hours, "hour")
|> append_if_positive(minutes, "minute")
|> append_if_positive(seconds, "second")
|> append_if_positive(centiseconds, "centisecond")
|> case do
[] -> "0 centiseconds"
parts -> Enum.join(parts, " ")
end
end
defp append_if_positive(parts, value, label) when value > 0 do
parts ++ ["#{value} #{label}#{plural(value)}"]
end
defp append_if_positive(parts, _value, _label), do: parts
defp plural(1), do: ""
defp plural(_), do: "s"
end