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:
parent
004189aaf9
commit
d392c2d788
2 changed files with 283 additions and 0 deletions
148
lib/towerops/devices/version_comparator.ex
Normal file
148
lib/towerops/devices/version_comparator.ex
Normal 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
|
||||
135
test/towerops/devices/version_comparator_test.exs
Normal file
135
test/towerops/devices/version_comparator_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue