towerops/src/snmpkit/formatting.gleam
Graham McIntire 348975dcc9 Rewrite polling_offset, changelog_parser, and user_agent_parser in Gleam (#103)
Migrate three pure-function modules from Elixir to Gleam:

- polling_offset: deterministic hash-based polling offset (pure Gleam, no FFI)
- changelog_parser: text parsing logic in Gleam, thin Elixir wrapper for File I/O
- user_agent_parser: regex-based UA parsing in Gleam with Erlang FFI for
  atom-keyed map conversion

Add gleam_regexp dependency for regex support in Gleam modules.
Update all call sites and tests to use Gleam module atoms.

Reviewed-on: graham/towerops-web#103
2026-03-21 10:11:06 -05:00

174 lines
5.2 KiB
Gleam

/// Pure formatting functions shared by SnmpKit.SnmpLib.Types and Utils.
///
/// Provides number formatting, byte size formatting, rate formatting,
/// response time formatting, string truncation, and SNMP TimeTicks uptime.
///
/// Compiles to `:snmpkit@formatting`.
import gleam/int
import gleam/list
import gleam/string
// ---------------------------------------------------------------------------
// FFI declarations
// ---------------------------------------------------------------------------
/// Format a float to a string with N decimal places.
@external(erlang, "snmpkit_formatting_ffi", "format_float")
fn format_float(value: Float, decimals: Int) -> String
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Format a non-negative integer with comma thousand-separators.
///
/// Example: `1234567` → `"1,234,567"`
pub fn format_number(number: Int) -> String {
case number < 0 {
True -> "-" <> format_positive_number(0 - number)
False -> format_positive_number(number)
}
}
/// Format bytes as a human-readable size string.
///
/// Uses binary units (1024-based): B, KB, MB, GB.
pub fn format_bytes(bytes: Int) -> String {
case bytes >= 1_073_741_824 {
True ->
format_float(int.to_float(bytes) /. 1_073_741_824.0, 1) <> " GB"
False ->
case bytes >= 1_048_576 {
True ->
format_float(int.to_float(bytes) /. 1_048_576.0, 1) <> " MB"
False ->
case bytes >= 1024 {
True ->
format_float(int.to_float(bytes) /. 1024.0, 1) <> " KB"
False -> int.to_string(bytes) <> " B"
}
}
}
}
/// Format a rate with SI-prefix units (K, M, G).
///
/// Example: `format_rate(1500000, "bps")` → `"1.5 Mbps"`
pub fn format_rate(value: Int, unit: String) -> String {
case value >= 1_000_000_000 {
True ->
format_float(int.to_float(value) /. 1_000_000_000.0, 1) <> " G" <> unit
False ->
case value >= 1_000_000 {
True ->
format_float(int.to_float(value) /. 1_000_000.0, 1) <> " M" <> unit
False ->
case value >= 1_000 {
True ->
format_float(int.to_float(value) /. 1_000.0, 1) <> " K" <> unit
False -> int.to_string(value) <> " " <> unit
}
}
}
}
/// Format microseconds as a human-readable response time.
///
/// Example: `1500` → `"1.50ms"`, `2500000` → `"2.50s"`, `500` → `"500μs"`
pub fn format_response_time(microseconds: Int) -> String {
case microseconds >= 1_000_000 {
True ->
format_float(int.to_float(microseconds) /. 1_000_000.0, 2) <> "s"
False ->
case microseconds >= 1_000 {
True ->
format_float(int.to_float(microseconds) /. 1_000.0, 2) <> "ms"
False -> int.to_string(microseconds) <> "μs"
}
}
}
/// Truncate a string to a maximum length, appending "..." if truncated.
///
/// For max_length <= 3, returns a plain slice with no ellipsis.
pub fn truncate_string(s: String, max_length: Int) -> String {
case max_length > 3 {
True ->
case string.length(s) <= max_length {
True -> s
False -> string.slice(s, 0, max_length - 3) <> "..."
}
False -> string.slice(s, 0, int.max(max_length, 0))
}
}
/// Format SNMP TimeTicks (centiseconds) as a human-readable uptime string.
///
/// Example: `9000` → `"1 minute 30 seconds"`, `42` → `"42 centiseconds"`
pub fn format_timeticks_uptime(centiseconds: Int) -> String {
let total_seconds = centiseconds / 100
let remaining_cs = centiseconds % 100
case total_seconds, remaining_cs {
0, 0 -> "0 centiseconds"
0, cs -> int.to_string(cs) <> " centiseconds"
_, _ -> build_time_parts(total_seconds, remaining_cs)
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
fn format_positive_number(number: Int) -> String {
number
|> int.to_string()
|> string.to_graphemes()
|> list.reverse()
|> list.sized_chunk(3)
|> list.map(fn(chunk) { chunk |> list.reverse() |> string.join("") })
|> list.reverse()
|> string.join(",")
}
fn build_time_parts(total_seconds: Int, centiseconds: Int) -> String {
let days = total_seconds / 86_400
let remaining = total_seconds % 86_400
let hours = remaining / 3600
let remaining = remaining % 3600
let minutes = remaining / 60
let seconds = remaining % 60
let parts = []
let parts = append_if_positive(parts, days, "day")
let parts = append_if_positive(parts, hours, "hour")
let parts = append_if_positive(parts, minutes, "minute")
let parts = append_if_positive(parts, seconds, "second")
let parts = append_if_positive(parts, centiseconds, "centisecond")
case parts {
[] -> "0 centiseconds"
_ -> string.join(parts, " ")
}
}
fn append_if_positive(
parts: List(String),
value: Int,
label: String,
) -> List(String) {
case value > 0 {
True ->
list.append(parts, [
int.to_string(value) <> " " <> label <> plural(value),
])
False -> parts
}
}
fn plural(n: Int) -> String {
case n {
1 -> ""
_ -> "s"
}
}