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).
148 lines
3.6 KiB
Elixir
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
|