feat: Implement distro-native version comparison for RPM, Debian, and Alpine packages
- Add RpmVersionComparer for RPM version comparison with epoch, version, and release handling. - Introduce DebianVersion for parsing Debian EVR (Epoch:Version-Release) strings. - Create ApkVersion for parsing Alpine APK version strings with suffix support. - Define IVersionComparator interface for version comparison with proof-line generation. - Implement VersionComparisonResult struct to encapsulate comparison results and proof lines. - Add tests for Debian and RPM version comparers to ensure correct functionality and edge case handling. - Create project files for the version comparison library and its tests.
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.VersionComparison.Models;
|
||||
|
||||
namespace StellaOps.VersionComparison.Comparers;
|
||||
|
||||
/// <summary>
|
||||
/// Compares Debian/Ubuntu package versions using dpkg semantics.
|
||||
/// Handles epoch:upstream_version-debian_revision with tilde pre-release support.
|
||||
/// </summary>
|
||||
public sealed class DebianVersionComparer : IVersionComparator, IComparer<DebianVersion>, IComparer<string>
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static DebianVersionComparer Instance { get; } = new();
|
||||
|
||||
private DebianVersionComparer() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ComparatorType ComparatorType => ComparatorType.Dpkg;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y)) return 0;
|
||||
if (x is null) return -1;
|
||||
if (y is null) return 1;
|
||||
|
||||
var xParsed = DebianVersion.TryParse(x, out var xVer);
|
||||
var yParsed = DebianVersion.TryParse(y, out var yVer);
|
||||
|
||||
if (xParsed && yParsed)
|
||||
{
|
||||
return Compare(xVer!, yVer!);
|
||||
}
|
||||
|
||||
if (xParsed) return 1;
|
||||
if (yParsed) return -1;
|
||||
|
||||
return string.Compare(x, y, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compare two parsed Debian versions.
|
||||
/// </summary>
|
||||
public int Compare(DebianVersion? x, DebianVersion? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y)) return 0;
|
||||
if (x is null) return -1;
|
||||
if (y is null) return 1;
|
||||
|
||||
// Epoch first
|
||||
var compare = x.Epoch.CompareTo(y.Epoch);
|
||||
if (compare != 0) return compare;
|
||||
|
||||
// Upstream version
|
||||
compare = CompareSegment(x.Version, y.Version);
|
||||
if (compare != 0) return compare;
|
||||
|
||||
// Debian revision
|
||||
compare = CompareSegment(x.Revision, y.Revision);
|
||||
return compare;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public VersionComparisonResult CompareWithProof(string? left, string? right)
|
||||
{
|
||||
var proofLines = new List<string>();
|
||||
|
||||
if (left is null && right is null)
|
||||
{
|
||||
proofLines.Add("Both versions are null: equal");
|
||||
return new VersionComparisonResult(0, [.. proofLines], ComparatorType.Dpkg);
|
||||
}
|
||||
|
||||
if (left is null)
|
||||
{
|
||||
proofLines.Add("Left version is null: less than right");
|
||||
return new VersionComparisonResult(-1, [.. proofLines], ComparatorType.Dpkg);
|
||||
}
|
||||
|
||||
if (right is null)
|
||||
{
|
||||
proofLines.Add("Right version is null: left is greater");
|
||||
return new VersionComparisonResult(1, [.. proofLines], ComparatorType.Dpkg);
|
||||
}
|
||||
|
||||
var leftParsed = DebianVersion.TryParse(left, out var leftVer);
|
||||
var rightParsed = DebianVersion.TryParse(right, out var rightVer);
|
||||
|
||||
if (!leftParsed || !rightParsed)
|
||||
{
|
||||
if (!leftParsed && !rightParsed)
|
||||
{
|
||||
var cmp = string.Compare(left, right, StringComparison.Ordinal);
|
||||
proofLines.Add($"Both versions invalid, fallback to string comparison: {ResultString(cmp)}");
|
||||
return new VersionComparisonResult(cmp, [.. proofLines], ComparatorType.Dpkg);
|
||||
}
|
||||
|
||||
if (!leftParsed)
|
||||
{
|
||||
proofLines.Add("Left version invalid, right valid: left is less");
|
||||
return new VersionComparisonResult(-1, [.. proofLines], ComparatorType.Dpkg);
|
||||
}
|
||||
|
||||
proofLines.Add("Right version invalid, left valid: left is greater");
|
||||
return new VersionComparisonResult(1, [.. proofLines], ComparatorType.Dpkg);
|
||||
}
|
||||
|
||||
// Compare epoch
|
||||
var epochCmp = leftVer!.Epoch.CompareTo(rightVer!.Epoch);
|
||||
if (epochCmp != 0)
|
||||
{
|
||||
proofLines.Add($"Epoch: {leftVer.Epoch} {CompareSymbol(epochCmp)} {rightVer.Epoch} ({ResultString(epochCmp)})");
|
||||
return new VersionComparisonResult(epochCmp, [.. proofLines], ComparatorType.Dpkg);
|
||||
}
|
||||
proofLines.Add($"Epoch: {leftVer.Epoch} == {rightVer.Epoch} (equal)");
|
||||
|
||||
// Compare upstream version
|
||||
var versionCmp = CompareSegmentWithProof(leftVer.Version, rightVer.Version, "Upstream version", proofLines);
|
||||
if (versionCmp != 0)
|
||||
{
|
||||
return new VersionComparisonResult(versionCmp, [.. proofLines], ComparatorType.Dpkg);
|
||||
}
|
||||
|
||||
// Compare revision
|
||||
var revisionCmp = CompareSegmentWithProof(leftVer.Revision, rightVer.Revision, "Debian revision", proofLines);
|
||||
return new VersionComparisonResult(revisionCmp, [.. proofLines], ComparatorType.Dpkg);
|
||||
}
|
||||
|
||||
private static int CompareSegmentWithProof(string left, string right, string segmentName, List<string> proofLines)
|
||||
{
|
||||
var cmp = CompareSegment(left, right);
|
||||
if (cmp == 0)
|
||||
{
|
||||
if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right))
|
||||
{
|
||||
proofLines.Add($"{segmentName}: (empty) == (empty) (equal)");
|
||||
}
|
||||
else
|
||||
{
|
||||
proofLines.Add($"{segmentName}: {left} == {right} (equal)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var leftDisplay = string.IsNullOrEmpty(left) ? "(empty)" : left;
|
||||
var rightDisplay = string.IsNullOrEmpty(right) ? "(empty)" : right;
|
||||
proofLines.Add($"{segmentName}: {leftDisplay} {CompareSymbol(cmp)} {rightDisplay} ({ResultString(cmp)})");
|
||||
}
|
||||
return cmp;
|
||||
}
|
||||
|
||||
private static string CompareSymbol(int cmp) => cmp < 0 ? "<" : cmp > 0 ? ">" : "==";
|
||||
|
||||
private static string ResultString(int cmp) => cmp < 0 ? "left is older" : cmp > 0 ? "left is newer" : "equal";
|
||||
|
||||
/// <summary>
|
||||
/// Compare two version/revision segments using dpkg semantics.
|
||||
/// </summary>
|
||||
internal static int CompareSegment(string left, string right)
|
||||
{
|
||||
var i = 0;
|
||||
var j = 0;
|
||||
|
||||
while (i < left.Length || j < right.Length)
|
||||
{
|
||||
// Skip non-alphanumeric (except tilde)
|
||||
while (i < left.Length && !IsAlphaNumeric(left[i]) && left[i] != '~')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
while (j < right.Length && !IsAlphaNumeric(right[j]) && right[j] != '~')
|
||||
{
|
||||
j++;
|
||||
}
|
||||
|
||||
var leftChar = i < left.Length ? left[i] : '\0';
|
||||
var rightChar = j < right.Length ? right[j] : '\0';
|
||||
|
||||
// Tilde sorts before everything (including empty)
|
||||
if (leftChar == '~' || rightChar == '~')
|
||||
{
|
||||
if (leftChar != rightChar)
|
||||
{
|
||||
return leftChar == '~' ? -1 : 1;
|
||||
}
|
||||
|
||||
if (leftChar == '~') i++;
|
||||
if (rightChar == '~') j++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var leftIsDigit = char.IsDigit(leftChar);
|
||||
var rightIsDigit = char.IsDigit(rightChar);
|
||||
|
||||
// Both numeric
|
||||
if (leftIsDigit && rightIsDigit)
|
||||
{
|
||||
var leftStart = i;
|
||||
while (i < left.Length && char.IsDigit(left[i])) i++;
|
||||
|
||||
var rightStart = j;
|
||||
while (j < right.Length && char.IsDigit(right[j])) j++;
|
||||
|
||||
// Trim leading zeros
|
||||
var leftTrimmed = leftStart;
|
||||
while (leftTrimmed < i && left[leftTrimmed] == '0') leftTrimmed++;
|
||||
|
||||
var rightTrimmed = rightStart;
|
||||
while (rightTrimmed < j && right[rightTrimmed] == '0') rightTrimmed++;
|
||||
|
||||
var leftLength = i - leftTrimmed;
|
||||
var rightLength = j - rightTrimmed;
|
||||
|
||||
if (leftLength != rightLength)
|
||||
{
|
||||
return leftLength.CompareTo(rightLength);
|
||||
}
|
||||
|
||||
var comparison = left.AsSpan(leftTrimmed, leftLength)
|
||||
.CompareTo(right.AsSpan(rightTrimmed, rightLength), StringComparison.Ordinal);
|
||||
if (comparison != 0) return comparison;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Digits sort after letters
|
||||
if (leftIsDigit) return 1;
|
||||
if (rightIsDigit) return -1;
|
||||
|
||||
// Character ordering
|
||||
var leftOrder = CharOrder(leftChar);
|
||||
var rightOrder = CharOrder(rightChar);
|
||||
|
||||
var orderComparison = leftOrder.CompareTo(rightOrder);
|
||||
if (orderComparison != 0) return orderComparison;
|
||||
|
||||
if (leftChar != rightChar) return leftChar.CompareTo(rightChar);
|
||||
|
||||
if (leftChar == '\0') return 0;
|
||||
|
||||
i++;
|
||||
j++;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool IsAlphaNumeric(char value) => char.IsLetterOrDigit(value);
|
||||
|
||||
private static int CharOrder(char value)
|
||||
{
|
||||
if (value == '\0') return 0;
|
||||
if (value == '~') return -1;
|
||||
if (char.IsDigit(value)) return 0;
|
||||
if (char.IsLetter(value)) return value;
|
||||
return value + 256;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.VersionComparison.Models;
|
||||
|
||||
namespace StellaOps.VersionComparison.Comparers;
|
||||
|
||||
/// <summary>
|
||||
/// Compares RPM package versions using rpmvercmp semantics.
|
||||
/// Handles epoch, version, release with tilde pre-release support.
|
||||
/// </summary>
|
||||
public sealed class RpmVersionComparer : IVersionComparator, IComparer<RpmVersion>, IComparer<string>
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static RpmVersionComparer Instance { get; } = new();
|
||||
|
||||
private RpmVersionComparer() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ComparatorType ComparatorType => ComparatorType.RpmEvr;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y)) return 0;
|
||||
if (x is null) return -1;
|
||||
if (y is null) return 1;
|
||||
|
||||
var xParsed = RpmVersion.TryParse(x, out var xVer);
|
||||
var yParsed = RpmVersion.TryParse(y, out var yVer);
|
||||
|
||||
if (xParsed && yParsed)
|
||||
{
|
||||
return Compare(xVer!, yVer!);
|
||||
}
|
||||
|
||||
if (xParsed) return 1;
|
||||
if (yParsed) return -1;
|
||||
|
||||
return string.Compare(x, y, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compare two parsed RPM versions.
|
||||
/// </summary>
|
||||
public int Compare(RpmVersion? x, RpmVersion? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y)) return 0;
|
||||
if (x is null) return -1;
|
||||
if (y is null) return 1;
|
||||
|
||||
// Epoch first
|
||||
var compare = x.Epoch.CompareTo(y.Epoch);
|
||||
if (compare != 0) return compare;
|
||||
|
||||
// Version
|
||||
compare = CompareSegment(x.Version, y.Version);
|
||||
if (compare != 0) return compare;
|
||||
|
||||
// Release
|
||||
compare = CompareSegment(x.Release, y.Release);
|
||||
return compare;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public VersionComparisonResult CompareWithProof(string? left, string? right)
|
||||
{
|
||||
var proofLines = new List<string>();
|
||||
|
||||
if (left is null && right is null)
|
||||
{
|
||||
proofLines.Add("Both versions are null: equal");
|
||||
return new VersionComparisonResult(0, [.. proofLines], ComparatorType.RpmEvr);
|
||||
}
|
||||
|
||||
if (left is null)
|
||||
{
|
||||
proofLines.Add("Left version is null: less than right");
|
||||
return new VersionComparisonResult(-1, [.. proofLines], ComparatorType.RpmEvr);
|
||||
}
|
||||
|
||||
if (right is null)
|
||||
{
|
||||
proofLines.Add("Right version is null: left is greater");
|
||||
return new VersionComparisonResult(1, [.. proofLines], ComparatorType.RpmEvr);
|
||||
}
|
||||
|
||||
var leftParsed = RpmVersion.TryParse(left, out var leftVer);
|
||||
var rightParsed = RpmVersion.TryParse(right, out var rightVer);
|
||||
|
||||
if (!leftParsed || !rightParsed)
|
||||
{
|
||||
if (!leftParsed && !rightParsed)
|
||||
{
|
||||
var cmp = string.Compare(left, right, StringComparison.Ordinal);
|
||||
proofLines.Add($"Both versions invalid, fallback to string comparison: {ResultString(cmp)}");
|
||||
return new VersionComparisonResult(cmp, [.. proofLines], ComparatorType.RpmEvr);
|
||||
}
|
||||
|
||||
if (!leftParsed)
|
||||
{
|
||||
proofLines.Add("Left version invalid, right valid: left is less");
|
||||
return new VersionComparisonResult(-1, [.. proofLines], ComparatorType.RpmEvr);
|
||||
}
|
||||
|
||||
proofLines.Add("Right version invalid, left valid: left is greater");
|
||||
return new VersionComparisonResult(1, [.. proofLines], ComparatorType.RpmEvr);
|
||||
}
|
||||
|
||||
// Compare epoch
|
||||
var epochCmp = leftVer!.Epoch.CompareTo(rightVer!.Epoch);
|
||||
if (epochCmp != 0)
|
||||
{
|
||||
proofLines.Add($"Epoch: {leftVer.Epoch} {CompareSymbol(epochCmp)} {rightVer.Epoch} ({ResultString(epochCmp)})");
|
||||
return new VersionComparisonResult(epochCmp, [.. proofLines], ComparatorType.RpmEvr);
|
||||
}
|
||||
proofLines.Add($"Epoch: {leftVer.Epoch} == {rightVer.Epoch} (equal)");
|
||||
|
||||
// Compare version
|
||||
var versionCmp = CompareSegmentWithProof(leftVer.Version, rightVer.Version, "Version", proofLines);
|
||||
if (versionCmp != 0)
|
||||
{
|
||||
return new VersionComparisonResult(versionCmp, [.. proofLines], ComparatorType.RpmEvr);
|
||||
}
|
||||
|
||||
// Compare release
|
||||
var releaseCmp = CompareSegmentWithProof(leftVer.Release, rightVer.Release, "Release", proofLines);
|
||||
return new VersionComparisonResult(releaseCmp, [.. proofLines], ComparatorType.RpmEvr);
|
||||
}
|
||||
|
||||
private static int CompareSegmentWithProof(string left, string right, string segmentName, List<string> proofLines)
|
||||
{
|
||||
var cmp = CompareSegment(left, right);
|
||||
if (cmp == 0)
|
||||
{
|
||||
proofLines.Add($"{segmentName}: {left} == {right} (equal)");
|
||||
}
|
||||
else
|
||||
{
|
||||
proofLines.Add($"{segmentName}: {left} {CompareSymbol(cmp)} {right} ({ResultString(cmp)})");
|
||||
}
|
||||
return cmp;
|
||||
}
|
||||
|
||||
private static string CompareSymbol(int cmp) => cmp < 0 ? "<" : cmp > 0 ? ">" : "==";
|
||||
|
||||
private static string ResultString(int cmp) => cmp < 0 ? "left is older" : cmp > 0 ? "left is newer" : "equal";
|
||||
|
||||
/// <summary>
|
||||
/// Compare two version/release segments using rpmvercmp semantics.
|
||||
/// </summary>
|
||||
internal static int CompareSegment(string? left, string? right)
|
||||
{
|
||||
left ??= string.Empty;
|
||||
right ??= string.Empty;
|
||||
|
||||
var i = 0;
|
||||
var j = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var leftHasTilde = SkipToNextSegment(left, ref i);
|
||||
var rightHasTilde = SkipToNextSegment(right, ref j);
|
||||
|
||||
if (leftHasTilde || rightHasTilde)
|
||||
{
|
||||
if (leftHasTilde && rightHasTilde) continue;
|
||||
return leftHasTilde ? -1 : 1;
|
||||
}
|
||||
|
||||
var leftEnd = i >= left.Length;
|
||||
var rightEnd = j >= right.Length;
|
||||
|
||||
if (leftEnd || rightEnd)
|
||||
{
|
||||
if (leftEnd && rightEnd) return 0;
|
||||
return leftEnd ? -1 : 1;
|
||||
}
|
||||
|
||||
var leftDigit = char.IsDigit(left[i]);
|
||||
var rightDigit = char.IsDigit(right[j]);
|
||||
|
||||
if (leftDigit && !rightDigit) return 1;
|
||||
if (!leftDigit && rightDigit) return -1;
|
||||
|
||||
int compare;
|
||||
if (leftDigit)
|
||||
{
|
||||
compare = CompareNumericSegment(left, ref i, right, ref j);
|
||||
}
|
||||
else
|
||||
{
|
||||
compare = CompareAlphaSegment(left, ref i, right, ref j);
|
||||
}
|
||||
|
||||
if (compare != 0) return compare;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SkipToNextSegment(string value, ref int index)
|
||||
{
|
||||
var sawTilde = false;
|
||||
while (index < value.Length)
|
||||
{
|
||||
var current = value[index];
|
||||
if (current == '~')
|
||||
{
|
||||
sawTilde = true;
|
||||
index++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (char.IsLetterOrDigit(current)) break;
|
||||
index++;
|
||||
}
|
||||
return sawTilde;
|
||||
}
|
||||
|
||||
private static int CompareNumericSegment(string value, ref int index, string other, ref int otherIndex)
|
||||
{
|
||||
var start = index;
|
||||
while (index < value.Length && char.IsDigit(value[index])) index++;
|
||||
|
||||
var otherStart = otherIndex;
|
||||
while (otherIndex < other.Length && char.IsDigit(other[otherIndex])) otherIndex++;
|
||||
|
||||
// Trim leading zeros
|
||||
var trimmedStart = start;
|
||||
while (trimmedStart < index && value[trimmedStart] == '0') trimmedStart++;
|
||||
|
||||
var otherTrimmedStart = otherStart;
|
||||
while (otherTrimmedStart < otherIndex && other[otherTrimmedStart] == '0') otherTrimmedStart++;
|
||||
|
||||
var length = index - trimmedStart;
|
||||
var otherLength = otherIndex - otherTrimmedStart;
|
||||
|
||||
// Longer number is greater
|
||||
if (length != otherLength) return length.CompareTo(otherLength);
|
||||
|
||||
// Same length: compare lexicographically
|
||||
return value.AsSpan(trimmedStart, length)
|
||||
.CompareTo(other.AsSpan(otherTrimmedStart, otherLength), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CompareAlphaSegment(string value, ref int index, string other, ref int otherIndex)
|
||||
{
|
||||
var start = index;
|
||||
while (index < value.Length && char.IsLetter(value[index])) index++;
|
||||
|
||||
var otherStart = otherIndex;
|
||||
while (otherIndex < other.Length && char.IsLetter(other[otherIndex])) otherIndex++;
|
||||
|
||||
var length = index - start;
|
||||
var otherLength = otherIndex - otherStart;
|
||||
|
||||
return value.AsSpan(start, length)
|
||||
.CompareTo(other.AsSpan(otherStart, otherLength), StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user