File: CommandFactory\CommandResolution\ProjectToolsCommandResolver.cs
Web Access
Project: src\src\sdk\src\Cli\dotnet\dotnet.csproj (dotnet)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using Microsoft.DotNet.Cli.Commands.Build;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;
using Microsoft.DotNet.InternalAbstractions;
using NuGet.Frameworks;
using NuGet.ProjectModel;
using NuGet.Versioning;
using ConcurrencyUtilities = NuGet.Common.ConcurrencyUtilities;

namespace Microsoft.DotNet.Cli.CommandFactory.CommandResolution;

public class ProjectToolsCommandResolver(
    IPackagedCommandSpecFactory packagedCommandSpecFactory,
    IEnvironmentProvider environment) : ICommandResolver
{
    private const string ProjectToolsCommandResolverName = "projecttoolscommandresolver";

    private readonly List<string> _allowedCommandExtensions = [FileNameSuffixes.DotNet.DynamicLib];
    private readonly IPackagedCommandSpecFactory _packagedCommandSpecFactory = packagedCommandSpecFactory;

    private readonly IEnvironmentProvider _environment = environment;

    public CommandSpec? Resolve(CommandResolverArguments commandResolverArguments)
    {
        if (commandResolverArguments.CommandName == null
            || commandResolverArguments.ProjectDirectory == null)
        {
            Reporter.Verbose.WriteLine(string.Format(
                CliStrings.InvalidCommandResolverArguments,
                ProjectToolsCommandResolverName));

            return null;
        }

        return ResolveFromProjectTools(commandResolverArguments);
    }

    private CommandSpec? ResolveFromProjectTools(CommandResolverArguments commandResolverArguments)
    {
        var projectFactory = new ProjectFactory(_environment);

        var project = projectFactory.GetProject(
            commandResolverArguments.ProjectDirectory,
            commandResolverArguments.Framework,
            commandResolverArguments.Configuration,
            commandResolverArguments.BuildBasePath,
            commandResolverArguments.OutputPath);

        if (project == null)
        {
            Reporter.Verbose.WriteLine(string.Format(
                CliStrings.DidNotFindProject, ProjectToolsCommandResolverName));

            return null;
        }

        var tools = project.GetTools();

        return ResolveCommandSpecFromAllToolLibraries(
            tools,
            commandResolverArguments.CommandName,
            commandResolverArguments.CommandArguments.OrEmptyIfNull(),
            project);
    }

    private CommandSpec? ResolveCommandSpecFromAllToolLibraries(
        IEnumerable<SingleProjectInfo> toolsLibraries,
        string commandName,
        IEnumerable<string> args,
        IProject project)
    {
        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.ResolvingCommandSpec,
            ProjectToolsCommandResolverName,
            toolsLibraries.Count()));

        foreach (var toolLibrary in toolsLibraries)
        {
            var commandSpec = ResolveCommandSpecFromToolLibrary(
                toolLibrary,
                commandName,
                args,
                project);

            if (commandSpec != null)
            {
                return commandSpec;
            }
        }

        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.FailedToResolveCommandSpec,
            ProjectToolsCommandResolverName));

        return null;
    }

    private CommandSpec? ResolveCommandSpecFromToolLibrary(
        SingleProjectInfo toolLibraryRange,
        string commandName,
        IEnumerable<string> args,
        IProject project)
    {
        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.AttemptingToResolveCommandSpec,
            ProjectToolsCommandResolverName,
            toolLibraryRange.Name));

        var possiblePackageRoots = GetPossiblePackageRoots(project).ToList();
        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.NuGetPackagesRoot,
            ProjectToolsCommandResolverName,
            string.Join(Environment.NewLine, possiblePackageRoots.Select((p) => $"- {p}"))));

        List<NuGetFramework> toolFrameworksToCheck = [project.DotnetCliToolTargetFramework];

        //  NuGet restore in Visual Studio may restore for netcoreapp1.0. So if that happens, fall back to
        //  looking for a netcoreapp1.0 or netcoreapp1.1 tool restore.
        if (project.DotnetCliToolTargetFramework.Framework == FrameworkConstants.FrameworkIdentifiers.NetCoreApp &&
            project.DotnetCliToolTargetFramework.Version >= new Version(2, 0, 0))
        {
            toolFrameworksToCheck.Add(NuGetFramework.Parse("netcoreapp1.1"));
            toolFrameworksToCheck.Add(NuGetFramework.Parse("netcoreapp1.0"));
        }

        LockFile? toolLockFile = null;
        NuGetFramework? toolTargetFramework = null;

        foreach (var toolFramework in toolFrameworksToCheck)
        {
            toolLockFile = GetToolLockFile(
                toolLibraryRange,
                toolFramework,
                possiblePackageRoots);

            if (toolLockFile != null)
            {
                toolTargetFramework = toolFramework;
                break;
            }
        }

        if (toolLockFile == null)
        {
            return null;
        }

        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.FoundToolLockFile,
            ProjectToolsCommandResolverName,
            toolLockFile.Path));

        var toolLibrary = toolLockFile.Targets
            .FirstOrDefault(t => toolTargetFramework == t.TargetFramework)
            ?.Libraries.FirstOrDefault(
                l => StringComparer.OrdinalIgnoreCase.Equals(l.Name, toolLibraryRange.Name));
        if (toolLibrary == null)
        {
            Reporter.Verbose.WriteLine(string.Format(
                CliStrings.LibraryNotFoundInLockFile,
                ProjectToolsCommandResolverName));

            return null;
        }

        var depsFileRoot = Path.GetDirectoryName(toolLockFile.Path)!;

        var depsFilePath = GetToolDepsFilePath(
            toolLibraryRange,
            toolTargetFramework!, // should be safe now because we found the toolLockFile
            toolLockFile,
            depsFileRoot,
            project.ToolDepsJsonGeneratorProject);

        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.AttemptingToCreateCommandSpec,
            ProjectToolsCommandResolverName));

        var commandSpec = _packagedCommandSpecFactory.CreateCommandSpecFromLibrary(
                toolLibrary,
                commandName,
                args,
                _allowedCommandExtensions,
                toolLockFile,
                depsFilePath,
                null);

        if (commandSpec == null)
        {
            Reporter.Verbose.WriteLine(string.Format(
                CliStrings.CommandSpecIsNull,
                ProjectToolsCommandResolverName));
        }

        commandSpec?.AddEnvironmentVariablesFromProject(project);

        return commandSpec;
    }

    private static IEnumerable<string> GetPossiblePackageRoots(IProject project)
    {
        if (project.TryGetLockFile(out LockFile lockFile))
        {
            return lockFile.PackageFolders.Select((packageFolder) => packageFolder.Path);
        }

        return [];
    }

    private LockFile? GetToolLockFile(
        SingleProjectInfo toolLibrary,
        NuGetFramework framework,
        IEnumerable<string> possibleNugetPackagesRoot)
    {
        foreach (var packagesRoot in possibleNugetPackagesRoot)
        {
            if (TryGetToolLockFile(toolLibrary, framework, packagesRoot, out var lockFile))
            {
                return lockFile;
            }
        }

        return null;
    }


    private static async Task<bool> FileExistsWithLock(string path)
    {
        return await ConcurrencyUtilities.ExecuteWithFileLockedAsync(
            path,
            lockedToken => Task.FromResult(File.Exists(path)),
            CancellationToken.None);
    }

    private bool TryGetToolLockFile(
        SingleProjectInfo toolLibrary,
        NuGetFramework framework,
        string nugetPackagesRoot,
        [NotNullWhen(true)] out LockFile? lockFile)
    {
        lockFile = null;
        var lockFilePath = GetToolLockFilePath(toolLibrary, framework, nugetPackagesRoot);

        if (!FileExistsWithLock(lockFilePath).Result)
        {
            return false;
        }

        try
        {
            lockFile = new LockFileFormat()
                .ReadWithLock(lockFilePath)
                .Result;
        }
        catch (FileFormatException)
        {
            throw;
        }

        return true;
    }

    private static string GetToolLockFilePath(
        SingleProjectInfo toolLibrary,
        NuGetFramework framework,
        string nugetPackagesRoot)
    {
        var toolPathCalculator = new ToolPathCalculator(nugetPackagesRoot);

        return toolPathCalculator.GetBestLockFilePath(
            toolLibrary.Name,
            VersionRange.Parse(toolLibrary.Version),
            framework);
    }

    private string GetToolDepsFilePath(
        SingleProjectInfo toolLibrary,
        NuGetFramework framework,
        LockFile toolLockFile,
        string depsPathRoot,
        string toolDepsJsonGeneratorProject)
    {
        var depsJsonPath = Path.Combine(
            depsPathRoot,
            toolLibrary.Name + FileNameSuffixes.DepsJson);

        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.ExpectDepsJsonAt,
            ProjectToolsCommandResolverName,
            depsJsonPath));

        EnsureToolJsonDepsFileExists(toolLockFile, framework, depsJsonPath, toolLibrary, toolDepsJsonGeneratorProject);

        return depsJsonPath;
    }

    private void EnsureToolJsonDepsFileExists(
        LockFile toolLockFile,
        NuGetFramework framework,
        string depsPath,
        SingleProjectInfo toolLibrary,
        string toolDepsJsonGeneratorProject)
    {
        if (!File.Exists(depsPath))
        {
            GenerateDepsJsonFile(toolLockFile, framework, depsPath, toolLibrary, toolDepsJsonGeneratorProject);
        }
    }

    internal void GenerateDepsJsonFile(
        LockFile toolLockFile,
        NuGetFramework framework,
        string depsPath,
        SingleProjectInfo toolLibrary,
        string toolDepsJsonGeneratorProject)
    {
        if (string.IsNullOrEmpty(toolDepsJsonGeneratorProject) ||
            !File.Exists(toolDepsJsonGeneratorProject))
        {
            throw new GracefulException(CliStrings.DepsJsonGeneratorProjectNotSet);
        }

        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.GeneratingDepsJson,
            depsPath));

        var tempDepsFile = Path.Combine(TemporaryDirectory.CreateSubdirectory(), Path.GetRandomFileName());

        List<string> args =
        [
            toolDepsJsonGeneratorProject,
            $"-property:ProjectAssetsFile=\"{toolLockFile.Path}\"",
            $"-property:ToolName={toolLibrary.Name}",
            $"-property:ProjectDepsFilePath={tempDepsFile}"
        ];

        var toolTargetFramework = toolLockFile.Targets.First().TargetFramework.GetShortFolderName();
        args.Add($"-property:TargetFramework={toolTargetFramework}");


        //  Look for the .props file in the Microsoft.NETCore.App package, until NuGet
        //  generates .props and .targets files for tool restores (https://github.com/NuGet/Home/issues/5037)
        var platformLibrary = toolLockFile.Targets
            .FirstOrDefault(t => framework == t.TargetFramework)
            ?.GetPlatformLibrary();

        if (platformLibrary != null)
        {
            string? buildRelativePath = platformLibrary.Build.FirstOrDefault()?.Path;

            var platformLibraryPath = toolLockFile.GetPackageDirectory(platformLibrary);

            if (platformLibraryPath != null && buildRelativePath != null)
            {
                //  Get rid of "_._" filename
                buildRelativePath = Path.GetDirectoryName(buildRelativePath)!;

                string platformLibraryBuildFolderPath = Path.Combine(platformLibraryPath, buildRelativePath);
                var platformLibraryPropsFile = Directory.GetFiles(platformLibraryBuildFolderPath, "*.props").FirstOrDefault();

                if (platformLibraryPropsFile != null)
                {
                    args.Add($"-property:AdditionalImport={platformLibraryPropsFile}");
                }
            }
        }

        //  Delete temporary file created by Path.GetTempFileName(), otherwise the GenerateBuildDependencyFile target
        //  will think the deps file is up-to-date and skip executing
        File.Delete(tempDepsFile);

        var msBuildExePath = _environment.GetEnvironmentVariable(Constants.MSBUILD_EXE_PATH);

        msBuildExePath = string.IsNullOrEmpty(msBuildExePath) ?
            Path.Combine(AppContext.BaseDirectory, "MSBuild.dll") :
            msBuildExePath;

        Reporter.Verbose.WriteLine(string.Format(CliStrings.MSBuildArgs,
            ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(args)));

        int result;
        string? stdOut;
        string? stdErr;

        var msbuildArgs = MSBuildArgs.AnalyzeMSBuildArguments(
            [.. args],
            CommonOptions.CreatePropertyOption(),
            CommonOptions.CreateRestorePropertyOption(),
            CommonOptions.CreateMSBuildTargetOption(),
            CommonOptions.CreateVerbosityOption(),
            CommonOptions.CreateNoLogoOption());

        var forwardingAppWithoutLogging = new MSBuildForwardingAppWithoutLogging(msbuildArgs, msBuildExePath);
        if (forwardingAppWithoutLogging.ExecuteMSBuildOutOfProc)
        {
            result = forwardingAppWithoutLogging
                .GetProcessStartInfo()
                .ExecuteAndCaptureOutput(out stdOut, out stdErr);
        }
        else
        {
            // Execute and capture output of MSBuild running in-process.
            var outWriter = new StringWriter();
            var errWriter = new StringWriter();
            var savedOutWriter = Console.Out;
            var savedErrWriter = Console.Error;
            try
            {
                Console.SetOut(outWriter);
                Console.SetError(errWriter);

                result = forwardingAppWithoutLogging.Execute();

                stdOut = outWriter.ToString();
                stdErr = errWriter.ToString();
            }
            finally
            {
                Console.SetOut(savedOutWriter);
                Console.SetError(savedErrWriter);
            }
        }

        if (result != 0)
        {
            Reporter.Verbose.WriteLine(string.Format(
                CliStrings.UnableToGenerateDepsJson,
                stdOut + Environment.NewLine + stdErr));

            throw new GracefulException(string.Format(CliStrings.UnableToGenerateDepsJson, toolDepsJsonGeneratorProject));
        }

        try
        {
            File.Move(tempDepsFile, depsPath);
        }
        catch (Exception e)
        {
            Reporter.Verbose.WriteLine(string.Format(
                CliStrings.UnableToGenerateDepsJson,
                e.Message));

            try
            {
                File.Delete(tempDepsFile);
            }
            catch (Exception e2)
            {
                Reporter.Verbose.WriteLine(string.Format(
                    CliStrings.UnableToDeleteTemporaryDepsJson,
                    e2.Message));
            }
        }
    }
}