diff --git a/src/rbmanager/Program.cs b/src/rbmanager/Program.cs index 33711f2..05bac5b 100644 --- a/src/rbmanager/Program.cs +++ b/src/rbmanager/Program.cs @@ -1,5 +1,6 @@ using System.IO.Compression; using System.Reflection; +using System.Text.RegularExpressions; namespace RbManager; @@ -208,11 +209,40 @@ internal static string Resolve(string query) { [var single] => single!, [] => throw new InvalidOperationException($"no installed ruby matches '{query}'"), - _ => throw new InvalidOperationException( + _ => HighestRevision(matches!) ?? throw new InvalidOperationException( $"'{query}' is ambiguous: {string.Join(", ", matches)}"), }; } + // ruby-[-]-: a reissued release package + // (SIGNING.md in ruby/actions) differs from the original only by the + // numeric revision after the version. The revision never collides + // with a prerelease segment, which starts with a letter (rc1, + // preview1), and dev snapshot names never parse here because their + // date/commit segments sit between the version and the platform. + private static readonly Regex PackageName = new( + @"^ruby-(?\d+\.\d+\.\d+(?:-[a-z][a-z0-9]*)?)(?:-(?\d+))?-(?(?:x64|x86|arm64)-.+)$", + RegexOptions.IgnoreCase); + + // When every match is the same ruby version on the same platform and + // they differ only in revision, the newest reissue wins; the + // superseded packages stay reachable by their full names. Anything + // else (different versions, dev snapshots, foreign names) stays + // ambiguous. + private static string? HighestRevision(string[] matches) + { + Match[] parsed = matches.Select(n => PackageName.Match(n)).ToArray(); + if (parsed.Any(m => !m.Success)) return null; + bool sameRuby = parsed + .DistinctBy(m => $"{m.Groups["ver"].Value}|{m.Groups["plat"].Value}", + StringComparer.OrdinalIgnoreCase) + .Count() == 1; + if (!sameRuby) return null; + return parsed + .MaxBy(m => m.Groups["rev"].Success ? int.Parse(m.Groups["rev"].Value) : 0)! + .Value; + } + internal static string? CurrentTarget() { var info = new DirectoryInfo(Current); diff --git a/tests/rbmanager.Tests/ResolveTests.cs b/tests/rbmanager.Tests/ResolveTests.cs index 2ff1fa0..cc490c8 100644 --- a/tests/rbmanager.Tests/ResolveTests.cs +++ b/tests/rbmanager.Tests/ResolveTests.cs @@ -74,4 +74,54 @@ public void NoRubiesDirectory_NoMatch() var ex = Assert.Throws(() => Program.Resolve("anything")); Assert.Equal("no installed ruby matches 'anything'", ex.Message); } + + // Reissued packages (ruby/actions SIGNING.md): same version and + // platform differing only in the trailing numeric revision resolve + // to the newest reissue instead of erroring as ambiguous. + + private const string R345 = "ruby-3.4.5-x64-mswin64_140"; + private const string R345r1 = "ruby-3.4.5-1-x64-mswin64_140"; + private const string R345r2 = "ruby-3.4.5-2-x64-mswin64_140"; + + [Fact] + public void RevisionsOfSameVersion_PickHighest() + { + using var sb = new RbSandbox(); + sb.Seed(R345, R345r2, R345r1); + Assert.Equal(R345r2, Program.Resolve("3.4.5")); + } + + [Fact] // revisions compare numerically, not lexicographically + public void RevisionsCompareNumerically() + { + using var sb = new RbSandbox(); + sb.Seed(R345r2, "ruby-3.4.5-10-x64-mswin64_140"); + Assert.Equal("ruby-3.4.5-10-x64-mswin64_140", Program.Resolve("3.4.5")); + } + + [Fact] // the superseded original stays reachable by its full name + public void SupersededOriginal_FullNameStillResolves() + { + using var sb = new RbSandbox(); + sb.Seed(R345, R345r1); + Assert.Equal(R345, Program.Resolve(R345)); + } + + [Fact] // revision preference never crosses version boundaries + public void RevisionPreference_DifferentVersionsStayAmbiguous() + { + using var sb = new RbSandbox(); + sb.Seed(R345, R345r1, "ruby-3.4.51-x64-mswin64_140"); // also contains "3.4.5" + var ex = Assert.Throws(() => Program.Resolve("3.4.5")); + Assert.Contains("is ambiguous", ex.Message); + } + + [Fact] // a prerelease is a different ruby, not a revision of the release + public void RevisionPreference_PrereleaseStaysAmbiguous() + { + using var sb = new RbSandbox(); + sb.Seed("ruby-3.4.0-x64-mswin64_140", "ruby-3.4.0-rc1-x64-mswin64_140"); + var ex = Assert.Throws(() => Program.Resolve("3.4.0")); + Assert.Contains("is ambiguous", ex.Message); + } }