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).
This commit is contained in:
Graham McIntire 2026-02-01 10:24:13 -06:00
parent 004189aaf9
commit d392c2d788
No known key found for this signature in database
2 changed files with 283 additions and 0 deletions

View file

@ -0,0 +1,148 @@
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

View file

@ -0,0 +1,135 @@
defmodule Towerops.Devices.VersionComparatorTest do
use ExUnit.Case, async: true
alias Towerops.Devices.VersionComparator
describe "compare/2" do
test "returns :eq for equal versions" do
assert VersionComparator.compare("7.14.1", "7.14.1") == :eq
end
test "returns :lt when first version is older (major)" do
assert VersionComparator.compare("6.14.1", "7.14.1") == :lt
end
test "returns :gt when first version is newer (major)" do
assert VersionComparator.compare("8.14.1", "7.14.1") == :gt
end
test "returns :lt when first version is older (minor)" do
assert VersionComparator.compare("7.13.1", "7.14.1") == :lt
end
test "returns :gt when first version is newer (minor)" do
assert VersionComparator.compare("7.15.1", "7.14.1") == :gt
end
test "returns :lt when first version is older (patch)" do
assert VersionComparator.compare("7.14.0", "7.14.1") == :lt
end
test "returns :gt when first version is newer (patch)" do
assert VersionComparator.compare("7.14.2", "7.14.1") == :gt
end
test "handles missing patch version (pads with 0)" do
assert VersionComparator.compare("7.14", "7.14.0") == :eq
assert VersionComparator.compare("7.14", "7.14.1") == :lt
end
test "handles missing minor and patch versions (pads with 0)" do
assert VersionComparator.compare("7", "7.0.0") == :eq
assert VersionComparator.compare("7", "8.0.0") == :lt
end
test "strips 'v' prefix (lowercase)" do
assert VersionComparator.compare("v7.14.1", "7.14.1") == :eq
end
test "strips 'V' prefix (uppercase)" do
assert VersionComparator.compare("V7.14.1", "7.14.1") == :eq
end
test "trims whitespace" do
assert VersionComparator.compare(" 7.14.1 ", "7.14.1") == :eq
end
test "ignores suffixes after hyphen" do
assert VersionComparator.compare("7.14.1-beta", "7.14.1") == :eq
assert VersionComparator.compare("7.14.1-beta", "7.14.1-release") == :eq
end
test "ignores suffixes after space" do
assert VersionComparator.compare("7.14.1 beta", "7.14.1") == :eq
end
test "handles leading zeros" do
assert VersionComparator.compare("007.014.001", "7.14.1") == :eq
end
test "handles empty string as invalid (falls back to 0.0.0)" do
assert VersionComparator.compare("", "1.0.0") == :lt
assert VersionComparator.compare("1.0.0", "") == :gt
assert VersionComparator.compare("", "") == :eq
end
test "handles non-numeric parts as invalid (falls back to 0.0.0)" do
assert VersionComparator.compare("abc", "1.0.0") == :lt
assert VersionComparator.compare("1.0.0", "abc") == :gt
assert VersionComparator.compare("7.abc.1", "7.0.1") == :lt
end
test "handles versions with dots but no numbers as invalid" do
assert VersionComparator.compare("v.1.2", "1.2.0") == :lt
end
test "handles 4-part versions as invalid (falls back to 0.0.0)" do
assert VersionComparator.compare("7.14.1.2", "7.14.1") == :lt
assert VersionComparator.compare("7.14.1", "7.14.1.2") == :gt
end
test "handles 5-part versions as invalid (falls back to 0.0.0)" do
assert VersionComparator.compare("7.14.1.2.3", "7.14.1") == :lt
assert VersionComparator.compare("7.14.1", "7.14.1.2.3") == :gt
end
test "handles negative numbers as invalid (falls back to 0.0.0)" do
assert VersionComparator.compare("7.-1.0", "7.0.0") == :lt
assert VersionComparator.compare("7.0.0", "7.-1.0") == :gt
end
test "compares invalid versions as equal" do
assert VersionComparator.compare("abc", "xyz") == :eq
assert VersionComparator.compare("7.14.1.2.3", "1.2.3.4.5") == :eq
end
end
describe "newer?/2" do
test "returns true when second version is newer" do
assert VersionComparator.newer?("7.14.1", "7.14.2") == true
assert VersionComparator.newer?("7.14.1", "7.15.0") == true
assert VersionComparator.newer?("7.14.1", "8.0.0") == true
end
test "returns false when second version is older" do
assert VersionComparator.newer?("7.14.2", "7.14.1") == false
assert VersionComparator.newer?("7.15.0", "7.14.1") == false
assert VersionComparator.newer?("8.0.0", "7.14.1") == false
end
test "returns false when versions are equal" do
assert VersionComparator.newer?("7.14.1", "7.14.1") == false
end
test "handles version prefixes and suffixes" do
assert VersionComparator.newer?("v7.14.1-beta", "V7.14.2") == true
assert VersionComparator.newer?("7.14.2", "v7.14.1-beta") == false
end
test "handles invalid versions" do
assert VersionComparator.newer?("abc", "1.0.0") == true
assert VersionComparator.newer?("1.0.0", "abc") == false
assert VersionComparator.newer?("abc", "xyz") == false
end
end
end