File: PathLookupHelperTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace Aspire.Cli.Tests.Utils;
 
[Trait("Partition", "2")]
public class PathLookupHelperTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public void ResolveExecutablePath_SearchesPath_WhenExecutableIsCommandName()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var binPath = Path.Combine(workspace.WorkspaceRoot.FullName, "bin");
        Directory.CreateDirectory(binPath);
 
        var executableName = OperatingSystem.IsWindows() ? "mycommand.exe" : "mycommand";
        var executablePath = Path.Combine(binPath, executableName);
        File.WriteAllText(executablePath, string.Empty);
        MakeExecutableOnUnix(executablePath);
 
        var result = PathLookupHelper.ResolveExecutablePath(
            executableName,
            new Dictionary<string, string> { ["PATH"] = binPath });
 
        Assert.Equal(executablePath, result);
    }
 
    [Fact]
    public void ResolveExecutablePath_Throws_WhenExecutableIsCommandNameAndNotFoundOnPath()
    {
        var exception = Assert.Throws<FileNotFoundException>(() => PathLookupHelper.ResolveExecutablePath(
            "missing-command",
            new Dictionary<string, string> { ["PATH"] = string.Empty }));
 
        Assert.Equal("missing-command", exception.FileName);
    }
 
    [Fact]
    public void ResolveExecutablePath_SkipsNonExecutableFilesOnUnix()
    {
        if (OperatingSystem.IsWindows())
        {
            return;
        }
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var firstBinPath = Path.Combine(workspace.WorkspaceRoot.FullName, "first-bin");
        var secondBinPath = Path.Combine(workspace.WorkspaceRoot.FullName, "second-bin");
        Directory.CreateDirectory(firstBinPath);
        Directory.CreateDirectory(secondBinPath);
 
        var nonExecutablePath = Path.Combine(firstBinPath, "mycommand");
        File.WriteAllText(nonExecutablePath, string.Empty);
        File.SetUnixFileMode(nonExecutablePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
 
        var executablePath = Path.Combine(secondBinPath, "mycommand");
        File.WriteAllText(executablePath, string.Empty);
        File.SetUnixFileMode(executablePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
 
        var result = PathLookupHelper.ResolveExecutablePath(
            "mycommand",
            new Dictionary<string, string> { ["PATH"] = string.Join(Path.PathSeparator, firstBinPath, secondBinPath) });
 
        Assert.Equal(executablePath, result);
    }
 
    [Theory]
    [InlineData("relative/mycommand")]
    [InlineData("./mycommand")]
    [InlineData("../mycommand")]
    public void ResolveExecutablePath_DoesNotSearchPath_WhenExecutablePathIsExplicit(string executablePath)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var binPath = Path.Combine(workspace.WorkspaceRoot.FullName, "bin");
        Directory.CreateDirectory(binPath);
        File.WriteAllText(Path.Combine(binPath, "mycommand"), string.Empty);
 
        var result = PathLookupHelper.ResolveExecutablePath(
            executablePath,
            new Dictionary<string, string> { ["PATH"] = binPath });
 
        Assert.Equal(executablePath, result);
    }
 
    [Fact]
    public void ResolveExecutablePath_DoesNotSearchPath_WhenWindowsExecutablePathIsDriveRelative()
    {
        if (!OperatingSystem.IsWindows())
        {
            return;
        }
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var binPath = Path.Combine(workspace.WorkspaceRoot.FullName, "bin");
        Directory.CreateDirectory(binPath);
        File.WriteAllText(Path.Combine(binPath, "mycommand.exe"), string.Empty);
 
        var result = PathLookupHelper.ResolveExecutablePath(
            "C:mycommand.exe",
            new Dictionary<string, string> { ["PATH"] = binPath });
 
        Assert.Equal("C:mycommand.exe", result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WhenCommandExistsOnPath_ReturnsFullPath()
    {
        // Arrange
        var existingFiles = new HashSet<string>
        {
            Path.Combine("/usr/bin", "mycommand")
        };
 
        // Act
        var result = PathLookupHelper.FindFullPathFromPath("mycommand", "/usr/bin:/usr/local/bin", ':', existingFiles.Contains, null);
 
        // Assert
        Assert.Equal(Path.Combine("/usr/bin", "mycommand"), result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WhenCommandNotOnPath_ReturnsNull()
    {
        // Arrange
        static bool AlwaysFalse(string _) => false;
 
        // Act
        var result = PathLookupHelper.FindFullPathFromPath("mycommand", "/usr/bin:/usr/local/bin", ':', AlwaysFalse, null);
 
        // Assert
        Assert.Null(result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WhenPathVariableIsEmpty_ReturnsNull()
    {
        // Arrange - when path is empty, no files should exist on that (non-existent) path
        static bool AlwaysFalse(string _) => false;
 
        // Act
        var result = PathLookupHelper.FindFullPathFromPath("mycommand", "", ':', AlwaysFalse, null);
 
        // Assert
        Assert.Null(result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WhenPathVariableIsNull_ReturnsNull()
    {
        // Arrange
        static bool AlwaysFalse(string _) => false;
 
        // Act
        var result = PathLookupHelper.FindFullPathFromPath("mycommand", null, ':', AlwaysFalse, null);
 
        // Assert
        Assert.Null(result);
    }
 
    [Fact]
    public void FindFullPathFromPath_ReturnsFirstMatchFromPath()
    {
        // Arrange
        var existingFiles = new HashSet<string>
        {
            Path.Combine("/first/path", "mycommand"),
            Path.Combine("/second/path", "mycommand")
        };
 
        // Act
        var result = PathLookupHelper.FindFullPathFromPath("mycommand", "/first/path:/second/path", ':', existingFiles.Contains, null);
 
        // Assert
        Assert.Equal(Path.Combine("/first/path", "mycommand"), result);
    }
 
    [Fact]
    public void FindAllFullPathsFromPath_ReturnsMatchesFromPath()
    {
        var firstPath = Path.Combine("/first/path", "mycommand");
        var secondPath = Path.Combine("/second/path", "mycommand");
        var existingFiles = new HashSet<string>
        {
            firstPath,
            secondPath
        };
 
        var result = PathLookupHelper.FindAllFullPathsFromPath("mycommand", "/first/path:/second/path", ':', existingFiles.Contains, null);
 
        Assert.Equal([firstPath, secondPath], result);
    }
 
    [Fact]
    public void FindAllFullPathsFromPath_WhenCommandNotOnPath_ReturnsEmpty()
    {
        static bool AlwaysFalse(string _) => false;
 
        var result = PathLookupHelper.FindAllFullPathsFromPath("mycommand", "/usr/bin:/usr/local/bin", ':', AlwaysFalse, null);
 
        Assert.Empty(result);
    }
 
    [Fact]
    public void FindAllFullPathsFromPath_SkipsNonExecutableFilesOnUnix()
    {
        if (OperatingSystem.IsWindows())
        {
            return;
        }
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var firstBinPath = Path.Combine(workspace.WorkspaceRoot.FullName, "first-bin");
        var secondBinPath = Path.Combine(workspace.WorkspaceRoot.FullName, "second-bin");
        Directory.CreateDirectory(firstBinPath);
        Directory.CreateDirectory(secondBinPath);
 
        var nonExecutablePath = Path.Combine(firstBinPath, "mycommand");
        File.WriteAllText(nonExecutablePath, string.Empty);
        File.SetUnixFileMode(nonExecutablePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
 
        var executablePath = Path.Combine(secondBinPath, "mycommand");
        File.WriteAllText(executablePath, string.Empty);
        File.SetUnixFileMode(executablePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
 
        static bool FileExistsAndIsExecutable(string path)
        {
            if (!File.Exists(path))
            {
                return false;
            }
 
            if (OperatingSystem.IsWindows())
            {
                return true;
            }
 
            const UnixFileMode ExecuteBits = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
            return (File.GetUnixFileMode(path) & ExecuteBits) != 0;
        }
 
        var result = PathLookupHelper.FindAllFullPathsFromPath(
            "mycommand",
            string.Join(Path.PathSeparator, firstBinPath, secondBinPath),
            Path.PathSeparator,
            FileExistsAndIsExecutable,
            null);
 
        Assert.Equal([executablePath], result);
    }
 
    [Fact]
    public void FindAllFullPathsFromPath_WithPathExtensions_ReturnsOneMatchPerDirectory()
    {
        var firstExePath = Path.Combine("first", "bin", "code.EXE");
        var firstCmdPath = Path.Combine("first", "bin", "code.CMD");
        var secondCmdPath = Path.Combine("second", "bin", "code.CMD");
        var existingFiles = new HashSet<string>
        {
            firstExePath,
            firstCmdPath,
            secondCmdPath
        };
        var pathExtensions = new[] { ".EXE", ".CMD" };
        var path = string.Join(';', Path.Combine("first", "bin"), Path.Combine("second", "bin"));
 
        var result = PathLookupHelper.FindAllFullPathsFromPath("code", path, ';', existingFiles.Contains, pathExtensions);
 
        Assert.Equal([firstExePath, secondCmdPath], result);
    }
 
    [Fact]
    public void FindFullPathFromPath_UsesCorrectPathSeparator()
    {
        // Arrange - use platform-agnostic paths for testing
        var dir = Path.Combine("testdir", "bin");
        var expectedPath = Path.Combine(dir, "mycommand");
        var existingFiles = new HashSet<string>
        {
            expectedPath
        };
 
        // Act
        var result = PathLookupHelper.FindFullPathFromPath("mycommand", $"{dir};otherdir", ';', existingFiles.Contains, null);
 
        // Assert
        Assert.Equal(expectedPath, result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WithPathExtensions_FindsCommandWithExtension()
    {
        // Arrange - simulate Windows behavior where "code" is actually "code.CMD"
        var dir = Path.Combine("testdir", "bin");
        var expectedPath = Path.Combine(dir, "code.CMD");
        var existingFiles = new HashSet<string>
        {
            expectedPath
        };
        var pathExtensions = new[] { ".COM", ".EXE", ".BAT", ".CMD" };
 
        // Act - searching for "code" should find "code.CMD"
        var result = PathLookupHelper.FindFullPathFromPath("code", dir, ';', existingFiles.Contains, pathExtensions);
 
        // Assert
        Assert.Equal(expectedPath, result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WithPathExtensions_FindsFirstMatchingExtension()
    {
        // Arrange - when multiple extensions match, returns the first one in PATHEXT order
        var dir = Path.Combine("testdir", "bin");
        var exePath = Path.Combine(dir, "code.EXE");
        var cmdPath = Path.Combine(dir, "code.CMD");
        var existingFiles = new HashSet<string>
        {
            exePath,
            cmdPath
        };
        var pathExtensions = new[] { ".COM", ".EXE", ".BAT", ".CMD" };
 
        // Act - should find .EXE before .CMD because .EXE comes first in PATHEXT
        var result = PathLookupHelper.FindFullPathFromPath("code", dir, ';', existingFiles.Contains, pathExtensions);
 
        // Assert
        Assert.Equal(exePath, result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WithPathExtensions_PrefersExtensionOverExactMatch()
    {
        // Arrange - when both exist, the extension-based file is preferred on Windows
        // This is important because Windows cannot execute extension-less scripts directly.
        // For example, "code" in VS Code's bin folder is a shell script that Windows can't run,
        // but "code.cmd" is the proper executable wrapper.
        var dir = Path.Combine("testdir", "bin");
        var exactPath = Path.Combine(dir, "code");
        var cmdPath = Path.Combine(dir, "code.CMD");
        var existingFiles = new HashSet<string>
        {
            exactPath,
            cmdPath
        };
        var pathExtensions = new[] { ".COM", ".EXE", ".BAT", ".CMD" };
 
        // Act - should find "code.CMD", not "code" (extension-based files preferred on Windows)
        var result = PathLookupHelper.FindFullPathFromPath("code", dir, ';', existingFiles.Contains, pathExtensions);
 
        // Assert
        Assert.Equal(cmdPath, result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WithPathExtensions_FallsBackToExactMatchIfNoExtensionFound()
    {
        // Arrange - when no extension-based file exists, fall back to exact match
        var dir = Path.Combine("testdir", "bin");
        var exactPath = Path.Combine(dir, "mytool");
        var existingFiles = new HashSet<string>
        {
            exactPath
        };
        var pathExtensions = new[] { ".COM", ".EXE", ".BAT", ".CMD" };
 
        // Act - no extension files exist, so should fall back to exact match
        var result = PathLookupHelper.FindFullPathFromPath("mytool", dir, ';', existingFiles.Contains, pathExtensions);
 
        // Assert
        Assert.Equal(exactPath, result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WithPathExtensions_DoesNotDuplicateExtension()
    {
        // Arrange - when command already has extension, don't duplicate it
        var dir = Path.Combine("testdir", "bin");
        var expectedPath = Path.Combine(dir, "code.CMD");
        var existingFiles = new HashSet<string>
        {
            expectedPath
        };
        var pathExtensions = new[] { ".COM", ".EXE", ".BAT", ".CMD" };
 
        // Act - searching for "code.CMD" should find "code.CMD", not "code.CMD.CMD"
        var result = PathLookupHelper.FindFullPathFromPath("code.CMD", dir, ';', existingFiles.Contains, pathExtensions);
 
        // Assert
        Assert.Equal(expectedPath, result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WithPathExtensions_CommandWithExtensionNotFound_ReturnsNull()
    {
        // Arrange - command has known extension but file doesn't exist
        var dir = Path.Combine("testdir", "bin");
        var existingFiles = new HashSet<string>();
        var pathExtensions = new[] { ".COM", ".EXE", ".BAT", ".CMD" };
 
        // Act - searching for "code.EXE" when it doesn't exist should return null
        var result = PathLookupHelper.FindFullPathFromPath("code.EXE", dir, ';', existingFiles.Contains, pathExtensions);
 
        // Assert
        Assert.Null(result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WithPathExtensions_CommandWithExtension_DoesNotTryOtherExtensions()
    {
        // Arrange - when command has a known extension, don't try other extensions
        var dir = Path.Combine("testdir", "bin");
        var cmdPath = Path.Combine(dir, "code.CMD");
        var existingFiles = new HashSet<string>
        {
            cmdPath
        };
        var pathExtensions = new[] { ".COM", ".EXE", ".BAT", ".CMD" };
 
        // Act - searching for "code.EXE" should NOT find "code.CMD"
        // (we should not strip .EXE and try .CMD)
        var result = PathLookupHelper.FindFullPathFromPath("code.EXE", dir, ';', existingFiles.Contains, pathExtensions);
 
        // Assert
        Assert.Null(result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WithNullPathExtensions_DoesNotTryExtensions()
    {
        // Arrange - simulate non-Windows behavior where PATHEXT is not used
        var dir = "/usr/bin";
        var existingFiles = new HashSet<string>
        {
            Path.Combine(dir, "code.cmd")
        };
 
        // Act - with null pathExtensions, should NOT find "code.cmd" when searching for "code"
        var result = PathLookupHelper.FindFullPathFromPath("code", dir, ':', existingFiles.Contains, null);
 
        // Assert
        Assert.Null(result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WithEmptyPathExtensions_DoesNotTryExtensions()
    {
        // Arrange
        var dir = "/usr/bin";
        var existingFiles = new HashSet<string>
        {
            Path.Combine(dir, "code.cmd")
        };
 
        // Act - with empty pathExtensions array, should NOT find "code.cmd" when searching for "code"
        var result = PathLookupHelper.FindFullPathFromPath("code", dir, ':', existingFiles.Contains, []);
 
        // Assert
        Assert.Null(result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WithPathExtensions_DirectoryOrderTakesPrecedenceOverExtensionOrder()
    {
        // Arrange - This test validates Windows PATH lookup semantics:
        // Windows searches each directory completely (trying all PATHEXT extensions) before moving to the next.
        // So if dir1 has code.CMD and dir2 has code.EXE, dir1/code.CMD should be found first,
        // even though .EXE comes before .CMD in PATHEXT order.
        var dir1 = Path.Combine("first", "bin");
        var dir2 = Path.Combine("second", "bin");
        var dir1CmdPath = Path.Combine(dir1, "code.CMD");
        var dir2ExePath = Path.Combine(dir2, "code.EXE");
        var existingFiles = new HashSet<string>
        {
            dir1CmdPath,  // Lower priority extension, but in first directory
            dir2ExePath   // Higher priority extension, but in second directory
        };
        var pathExtensions = new[] { ".COM", ".EXE", ".BAT", ".CMD" };
 
        // Act - should find dir1/code.CMD because directory order takes precedence
        var result = PathLookupHelper.FindFullPathFromPath("code", $"{dir1};{dir2}", ';', existingFiles.Contains, pathExtensions);
 
        // Assert - directory order (dir1) takes precedence over extension order (.EXE before .CMD)
        Assert.Equal(dir1CmdPath, result);
    }
 
    [Fact]
    public void FindFullPathFromPath_WithPathExtensions_ExactMatchInFirstDirBeatsExtensionInSecondDir()
    {
        // Arrange - exact match in first directory should be found before extension match in second directory
        var dir1 = Path.Combine("first", "bin");
        var dir2 = Path.Combine("second", "bin");
        var dir1ExactPath = Path.Combine(dir1, "mytool");
        var dir2ExePath = Path.Combine(dir2, "mytool.EXE");
        var existingFiles = new HashSet<string>
        {
            dir1ExactPath,  // Exact match in first directory
            dir2ExePath     // Extension match in second directory
        };
        var pathExtensions = new[] { ".COM", ".EXE", ".BAT", ".CMD" };
 
        // Act - should find dir1/mytool because directory order takes precedence
        // Note: Within a directory, extension matches are tried first, then exact match.
        // But across directories, the first directory's results (including fallback) take precedence.
        var result = PathLookupHelper.FindFullPathFromPath("mytool", $"{dir1};{dir2}", ';', existingFiles.Contains, pathExtensions);
 
        // Assert - dir1 exact match is found because no extension match exists in dir1
        Assert.Equal(dir1ExactPath, result);
    }
 
    private static void MakeExecutableOnUnix(string path)
    {
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(path, File.GetUnixFileMode(path) | UnixFileMode.UserExecute);
        }
    }
}