diff --git a/README.md b/README.md index 1aa86f5..5ddf372 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ 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 is a bare exe; `setup` copies it to @@ -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 diff --git a/docs/test-plan.md b/docs/test-plan.md index 4876dd6..36231ea 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -37,6 +37,7 @@ Command surface and contracts: | `rb msvc [--vsver ] [--] ` | 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 ] [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 ( )` in cargo's shape, from the assembly's informational version (whose `+` 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: ` to @@ -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 ( )`, 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. diff --git a/src/rbmanager/Program.cs b/src/rbmanager/Program.cs index 1db3c54..33711f2 100644 --- a/src/rbmanager/Program.cs +++ b/src/rbmanager/Program.cs @@ -29,6 +29,7 @@ private static async Task 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. @@ -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 ) + version print the rbmanager version """); return 2; } @@ -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=) + // 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()?.InformationalVersion, + self.GetName().Version?.ToString(), + self.GetCustomAttributes() + .FirstOrDefault(a => a.Key == "CommitDate")?.Value); + } + + // cargo's shape, `cargo 1.75.0 (1d8b05cdd 2023-11-20)`. The commit + // rides along as SourceRevisionId's "+" 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 InstalledRubies() => Directory.Exists(Rubies) ? Directory.EnumerateDirectories(Rubies).Order() : []; diff --git a/src/rbmanager/rbmanager.csproj b/src/rbmanager/rbmanager.csproj index d5176c6..87d6768 100644 --- a/src/rbmanager/rbmanager.csproj +++ b/src/rbmanager/rbmanager.csproj @@ -27,6 +27,33 @@ Copyright (c) 2026 Hiroshi SHIBATA + + + + + + + + + + + $(RbCommit) + + + + + + diff --git a/tests/rbmanager.Tests/CliE2eTests.cs b/tests/rbmanager.Tests/CliE2eTests.cs index f548ede..980a845 100644 --- a/tests/rbmanager.Tests/CliE2eTests.cs +++ b/tests/rbmanager.Tests/CliE2eTests.cs @@ -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() { diff --git a/tests/rbmanager.Tests/PublishE2eTests.cs b/tests/rbmanager.Tests/PublishE2eTests.cs index 334d2ca..72a9e94 100644 --- a/tests/rbmanager.Tests/PublishE2eTests.cs +++ b/tests/rbmanager.Tests/PublishE2eTests.cs @@ -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() { diff --git a/tests/rbmanager.Tests/VersionTests.cs b/tests/rbmanager.Tests/VersionTests.cs new file mode 100644 index 0000000..cf78932 --- /dev/null +++ b/tests/rbmanager.Tests/VersionTests.cs @@ -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); + } +}