diff --git a/documentation/general/dotnetup/designs/unix-environment-setup.md b/documentation/general/dotnetup/designs/unix-environment-setup.md index 2ccb4bce58d6..fa8015b42248 100644 --- a/documentation/general/dotnetup/designs/unix-environment-setup.md +++ b/documentation/general/dotnetup/designs/unix-environment-setup.md @@ -54,7 +54,8 @@ dotnetup defaultinstall system |-------|---------------|-----------| | **bash** | `~/.bashrc` (always) + the first existing of `~/.bash_profile` / `~/.profile` (creates `~/.profile` if neither exists) | `.bashrc` covers Linux terminals (non-login shells). The login profile covers macOS Terminal and SSH sessions. We never create `~/.bash_profile` to avoid shadowing an existing `~/.profile`. | | **zsh** | `$ZDOTDIR/.zshrc` when `ZDOTDIR` is set; otherwise `~/.zshrc` (created if needed) | Covers all interactive zsh sessions. `~/.zshenv` is avoided because on macOS, `/etc/zprofile` runs `path_helper` which resets PATH after `.zshenv` loads. | -| **pwsh** | `~/.config/powershell/Microsoft.PowerShell_profile.ps1` (creates directory and file if needed) | Standard PowerShell profile path on Unix. | +| **pwsh** (Unix) | `~/.config/powershell/profile.ps1` (creates directory and file if needed) | Standard PowerShell profile path on Unix. Targets the `CurrentUserAllHosts` slot so the entry applies to every host (terminal, VS Code, embedded). | +| **pwsh** (Windows) | `\WindowsPowerShell\profile.ps1` and `\PowerShell\profile.ps1` -- both flavors are written unconditionally (creates directories and files if needed). `` honors OneDrive Known Folder redirection via `Environment.GetFolderPath(MyDocuments)`. | Windows PowerShell 5.1 ships in-box and PowerShell 7+ (pwsh) installs separately, with disjoint profile folders. We write `profile.ps1` (`CurrentUserAllHosts`) for both flavors so the configuration is future-proof: if pwsh is installed later, dotnet is already wired up. `profile.ps1` is a standard PowerShell file; creating it for a not-yet-installed flavor is harmless. | The home directory used for these lookups comes from the user's current environment (`HOME`, or `USERPROFILE` / `Environment.SpecialFolder.UserProfile` as a fallback). dotnetup fails with a clear error if it cannot determine a writable profile location. diff --git a/src/Installer/dotnetup.Library/Commands/Init/InitWorkflows.cs b/src/Installer/dotnetup.Library/Commands/Init/InitWorkflows.cs index d51f5db4b251..f4b8bc06c7bf 100644 --- a/src/Installer/dotnetup.Library/Commands/Init/InitWorkflows.cs +++ b/src/Installer/dotnetup.Library/Commands/Init/InitWorkflows.cs @@ -105,7 +105,7 @@ public List InitWalkthrough( if (pathPreference is PathPreference.ShellProfile) { - _dotnetEnvironment.ApplyTerminalProfileModifications(installRoot.Path, command.ShellProvider); + _dotnetEnvironment.ApplyTerminalProfileModifications(installRoot.Path, shellProvider: command.ShellProvider); } if (ShouldReplaceSystemConfiguration(pathPreference)) diff --git a/src/Installer/dotnetup.Library/DotnetEnvironmentManager.cs b/src/Installer/dotnetup.Library/DotnetEnvironmentManager.cs index b07c13edad32..79177981513f 100644 --- a/src/Installer/dotnetup.Library/DotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup.Library/DotnetEnvironmentManager.cs @@ -267,6 +267,10 @@ private static void TryAddPath(List paths, string path) } } + /// + /// Applies machine/user-level environment configuration (PATH and DOTNET_ROOT environment + /// variables) to point to either the system (Program Files) or user dotnet install location. + /// public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null) { if (OperatingSystem.IsWindows()) @@ -313,46 +317,74 @@ public void ApplyEnvironmentModifications(InstallType installType, string? dotne } } - public void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvider? shellProvider = null) + /// + /// Applies dotnetup's profile-file modifications for the current user's shell environment, + /// which set up the PATH and DOTNET_ROOT environment variables for the user's shell. + /// + public void ApplyTerminalProfileModifications(string dotnetRoot, InstallType installType = InstallType.User, IEnvShellProvider? shellProvider = null) { ArgumentNullException.ThrowIfNull(dotnetRoot); if (OperatingSystem.IsWindows()) { - // Not implemented yet on Windows - return; + ApplyTerminalProfileModificationsWindows(dotnetRoot, installType, shellProvider); } else { - ConfigureInstallTypeUnix(InstallType.User, dotnetRoot, shellProvider); + ApplyTerminalProfileModificationsUnix(dotnetRoot, installType, shellProvider); + } + } + + private void ApplyTerminalProfileModificationsWindows(string dotnetRoot, InstallType installType, IEnvShellProvider? shellProvider) + { + var dotnetupPath = ShellProviderHelpers.GetDotnetupExecutablePathOrThrow(); + // The current shell provider on Windows is always the PowerShell provider (see + // ShellDetection.GetCurrentShellProvider). We accept a caller-supplied provider for + // testability. + shellProvider ??= new PowerShellEnvShellProvider(); + + if (installType == InstallType.System) + { + // System install: dotnet is assumed to be on PATH already (configured by the system + // installer / admin). The profile entry only adds dotnetup to PATH. + ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true); + return; } + + // If the install root matches the default dotnetup-managed path, omit it from the profile + // entry so `print-env-script` falls back to its built-in default-detection logic. This + // keeps profile entries minimal and portable across user-resolved default paths. + string? profileDotnetRoot = DotnetupUtilities.PathsEqual(dotnetRoot, GetDefaultDotnetInstallPath()) + ? null + : dotnetRoot; + + ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetInstallPath: profileDotnetRoot); } - private void ConfigureInstallTypeUnix(InstallType installType, string? dotnetRoot, IEnvShellProvider? shellProvider) + private void ApplyTerminalProfileModificationsUnix(string dotnetRoot, InstallType installType, IEnvShellProvider? shellProvider) { var dotnetupPath = ShellProviderHelpers.GetDotnetupExecutablePathOrThrow(); shellProvider = ShellDetection.GetCurrentShellProviderOrThrow(shellProvider); - switch (installType) + if (installType == InstallType.System) { - case InstallType.User: - if (string.IsNullOrEmpty(dotnetRoot)) - { - throw new ArgumentNullException(nameof(dotnetRoot)); - } + // System install: dotnet is assumed to be on PATH already (e.g. /usr/share/dotnet + // configured by the system package manager). The profile entry only adds dotnetup + // to PATH. + ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true); + return; + } - string? profileDotnetRoot = DotnetupUtilities.PathsEqual(dotnetRoot, GetDefaultDotnetInstallPath()) - ? null - : dotnetRoot; - - ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetInstallPath: profileDotnetRoot); - break; - case InstallType.System: - ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true); - break; - default: - throw new ArgumentException($"Unknown install type: {installType}", nameof(installType)); + if (string.IsNullOrEmpty(dotnetRoot)) + { + throw new ArgumentNullException(nameof(dotnetRoot)); } + + string? profileDotnetRoot = DotnetupUtilities.PathsEqual(dotnetRoot, GetDefaultDotnetInstallPath()) + ? null + : dotnetRoot; + + ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetInstallPath: profileDotnetRoot); } /// diff --git a/src/Installer/dotnetup.Library/IDotnetEnvironmentManager.cs b/src/Installer/dotnetup.Library/IDotnetEnvironmentManager.cs index ce958e703796..0a001b2bcc8f 100644 --- a/src/Installer/dotnetup.Library/IDotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup.Library/IDotnetEnvironmentManager.cs @@ -24,9 +24,20 @@ internal interface IDotnetEnvironmentManager List GetExistingSystemInstalls(); + /// + /// Applies machine/user-level environment configuration (PATH and DOTNET_ROOT environment + /// variables) to point to either the system (Program Files) or user dotnet install location. + /// void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null); - void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvider? shellProvider = null); + /// + /// Applies dotnetup's profile-file modifications for the current user's shell environment, + /// which set up the PATH and DOTNET_ROOT environment variables for the user's shell. + /// When is , dotnet is + /// assumed to already be on PATH (set up outside the profile), so the entry only adds + /// dotnetup to PATH. + /// + void ApplyTerminalProfileModifications(string dotnetRoot, InstallType installType = InstallType.User, IEnvShellProvider? shellProvider = null); /// /// Updates the global.json file to reflect the installed SDK version, diff --git a/src/Installer/dotnetup.Library/Shell/IEnvShellProvider.cs b/src/Installer/dotnetup.Library/Shell/IEnvShellProvider.cs index faa8c7b45e24..7924ec6dd7f3 100644 --- a/src/Installer/dotnetup.Library/Shell/IEnvShellProvider.cs +++ b/src/Installer/dotnetup.Library/Shell/IEnvShellProvider.cs @@ -38,6 +38,15 @@ public interface IEnvShellProvider /// IReadOnlyList GetProfilePaths(); + /// + /// The encoding to use when creating a brand-new profile file. Existing files always + /// have their detected encoding (including BOM presence) preserved by + /// ; this is only consulted when no file exists yet. + /// Defaults to UTF-8 without BOM, which is the safe choice for POSIX shells (bash/zsh + /// would otherwise execute the BOM bytes as a command). + /// + Encoding NewFileEncoding => new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + /// /// Generates the shell command block to append to a shell profile that will eval dotnetup's env script. /// adds the surrounding marker comments when it writes the block. diff --git a/src/Installer/dotnetup.Library/Shell/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup.Library/Shell/PowerShellEnvShellProvider.cs index f5293c7b2584..e208877196c1 100644 --- a/src/Installer/dotnetup.Library/Shell/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup.Library/Shell/PowerShellEnvShellProvider.cs @@ -13,6 +13,13 @@ public class PowerShellEnvShellProvider : IEnvShellProvider public override string ToString() => ArgumentName; + // Subfolder names under the user's Documents folder that hold each flavor's profile files. + internal const string WindowsPowerShellProfileFolder = "WindowsPowerShell"; + internal const string PowerShellCoreProfileFolder = "PowerShell"; + + // CurrentUserAllHosts profile filename. Used for all flavors and on all platforms. + internal const string ProfileFileName = "profile.ps1"; + public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = "", bool includeDotnet = true) { var escapedPath = ShellProviderHelpers.EscapePowerShellPath(dotnetInstallPath); @@ -38,8 +45,63 @@ public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = " public IReadOnlyList GetProfilePaths() { + if (OperatingSystem.IsWindows()) + { + var documentsFolder = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + return GetWindowsProfilePaths(documentsFolder); + } + var profileDir = ShellProviderHelpers.GetPowerShellProfileDirectoryOrThrow(); - return [Path.Combine(profileDir, "Microsoft.PowerShell_profile.ps1")]; + return [Path.Combine(profileDir, ProfileFileName)]; + } + + // Windows PowerShell 5.1 reads .ps1 files without a BOM as the system ANSI code page, + // so a BOM-less profile containing non-ASCII characters (e.g. an install path under a + // username with accented or CJK characters) would be misinterpreted and likely fail + // to invoke dotnetup. On Windows we therefore create new PowerShell profile files as + // UTF-8 with BOM. PowerShell 7+ handles a BOM transparently, so it's safe to use the + // same encoding for the pwsh profile location too. On non-Windows we keep the BOM-less + // default — PowerShell 7 on Linux/macOS treats BOM-less .ps1 files as UTF-8. + // + // Existing profile files (regardless of where they live) keep their detected encoding; + // see ShellProfileManager.ReadProfileFile. + public Encoding NewFileEncoding => OperatingSystem.IsWindows() + ? new UTF8Encoding(encoderShouldEmitUTF8Identifier: true) + : new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + // Write to profile.ps1, which applies to all PowerShell hosts, rather than + // Microsoft.PowerShell_profile.ps1, which only applies to the console host. + // + // We want dotnetup to be available "everywhere". On Windows we would modify the + // PATH if we could and that would apply to cmd also, but there's not a good way + // to override the Admin PATH that the MSI / Program Files installers set. + // + // Worth noting: + // * Most install scripts (rustup, conda init, oh-my-posh, dotnet-install) write to the + // host-specific Microsoft.PowerShell_profile.ps1. Users may have learned to look there. + // * $PROFILE (bare) points to Microsoft.PowerShell_profile.ps1. A user + // running `notepad $PROFILE` won't see our entry -- they have to know to open + // $PROFILE.CurrentUserAllHosts. + // * profile.ps1 runs in non-interactive embedded hosts too. Our entry shells out + // (& '...\dotnetup.exe' print-env-script | Out-String) every time PowerShell starts. + // + // We write to both Windows PowerShell (5.1) and PowerShell 7+ profile locations + // unconditionally, regardless of whether each flavor is currently installed. This + // means that the user installs pwsh later, dotnet is already wired up. + internal static IReadOnlyList GetWindowsProfilePaths(string documentsFolder) + { + if (string.IsNullOrEmpty(documentsFolder)) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.ContextResolutionFailed, + "Unable to locate the current user's Documents folder."); + } + + return + [ + Path.Combine(documentsFolder, WindowsPowerShellProfileFolder, ProfileFileName), + Path.Combine(documentsFolder, PowerShellCoreProfileFolder, ProfileFileName), + ]; } public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) diff --git a/src/Installer/dotnetup.Library/Shell/ShellProfileManager.cs b/src/Installer/dotnetup.Library/Shell/ShellProfileManager.cs index d19b6c44c6ee..46cdb71300bb 100644 --- a/src/Installer/dotnetup.Library/Shell/ShellProfileManager.cs +++ b/src/Installer/dotnetup.Library/Shell/ShellProfileManager.cs @@ -41,7 +41,7 @@ public static IReadOnlyList AddProfileEntries( foreach (var profilePath in profilePaths) { - if (EnsureEntryInFile(profilePath, entry)) + if (EnsureEntryInFile(profilePath, entry, provider.NewFileEncoding)) { modifiedFiles.Add(profilePath); } @@ -75,7 +75,7 @@ public static IReadOnlyList RemoveProfileEntries(IEnvShellProvider provi /// it is replaced in-place to preserve the user's ordering. Otherwise the entry is appended. /// Returns true if the file was modified, false if the entry was already correct. /// - private static bool EnsureEntryInFile(string profilePath, string entry) + private static bool EnsureEntryInFile(string profilePath, string entry, Encoding newFileEncoding) { var directory = Path.GetDirectoryName(profilePath); if (!string.IsNullOrEmpty(directory)) @@ -88,7 +88,7 @@ private static bool EnsureEntryInFile(string profilePath, string entry) // New file — write the managed block using a consistent newline style. var newFileState = new ProfileFileState( [.. GetWrappedEntryLines(entry)], - new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + newFileEncoding, Environment.NewLine, EndsWithTrailingNewLine: true); WriteProfileFile(profilePath, newFileState); diff --git a/test/dotnetup.Tests/DefaultInstallCommandTests.cs b/test/dotnetup.Tests/DefaultInstallCommandTests.cs index 9e48e653d71b..ff640c9162df 100644 --- a/test/dotnetup.Tests/DefaultInstallCommandTests.cs +++ b/test/dotnetup.Tests/DefaultInstallCommandTests.cs @@ -49,7 +49,7 @@ public void DefaultInstallUser_DoesNotPassDefaultInstallPathToPwshProfileOnUnix( exitCode.Should().Be(0, output); - string profilePath = Path.Combine(_tempHome, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"); + string profilePath = Path.Combine(_tempHome, ".config", "powershell", "profile.ps1"); File.Exists(profilePath).Should().BeTrue(); var profileContents = File.ReadAllText(profilePath); profileContents.Should().Contain("print-env-script --shell pwsh"); diff --git a/test/dotnetup.Tests/EnvShellProviderTests.cs b/test/dotnetup.Tests/EnvShellProviderTests.cs index aef852cf9b4d..680e0b995813 100644 --- a/test/dotnetup.Tests/EnvShellProviderTests.cs +++ b/test/dotnetup.Tests/EnvShellProviderTests.cs @@ -140,6 +140,22 @@ public void PowerShellProvider_ShouldGenerateValidScript() script.Should().Contain("[IO.Path]::PathSeparator"); } + [Fact] + public void PowerShellProvider_NewFileEncoding_IsBomFullOnWindowsBomLessElsewhere() + { + // IEnvShellProvider's NewFileEncoding is consulted only when creating brand-new + // profile files; existing files always keep their detected encoding. On Windows, + // Windows PowerShell 5.1 reads BOM-less .ps1 files as the system ANSI code page + // and would mis-decode profiles containing non-ASCII paths, so the PowerShell + // provider opts into UTF-8 with BOM. On other OSes BOM-less UTF-8 is the + // PowerShell 7+ default. + IEnvShellProvider provider = new PowerShellEnvShellProvider(); + var encoding = provider.NewFileEncoding; + + encoding.Should().BeOfType(); + encoding.GetPreamble().Length.Should().Be(OperatingSystem.IsWindows() ? 3 : 0); + } + [Fact] public void PowerShellProvider_ShouldIncludeDotnetupDirInPath() { diff --git a/test/dotnetup.Tests/MockDotnetInstallManager.cs b/test/dotnetup.Tests/MockDotnetInstallManager.cs index a7a042adec52..b22cf018f35f 100644 --- a/test/dotnetup.Tests/MockDotnetInstallManager.cs +++ b/test/dotnetup.Tests/MockDotnetInstallManager.cs @@ -63,7 +63,7 @@ public void ApplyEnvironmentModifications(InstallType installType, string? dotne LastDotnetRootForEnvironmentModifications = dotnetRoot; } - public void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvider? shellProvider = null) + public void ApplyTerminalProfileModifications(string dotnetRoot, InstallType installType = InstallType.User, IEnvShellProvider? shellProvider = null) { ApplyTerminalProfileModificationsCallCount++; LastDotnetRootForTerminalProfileModifications = dotnetRoot; diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index 2c9c2424ddc6..6aad7315420b 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -101,6 +101,35 @@ public void AddProfileEntries_DoesNotLeaveBackupOfExistingFile() File.ReadAllText(profilePath).Should().NotBe(originalContent); } + [Fact] + public void AddProfileEntries_NewFile_DefaultsToBomLessUtf8() + { + var profilePath = Path.Combine(_tempDir, "new-default.sh"); + var provider = new TestShellProvider(_tempDir, "new-default.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + var bytes = File.ReadAllBytes(profilePath); + bytes.AsSpan().StartsWith(Encoding.UTF8.Preamble).Should().BeFalse( + "new files should default to BOM-less UTF-8 for POSIX shells"); + } + + [Fact] + public void AddProfileEntries_NewFile_HonorsProviderNewFileEncoding() + { + var profilePath = Path.Combine(_tempDir, "new-bom.ps1"); + var provider = new TestShellProvider(_tempDir, "new-bom.ps1") + { + NewFileEncodingOverride = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), + }; + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + var bytes = File.ReadAllBytes(profilePath); + bytes.AsSpan().StartsWith(Encoding.UTF8.Preamble).Should().BeTrue( + "providers that opt into a BOM should have new files written with that BOM"); + } + [Fact] public void AddProfileEntries_PreservesUtf8BomAndCrLfLineEndings() { @@ -553,8 +582,137 @@ public void PowerShellProvider_GetProfilePaths_ReturnsProfilePs1() var provider = new PowerShellEnvShellProvider(); var paths = provider.GetProfilePaths(); - paths.Should().HaveCount(1); - paths[0].Should().EndWith("Microsoft.PowerShell_profile.ps1"); + if (OperatingSystem.IsWindows()) + { + // Windows returns one path per installed PowerShell flavor; Windows PowerShell 5.1 + // ships in-box on supported Windows versions, so at minimum we expect that profile. + paths.Should().NotBeEmpty(); + paths.Should().AllSatisfy(p => p.Should().EndWith("profile.ps1")); + } + else + { + paths.Should().HaveCount(1); + paths[0].Should().EndWith("profile.ps1"); + paths[0].Should().NotEndWith("Microsoft.PowerShell_profile.ps1"); + } + } + + [Fact] + public void PowerShellProvider_GetWindowsProfilePaths_ReturnsBothFlavors() + { + var documents = Path.Combine(_tempDir, "Documents"); + + var paths = PowerShellEnvShellProvider.GetWindowsProfilePaths(documents); + + paths.Should().HaveCount(2); + paths[0].Should().Be(Path.Combine(documents, "WindowsPowerShell", "profile.ps1")); + paths[1].Should().Be(Path.Combine(documents, "PowerShell", "profile.ps1")); + } + + [Fact] + public void PowerShellProvider_GetWindowsProfilePaths_HonorsCustomDocumentsFolder() + { + // Simulates OneDrive Known Folder redirection: Documents is at a non-default location. + var redirected = Path.Combine(_tempDir, "OneDrive", "Documents"); + + var paths = PowerShellEnvShellProvider.GetWindowsProfilePaths(redirected); + + paths.Should().HaveCount(2); + paths.Should().AllSatisfy(p => p.Should().StartWith(redirected)); + } + + [Fact] + public void PowerShellProvider_GetWindowsProfilePaths_ThrowsWhenDocumentsFolderMissing() + { + var act = () => PowerShellEnvShellProvider.GetWindowsProfilePaths(string.Empty); + + act.Should().Throw() + .And.ErrorCode.Should().Be(DotnetInstallErrorCode.ContextResolutionFailed); + } + + [Fact] + public void EnvironmentManager_ApplyTerminalProfileModifications_WritesUserEntryThroughProvider() + { + var manager = new DotnetEnvironmentManager(); + var provider = new TestShellProvider(_tempDir, "user-profile.sh"); + + manager.ApplyTerminalProfileModifications(FakeDotnetInstallPath, InstallType.User, provider); + + var profilePath = Path.Combine(_tempDir, "user-profile.sh"); + File.Exists(profilePath).Should().BeTrue(); + var content = File.ReadAllText(profilePath); + content.Should().Contain(ShellProfileManager.BeginMarkerComment); + content.Should().Contain("print-env-script"); + // User install with a non-default install root should pass --dotnet-install-path through. + content.Should().Contain("--dotnet-install-path"); + content.Should().NotContain("--dotnetup-only"); + } + + [Fact] + public void EnvironmentManager_ApplyTerminalProfileModifications_SystemInstall_WritesDotnetupOnlyEntry() + { + var manager = new DotnetEnvironmentManager(); + var provider = new TestShellProvider(_tempDir, "system-profile.sh"); + + // dotnetRoot is irrelevant for System (dotnet assumed already on PATH), but the parameter + // is non-nullable so pass the fake path. + manager.ApplyTerminalProfileModifications(FakeDotnetInstallPath, InstallType.System, provider); + + var profilePath = Path.Combine(_tempDir, "system-profile.sh"); + File.Exists(profilePath).Should().BeTrue(); + var content = File.ReadAllText(profilePath); + content.Should().Contain(ShellProfileManager.BeginMarkerComment); + content.Should().Contain("--dotnetup-only"); + content.Should().NotContain("--dotnet-install-path"); + } + + [Fact] + public void EnvironmentManager_ApplyTerminalProfileModifications_DefaultInstallPath_OmitsInstallPathFlag() + { + var manager = new DotnetEnvironmentManager(); + var provider = new TestShellProvider(_tempDir, "default-profile.sh"); + + // When the install root equals the manager's default install path, the path should be + // omitted from the entry so `print-env-script` can fall back to its default-detection. + var defaultPath = manager.GetDefaultDotnetInstallPath(); + + manager.ApplyTerminalProfileModifications(defaultPath, InstallType.User, provider); + + var profilePath = Path.Combine(_tempDir, "default-profile.sh"); + File.Exists(profilePath).Should().BeTrue(); + var content = File.ReadAllText(profilePath); + content.Should().Contain(ShellProfileManager.BeginMarkerComment); + content.Should().NotContain("--dotnet-install-path"); + content.Should().NotContain("--dotnetup-only"); + } + + [Fact] + public void EnvironmentManager_ApplyTerminalProfileModifications_ThrowsOnNullDotnetRoot() + { + var manager = new DotnetEnvironmentManager(); + var provider = new TestShellProvider(_tempDir, "null-profile.sh"); + + var act = () => manager.ApplyTerminalProfileModifications(null!, InstallType.User, provider); + + act.Should().Throw(); + } + + [Fact] + public void EnvironmentManager_ApplyTerminalProfileModifications_WritesToAllProviderPaths() + { + // Simulates Windows where PowerShellEnvShellProvider returns multiple profile paths + // (one per PowerShell flavor). Verifies the dispatch threads through to every path. + var manager = new DotnetEnvironmentManager(); + var provider = new TestShellProvider(_tempDir, "flavor1.ps1", "flavor2.ps1"); + + manager.ApplyTerminalProfileModifications(FakeDotnetInstallPath, InstallType.User, provider); + + var path1 = Path.Combine(_tempDir, "flavor1.ps1"); + var path2 = Path.Combine(_tempDir, "flavor2.ps1"); + File.Exists(path1).Should().BeTrue(); + File.Exists(path2).Should().BeTrue(); + File.ReadAllText(path1).Should().Contain(ShellProfileManager.BeginMarkerComment); + File.ReadAllText(path2).Should().Contain(ShellProfileManager.BeginMarkerComment); } /// @@ -573,6 +731,10 @@ public TestShellProvider(string dir, params string[] fileNames) public string Extension => "sh"; public string? HelpDescription => null; public string? ProfileEntryOverride { get; init; } + public Encoding? NewFileEncodingOverride { get; init; } + + Encoding IEnvShellProvider.NewFileEncoding + => NewFileEncodingOverride ?? new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, bool includeDotnet = true) => includeDotnet