diff --git a/README.md b/README.md index 7631478..25cb11b 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,13 @@ rather than after the tool. rbmanager remains the product name. rb setup [--yes] copy rb onto PATH and set up the VC++ runtime rb install install a ruby binary package rb list list installed rubies +rb list --remote list the builds the binary index offers rb use switch the active ruby rb uninstall remove an installed ruby rb msvc run a command with the MSVC build env applied rb msvc enable [shell] print the MSVC build env to eval (cmd|powershell) rb msvc --list list installed Visual Studio C++ toolchains -rb version print the rbmanager version +rb version print the rbmanager version (also --version, -V) ``` rb is a bare exe; `setup` copies it to @@ -52,6 +53,18 @@ verified against the sha256 recorded in the index. An unsigned build (all dev snapshots are unsigned) installs with a warning. A zip path or URL skips the index and installs directly. +`list --remote` prints what the index currently offers for this +platform, newest first: the package name, the channel, and the tags +`install` accepts for it. It is the counterpart of `list`, which shows +what is installed, and it exists so that nothing outside rbmanager has +to fetch and interpret the feed. + +The feed's `schema` number is how an index that has moved on tells an +old rb so. Every command that reads the index (`install`, +`list --remote`) fails on an unknown schema with the running version +and , which is the one +signal rb can give about its own age. + `msvc` activates an installed Visual Studio (or Build Tools) MSVC toolchain for building C extension gems and runs the rest of the command line under it, as in `rb msvc gem install nokogiri`; @@ -64,10 +77,12 @@ version, and `msvc --list` shows what is installed. Apart from `msvc` operations are spelled as flags. `version` identifies the running binary, in cargo's shape, as in -`rbmanager 0.1.0 (9a1b2c3 2026-07-28)`. The version is the release tag -the build came from, or the number in `rbmanager.csproj` between -releases. The commit and date are stamped in at build time, and are -omitted when there is no git checkout to read them from. +`rbmanager 0.1.0 (9a1b2c3 2026-07-28)`. `--version` and `-V` print the +same line, so probing an unknown rb never lands in usage. The version +is the release tag the build came from, or the number in +`rbmanager.csproj` between releases. The commit and date are stamped in +at build time, and are omitted when there is no git checkout to read +them from. The official mswin packages deliberately do not bundle vcruntime140.dll (https://bugs.ruby-lang.org/issues/22180) and expect diff --git a/docs/test-plan.md b/docs/test-plan.md index cae1e7f..407a958 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -417,6 +417,8 @@ values are whatever the build stamped in and the tests pin the shape. 88. (E2E) `rb version` → exit 0 and one line matching `rbmanager ( )`, the parenthetical optional. +118. (E2E) `rb --version` and `rb -V` print that same line and exit 0, + so probing an unknown rb never lands in usage. 89. (Unit) `FormatVersion` with all three present → the full cargo-shaped line. 90. (Unit) `FormatVersion` with the commit, the date, or both missing → @@ -450,8 +452,8 @@ Unit (`BinaryIndexTests`): 99. `Parse` on a page in the published feed's shape → every key of the build populated, including the snake_case `commit_date` / `published_at` mappings. -100. `Parse` with `schema: 2` → error naming the schema and telling the - user to upgrade rb. +100. `Parse` with `schema: 2` → error naming the schema, the running + `SelfVersion`, and the releases page to upgrade from. 101. A series tag (`4.0`, `4`) sits on every release of the series → the highest version wins, in either feed order. 102. Two revisions of one version → the higher revision wins, in either @@ -478,7 +480,7 @@ Integration (`InstallFromIndexTests`, Serial): 113. A non-null `next` chains to the following page (relative to the feed URL). 114. `RBMANAGER_INDEX_URL` accepts a `file://` URL. -115. `schema: 2` in the feed → the upgrade-rb error, nothing installed. +115. `schema: 2` in the feed → the upgrade error, nothing installed. 116. No matching build → `no binary package matches '' in the index`. 117. A missing zip path (`.zip` suffix or path separator) fails as a missing file and never falls through to index resolution. @@ -490,6 +492,30 @@ Network (`BinaryIndexNetworkTests`, `Category=Network`, gated on `x64-mswin64_140` build with a well-formed sha256 and a cache.ruby-lang.org URL. +### 4.14 Program + BinaryIndex: `rb list --remote` + +The same feed and the same `RBMANAGER_INDEX_URL` seam as 4.13, rendered +instead of installed: one line per build for this platform, newest +first, in the order `Pick` resolves. + +Unit (`BinaryIndexTests`): + +119. `Available` orders newest first, version before revision. +120. `Available` drops builds of other platforms. + +Integration (`ListRemoteTests`, Serial): + +121. Name, channel and the `install` tags, newest first, in aligned + columns. +122. An index with nothing for this platform → empty stdout, a note on + stderr, exit 0. +123. `schema: 2` fails here as it does on install. + +E2E (`CliE2eTests`): + +124. `--remote` is the only flag `list` takes, and it takes no + argument; anything else is usage and exit 2. + ## 5. Execution plan Phased so each phase leaves the tree green. diff --git a/src/rbmanager/BinaryIndex.cs b/src/rbmanager/BinaryIndex.cs index f563f09..2376e5f 100644 --- a/src/rbmanager/BinaryIndex.cs +++ b/src/rbmanager/BinaryIndex.cs @@ -22,7 +22,13 @@ internal static class BinaryIndex ? url : DefaultUrl; - public static async Task Resolve(string query) + public static async Task Resolve(string query) => + Pick(await FetchAll(), query) ?? throw new InvalidOperationException( + $"no binary package matches '{query}' in the index"); + + public static async Task Available() => Available(await FetchAll()); + + private static async Task> FetchAll() { var page = new Uri(Url, UriKind.Absolute); var builds = new List(); @@ -33,8 +39,7 @@ public static async Task Resolve(string query) if (index.Next is null) break; page = new Uri(page, index.Next); } - return Pick(builds, query) ?? throw new InvalidOperationException( - $"no binary package matches '{query}' in the index"); + return builds; } private static async Task Fetch(Uri uri) @@ -48,25 +53,36 @@ internal static IndexPage Parse(string json) { IndexPage page = JsonSerializer.Deserialize(json, IndexJsonContext.Default.IndexPage) ?? throw new InvalidOperationException("the binary index is empty"); + // The schema number is the only channel the feed has for telling + // an old rb that it is old. if (page.Schema != 1) throw new InvalidOperationException( - $"the binary index has schema {page.Schema}, which this rb does not understand; upgrade rb"); + $"{Program.SelfVersion()} does not understand schema {page.Schema} " + + $"of the binary index. Upgrade from {Program.ReleasesUrl}"); return page; } // The newest match wins regardless of feed order: series tags like // "4.0" sit on every 4.0.x release, and dev tags like "4.1-dev" on - // every snapshot of the series. Ordering by version, then reissue - // revision (SIGNING.md in ruby/actions), then commit date keeps this - // consistent with Program.Resolve's revision handling for installed - // rubies. + // every snapshot of the series. internal static Build? Pick(IEnumerable builds, string query) => builds .Where(b => b.Platform == Platform) .Where(b => b.Tags.Contains(query, StringComparer.OrdinalIgnoreCase) || string.Equals(b.Name, query, StringComparison.OrdinalIgnoreCase)) - .MaxBy(b => (NumericVersion(b.Version), b.Revision ?? 0, - b.CommitDate ?? b.PublishedAt ?? "", b.Commit ?? "")); + .MaxBy(Rank); + + // Everything installable here, newest first. Same order Pick resolves + // in, so a tag always installs the topmost line carrying it. + internal static Build[] Available(IEnumerable builds) => + builds.Where(b => b.Platform == Platform).OrderByDescending(Rank).ToArray(); + + // Version, then reissue revision (SIGNING.md in ruby/actions), then + // commit date, which keeps this consistent with Program.Resolve's + // revision handling for installed rubies. + private static (Version, int, string, string) Rank(Build b) => + (NumericVersion(b.Version), b.Revision ?? 0, + b.CommitDate ?? b.PublishedAt ?? "", b.Commit ?? ""); // The numeric prefix of `version` ("4.1.0dev" and "4.1.0-rc1" both // compare as 4.1.0). Channel suffixes never decide between two diff --git a/src/rbmanager/Program.cs b/src/rbmanager/Program.cs index 08ec49e..0195de0 100644 --- a/src/rbmanager/Program.cs +++ b/src/rbmanager/Program.cs @@ -18,6 +18,8 @@ internal static class Program private static string Rubies => Path.Combine(Root, "rubies"); private static string Current => Path.Combine(Root, "current"); + internal const string ReleasesUrl = "https://github.com/ruby/rbmanager/releases"; + private static async Task Main(string[] args) { try @@ -28,9 +30,13 @@ private static async Task Main(string[] args) ["setup", "--yes" or "-y"] => await Setup(assumeYes: true), ["install", var source] => await Install(source), ["list"] => List(), + ["list", "--remote"] => await ListRemote(), ["use", var name] => Use(name), ["uninstall", var name] => Uninstall(name), - ["version"] => Version(), + // Probing a tool with --version is how a caller identifies + // the build it got, so answering with usage reads as a + // broken binary rather than as an old one. + ["version" or "--version" or "-V"] => Version(), // Everything after `msvc` belongs to Msvc's own parser: it // owns one reserved word (`enable`) and passes the rest // through as the user's command line. @@ -55,6 +61,7 @@ setup [--yes] copy rb onto PATH and set up the VC++ runtime install install a ruby binary package resolved from the binary index, or from a zip file or URL list list installed rubies + list --remote list the builds the binary index offers use switch the active ruby uninstall remove an installed ruby msvc run a command with the MSVC build env applied @@ -62,6 +69,7 @@ msvc enable [shell] print the MSVC build env to eval (cmd|powershell) msvc --list list installed Visual Studio C++ toolchains (msvc and msvc enable accept --vsver ) version print the rbmanager version + (also --version, -V) """); return 2; } @@ -178,6 +186,26 @@ internal static int List() return 0; } + // The index side of `list`, so that finding a build never requires + // fetching and interpreting the feed. The tags are the arguments + // `install` takes, which is why they carry the line. + internal static async Task ListRemote() + { + Build[] builds = await BinaryIndex.Available(); + if (builds.Length == 0) + { + Console.Error.WriteLine( + $"rb: the binary index offers no {BinaryIndex.Platform} builds"); + return 0; + } + int name = builds.Max(b => b.Name.Length); + int channel = builds.Max(b => b.Channel.Length); + foreach (Build b in builds) + Console.WriteLine($"{b.Name.PadRight(name)} {b.Channel.PadRight(channel)} " + + string.Join(", ", b.Tags)); + return 0; + } + internal static int Use(string query) { SwitchTo(Resolve(query)); diff --git a/tests/rbmanager.Tests/BinaryIndexTests.cs b/tests/rbmanager.Tests/BinaryIndexTests.cs index d76068c..8654aef 100644 --- a/tests/rbmanager.Tests/BinaryIndexTests.cs +++ b/tests/rbmanager.Tests/BinaryIndexTests.cs @@ -68,13 +68,14 @@ public void Parse_PublishedShape_PopulatesEveryKey() Assert.False(b.Signed); } - [Fact] // case 100 + [Fact] // case 100: an rb older than the feed names itself and the remedy public void Parse_UnsupportedSchema_Throws() { var ex = Assert.Throws( () => BinaryIndex.Parse("""{"schema": 2, "next": null, "builds": []}""")); Assert.Contains("schema 2", ex.Message); - Assert.Contains("upgrade rb", ex.Message); + Assert.StartsWith(Program.SelfVersion(), ex.Message); + Assert.Contains(Program.ReleasesUrl, ex.Message); } [Fact] // case 101: a series tag sits on every release of the series @@ -158,6 +159,38 @@ public void Pick_ExactName_Resolves() Assert.Equal(b.Name, BinaryIndex.Pick([b], "RUBY-4.0.5-X64-MSWIN64_140")!.Name); } + [Fact] // case 119: the order `rb list --remote` prints, newest first + public void Available_NewestFirst() + { + Build[] builds = + [ + Make("ruby-4.0.4-x64-mswin64_140", "4.0.4", tags: ["4.0.4", "4.0"]), + Make("ruby-4.0.5-x64-mswin64_140", "4.0.5", revision: 0, tags: ["4.0.5-0"]), + Make("ruby-4.0.5-1-x64-mswin64_140", "4.0.5", revision: 1, tags: ["4.0.5-1"]), + ]; + + Assert.Equal( + [ + "ruby-4.0.5-1-x64-mswin64_140", + "ruby-4.0.5-x64-mswin64_140", + "ruby-4.0.4-x64-mswin64_140", + ], + BinaryIndex.Available(builds).Select(b => b.Name).ToArray()); + } + + [Fact] // case 120: builds this rb cannot install are not offered + public void Available_ForeignPlatform_Filtered() + { + Build[] builds = + [ + Make("ruby-4.0.5-arm64-mswin64_140", "4.0.5", platform: "arm64-mswin64_140"), + Make("ruby-4.0.5-x64-mswin64_140", "4.0.5"), + ]; + + Build b = Assert.Single(BinaryIndex.Available(builds)); + Assert.Equal("ruby-4.0.5-x64-mswin64_140", b.Name); + } + [Fact] // case 107 public void Pick_NoMatch_ReturnsNull() { diff --git a/tests/rbmanager.Tests/CliE2eTests.cs b/tests/rbmanager.Tests/CliE2eTests.cs index 980a845..9065487 100644 --- a/tests/rbmanager.Tests/CliE2eTests.cs +++ b/tests/rbmanager.Tests/CliE2eTests.cs @@ -48,6 +48,14 @@ public void MissingRequiredArgument_Usage_Exit2(params string[] command) Assert.Contains("usage: rb ", r.Out); } + [Fact] // case 124: --remote is the only flag `list` takes + public void ListWithUnknownFlag_Usage_Exit2() + { + using var sb = new E2eSandbox(); + Assert.Equal(2, sb.Run("list", "--online").ExitCode); + Assert.Equal(2, sb.Run("list", "--remote", "extra").ExitCode); + } + [Fact] // case 37 public void FailingCommand_ErrorToStderr_Exit1_EmptyStdout() { @@ -58,11 +66,14 @@ public void FailingCommand_ErrorToStderr_Exit1_EmptyStdout() Assert.StartsWith("rb: ", r.Err); } - [Fact] // case 88 - public void Version_OneLine_Exit0() + [Theory] // cases 88, 118: the flag spellings print the same line + [InlineData("version")] + [InlineData("--version")] + [InlineData("-V")] + public void Version_OneLine_Exit0(string spelling) { using var sb = new E2eSandbox(); - RbResult r = sb.Run("version"); + RbResult r = sb.Run(spelling); Assert.Equal(0, r.ExitCode); string line = Assert.Single(Lines(r.Out)); diff --git a/tests/rbmanager.Tests/InstallFromIndexTests.cs b/tests/rbmanager.Tests/InstallFromIndexTests.cs index f628a29..0eab05e 100644 --- a/tests/rbmanager.Tests/InstallFromIndexTests.cs +++ b/tests/rbmanager.Tests/InstallFromIndexTests.cs @@ -156,7 +156,7 @@ public async Task InstallNewerSchema_Throws() var ex = await Assert.ThrowsAsync( () => Program.Install("4.1-dev")); - Assert.Contains("upgrade rb", ex.Message); + Assert.Contains(Program.ReleasesUrl, ex.Message); } [Fact] // case 116 diff --git a/tests/rbmanager.Tests/ListRemoteTests.cs b/tests/rbmanager.Tests/ListRemoteTests.cs new file mode 100644 index 0000000..0681a53 --- /dev/null +++ b/tests/rbmanager.Tests/ListRemoteTests.cs @@ -0,0 +1,98 @@ +using RbManager.Tests.Support; + +namespace RbManager.Tests; + +// `rb list --remote`: the index rendered as columns, with the feed +// redirected to a local file via RBMANAGER_INDEX_URL. Serial (env vars + +// console). +[Trait("Category", "Integration")] +[Collection(Serial.Name)] +public class ListRemoteTests +{ + private const string Dev = "ruby-4.1.0dev-20260821-0123456789-x64-mswin64_140"; + private const string Rel = "ruby-4.0.5-x64-mswin64_140"; + + private static string BuildJson(string name, string version, string channel, + string[] tags, string platform = BinaryIndex.Platform, string? commitDate = null) => $$""" + { + "name": "{{name}}", + "version": "{{version}}", + "channel": "{{channel}}", + "revision": null, + "tags": [{{string.Join(", ", tags.Select(t => $"\"{t}\""))}}], + "platform": "{{platform}}", + "url": "https://example.invalid/{{name}}.zip", + "sha256": "{{new string('0', 64)}}", + "size": 1, + "commit": null, + "commit_date": {{(commitDate is null ? "null" : $"\"{commitDate}\"")}}, + "published_at": null, + "signed": false + } + """; + + private static void WriteIndex(RbSandbox sb, EnvScope env, params string[] builds) + { + string path = Path.Combine(sb.Root, "_index", "index.json"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, $$""" + {"schema": 1, "next": null, "builds": [{{string.Join(",", builds)}}]} + """); + env.Set("RBMANAGER_INDEX_URL", path); + } + + [Fact] // case 121: name, channel and the tags `install` accepts + public async Task ListsBuildsNewestFirstInColumns() + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + using var env = new EnvScope(); + WriteIndex(sb, env, + BuildJson(Rel, "4.0.5", "release", ["4.0.5", "4.0", "4"]), + BuildJson(Dev, "4.1.0dev", "dev", ["ruby-dev", "4.1-dev"], + commitDate: "2026-08-21")); + + int rc = await Program.ListRemote(); + + Assert.Equal(0, rc); + Assert.Equal( + [ + $"{Dev} dev ruby-dev, 4.1-dev", + $"{Rel} release 4.0.5, 4.0, 4", + ], + cap.OutLines); + } + + [Fact] // case 122: an index with nothing for this platform is not an error + public async Task NoBuildsForThePlatform_NotesOnStderr_Exit0() + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + using var env = new EnvScope(); + WriteIndex(sb, env, + BuildJson("ruby-4.0.5-arm64-mswin64_140", "4.0.5", "release", ["4.0.5"], + platform: "arm64-mswin64_140")); + + int rc = await Program.ListRemote(); + + Assert.Equal(0, rc); + Assert.Equal("", cap.Out); + Assert.Contains($"no {BinaryIndex.Platform} builds", cap.Err); + } + + [Fact] // case 123: an index this rb does not understand fails here too + public async Task UnsupportedSchema_Throws() + { + using var sb = new RbSandbox(); + using var cap = new ConsoleCapture(); + using var env = new EnvScope(); + string path = Path.Combine(sb.Root, "_index", "index.json"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, """{"schema": 2, "next": null, "builds": []}"""); + env.Set("RBMANAGER_INDEX_URL", path); + + var ex = await Assert.ThrowsAsync(Program.ListRemote); + + Assert.Contains("schema 2", ex.Message); + } +}