diff --git a/MedallionShell.Tests/GeneralTest.cs b/MedallionShell.Tests/GeneralTest.cs index c1a7f42..7d6bb9f 100644 --- a/MedallionShell.Tests/GeneralTest.cs +++ b/MedallionShell.Tests/GeneralTest.cs @@ -17,6 +17,12 @@ namespace Medallion.Shell.Tests public class GeneralTest { + [Test] + public void TestCommandWithoutFullyQualifiedPath() + { + Assert.That(TestShell.Run("git", "--version").StandardOutput.ReadToEnd(), Does.StartWith("git version")); + } + [Test] public void TestGrep() { diff --git a/MedallionShell.Tests/SystemPathSearcherTest.cs b/MedallionShell.Tests/SystemPathSearcherTest.cs new file mode 100644 index 0000000..e3202d5 --- /dev/null +++ b/MedallionShell.Tests/SystemPathSearcherTest.cs @@ -0,0 +1,49 @@ +using NUnit.Framework; + +namespace Medallion.Shell.Tests; + +public class SystemPathSearcherTest +{ + [Platform("Win", Reason = "Tests Windows-specific executables")] + [TestCase("dotnet", @"C:\Program Files\dotnet\dotnet.exe")] + [TestCase("dotnet.exe", @"C:\Program Files\dotnet\dotnet.exe")] + [TestCase("where.exe", @"C:\Windows\System32\where.exe")] + [TestCase("cmd", @"C:\Windows\System32\cmd.exe")] + [TestCase("cmd.exe", @"C:\Windows\System32\cmd.exe")] + [TestCase("powershell.exe", @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe")] + [TestCase("explorer.exe", @"C:\Windows\explorer.exe")] + [TestCase("git.exe", @"C:\Program Files\Git\cmd\git.exe")] + [TestCase("does.not.exist", null)] + // echo is not a program on Windows but an internal command in cmd.exe or powershell.exe. + // However, things like git may still install echo (e.g. C:\Program Files\Git\usr\bin\echo.EXE) + // so there's no guarantee for echo on Windows. + public void TestGetFullPathOnWindows(string executable, string? expected) + { + StringAssert.AreEqualIgnoringCase(expected, SystemPathSearcher.GetFullPathUsingSystemPathOrDefault(executable)); + + var command = Command.Run("where", executable); + command.StandardOutput.ReadToEnd().Trim().ShouldEqual( + expected ?? string.Empty, + $"Exit code: {command.Result.ExitCode}, StdErr: '{command.Result.StandardError}'"); + } + + [Platform("Unix", Reason = "Tests Unix-specific executables")] + [TestCase("dotnet", "/usr/bin/dotnet")] + [TestCase("which", "/usr/bin/which")] + [TestCase("head", "/usr/bin/head")] + [TestCase("sh", "/bin/sh")] + [TestCase("ls", "/bin/ls")] + [TestCase("grep", "/bin/grep")] + [TestCase("sleep", "/bin/sleep")] + [TestCase("echo", "/bin/echo")] + [TestCase("does.not.exist", null)] + public void TestGetFullPathOnLinux(string executable, string? expected) + { + SystemPathSearcher.GetFullPathUsingSystemPathOrDefault(executable).ShouldEqual(expected); + + var command = Command.Run("which", executable); + command.StandardOutput.ReadToEnd().Trim().ShouldEqual( + expected ?? string.Empty, + $"Exit code: {command.Result.ExitCode}, StdErr: '{command.Result.StandardError}'"); + } +} \ No newline at end of file diff --git a/MedallionShell/Shell.cs b/MedallionShell/Shell.cs index 95f80e1..4683259 100644 --- a/MedallionShell/Shell.cs +++ b/MedallionShell/Shell.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.IO; using System.Linq; using System.Text; using System.Threading; @@ -38,10 +39,15 @@ public Command Run(string executable, IEnumerable? arguments = null, Act var finalOptions = this.GetOptions(options); + var executablePath = finalOptions.SearchOnSystemPath + && SystemPathSearcher.GetFullPathUsingSystemPathOrDefault(executable) is { } fullPath + ? fullPath + : executable; + var processStartInfo = new ProcessStartInfo { CreateNoWindow = true, - FileName = executable, + FileName = executablePath, RedirectStandardError = true, RedirectStandardInput = true, RedirectStandardOutput = true, @@ -201,6 +207,7 @@ internal Options() internal CommandLineSyntax CommandLineSyntax { get; private set; } = default!; // assigned in RestoreDefaults internal bool ThrowExceptionOnError { get; private set; } internal bool DisposeProcessOnExit { get; private set; } + internal bool SearchOnSystemPath { get; private set; } internal TimeSpan ProcessTimeout { get; private set; } internal Encoding? ProcessStreamEncoding { get; private set; } internal CancellationToken ProcessCancellationToken { get; private set; } @@ -318,6 +325,17 @@ public Options DisposeOnExit(bool value = true) return this; } + /// + /// If specified, the underlying will search for the system path, like a shell would. + /// + /// Defaults to false + /// + public Options SearchSystemPath(bool value = false) + { + this.SearchOnSystemPath = value; + return this; + } + /// /// Specifies the to use for escaping arguments. Defaults to /// an appropriate value for the current platform diff --git a/MedallionShell/SystemPathSearcher.cs b/MedallionShell/SystemPathSearcher.cs new file mode 100644 index 0000000..e0e194a --- /dev/null +++ b/MedallionShell/SystemPathSearcher.cs @@ -0,0 +1,24 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; + +namespace Medallion.Shell; + +internal static class SystemPathSearcher +{ + public static string? GetFullPathUsingSystemPathOrDefault(string executable) + { + if (executable.Contains(Path.DirectorySeparatorChar)) { return null; } + if (Environment.GetEnvironmentVariable("PATH") is not { } pathEnvironmentVariable) { return null; } + + var paths = pathEnvironmentVariable.Split(Path.PathSeparator); + var pathExtensions = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + && Environment.GetEnvironmentVariable("PATHEXT") is { } pathTextEnvironmentVariable + ? [.. pathTextEnvironmentVariable.Split(Path.PathSeparator), string.Empty] + : new[] { string.Empty }; + + return paths.SelectMany(path => pathExtensions.Select(pathExtension => Path.Combine(path, executable + pathExtension))) + .FirstOrDefault(File.Exists); + } +}