From 7ddf5b32cb2c6feb51deed29d95fb8b097fd938d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 23 Jul 2026 14:43:12 +0900 Subject: [PATCH 1/2] Add --vsver selection and rb msvc list The activation commands always resolved the newest Visual Studio via vswhere -latest. A product year (2017/2019/2022/2026) can now be pinned per invocation with --vsver or per session with RBMANAGER_VSVER, mapped to vswhere -version ranges, and rb msvc list shows the installed toolchains. The year is derived from the installationVersion major because the Dev18 series reports catalog.productLineVersion "18" even though its displayName says 2026. Co-Authored-By: Claude Fable 5 --- README.md | 6 +- docs/msvc-enable.md | 40 +++++++ src/rbmanager/Msvc.cs | 220 +++++++++++++++++++++++++++++++++++---- src/rbmanager/Program.cs | 10 +- 4 files changed, 251 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 044fbd3..efcb4a7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ rb use switch the active ruby rb uninstall remove an installed ruby rb msvc enable [shell] print the MSVC build env to eval (cmd|powershell) rb msvc exec run a command with the MSVC build env applied +rb msvc list list installed Visual Studio C++ toolchains ``` rb is a bare exe; `setup` copies it to @@ -40,7 +41,10 @@ it (signature-verified, elevated); `--yes` skips the consent prompt. `msvc enable` and `msvc exec` activate an installed Visual Studio (or Build Tools) MSVC toolchain for building C extension gems; see -[docs/msvc-enable.md](docs/msvc-enable.md). +[docs/msvc-enable.md](docs/msvc-enable.md). By default the newest +install wins; `--vsver <2017|2019|2022|2026|latest>` (or the +`RBMANAGER_VSVER` environment variable) pins a specific Visual Studio +version, and `msvc list` shows what is installed. The official mswin packages deliberately do not bundle vcruntime140.dll (https://bugs.ruby-lang.org/issues/22180) and expect diff --git a/docs/msvc-enable.md b/docs/msvc-enable.md index 66d91e6..ecdcad8 100644 --- a/docs/msvc-enable.md +++ b/docs/msvc-enable.md @@ -96,6 +96,46 @@ wrong prints syntax the shell cannot eval. Explicit selection with a sensible default is the safer contract; auto-detection can be layered on later as a convenience without changing the interface. +## Visual Studio version selection + +Both surfaces take `--vsver ` (`2017|2019|2022|2026|latest`, +also `--vsver=`), with the `RBMANAGER_VSVER` environment +variable as a session-wide default; precedence is flag, then env var, +then newest installed. The value is the product year because that is +the user's mental model ("I installed Build Tools 2019"); internally +each year maps to a fixed `vswhere -version` range +(`2019 = [16.0,17.0)`), so selection is one extra argument on the +existing query and `-latest` still picks the newest within the range. +The explicit `latest` value exists to override the env var per +invocation. `rb msvc list` shows the installed toolchains, newest +first, with a `*` on the one default resolution would pick (same +notation as `rb list`). + +For `exec`, options are recognized only before the command and `--` +ends option parsing, so the user command is never reinterpreted. When +the requested year is not installed, the warning names it, lists the +years that are, and suggests the matching +`Microsoft.VisualStudio..BuildTools` winget package. + +The default stays "newest installed" because the mswin ABI +(`x64-mswin64_140`, the vcruntime140 family) is compatible across all +VS 2015+ toolsets; there is no need to match the VS that built Ruby +itself. Pinning is the escape hatch for toolset-specific bugs or an +organization standard, not the normal path. + +One trap for future readers: the year cannot be taken from vswhere's +`catalog.productLineVersion`. The Dev18 series reports `18` there even +though its displayName says "Visual Studio Build Tools 2026", so the +year shown by `rb msvc list` (and matched by `--vsver`) is derived +from the `installationVersion` major instead. + +`--vsver` deliberately does not cover VsDevCmd's `-vcvars_ver` (the +toolset-within-an-install axis); a future `--toolset` can add that +without touching this interface. A persistent `rb msvc use ` is +also deliberately absent: rbmanager has no config file, and a +persistent pin would go silently stale when VS installs change, the +same staleness argument that rejected caching below. + ## VS discovery Discovery uses `vswhere.exe`, which ships with the VS Installer at the diff --git a/src/rbmanager/Msvc.cs b/src/rbmanager/Msvc.cs index f60d266..0bf0b10 100644 --- a/src/rbmanager/Msvc.cs +++ b/src/rbmanager/Msvc.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text.Json; namespace RbManager; @@ -12,15 +13,36 @@ namespace RbManager; // Studio's Developer Command Prompt), and exposes that environment two // ways: // -// rb msvc enable [cmd|powershell|pwsh] print env assignments to eval +// rb msvc enable [--vsver ] [cmd|powershell|pwsh] +// print env assignments to eval // in the current shell -// rb msvc exec run one command with the +// rb msvc exec [--vsver ] [--] +// run one command with the // toolchain already applied // (no shell mutation) +// rb msvc list list installed VS C++ toolchains // -// See docs/msvc-enable.md for the design rationale. +// The VS version is picked as --vsver flag > RBMANAGER_VSVER > newest +// installed. See docs/msvc-enable.md for the design rationale. internal static class Msvc { + // Product year <-> installationVersion major. The year is the + // user-facing name (displayName, installer branding, winget ids); the + // major is what installationVersion carries. The year cannot be read + // from catalog.productLineVersion: the Dev18 series reports "18" there + // even though its displayName says 2026. + private static readonly (string Year, int Major)[] VsProducts = + [("2017", 15), ("2019", 16), ("2022", 17), ("2026", 18)]; + + // Product year -> vswhere -version range. + internal static readonly IReadOnlyDictionary VsVerRanges = + VsProducts.ToDictionary(p => p.Year, p => $"[{p.Major}.0,{p.Major + 1}.0)"); + + private static string? YearOfVersion(string installationVersion) => + Version.TryParse(installationVersion, out Version? v) && + VsProducts.FirstOrDefault(p => p.Major == v.Major) is { Year: { } year } + ? year : null; + // vswhere ships at a fixed, versionless path with the VS Installer and // is the only supported way to locate installs (including Build-Tools- // only ones, which require -products *). Settable (and RBMANAGER_VSWHERE- @@ -35,16 +57,88 @@ internal static class Msvc // RBMANAGER_VSDEVCMD short-circuits VS discovery with a caller-supplied // VsDevCmd.bat (a stub in tests), so Enable/Exec are exercisable without // a real Visual Studio install. - private static string? ResolveVsDevCmd() => + private static string? ResolveVsDevCmd(string? year) => Environment.GetEnvironmentVariable("RBMANAGER_VSDEVCMD") is { Length: > 0 } stub ? stub - : LocateVsDevCmd(); + : LocateVsDevCmd(year); + + // Resolves the effective VS product year: the --vsver flag wins over + // RBMANAGER_VSVER; null (or the explicit "latest", useful to override + // the env var per invocation) means the newest installed. + internal static string? EffectiveVsVer(string? flag) + { + string? v = flag is { Length: > 0 } + ? flag + : Environment.GetEnvironmentVariable("RBMANAGER_VSVER"); + if (v is null or "" or "latest") return null; + if (!VsVerRanges.ContainsKey(v)) + throw new InvalidOperationException( + $"unknown Visual Studio version '{v}' " + + $"(expected {string.Join(", ", VsProducts.Select(p => p.Year))}, or latest)"); + return v; + } + + // enable arguments: [--vsver ] [shell], in either order. + // Returns null when the arguments do not parse (caller prints usage). + internal static (string? Shell, string? VsVer)? EnableArgs(string[] args) + { + string? shell = null, vsver = null; + for (int i = 0; i < args.Length; i++) + { + string a = args[i]; + if (a == "--vsver") + { + if (++i >= args.Length) return null; + vsver = args[i]; + } + else if (a.StartsWith("--vsver=", StringComparison.Ordinal)) + { + vsver = a["--vsver=".Length..]; + if (vsver.Length == 0) return null; + } + else if (a.StartsWith('-') || shell is not null) return null; + else shell = a; + } + return (shell, vsver); + } - public static int Enable(string? shell) + // exec arguments: [--vsver ] [--] . Options are + // recognized only before the command, so the user command is never + // reinterpreted; `--` ends option parsing for commands that start + // with a dash. Returns null when the arguments do not parse or no + // command remains (caller prints usage). + internal static (string[] Command, string? VsVer)? ExecArgs(string[] args) + { + string? vsver = null; + int i = 0; + while (i < args.Length) + { + string a = args[i]; + if (a == "--") { i++; break; } + if (a == "--vsver") + { + if (i + 1 >= args.Length) return null; + vsver = args[i + 1]; + i += 2; + } + else if (a.StartsWith("--vsver=", StringComparison.Ordinal)) + { + vsver = a["--vsver=".Length..]; + if (vsver.Length == 0) return null; + i++; + } + else if (a.StartsWith("--", StringComparison.Ordinal)) return null; + else break; + } + return i < args.Length ? (args[i..], vsver) : null; + } + + public static int Enable(string? shell, string? vsver = null) { Shell target = ParseShell(shell); - string? vsdevcmd = ResolveVsDevCmd(); - if (vsdevcmd is null) return WarnMissingToolchain(); + string? year = EffectiveVsVer(vsver); + string? vsdevcmd = ResolveVsDevCmd(year); + if (vsdevcmd is null) return WarnMissingToolchain(year); var env = ActivatedDelta(vsdevcmd); foreach ((string key, string value) in env) Console.WriteLine(Assignment(target, key, value)); @@ -55,10 +149,11 @@ public static int Enable(string? shell) return 0; } - public static int Exec(string[] command) + public static int Exec(string[] command, string? vsver = null) { - string? vsdevcmd = ResolveVsDevCmd(); - if (vsdevcmd is null) return WarnMissingToolchain(); + string? year = EffectiveVsVer(vsver); + string? vsdevcmd = ResolveVsDevCmd(year); + if (vsdevcmd is null) return WarnMissingToolchain(year); var delta = ActivatedDelta(vsdevcmd); // cmd.exe /c so that .cmd shims (gem, bundle) and PATHEXT resolve // the way they would if the user had typed the command directly. @@ -122,10 +217,11 @@ public static int Exec(string[] command) } } - // Resolves the newest VS install that carries the MSVC toolset and - // returns its VsDevCmd.bat, or null when no usable toolchain exists - // (vswhere absent, no matching install, or VsDevCmd.bat missing). - internal static string? LocateVsDevCmd() + // Resolves the newest VS install that carries the MSVC toolset (within + // the given product year when one is requested) and returns its + // VsDevCmd.bat, or null when no usable toolchain exists (vswhere + // absent, no matching install, or VsDevCmd.bat missing). + internal static string? LocateVsDevCmd(string? year = null) { if (!File.Exists(VsWhere)) return null; var psi = new ProcessStartInfo @@ -137,13 +233,19 @@ public static int Exec(string[] command) // -products * finds Build-Tools-only installs; -requires narrows to // installs that carry the MSVC x64/x86 toolset (this component id is // stable across VS versions, unlike the legacy Microsoft.VisualCpp.* - // ids); -latest picks the newest when several qualify. + // ids); -version narrows to the requested product year; -latest + // picks the newest when several qualify. foreach (string a in new[] { "-latest", "-products", "*", "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath", }) psi.ArgumentList.Add(a); + if (year is not null) + { + psi.ArgumentList.Add("-version"); + psi.ArgumentList.Add(VsVerRanges[year]); + } using var proc = Process.Start(psi) ?? throw new InvalidOperationException("failed to launch vswhere"); @@ -154,19 +256,95 @@ public static int Exec(string[] command) return File.Exists(vsdevcmd) ? vsdevcmd : null; } + internal sealed record VsInstall(string Year, string Version, string Path); + + // All installs carrying the MSVC toolset, newest first (the first entry + // is what -latest resolves to). Empty when vswhere is absent or finds + // nothing. The year comes from the installationVersion major (see + // VsProducts), falling back to catalog.productLineVersion for majors + // this build does not know yet. + internal static List Installs() + { + if (!File.Exists(VsWhere)) return []; + var psi = new ProcessStartInfo + { + FileName = VsWhere, + RedirectStandardOutput = true, + UseShellExecute = false, + StandardOutputEncoding = System.Text.Encoding.UTF8, + }; + foreach (string a in new[] + { + "-products", "*", + "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "-format", "json", "-utf8", + }) psi.ArgumentList.Add(a); + + using var proc = Process.Start(psi) + ?? throw new InvalidOperationException("failed to launch vswhere"); + string json = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(); + if (proc.ExitCode != 0) return []; + + var installs = new List(); + using var doc = JsonDocument.Parse(json); + foreach (JsonElement e in doc.RootElement.EnumerateArray()) + { + if (e.GetProperty("installationPath").GetString() is not { } path || + e.GetProperty("installationVersion").GetString() is not { } version) + continue; + string year = YearOfVersion(version) + ?? (e.TryGetProperty("catalog", out JsonElement catalog) && + catalog.TryGetProperty("productLineVersion", out JsonElement line) && + line.GetString() is { Length: > 0 } y + ? y : "?"); + installs.Add(new VsInstall(year, version, path)); + } + return installs + .OrderByDescending(i => System.Version.TryParse(i.Version, out var v) + ? v : new Version(0, 0)) + .ToList(); + } + + // `rb msvc list`: one line per install, `*` marking what the current + // default resolution (--vsver unset, so RBMANAGER_VSVER or newest) + // would pick, in the same style as `rb list`. + public static int List() + { + var installs = Installs(); + if (installs.Count == 0) return WarnMissingToolchain(null); + string? year = EffectiveVsVer(null); + VsInstall? picked = year is null + ? installs[0] + : installs.FirstOrDefault(i => i.Year == year); + int width = installs.Max(i => i.Version.Length); + foreach (VsInstall i in installs) + Console.WriteLine( + $"{(ReferenceEquals(i, picked) ? "*" : " ")} {i.Year} " + + $"{i.Version.PadRight(width)} {i.Path}"); + return 0; + } + // Fails fast with the setup steps instead of letting mkmf die later // with its cryptic "install development tools first". stderr only, so - // an eval'd `rb msvc enable` pipeline never swallows it. - private static int WarnMissingToolchain() + // an eval'd `rb msvc enable` pipeline never swallows it. When a + // specific year was requested, names it, lists the years that are + // installed, and suggests the matching Build Tools package. + private static int WarnMissingToolchain(string? year) { - Console.Error.WriteLine(""" - rb: warning: no Visual Studio C++ toolchain found; native extensions cannot be built. + string product = year is null ? "" : $" {year}"; + string installed = ""; + if (year is not null && + Installs().Select(i => i.Year).Distinct().Order().ToArray() is { Length: > 0 } years) + installed = $" (installed: {string.Join(", ", years)})"; + Console.Error.WriteLine($""" + rb: warning: no Visual Studio{product} C++ toolchain found{installed}; native extensions cannot be built. To set one up: 1. Install the "Desktop development with C++" workload, e.g. - winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --add Microsoft.VisualStudio.Workload.VCTools" + winget install Microsoft.VisualStudio.{year ?? "2022"}.BuildTools --override "--quiet --add Microsoft.VisualStudio.Workload.VCTools" (any Visual Studio edition with that workload also works) diff --git a/src/rbmanager/Program.cs b/src/rbmanager/Program.cs index 3b364ef..8f8a220 100644 --- a/src/rbmanager/Program.cs +++ b/src/rbmanager/Program.cs @@ -29,9 +29,11 @@ private static async Task Main(string[] args) ["list"] => List(), ["use", var name] => Use(name), ["uninstall", var name] => Uninstall(name), - ["msvc", "enable"] => Msvc.Enable(null), - ["msvc", "enable", var shell] => Msvc.Enable(shell), - ["msvc", "exec", .. var command] when command.Length > 0 => Msvc.Exec(command), + ["msvc", "enable", .. var rest] => + Msvc.EnableArgs(rest) is { } en ? Msvc.Enable(en.Shell, en.VsVer) : Usage(), + ["msvc", "exec", .. var rest] => + Msvc.ExecArgs(rest) is { } ex ? Msvc.Exec(ex.Command, ex.VsVer) : Usage(), + ["msvc", "list"] => Msvc.List(), _ => Usage(), }; } @@ -54,6 +56,8 @@ list list installed rubies uninstall remove an installed ruby msvc enable [shell] print the MSVC build env to eval (cmd|powershell) msvc exec run a command with the MSVC build env applied + (both accept --vsver to pick a VS version) + msvc list list installed Visual Studio C++ toolchains """); return 2; } From c30ec81c73fbae9bd45173331c3ac5f98b1227f5 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 23 Jul 2026 14:43:13 +0900 Subject: [PATCH 2/2] Add tests for --vsver and rb msvc list Covers the argument parsers, the flag/env precedence, the missing-year warning, and per-year resolution against real installs. Plan cases are numbered 76-85 because 75 was already taken by the AOT publish smoke. Co-Authored-By: Claude Fable 5 --- docs/test-plan.md | 45 +++++++++- tests/rbmanager.Tests/CliE2eTests.cs | 4 + tests/rbmanager.Tests/MsvcActivationTests.cs | 86 ++++++++++++++++++++ tests/rbmanager.Tests/MsvcHelperTests.cs | 73 +++++++++++++++++ tests/rbmanager.Tests/MsvcVsTests.cs | 48 +++++++++++ 5 files changed, 253 insertions(+), 3 deletions(-) diff --git a/docs/test-plan.md b/docs/test-plan.md index e2e0af6..17fd7ec 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -34,8 +34,9 @@ Command surface and contracts: | `rb list` | Installed names sorted, active one starred | 0 | | `rb use ` | Resolve query (exact or case-insensitive substring; must be unambiguous), recreate the `current` junction, ensure PATH | 0 / 1 | | `rb uninstall ` | Resolve; if active, delete the junction first and print a hint; delete the install dir recursively | 0 / 1 | -| `rb msvc enable [shell]` | Locate VsDevCmd via vswhere, compute the env delta of activation, print per-shell assignments plus an unset of `NoDefaultCurrentDirectoryInExePath`. Shell defaults to PowerShell; `cmd`/`bat` selects cmd syntax. No toolchain: actionable warning on stderr | 0 / 1 | -| `rb msvc exec ` | Same delta applied to a `cmd /s /c` child (so `.cmd` shims resolve); removes `NoDefaultCurrentDirectoryInExePath`; propagates the child's exit code | child / 1 | +| `rb msvc enable [--vsver ] [shell]` | Locate VsDevCmd via vswhere (narrowed to the requested VS product year, if any; `--vsver` > `RBMANAGER_VSVER` > newest), compute the env delta of activation, print per-shell assignments plus an unset of `NoDefaultCurrentDirectoryInExePath`. Shell defaults to PowerShell; `cmd`/`bat` selects cmd syntax. No toolchain: actionable warning on stderr | 0 / 1 | +| `rb msvc exec [--vsver ] [--] ` | Same delta applied 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 | child / 1 | +| `rb msvc list` | All installs carrying the MSVC toolset, newest first, `*` on the one default resolution would pick. No toolchain: same warning as enable/exec | 0 / 1 | | anything else | Usage text | 2 | Any thrown exception is caught in `Main`, printed as `rb: ` to @@ -210,7 +211,10 @@ Drive the exe built by `dotnet build` (see section 5 for AOT). 35. Unknown command → usage, exit 2. 36. `install` with no argument, `use` with no argument, `msvc` with no subcommand, `msvc exec` with no command → usage, exit 2 (the - `msvc exec` pattern requires a non-empty command). + `msvc exec` pattern requires a non-empty command). Malformed msvc + options too: `msvc exec --vsver` (no value), `msvc exec --vsver + 2022` (no command), `msvc enable --vsver` (no value), `msvc enable` + with two shells. 37. Failing command (e.g. `use nosuch`) → stderr starts with `rb: `, exit 1, stdout empty. 38. Full lifecycle: install A → list (A starred) → install B → list (B @@ -280,6 +284,20 @@ All against `HKCU\Software\rbmanager-tests\` with quotes; empty string → `""`; tab → quoted; embedded `"` → not escaped (pin as known limitation; see 6.7). +Added with `--vsver` (numbered after the sections below; 75 was +already taken by the AOT publish smoke): + +76. `EnableArgs`: shell and `--vsver `/`--vsver=` parse in + either order; missing/empty value, unknown option, or two shells → + null. +77. `ExecArgs`: options are leading-only; `--` ends option parsing and + passes `--vsver`-looking tokens through as the command; an option + token after the first command token is command, not option; + missing/empty value, unknown leading option, or no remaining + command → null. +78. `VsVerRanges` maps exactly 2017/2019/2022/2026 to the + `[15.0,16.0)`-style installationVersion ranges. + ### 4.9 Msvc: activation with a stub VsDevCmd — Integration Stub `.bat` fixture written per test, e.g. sets `RB_TEST_NEW=hello`, @@ -315,6 +333,19 @@ contains `=` and one containing non-ASCII, and `exit /b 0`. child (child echoes `%1`-style or a tiny script writes its argv to a file). +Added with `--vsver`: + +79. `EffectiveVsVer`: flag > `RBMANAGER_VSVER` > null (latest); the + explicit `latest` value overrides the env var back to newest; an + unknown year (flag or env) throws `unknown Visual Studio version`. +80. `Enable` with a `--vsver` year and the `RBMANAGER_VSDEVCMD` stub + set → the stub still short-circuits discovery. +81. `Enable`/`Exec` with a requested year and `VsWhere` nonexistent → + stderr names the year and suggests the matching + `Microsoft.VisualStudio..BuildTools` winget id, exit 1. +82. `List` with `VsWhere` nonexistent → same warning as enable/exec, + exit 1, stdout empty. + ### 4.10 Msvc against real Visual Studio — RequiresVS (opt-in) Skipped unless vswhere resolves an install (use a runtime skip, e.g. @@ -324,6 +355,14 @@ Skipped unless vswhere resolves an install (use a runtime skip, e.g. 73. `ActivatedDelta` includes `INCLUDE`, `LIB`, and a `PATH` change. 74. `rb msvc exec cl` (E2E) exits 0 with cl's banner on stderr. +Added with `--vsver`: + +83. `LocateVsDevCmd()` for every installed year resolves a + `VsDevCmd.bat` under that year's install path. +84. `rb msvc list` (E2E) prints one line per install, newest first, + `*` on the first, install path on each line. +85. `rb msvc exec --vsver cl` (E2E) runs cl. + ### 4.11 AOT publish smoke — E2E (opt-in, slow) 75. `dotnet publish -r win-x64` the product, run diff --git a/tests/rbmanager.Tests/CliE2eTests.cs b/tests/rbmanager.Tests/CliE2eTests.cs index cd0394a..3bc0e41 100644 --- a/tests/rbmanager.Tests/CliE2eTests.cs +++ b/tests/rbmanager.Tests/CliE2eTests.cs @@ -36,6 +36,10 @@ public void UnknownCommand_Usage_Exit2() [InlineData("use")] [InlineData("msvc")] [InlineData("msvc", "exec")] + [InlineData("msvc", "exec", "--vsver")] + [InlineData("msvc", "exec", "--vsver", "2022")] + [InlineData("msvc", "enable", "--vsver")] + [InlineData("msvc", "enable", "cmd", "pwsh")] public void MissingRequiredArgument_Usage_Exit2(params string[] command) { using var sb = new E2eSandbox(); diff --git a/tests/rbmanager.Tests/MsvcActivationTests.cs b/tests/rbmanager.Tests/MsvcActivationTests.cs index a24eb35..d1a804a 100644 --- a/tests/rbmanager.Tests/MsvcActivationTests.cs +++ b/tests/rbmanager.Tests/MsvcActivationTests.cs @@ -106,6 +106,7 @@ public void EnableAndExec_NoToolchain_WarnOnStderr() using var tmp = new TempDir(); using var env = new EnvScope(); env.Set("RBMANAGER_VSDEVCMD", null); // force real discovery + env.Set("RBMANAGER_VSVER", null); using var vsw = new VsWhereScope(tmp.At("no-vswhere.exe")); using (var cap = new ConsoleCapture()) @@ -181,6 +182,91 @@ public void Exec_ResolvesCmdShimOnActivatedPath() Assert.Equal(42, rc); } + [Fact] // case 79: --vsver flag > RBMANAGER_VSVER > latest (null) + public void EffectiveVsVer_FlagBeatsEnvBeatsLatest() + { + using var env = new EnvScope(); + env.Set("RBMANAGER_VSVER", null); + Assert.Null(Msvc.EffectiveVsVer(null)); + Assert.Equal("2022", Msvc.EffectiveVsVer("2022")); + + env.Set("RBMANAGER_VSVER", "2019"); + Assert.Equal("2019", Msvc.EffectiveVsVer(null)); + Assert.Equal("2022", Msvc.EffectiveVsVer("2022")); + // explicit latest overrides the env var back to newest + Assert.Null(Msvc.EffectiveVsVer("latest")); + } + + [Fact] // case 79 + public void EffectiveVsVer_UnknownYear_Throws() + { + using var env = new EnvScope(); + env.Set("RBMANAGER_VSVER", null); + var ex = Assert.Throws(() => Msvc.EffectiveVsVer("2020")); + Assert.Equal( + "unknown Visual Studio version '2020' (expected 2017, 2019, 2022, 2026, or latest)", + ex.Message); + // a bad env var fails the same way + env.Set("RBMANAGER_VSVER", "vs2022"); + Assert.Throws(() => Msvc.EffectiveVsVer(null)); + } + + [Fact] // case 80: RBMANAGER_VSDEVCMD still short-circuits discovery + public void Enable_WithVsVer_StubStillWins() + { + using var tmp = new TempDir(); + using var env = new EnvScope(); + env.Set("RBMANAGER_VSDEVCMD", Bat(tmp, "set RB_TEST_NEW=hello")); + using var cap = new ConsoleCapture(); + + int rc = Msvc.Enable("powershell", "2019"); + + Assert.Equal(0, rc); + Assert.Contains("$env:RB_TEST_NEW = 'hello'", cap.OutLines); + } + + [Fact] // case 81: requested year absent -> names the year, exit 1 + public void EnableAndExec_RequestedYearMissing_WarnNamesYear() + { + using var tmp = new TempDir(); + using var env = new EnvScope(); + env.Set("RBMANAGER_VSDEVCMD", null); // force real discovery + env.Set("RBMANAGER_VSVER", null); + using var vsw = new VsWhereScope(tmp.At("no-vswhere.exe")); + + using (var cap = new ConsoleCapture()) + { + int rc = Msvc.Enable("powershell", "2019"); + Assert.Equal(1, rc); + Assert.Equal("", cap.Out); + Assert.Contains("no Visual Studio 2019 C++ toolchain found", cap.Err); + Assert.Contains("winget install Microsoft.VisualStudio.2019.BuildTools", cap.Err); + } + + using (var cap = new ConsoleCapture()) + { + int rc = Msvc.Exec(["cmd", "/c", "echo", "x"], "2017"); + Assert.Equal(1, rc); + Assert.Contains("no Visual Studio 2017 C++ toolchain found", cap.Err); + } + } + + [Fact] // case 82: no vswhere -> list warns like enable/exec, exit 1 + public void List_NoToolchain_WarnOnStderr() + { + using var tmp = new TempDir(); + using var env = new EnvScope(); + env.Set("RBMANAGER_VSVER", null); + using var vsw = new VsWhereScope(tmp.At("no-vswhere.exe")); + using var cap = new ConsoleCapture(); + + int rc = Msvc.List(); + + Assert.Equal(1, rc); + Assert.Equal("", cap.Out); + Assert.Contains("no Visual Studio C++ toolchain found", cap.Err); + } + [Fact] // case 71: an argument with spaces survives as one argument public void Exec_QuotesArgumentWithSpaces() { diff --git a/tests/rbmanager.Tests/MsvcHelperTests.cs b/tests/rbmanager.Tests/MsvcHelperTests.cs index 01400ca..3ca4635 100644 --- a/tests/rbmanager.Tests/MsvcHelperTests.cs +++ b/tests/rbmanager.Tests/MsvcHelperTests.cs @@ -70,4 +70,77 @@ public void QuoteArg() // pin 6.7: embedded quote is not escaped, only wrapped when needed Assert.Equal("a\"b", Msvc.QuoteArg("a\"b")); } + + private static void AssertEnable(string? shell, string? vsver, params string[] args) + { + var parsed = Msvc.EnableArgs(args); + Assert.NotNull(parsed); + Assert.Equal(shell, parsed.Value.Shell); + Assert.Equal(vsver, parsed.Value.VsVer); + } + + private static void AssertExec(string[] command, string? vsver, params string[] args) + { + var parsed = Msvc.ExecArgs(args); + Assert.NotNull(parsed); + Assert.Equal(command, parsed.Value.Command); + Assert.Equal(vsver, parsed.Value.VsVer); + } + + [Fact] // case 76 + public void EnableArgs_ShellAndVsVer_EitherOrder() + { + AssertEnable(null, null); + AssertEnable("cmd", null, "cmd"); + AssertEnable(null, "2019", "--vsver", "2019"); + AssertEnable("cmd", "2019", "--vsver", "2019", "cmd"); + AssertEnable("cmd", "2019", "cmd", "--vsver=2019"); + } + + [Fact] // case 76 + public void EnableArgs_Malformed_Null() + { + Assert.Null(Msvc.EnableArgs(["--vsver"])); // missing value + Assert.Null(Msvc.EnableArgs(["--vsver="])); // empty value + Assert.Null(Msvc.EnableArgs(["--bogus"])); // unknown option + Assert.Null(Msvc.EnableArgs(["cmd", "pwsh"])); // two shells + } + + [Fact] // case 77 + public void ExecArgs_LeadingOptionsThenCommand() + { + AssertExec(["gem", "install", "json"], null, "gem", "install", "json"); + AssertExec(["cl"], "2019", "--vsver", "2019", "cl"); + AssertExec(["cl"], "2019", "--vsver=2019", "cl"); + AssertExec(["cl"], "2019", "--vsver", "2019", "--", "cl"); + } + + [Fact] // case 77: after `--` everything is command, never options + public void ExecArgs_DoubleDash_PassesOptionsThrough() => + AssertExec(["--vsver", "2019"], null, "--", "--vsver", "2019"); + + [Fact] // case 77: options past the first command token stay untouched + public void ExecArgs_OptionAfterCommand_IsCommand() => + AssertExec(["ruby", "--vsver", "x"], null, "ruby", "--vsver", "x"); + + [Fact] // case 77 + public void ExecArgs_Malformed_Null() + { + Assert.Null(Msvc.ExecArgs([])); // no command + Assert.Null(Msvc.ExecArgs(["--vsver"])); // missing value + Assert.Null(Msvc.ExecArgs(["--vsver", "2019"])); // option but no command + Assert.Null(Msvc.ExecArgs(["--vsver=", "cl"])); // empty value + Assert.Null(Msvc.ExecArgs(["--bogus", "cl"])); // unknown option + Assert.Null(Msvc.ExecArgs(["--"])); // separator alone + } + + [Fact] // case 78: the year map covers exactly the VsDevCmd-era products + public void VsVerRanges_YearToInstallationVersionRange() + { + Assert.Equal("[15.0,16.0)", Msvc.VsVerRanges["2017"]); + Assert.Equal("[16.0,17.0)", Msvc.VsVerRanges["2019"]); + Assert.Equal("[17.0,18.0)", Msvc.VsVerRanges["2022"]); + Assert.Equal("[18.0,19.0)", Msvc.VsVerRanges["2026"]); + Assert.Equal(4, Msvc.VsVerRanges.Count); + } } diff --git a/tests/rbmanager.Tests/MsvcVsTests.cs b/tests/rbmanager.Tests/MsvcVsTests.cs index 7db723f..f55ccd0 100644 --- a/tests/rbmanager.Tests/MsvcVsTests.cs +++ b/tests/rbmanager.Tests/MsvcVsTests.cs @@ -43,4 +43,52 @@ public void Exec_Cl_RunsCompiler() // cl with no input files prints its version banner to stderr. Assert.Contains("Microsoft", r.Err); } + + [SkippableFact] // case 83: --vsver resolves the newest install of that year + public void LocateVsDevCmd_PerYear_ResolvesMatchingInstall() + { + var installs = Msvc.Installs(); + Skip.If(installs.Count == 0, "no Visual Studio C++ toolchain installed"); + + foreach (var expected in installs.GroupBy(i => i.Year).Select(g => g.First())) + { + Skip.If(!Msvc.VsVerRanges.ContainsKey(expected.Year), + $"unmapped product year {expected.Year}"); + string? bat = Msvc.LocateVsDevCmd(expected.Year); + Assert.NotNull(bat); + Assert.StartsWith(expected.Path, bat, StringComparison.OrdinalIgnoreCase); + } + } + + [SkippableFact] // case 84 + public void List_MarksDefaultAndPrintsYears() + { + var installs = Msvc.Installs(); + Skip.If(installs.Count == 0, "no Visual Studio C++ toolchain installed"); + using var sb = new E2eSandbox(); + + RbResult r = sb.Run("msvc", "list"); + + Assert.Equal(0, r.ExitCode); + string[] lines = r.Out.Replace("\r\n", "\n").TrimEnd('\n').Split('\n'); + Assert.Equal(installs.Count, lines.Length); + // newest first carries the default marker; every line names its year + Assert.StartsWith($"* {installs[0].Year}", lines[0]); + foreach ((string line, var install) in lines.Zip(installs)) + Assert.Contains(install.Path, line); + } + + [SkippableFact] // case 85: exec with an installed year still finds cl + public void Exec_WithVsVer_RunsCompiler() + { + var installs = Msvc.Installs(); + Skip.If(installs.Count == 0, "no Visual Studio C++ toolchain installed"); + string year = installs[0].Year; + Skip.If(!Msvc.VsVerRanges.ContainsKey(year), $"unmapped product year {year}"); + using var sb = new E2eSandbox(); + + RbResult r = sb.Run("msvc", "exec", "--vsver", year, "cl"); + + Assert.Contains("Microsoft", r.Err); + } }