towerops/lib/towerops/devices/version_comparator.ex
Graham McIntire d392c2d788
Add VersionComparator module for semantic version comparison
Implements compare/2 and newer?/2 functions for comparing semantic versions.

Parsing rules:
- Supports 0-3 part versions (e.g., '7', '7.14', '7.14.1')
- Strips v/V prefix and whitespace
- Ignores suffixes after hyphen or space
- Pads missing parts with 0
- Invalid versions (4+ parts, non-numeric, negative) fall back to 0.0.0

Part of firmware version tracking feature (Phase 2).
2026-02-01 10:24:13 -06:00

148 lines
3.6 KiB
Elixir

defmodule Towerops.Devices.VersionComparator do
@moduledoc """
Compares semantic version strings.
Supports versions with 0-3 parts (e.g., "7", "7.14", "7.14.1").
Versions with 4+ parts are considered invalid and fall back to {0, 0, 0}.
## Parsing Rules
- Strips "v" or "V" prefix (case-insensitive)
- Trims whitespace
- Splits on "-" or " " and ignores suffixes (e.g., "7.14.1-beta" → "7.14.1")
- Pads missing parts with 0 (e.g., "7.14" → {7, 14, 0})
- 0-3 parts: VALID
- 4+ parts: INVALID → fallback to {0, 0, 0}
- Non-numeric parts: INVALID
- Negative numbers: INVALID
- Leading zeros: VALID (e.g., "007.014.001" → {7, 14, 1})
## Examples
iex> VersionComparator.compare("7.14.1", "7.14.2")
:lt
iex> VersionComparator.compare("8.0.0", "7.14.1")
:gt
iex> VersionComparator.compare("7.14.1", "7.14.1")
:eq
iex> VersionComparator.newer?("7.14.1", "7.14.2")
true
iex> VersionComparator.newer?("7.14.2", "7.14.1")
false
"""
@doc """
Compares two version strings.
Returns `:lt` if the first version is older, `:gt` if newer, or `:eq` if equal.
## Examples
iex> VersionComparator.compare("7.14.1", "7.14.2")
:lt
iex> VersionComparator.compare("8.0.0", "7.14.1")
:gt
iex> VersionComparator.compare("v7.14.1", "V7.14.1")
:eq
"""
def compare(version1, version2) do
v1_tuple = parse_version(version1)
v2_tuple = parse_version(version2)
cond do
v1_tuple < v2_tuple -> :lt
v1_tuple > v2_tuple -> :gt
true -> :eq
end
end
@doc """
Returns true if the second version is newer than the first.
## Examples
iex> VersionComparator.newer?("7.14.1", "7.14.2")
true
iex> VersionComparator.newer?("7.14.2", "7.14.1")
false
iex> VersionComparator.newer?("7.14.1", "7.14.1")
false
"""
def newer?(current_version, new_version) do
compare(current_version, new_version) == :lt
end
# Private helper to parse version string into {major, minor, patch} tuple
defp parse_version(version) when is_binary(version) do
version
|> String.trim()
|> strip_prefix()
|> strip_suffix()
|> parse_parts()
end
# Strip "v" or "V" prefix
defp strip_prefix("v" <> rest), do: rest
defp strip_prefix("V" <> rest), do: rest
defp strip_prefix(version), do: version
# Strip suffixes after "-" or " "
defp strip_suffix(version) do
version
|> String.split(["-", " "], parts: 2)
|> List.first()
end
# Parse version parts into {major, minor, patch} tuple
defp parse_parts(""), do: {0, 0, 0}
defp parse_parts(version) do
parts = String.split(version, ".")
# 4+ parts is invalid
if length(parts) > 3 do
{0, 0, 0}
else
parse_numeric_parts(parts)
end
end
# Convert parts to integers, pad with 0s, validate
defp parse_numeric_parts(parts) do
case parse_and_validate(parts) do
{:ok, numbers} -> pad_to_three(numbers)
:error -> {0, 0, 0}
end
end
# Parse each part as integer and validate (no negatives, must be numeric)
defp parse_and_validate(parts) do
results =
Enum.map(parts, fn part ->
case Integer.parse(part) do
{num, ""} when num >= 0 -> {:ok, num}
_ -> :error
end
end)
if Enum.all?(results, &match?({:ok, _}, &1)) do
{:ok, Enum.map(results, fn {:ok, num} -> num end)}
else
:error
end
end
# Pad list to 3 elements with 0s, then convert to tuple
defp pad_to_three(numbers) do
padded = numbers ++ List.duplicate(0, 3 - length(numbers))
List.to_tuple(Enum.take(padded, 3))
end
end