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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ 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 is a bare exe; `setup` copies it to
Expand All @@ -50,6 +51,12 @@ version, and `msvc --list` shows what is installed. Apart from
`enable`, every word after `msvc` is the command to run, so future
`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.

The official mswin packages deliberately do not bundle
vcruntime140.dll (https://bugs.ruby-lang.org/issues/22180) and expect
the machine-wide Microsoft Visual C++ Redistributable instead. That
Expand Down
23 changes: 23 additions & 0 deletions docs/test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Command surface and contracts:
| `rb msvc [--vsver <year>] [--] <cmd...>` | Locate VsDevCmd via vswhere (narrowed to the requested VS product year, if any; `--vsver` > `RBMANAGER_VSVER` > newest), compute the env delta of activation and apply it to a `cmd /s /c` child (so `.cmd` shims resolve); removes `NoDefaultCurrentDirectoryInExePath`; propagates the child's exit code. Options are leading-only; `--` ends option parsing. `enable` is the only reserved word after `msvc` | child / 1 |
| `rb msvc enable [--vsver <year>] [shell]` | Same delta printed as per-shell assignments plus an unset of `NoDefaultCurrentDirectoryInExePath`. Shell defaults to PowerShell; `cmd`/`bat` selects cmd syntax. `--vsver` parses on either side of `enable`. No toolchain: actionable warning on stderr | 0 / 1 |
| `rb msvc --list` | All installs carrying the MSVC toolset, newest first, `*` on the one default resolution would pick. Terminal: nothing may follow it. No toolchain: same warning as the other two | 0 / 1 / 2 |
| `rb version` | Print `rbmanager <version> (<commit> <date>)` in cargo's shape, from the assembly's informational version (whose `+<commit>` suffix carries the commit) and the `CommitDate` assembly metadata. The parenthetical is dropped when the build had no git checkout, and the assembly version stands in for a missing informational version | 0 |
| anything else | Usage text | 2 |

Any thrown exception is caught in `Main`, printed as `rb: <message>` to
Expand Down Expand Up @@ -390,6 +391,28 @@ Added with `--vsver`:
the `LibraryImport` P/Invokes survive AOT; this is the one place
the AOT binary differs meaningfully from the CoreCLR build).

### 4.12 Program: `rb version` — Unit + E2E + Publish

Everything is read back from the assembly instead of a literal, so the
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.
89. (Unit) `FormatVersion` with all three present → the full
cargo-shaped line.
90. (Unit) `FormatVersion` with the commit, the date, or both missing →
the parenthetical carries only what is there, or is dropped
entirely. A prerelease tag keeps its own `-rc1` suffix.
91. (Unit) `FormatVersion` falls back to the assembly version when there
is no informational version, and to `unknown` when there is neither.
92. (Unit) `SelfVersion` for the current build starts with `rbmanager `
and a parseable version.
93. (Publish) The same command against the AOT exe prints exactly what
the in-process resolution returns. NativeAOT is the one place the
assembly-attribute lookup could come back empty, and both builds
come from the same source tree at the same commit, so any
divergence is AOT.

## 5. Execution plan

Phased so each phase leaves the tree green.
Expand Down
37 changes: 37 additions & 0 deletions src/rbmanager/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ private static async Task<int> Main(string[] args)
["list"] => List(),
["use", var name] => Use(name),
["uninstall", var name] => Uninstall(name),
["version"] => 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 Down Expand Up @@ -58,6 +59,7 @@ list list installed rubies
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
""");
return 2;
}
Expand Down Expand Up @@ -158,6 +160,41 @@ internal static int Uninstall(string query)
return 0;
}

// Which build is this? An upgraded rb.exe is otherwise only
// distinguishable by its file timestamp.
private static int Version()
{
Console.WriteLine(SelfVersion());
return 0;
}

// Read back from the assembly rather than kept as a literal here. The
// release workflow stamps the version from the tag (-p:Version=<tag>)
// and rbmanager.csproj stamps the commit, so constants in this file
// would drift from the shipped exe.
internal static string SelfVersion()
{
Assembly self = Assembly.GetExecutingAssembly();
return FormatVersion(
self.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion,
self.GetName().Version?.ToString(),
self.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(a => a.Key == "CommitDate")?.Value);
}

// cargo's shape, `cargo 1.75.0 (1d8b05cdd 2023-11-20)`. The commit
// rides along as SourceRevisionId's "+<commit>" suffix on the
// informational version. A build with no git checkout to ask has
// neither commit nor date, and then only the version is printed.
internal static string FormatVersion(string? informational, string? assembly, string? date)
{
string[] parts = (informational is { Length: > 0 } ? informational
: assembly ?? "unknown").Split('+', 2);
string stamp = string.Join(' ', new[] { parts.Length > 1 ? parts[1] : null, date }
.Where(s => !string.IsNullOrEmpty(s)));
return stamp.Length == 0 ? $"rbmanager {parts[0]}" : $"rbmanager {parts[0]} ({stamp})";
}

private static IEnumerable<string> InstalledRubies() =>
Directory.Exists(Rubies) ? Directory.EnumerateDirectories(Rubies).Order() : [];

Expand Down
27 changes: 27 additions & 0 deletions src/rbmanager/rbmanager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,33 @@
<Copyright>Copyright (c) 2026 Hiroshi SHIBATA</Copyright>
</PropertyGroup>

<!-- Build stamp behind `rb version`, in cargo's shape
(`cargo 1.75.0 (1d8b05cdd 2023-11-20)`). The version alone does not
separate two builds of the same tag. Skipped when there is no git
checkout to ask, and `rb version` then prints the version alone. -->
<Target Name="StampCommit"
BeforeTargets="AddSourceRevisionToInformationalVersion"
Condition="Exists('$(MSBuildProjectDirectory)\..\..\.git')">
<!-- %25%25 survives both MSBuild's and cmd's unescaping, leaving git
with a bare % in its format string. -->
<Exec Command="git log -1 --format=%25%25h" ContinueOnError="true"
WorkingDirectory="$(MSBuildProjectDirectory)"
ConsoleToMSBuild="true" StandardOutputImportance="low">
<Output TaskParameter="ConsoleOutput" PropertyName="RbCommit" />
</Exec>
<Exec Command="git log -1 --format=%25%25cs" ContinueOnError="true"
WorkingDirectory="$(MSBuildProjectDirectory)"
ConsoleToMSBuild="true" StandardOutputImportance="low">
<Output TaskParameter="ConsoleOutput" PropertyName="RbCommitDate" />
</Exec>
<PropertyGroup Condition="'$(RbCommit)' != ''">
<SourceRevisionId>$(RbCommit)</SourceRevisionId>
</PropertyGroup>
<ItemGroup Condition="'$(RbCommitDate)' != ''">
<AssemblyMetadata Include="CommitDate" Value="$(RbCommitDate)" />
</ItemGroup>
</Target>

<ItemGroup>
<!-- Same CA trust hook the MSI overlay ships; injected into every
extracted runtime so both install channels behave identically. -->
Expand Down
12 changes: 12 additions & 0 deletions tests/rbmanager.Tests/CliE2eTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ public void FailingCommand_ErrorToStderr_Exit1_EmptyStdout()
Assert.StartsWith("rb: ", r.Err);
}

[Fact] // case 88
public void Version_OneLine_Exit0()
{
using var sb = new E2eSandbox();
RbResult r = sb.Run("version");

Assert.Equal(0, r.ExitCode);
string line = Assert.Single(Lines(r.Out));
// cargo's shape, e.g. `rbmanager 0.1.0 (9a1b2c3 2026-07-28)`.
Assert.Matches(@"^rbmanager \d+(\.\d+)+( \([0-9a-f]+ \d{4}-\d{2}-\d{2}\))?$", line);
}

[Fact] // case 38
public void FullLifecycle_ThroughProcessBoundary()
{
Expand Down
15 changes: 15 additions & 0 deletions tests/rbmanager.Tests/PublishE2eTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ public void Install_UnderAot_WritesTrustHookAndSwitches()
Assert.True(File.Exists(hook));
}

// Compared against the in-process resolution rather than a literal.
// Both come from the same source tree at the same commit, so any
// divergence is AOT dropping the assembly metadata `rb version` reads.
[SkippableFact] // case 93
public void Version_UnderAot_MatchesTheInProcessResolution()
{
Skip.If(_fx.ExePath is null, _fx.SkipReason);
using var sb = new E2eSandbox();

RbResult r = sb.RunExe(_fx.ExePath!, "version");

Assert.Equal(0, r.ExitCode);
Assert.Equal(Program.SelfVersion(), r.Out.Trim());
}

[SkippableFact] // case 32: self-copy skip works on the self-contained exe
public void Setup_FromCopiedExe_NoSelfCopyError()
{
Expand Down
44 changes: 44 additions & 0 deletions tests/rbmanager.Tests/VersionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
namespace RbManager.Tests;

// Plan 4.12: the line behind `rb version`, in cargo's shape. The values come
// from whatever the build stamped into the assembly, so these pin the
// formatting and the degraded cases rather than a literal.
[Trait("Category", "Unit")]
public class VersionTests
{
[Fact] // case 89
public void Format_VersionCommitAndDate()
{
Assert.Equal("rbmanager 0.1.0 (9a1b2c3 2026-07-28)",
Program.FormatVersion("0.1.0+9a1b2c3", "0.1.0.0", "2026-07-28"));
}

[Theory] // case 90
// No git checkout to ask: no suffix, no date, so no parenthetical.
[InlineData("0.1.0", null, "rbmanager 0.1.0")]
// The date is a separate attribute, so either half can go missing.
[InlineData("0.1.0+9a1b2c3", null, "rbmanager 0.1.0 (9a1b2c3)")]
[InlineData("0.1.0", "2026-07-28", "rbmanager 0.1.0 (2026-07-28)")]
// A prerelease tag keeps its own hyphenated suffix.
[InlineData("1.2.3-rc1+9a1b2c3", "2026-07-28", "rbmanager 1.2.3-rc1 (9a1b2c3 2026-07-28)")]
public void Format_PartialStamps(string? informational, string? date, string expected) =>
Assert.Equal(expected, Program.FormatVersion(informational, "0.1.0.0", date));

[Theory] // case 91
// Without the informational version the assembly version stands in, and
// with neither there is still a line to print.
[InlineData(null, "0.1.0.0", "rbmanager 0.1.0.0")]
[InlineData("", null, "rbmanager unknown")]
public void Format_FallsBackToTheAssemblyVersion(
string? informational, string? assembly, string expected) =>
Assert.Equal(expected, Program.FormatVersion(informational, assembly, null));

[Fact] // case 92
public void SelfVersion_ReportsThisBuild()
{
string line = Program.SelfVersion();

Assert.StartsWith("rbmanager ", line);
Assert.True(Version.TryParse(line.Split(' ')[1], out _), line);
}
}
Loading