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/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/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; } 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); + } }