Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <version|zip> install a ruby binary package
rb list list installed rubies
rb list --remote list the builds the binary index offers
rb use <version> switch the active ruby
rb uninstall <version> remove an installed ruby
rb msvc <command...> 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
Expand All @@ -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 <https://github.com/ruby/rbmanager/releases>, 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`;
Expand All @@ -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
Expand Down
32 changes: 29 additions & 3 deletions docs/test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <version> (<commit> <date>)`, 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 →
Expand Down Expand Up @@ -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
Expand All @@ -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 '<q>' 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.
Expand All @@ -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.
Expand Down
36 changes: 26 additions & 10 deletions src/rbmanager/BinaryIndex.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ internal static class BinaryIndex
? url
: DefaultUrl;

public static async Task<Build> Resolve(string query)
public static async Task<Build> Resolve(string query) =>
Pick(await FetchAll(), query) ?? throw new InvalidOperationException(
$"no binary package matches '{query}' in the index");

public static async Task<Build[]> Available() => Available(await FetchAll());

private static async Task<List<Build>> FetchAll()
{
var page = new Uri(Url, UriKind.Absolute);
var builds = new List<Build>();
Expand All @@ -33,8 +39,7 @@ public static async Task<Build> 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<string> Fetch(Uri uri)
Expand All @@ -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<Build> 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<Build> 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
Expand Down
30 changes: 29 additions & 1 deletion src/rbmanager/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> Main(string[] args)
{
try
Expand All @@ -28,9 +30,13 @@ private static async Task<int> 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.
Expand All @@ -55,13 +61,15 @@ setup [--yes] copy rb onto PATH and set up the VC++ runtime
install <version|zip> 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 <version> switch the active ruby
uninstall <version> remove an installed ruby
msvc <command...> run a command with the MSVC build env applied
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 <year>)
version print the rbmanager version
(also --version, -V)
""");
return 2;
}
Expand Down Expand Up @@ -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<int> 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));
Expand Down
37 changes: 35 additions & 2 deletions tests/rbmanager.Tests/BinaryIndexTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvalidOperationException>(
() => 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
Expand Down Expand Up @@ -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()
{
Expand Down
17 changes: 14 additions & 3 deletions tests/rbmanager.Tests/CliE2eTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ public void MissingRequiredArgument_Usage_Exit2(params string[] command)
Assert.Contains("usage: rb <command>", 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()
{
Expand All @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion tests/rbmanager.Tests/InstallFromIndexTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ public async Task InstallNewerSchema_Throws()
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => Program.Install("4.1-dev"));

Assert.Contains("upgrade rb", ex.Message);
Assert.Contains(Program.ReleasesUrl, ex.Message);
}

[Fact] // case 116
Expand Down
Loading
Loading