diff --git a/source/Octopus.Versioning.Tests/LexicographicSortedVersion/LexicographicSortedVersionCompareTests.cs b/source/Octopus.Versioning.Tests/LexicographicSortedVersion/LexicographicSortedVersionCompareTests.cs new file mode 100644 index 0000000..ab92cc6 --- /dev/null +++ b/source/Octopus.Versioning.Tests/LexicographicSortedVersion/LexicographicSortedVersionCompareTests.cs @@ -0,0 +1,63 @@ +using System; +using NUnit.Framework; +using Octopus.Versioning.Lexicographic; + +namespace Octopus.Versioning.Tests.LexicographicSortedVersion; + +public class LexicographicSortedVersionCompareTests +{ + static readonly LexicographicSortedVersionParser LexicographicSortedVersionParser = new(); + + [Test] + [TestCase("release", "release", 0)] + [TestCase("release", "qrelease", 1)] + [TestCase("release", "srelease", -1)] + [TestCase("123", "123", 0)] + [TestCase("123", "100", 1)] + [TestCase("123", "321", -1)] + [TestCase("123Release", "123Release", 0)] + [TestCase("123Release", "100Release", 1)] + [TestCase("123Release", "321Release", -1)] + [TestCase("release-1", "release-1", 0)] + [TestCase("release-1", "release-0", 1)] + [TestCase("release-1", "release-2", -1)] + [TestCase("release.1", "release.1", 0)] + [TestCase("release.1", "release.0", 1)] + [TestCase("release.1", "release.2", -1)] + [TestCase("release_1", "release_1", 0)] + [TestCase("release_1", "release_0", 1)] + [TestCase("release_1", "release_2", -1)] + [TestCase("release-1", "release_1", 0)] + [TestCase("release.1", "release_1", 0)] + [TestCase("release-1", "release_0", 1)] + [TestCase("release.1", "release_2", -1)] + [TestCase("release+123", "release+321", 0)] + [TestCase("release-1+123", "release-1+321", 0)] + [TestCase("release-1+123", "release-0+321", 1)] + [TestCase("release-1+123", "release-2+321", -1)] + public void TestComparisons(string version1, string version2, int result) + { + var parsedVersion1 = LexicographicSortedVersionParser.Parse(version1); + var parsedVersion2 = LexicographicSortedVersionParser.Parse(version2); + Assert.AreEqual(result, parsedVersion1.CompareTo(parsedVersion2)); + } + + [Test] + [TestCase("release-1", "release-1", true)] + [TestCase("release-1", "release-2", false)] + public void TestEquality(string version1, string version2, bool result) + { + var parsedVersion1 = LexicographicSortedVersionParser.Parse(version1); + var parsedVersion2 = LexicographicSortedVersionParser.Parse(version2); + Assert.AreEqual(result, Equals(parsedVersion1, parsedVersion2)); + } + + [Test] + public void TestGetHashCode() + { + var versionString = "release-1"; + var parsedVersion = LexicographicSortedVersionParser.Parse(versionString); + + Assert.AreEqual(versionString.GetHashCode(), parsedVersion.GetHashCode()); + } +} \ No newline at end of file diff --git a/source/Octopus.Versioning.Tests/LexicographicSortedVersion/LexicographicSortedVersionParserTests.cs b/source/Octopus.Versioning.Tests/LexicographicSortedVersion/LexicographicSortedVersionParserTests.cs new file mode 100644 index 0000000..3b305c5 --- /dev/null +++ b/source/Octopus.Versioning.Tests/LexicographicSortedVersion/LexicographicSortedVersionParserTests.cs @@ -0,0 +1,81 @@ +using System; +using NUnit.Framework; +using Octopus.Versioning.Lexicographic; + +namespace Octopus.Versioning.Tests.LexicographicSortedVersion; + +[TestFixture] +public class LexicographicSortedVersionParserTests +{ + [Test] + // Release + [TestCase("foobar", "foobar", "")] + [TestCase("2db4a87840113c", "2db4a87840113c", "")] + [TestCase("123456", "123456", "")] + [TestCase("foobar-qwerty", "foobar-qwerty", "")] + [TestCase("foobar.qwerty", "foobar.qwerty", "")] + [TestCase("foobar_qwerty", "foobar_qwerty", "")] + [TestCase("foobar-12345", "foobar-12345", "")] + // Metadata + [TestCase("foobar+12345", "foobar", "12345")] + [TestCase("foobar+123.456", "foobar", "123.456")] + [TestCase("foobar+123_456", "foobar", "123_456")] + [TestCase("foobar+123-456", "foobar", "123-456")] + [TestCase("foobar+123+456", "foobar", "123+456")] + [TestCase("foobar+1.2_3-4+5", "foobar", "1.2_3-4+5")] + [TestCase("foobar+qwerty", "foobar", "qwerty")] + [TestCase("foobar-qwerty+12345", "foobar-qwerty", "12345")] + // Fail Cases + [TestCase("!@#$%^", "", "")] + [TestCase("foobar-!@#$%", "", "")] + [TestCase("foobar-qwerty+!@#$%", "", "")] + [TestCase("foo bar", "", "")] + [TestCase("foobar-qwe ty", "", "")] + [TestCase("foobar+123 456", "", "")] + [TestCase("foo bar-qwe ty+123 456", "", "")] + [TestCase("!foobar", "", "")] + [TestCase("foo!bar", "", "")] + [TestCase("foobar!", "", "")] + public void ShouldParseSuccessfully(string input, string expectedRelease, string expectedMetadata) + { + _ = new LexicographicSortedVersionParser().TryParse(input, out var parsedVersion); + AssertVersionNumbersAreZero(parsedVersion); + Assert.AreEqual(expectedRelease, parsedVersion.Release); + Assert.AreEqual(expectedMetadata, parsedVersion.Metadata); + } + + [Test] + public void ShouldThrowExceptionOnEmptyInput() + { + var input = ""; + Assert.Catch(() => new LexicographicSortedVersionParser().Parse(input)); + } + + [Test] + public void ShouldThrowExceptionOnWhiteSpaceInput() + { + var input = " "; + Assert.Catch(() => new LexicographicSortedVersionParser().Parse(input)); + } + + [Test] + public void ShouldThrowExceptionOnNullInput() + { + Assert.Catch(() => new LexicographicSortedVersionParser().Parse(null)); + } + + [Test] + public void ShouldThrowExceptionOnFailureToParse() + { + var input = "bad versions string"; + Assert.Catch(() => new LexicographicSortedVersionParser().Parse(input)); + } + + void AssertVersionNumbersAreZero(Lexicographic.LexicographicSortedVersion version) + { + Assert.AreEqual(0, version.Major); + Assert.AreEqual(0, version.Minor); + Assert.AreEqual(0, version.Patch); + Assert.AreEqual(0, version.Revision); + } +} \ No newline at end of file diff --git a/source/Octopus.Versioning/Lexicographic/LexicographicSortedVersion.cs b/source/Octopus.Versioning/Lexicographic/LexicographicSortedVersion.cs new file mode 100644 index 0000000..d6a2b3e --- /dev/null +++ b/source/Octopus.Versioning/Lexicographic/LexicographicSortedVersion.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Octopus.Versioning.Lexicographic +{ + public class LexicographicSortedVersion: IVersion + { + public LexicographicSortedVersion(string release, string? metadata, string? originalString) + { + Metadata = metadata; + Release = release; + OriginalString = originalString ?? string.Empty; + } + + public int Major => 0; + public int Minor => 0; + public int Patch => 0; + public int Revision => 0; + public bool IsPrerelease => false; + public IEnumerable ReleaseLabels => Enumerable.Empty(); + public string? Metadata { get; } + public bool HasMetadata => !string.IsNullOrWhiteSpace(Metadata); + public string Release { get; } + public string OriginalString { get; } + + public VersionFormat Format => VersionFormat.Lexicographic; + + public int CompareTo(object obj) + { + if (!(obj is IVersion objVersion)) + return -1; + + if (string.Compare(Release.AlphaNumericOnly(), (objVersion.Release ?? string.Empty).AlphaNumericOnly(), StringComparison.Ordinal) != 0) + return CompareReleaseLabels(Release.AlphaNumericOnly().Split('.', '-', '_'), (objVersion.Release ?? string.Empty).AlphaNumericOnly().Split('.', '-', '_')); + + return 0; + } + + public override string ToString() + { + return OriginalString; + } + + public override bool Equals(object obj) + { + if (obj is IVersion objVersion) + return CompareTo(objVersion) == 0; + + return false; + } + + public override int GetHashCode() + { + return Release.GetHashCode(); + } + + /// + /// Compares sets of release labels. + /// + static int CompareReleaseLabels(IEnumerable version1, IEnumerable version2) + { + var result = 0; + + using var a = version1.GetEnumerator(); + using var b = version2.GetEnumerator(); + + var aExists = a.MoveNext(); + var bExists = b.MoveNext(); + + while (aExists || bExists) + { + if (!aExists && bExists) + return -1; + + if (aExists && !bExists) + return 1; + + // compare the labels + result = CompareRelease(a.Current, b.Current); + + if (result != 0) + return result; + + aExists = a.MoveNext(); + bExists = b.MoveNext(); + } + + return result; + } + + /// + /// Release labels are compared as numbers if they are numeric, otherwise they will be compared + /// as strings. + /// + static int CompareRelease(string version1, string version2) + { + var version1Num = 0; + var version2Num = 0; + var result = 0; + + // check if the identifiers are numeric + var v1IsNumeric = int.TryParse(version1, out version1Num); + var v2IsNumeric = int.TryParse(version2, out version2Num); + + // if both are numeric compare them as numbers + if (v1IsNumeric && v2IsNumeric) + { + result = version1Num.CompareTo(version2Num); + } + else if (v1IsNumeric || v2IsNumeric) + { + // numeric labels come before alpha labels + if (v1IsNumeric) + result = -1; + else + result = 1; + } + else + { + // Ignoring 2.0.0 case sensitive compare. Everything will be compared case insensitively as 2.0.1 specifies. + var stringCompareResult = StringComparer.OrdinalIgnoreCase.Compare(version1, version2); + if (stringCompareResult < 0) + { + result = -1; + } + else if (stringCompareResult > 0) + { + result = 1; + } + } + + return result; + } + } +} \ No newline at end of file diff --git a/source/Octopus.Versioning/Lexicographic/LexicographicSortedVersionParser.cs b/source/Octopus.Versioning/Lexicographic/LexicographicSortedVersionParser.cs new file mode 100644 index 0000000..0476d02 --- /dev/null +++ b/source/Octopus.Versioning/Lexicographic/LexicographicSortedVersionParser.cs @@ -0,0 +1,56 @@ +using System; +using System.Text.RegularExpressions; + +namespace Octopus.Versioning.Lexicographic +{ + public class LexicographicSortedVersionParser + { + const string Release = "release"; + const string Meta = "buildmetadata"; + + static readonly Regex VersionRegex = new Regex($@"^(?<{Release}>([A-Za-z0-9]*?)([.\-_\\]([A-Za-z0-9.\-_\\]*?)?)?)?" + + $@"(?:\+(?<{Meta}>[A-Za-z0-9_\-.\\+]*?))?\s*$" + ); + + public LexicographicSortedVersion Parse(string? version) + { + if (string.IsNullOrWhiteSpace(version)) + throw new ArgumentException("The version can not be an empty string"); + + var sanitisedVersion = version ?? string.Empty; + // SemVerFactory treated the original string as if it had no spaces at all + var noSpaces = sanitisedVersion.Replace(" ", ""); + + // We parse on the original string. This *does not* tolerate spaces in prerelease fields or metadata + // just like SemVerFactory. + var result = VersionRegex.Match(sanitisedVersion); + + if (!result.Success) + throw new ArgumentException("The supplied version was not valid"); + + return new LexicographicSortedVersion( + result.Groups[Release].Success ? result.Groups[Release].Value : string.Empty, + result.Groups[Meta].Success ? result.Groups[Meta].Value : string.Empty, + noSpaces + ); + } + + public bool TryParse(string version, out LexicographicSortedVersion parsedVersion) + { + try + { + parsedVersion = Parse(version); + return true; + } + catch + { + parsedVersion = new LexicographicSortedVersion( + string.Empty, + string.Empty, + null + ); + return false; + } + } + } +} \ No newline at end of file diff --git a/source/Octopus.Versioning/VersionFactory.cs b/source/Octopus.Versioning/VersionFactory.cs index d079b93..76adb04 100644 --- a/source/Octopus.Versioning/VersionFactory.cs +++ b/source/Octopus.Versioning/VersionFactory.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Octopus.Versioning.Docker; +using Octopus.Versioning.Lexicographic; using Octopus.Versioning.Maven; using Octopus.Versioning.Octopus; using Octopus.Versioning.Semver; @@ -19,6 +20,8 @@ public static IVersion CreateVersion(string input, VersionFormat format) return CreateDockerTag(input); case VersionFormat.Octopus: return CreateOctopusVersion(input); + case VersionFormat.Lexicographic: + return CreateLexicographicSortedVersion(input); default: return CreateSemanticVersion(input); } @@ -34,6 +37,8 @@ public static IVersion CreateVersion(string input, VersionFormat format) return TryCreateDockerTag(input); case VersionFormat.Octopus: return TryCreateOctopusVersion(input); + case VersionFormat.Lexicographic: + return TryCreateLexicographicSortedVersion(input); default: return TryCreateSemanticVersion(input); } @@ -150,5 +155,22 @@ public static IVersion CreateOctopusVersion(string input) return null; } } + + public static IVersion CreateLexicographicSortedVersion(string input) + { + return new LexicographicSortedVersionParser().Parse(input); + } + + public static IVersion? TryCreateLexicographicSortedVersion(string input) + { + try + { + return CreateLexicographicSortedVersion(input); + } + catch + { + return null; + } + } } } \ No newline at end of file diff --git a/source/Octopus.Versioning/VersionFormat.cs b/source/Octopus.Versioning/VersionFormat.cs index 12051fc..5f3054c 100644 --- a/source/Octopus.Versioning/VersionFormat.cs +++ b/source/Octopus.Versioning/VersionFormat.cs @@ -7,6 +7,7 @@ public enum VersionFormat Semver, Maven, Docker, - Octopus + Octopus, + Lexicographic } } \ No newline at end of file