From d392c2d7889db9077a9f0473fa4cfccbfaa2c46b Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 1 Feb 2026 10:24:13 -0600 Subject: [PATCH] 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). --- lib/towerops/devices/version_comparator.ex | 148 ++++++++++++++++++ .../devices/version_comparator_test.exs | 135 ++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 lib/towerops/devices/version_comparator.ex create mode 100644 test/towerops/devices/version_comparator_test.exs diff --git a/lib/towerops/devices/version_comparator.ex b/lib/towerops/devices/version_comparator.ex new file mode 100644 index 00000000..849159bd --- /dev/null +++ b/lib/towerops/devices/version_comparator.ex @@ -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 diff --git a/test/towerops/devices/version_comparator_test.exs b/test/towerops/devices/version_comparator_test.exs new file mode 100644 index 00000000..8e331ab6 --- /dev/null +++ b/test/towerops/devices/version_comparator_test.exs @@ -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