Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions MedallionShell.Tests/GeneralTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
49 changes: 49 additions & 0 deletions MedallionShell.Tests/SystemPathSearcherTest.cs
Original file line number Diff line number Diff line change
@@ -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}'");
}
}
20 changes: 19 additions & 1 deletion MedallionShell/Shell.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,10 +39,15 @@ public Command Run(string executable, IEnumerable<object>? 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,
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -318,6 +325,17 @@ public Options DisposeOnExit(bool value = true)
return this;
}

/// <summary>
/// If specified, the underlying <see cref="Process"/> will search for the system path, like a shell would.
///
/// Defaults to false
/// </summary>
public Options SearchSystemPath(bool value = false)
{
this.SearchOnSystemPath = value;
return this;
}

/// <summary>
/// Specifies the <see cref="CommandLineSyntax"/> to use for escaping arguments. Defaults to
/// an appropriate value for the current platform
Expand Down
24 changes: 24 additions & 0 deletions MedallionShell/SystemPathSearcher.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}