File: Commands\Run\CommonRunHelpers.cs
Web Access
Project: 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.CommandLine;
using System.CommandLine.Parsing;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.FileBasedPrograms;
using Microsoft.DotNet.ProjectTools;

namespace Microsoft.DotNet.Cli.Commands.Run;

/// <summary>
/// Provides behavior shared by managed and Native AOT implementations of the <c>run</c> command.
/// </summary>
internal static class CommonRunHelpers
{
    /// <summary>
    /// Finds the only project in a directory.
    /// </summary>
    /// <param name="directory">The directory to search.</param>
    /// <returns>The project path, or <see langword="null"/> when no project is present.</returns>
    /// <exception cref="GracefulException">More than one project is present.</exception>
    internal static string? TryFindSingleProjectInDirectory(string directory)
    {
        using IEnumerator<string> projectFileEnumerator = Directory.EnumerateFiles(directory, "*.*proj").GetEnumerator();
        if (!projectFileEnumerator.MoveNext())
        {
            return null;
        }

        string projectFile = projectFileEnumerator.Current;
        return projectFileEnumerator.MoveNext()
            ? throw new GracefulException(CliCommandStrings.RunCommandExceptionMultipleProjects, directory)
            : projectFile;
    }

    /// <summary>
    /// Creates the global properties common to managed and Native AOT file-based runs.
    /// </summary>
    /// <returns>A case-insensitive property dictionary.</returns>
    internal static Dictionary<string, string> CreateFileBasedRunGlobalProperties()
        => new(VirtualProjectBuilder.GetGlobalBuildProperties(), StringComparer.OrdinalIgnoreCase)
        {
            ["ProvideCommandLineArgs"] = bool.TrueString,
        };

    /// <summary>
    /// Combines evaluated, application, and launch-profile arguments using <c>dotnet run</c> precedence.
    /// </summary>
    /// <param name="baseArguments">Arguments from evaluated or cached run properties.</param>
    /// <param name="applicationArguments">Explicit application arguments.</param>
    /// <param name="launchProfileArguments">Arguments from the selected launch profile.</param>
    /// <param name="appendApplicationArgumentsToBase">Whether explicit arguments should be appended to non-empty base arguments.</param>
    /// <returns>The escaped command arguments.</returns>
    internal static string CombineRunArguments(
        string? baseArguments,
        string[] applicationArguments,
        string? launchProfileArguments,
        bool appendApplicationArgumentsToBase = false)
    {
        if (applicationArguments.Length != 0)
        {
            string escapedArguments = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(applicationArguments);
            return appendApplicationArgumentsToBase && !string.IsNullOrEmpty(baseArguments)
                ? $"{baseArguments} {escapedArguments}"
                : escapedArguments;
        }

        return string.IsNullOrEmpty(baseArguments) && launchProfileArguments is not null
            ? launchProfileArguments
            : baseArguments ?? string.Empty;
    }

    /// <summary>
    /// Splits parsed application arguments at <c>--</c> and verifies that the parser preserved the suffix.
    /// </summary>
    /// <param name="parseResult">The parsed run invocation.</param>
    /// <param name="applicationArguments">The parser's application arguments.</param>
    /// <param name="argumentCountBeforeDoubleDash">Receives the number of application arguments before <c>--</c>.</param>
    /// <param name="argumentsAfterDoubleDash">Receives the literal token values after <c>--</c>.</param>
    /// <returns><see langword="true"/> when the token and argument views agree.</returns>
    internal static bool TrySplitApplicationArgumentsAtDoubleDash(
        ParseResult parseResult,
        IReadOnlyList<string> applicationArguments,
        out int argumentCountBeforeDoubleDash,
        out string[] argumentsAfterDoubleDash)
    {
        int doubleDashIndex = parseResult.Tokens.ToList().FindIndex(static token => token.Type == TokenType.DoubleDash);
        argumentsAfterDoubleDash = doubleDashIndex < 0
            ? []
            : [.. parseResult.Tokens.Skip(doubleDashIndex + 1).Select(static token => token.Value)];
        argumentCountBeforeDoubleDash = applicationArguments.Count - argumentsAfterDoubleDash.Length;
        return argumentCountBeforeDoubleDash >= 0 &&
            applicationArguments.Skip(argumentCountBeforeDoubleDash).SequenceEqual(argumentsAfterDoubleDash, StringComparer.Ordinal);
    }

    /// <summary>
    /// Creates a dictionary of global properties for MSBuild from the command line arguments.
    /// This includes properties that are passed via the command line, as well as some
    /// properties that are set to improve performance at the cost of correctness -
    /// specifically Compile, None, and EmbeddedResource items are not globbed by default.
    /// See <see cref="Commands.Restore.RestoringCommand.RestoreOptimizationProperties"/> for more details.
    /// </summary>
    public static Dictionary<string, string> GetGlobalPropertiesFromArgs(MSBuildArgs msbuildArgs)
    {
        var globalProperties = msbuildArgs.GlobalProperties?.ToDictionary() ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        globalProperties[Constants.MSBuildExtensionsPath] = AppContext.BaseDirectory;
        return globalProperties;
    }

    /// <summary>
    /// Applies adjustments to MSBuild arguments to better suit LLM/agentic environments, if such an environment is detected.
    /// </summary>
    public static MSBuildArgs AdjustMSBuildForLLMs(MSBuildArgs msbuildArgs)
    {
        if (new Telemetry.LLMEnvironmentDetectorForTelemetry().IsLLMEnvironment())
        {
            // disable the live-update display of the TerminalLogger, which wastes tokens
            return msbuildArgs.CloneWithAdditionalArgs(Constants.TerminalLogger_DisableNodeDisplay);
        }
        else
        {
            return msbuildArgs;
        }
    }

    /// <summary>
    /// Finds and parses the selected launch profile.
    /// </summary>
    /// <param name="projectOrEntryPointFilePath">The project or entry-point path, or <see langword="null"/> when launch-settings discovery is unavailable.</param>
    /// <param name="launchProfile">The requested launch-profile name.</param>
    /// <param name="noLaunchProfile">Whether launch profiles are disabled.</param>
    /// <param name="reportUsingLaunchSettings">Whether to report the selected launch-settings file.</param>
    /// <param name="report">Receives launch-settings diagnostics and whether each belongs on the error channel.</param>
    /// <returns>The parsed launch profile or its failure reason.</returns>
    public static LaunchProfileParseResult ReadLaunchProfile(
        string? projectOrEntryPointFilePath,
        string? launchProfile,
        bool noLaunchProfile,
        bool reportUsingLaunchSettings,
        Action<string, bool> report)
    {
        if (noLaunchProfile || projectOrEntryPointFilePath is null)
        {
            return LaunchProfileParseResult.Success(model: null);
        }

        string? launchSettingsPath = LaunchSettings.TryFindLaunchSettingsFile(
            projectOrEntryPointFilePath,
            launchProfile,
            report);
        if (launchSettingsPath is null)
        {
            return LaunchProfileParseResult.Success(model: null);
        }

        if (reportUsingLaunchSettings)
        {
            report(string.Format(CliCommandStrings.UsingLaunchSettingsFromMessage, launchSettingsPath), true);
        }

        return LaunchSettings.ReadProfileSettingsFromFile(launchSettingsPath, launchProfile);
    }

    /// <summary>
    /// Applies launch-profile environment variables followed by command-line or evaluated overrides.
    /// </summary>
    /// <param name="launchProfile">The selected launch profile.</param>
    /// <param name="environmentVariables">Environment variables that override profile values.</param>
    /// <param name="apply">Applies one environment variable to the launch.</param>
    public static void ApplyLaunchEnvironmentVariables(
        LaunchProfile? launchProfile,
        IReadOnlyDictionary<string, string> environmentVariables,
        Action<string, string?> apply)
    {
        if (launchProfile is ProjectLaunchProfile { ApplicationUrl.Length: > 0 } projectProfile)
        {
            apply("ASPNETCORE_URLS", projectProfile.ApplicationUrl);
        }

        if (launchProfile is not null)
        {
            apply("DOTNET_LAUNCH_PROFILE", launchProfile.LaunchProfileName);
            foreach ((string name, string value) in launchProfile.EnvironmentVariables)
            {
                apply(name, value);
            }
        }

        foreach ((string name, string value) in environmentVariables)
        {
            apply(name, value);
        }
    }

#if !CLI_AOT
    /// <summary>
    /// Creates a TerminalLogger or ConsoleLogger based on the provided MSBuild arguments.
    /// If the environment is detected to be an LLM environment, the logger is adjusted to
    /// better suit that environment.
    /// </summary>
    /// <remarks>
    /// This uses the in-process MSBuild logging APIs (<c>Microsoft.Build.*</c>) and so is excluded
    /// from the AOT build, which only ever forwards MSBuild out-of-process.
    /// </remarks>
    public static Microsoft.Build.Framework.ILogger GetConsoleLogger(MSBuildArgs args) =>
        Microsoft.Build.Logging.TerminalLogger.CreateTerminalOrConsoleLogger([.. AdjustMSBuildForLLMs(args).OtherMSBuildArgs]);
#endif
}