File: Commands\Test\VSTest\TestCommand.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.CommandLine;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.Versioning;
using System.Text.RegularExpressions;
using Microsoft.DotNet.Cli.Commands.Restore;
using Microsoft.DotNet.Cli.CommandLine;
using Microsoft.DotNet.Cli.Extensions;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;

namespace Microsoft.DotNet.Cli.Commands.Test;

public class TestCommand(
    MSBuildArgs msbuildArgs,
    bool noRestore,
    string? msbuildPath = null) : RestoringCommand(msbuildArgs, noRestore, msbuildPath)
{
    public static int Run(ParseResult parseResult)
    {
        parseResult.HandleDebugSwitch();

        FeatureFlag.Instance.PrintFlagFeatureState();

        // We use also current process id for the correlation id for possible future usage in case we need to know the parent process
        // from the VSTest side.
        string testSessionCorrelationId = $"{Environment.ProcessId}_{Guid.NewGuid()}";

        string[] args = parseResult.GetArguments();

        if (VSTestTrace.TraceEnabled)
        {
            string commandLineParameters = "";
            if (args.Length > 0)
            {
                commandLineParameters = args.Aggregate((a, b) => $"{a} | {b}");
            }
            VSTestTrace.SafeWriteTrace(() => $"Argument list: '{commandLineParameters}'");
        }

        (args, string[] settings) = SeparateSettingsFromArgs(args);

        // Fix for https://github.com/Microsoft/vstest/issues/1453
        // Run dll/exe directly using the VSTestForwardingApp
        // Note: ContainsBuiltTestSources need to know how many settings are there, to skip those from unmatched tokens
        // When we don't have settings, we pass 0.
        // When we have settings, we want to exclude the '--' as it doesn't end up in unmatched tokens, so we pass settings.Length - 1
        if (ContainsBuiltTestSources(parseResult, GetSettingsCount(settings)))
        {
            return ForwardToVSTestConsole(parseResult, args, settings, testSessionCorrelationId);
        }

        return ForwardToMsbuild(parseResult, settings, testSessionCorrelationId);
    }

    internal /*internal for testing*/ static (string[] Args, string[] Settings) SeparateSettingsFromArgs(string[] args)
    {
        // settings parameters are after -- (including --), these should not be considered by the parser
        string[] settings = [.. args.SkipWhile(a => a != "--")];
        // all parameters before --
        args = [.. args.TakeWhile(a => a != "--")];
        return (args, settings);
    }

    internal /*internal for testing*/ static int GetSettingsCount(string[] settings)
    {
        if (settings.Length == 0)
        {
            return 0;
        }

        Debug.Assert(settings[0] == "--", "Settings should start with --");
        return settings.Length - 1;
    }

    private static int ForwardToMsbuild(ParseResult parseResult, string[] settings, string testSessionCorrelationId)
    {
        var definition = (TestCommandDefinition.VSTest)parseResult.CommandResult.Command;

        // Workaround for https://github.com/Microsoft/vstest/issues/1503
        const string NodeWindowEnvironmentName = "MSBUILDENSURESTDOUTFORTASKPROCESSES";
        string? previousNodeWindowSetting = Environment.GetEnvironmentVariable(NodeWindowEnvironmentName);
        try
        {
            var forceLegacyOutput = previousNodeWindowSetting == "1";
            var properties = GetUserSpecifiedExplicitMSBuildProperties(parseResult);
            var hasUserMSBuildOutputProperty = properties.TryGetValue("VsTestUseMSBuildOutput", out var propertyValue);

            string[] additionalBuildProperties;

            var useTerminalLogger = TerminalLoggerDetector.ProcessTerminalLoggerConfiguration(parseResult);

            if (useTerminalLogger == TerminalLoggerMode.Invalid)
            {
                // TL option is invalid we want terminal logger to fail in its own way and don't want to disable it.
                // Do noting.
                additionalBuildProperties = [];
            }
            else if (forceLegacyOutput)
            {
                additionalBuildProperties = SetLegacyVSTestWorkarounds(NodeWindowEnvironmentName);
            }
            else if (useTerminalLogger == TerminalLoggerMode.Off)
            {
                additionalBuildProperties = SetLegacyVSTestWorkarounds(NodeWindowEnvironmentName);
            }
            else if (hasUserMSBuildOutputProperty)
            {
                if (propertyValue!.ToLowerInvariant() == "false") // known safe because of boolean check
                {
                    additionalBuildProperties = SetLegacyVSTestWorkarounds(NodeWindowEnvironmentName);
                }
                else
                {
                    // the property is already present don't add it.
                    additionalBuildProperties = [];
                }
            }
            else
            {
                // Enable TL mode.
                additionalBuildProperties = ["--property:VsTestUseMSBuildOutput=true"];
            }

            int exitCode = FromParseResult(parseResult, settings, testSessionCorrelationId, additionalBuildProperties).Execute();

            // We run post processing also if execution is failed for possible partial successful result to post process.
            exitCode |= RunArtifactPostProcessingIfNeeded(testSessionCorrelationId, parseResult.GetValue(definition.DiagOption), FeatureFlag.Instance);

            return exitCode;
        }
        finally
        {
            Environment.SetEnvironmentVariable(NodeWindowEnvironmentName, previousNodeWindowSetting);
        }

        static string[] SetLegacyVSTestWorkarounds(string NodeWindowEnvironmentName)
        {
            string[] additionalBuildProperties;
            // User explicitly disabled the new logger. Use workarounds needed for old logger.
            // Workaround for https://github.com/Microsoft/vstest/issues/1503
            Environment.SetEnvironmentVariable(NodeWindowEnvironmentName, "1");
            additionalBuildProperties = ["-nodereuse:false"];
            return additionalBuildProperties;
        }
    }

    private static int ForwardToVSTestConsole(ParseResult parseResult, string[] args, string[] settings, string testSessionCorrelationId)
    {
        var definition = (TestCommandDefinition.VSTest)parseResult.CommandResult.Command;

        List<string> convertedArgs = new VSTestArgumentConverter().Convert(args, out List<string> ignoredArgs);
        if (ignoredArgs.Any())
        {
            Reporter.Output.WriteLine(string.Format(CliCommandStrings.IgnoredArgumentsMessage, string.Join(" ", ignoredArgs)).Yellow());
        }

        // merge the args settings, we don't need to escape
        // one more time, there is no extra hop via msbuild
        convertedArgs.AddRange(settings);

        if (!FeatureFlag.Instance.IsSet(FeatureFlag.DISABLE_ARTIFACTS_POSTPROCESSING))
        {
            // Add artifacts processing mode and test session id for the artifact post-processing
            convertedArgs.Add("--artifactsProcessingMode-collect");
            convertedArgs.Add($"--testSessionCorrelationId:{testSessionCorrelationId}");
        }

        int exitCode = new VSTestForwardingApp(convertedArgs).Execute();

        // We run post processing also if execution is failed for possible partial successful result to post process.
        exitCode |= RunArtifactPostProcessingIfNeeded(testSessionCorrelationId, parseResult.GetValue(definition.DiagOption), FeatureFlag.Instance);

        return exitCode;
    }

    public static TestCommand FromArgs(string[] args, string? testSessionCorrelationId = null, string? msbuildPath = null)
    {
        var parseResult = Parser.Parse(["dotnet", "test", .. args]);

        // settings parameters are after -- (including --), these should not be considered by the parser
        string[] settings = [.. args.SkipWhile(a => a != "--")];
        if (string.IsNullOrEmpty(testSessionCorrelationId))
        {
            testSessionCorrelationId = $"{Environment.ProcessId}_{Guid.NewGuid()}";
        }

        return FromParseResult(parseResult, settings, testSessionCorrelationId, [], msbuildPath);
    }

    private static TestCommand FromParseResult(ParseResult result, string[] settings, string testSessionCorrelationId, string[] additionalBuildProperties, string? msbuildPath = null)
    {
        var definition = (TestCommandDefinition.VSTest)result.CommandResult.Command;

        result.ShowHelpOrErrorIfAppropriate();

        // Extra msbuild properties won't be parsed and so end up in the UnmatchedTokens list. In addition to those
        // properties, all the test settings properties are also considered as unmatched but we don't want to forward
        // these as-is to msbuild. So we filter out the test settings properties from the unmatched tokens,
        // by only taking values until the first item after `--`. (`--` is not present in the UnmatchedTokens).
        var unMatchedNonSettingsArgs = settings.Length > 1
            ? result.UnmatchedTokens.TakeWhile(x => x != settings[1])
            : result.UnmatchedTokens;

        var parsedArgs =
            result.OptionValuesToBeForwarded(definition.Options) // all msbuild-recognized tokens
                .Concat(unMatchedNonSettingsArgs); // all tokens that the test-parser doesn't explicitly track (minus the settings tokens)

        VSTestTrace.SafeWriteTrace(() => $"MSBuild args from forwarded options: {string.Join(", ", parsedArgs)}");

        List<string> msbuildArgs = [.. additionalBuildProperties, .. parsedArgs];

        if (settings.Any())
        {
            // skip '--' and escape every \ to be \\ and every " to be \" to survive the next hop
            string[] escaped = [.. settings.Skip(1).Select(s => s.Replace("\\", "\\\\").Replace("\"", "\\\""))];

            string runSettingsArg = string.Join(";", escaped);
            msbuildArgs.Add($"-property:VSTestCLIRunSettings=\"{runSettingsArg}\"");
        }

        string? verbosityArg = result.ForwardedOptionValues(definition, "--verbosity")?.SingleOrDefault() ?? null;
        if (verbosityArg != null)
        {
            string[] verbosity = verbosityArg.Split(':', 2);
            if (verbosity.Length == 2)
            {
                msbuildArgs.Add($"-property:VSTestVerbosity={verbosity[1]}");
            }
        }

        if (!FeatureFlag.Instance.IsSet(FeatureFlag.DISABLE_ARTIFACTS_POSTPROCESSING))
        {
            // Add artifacts processing mode and test session id for the artifact post-processing
            msbuildArgs.Add("-property:VSTestArtifactsProcessingMode=collect");
            msbuildArgs.Add($"-property:VSTestSessionCorrelationId={testSessionCorrelationId}");
        }

        bool noRestore = result.GetValue(definition.NoRestoreOption) || result.GetValue(definition.NoBuildOption);

        var parsedMSBuildArgs = MSBuildArgs.AnalyzeMSBuildArguments(
            msbuildArgs,
            CommonOptions.CreatePropertyOption(),
            CommonOptions.CreateRestorePropertyOption(),
            CommonOptions.CreateRequiredMSBuildTargetOption("VSTest"),
            CommonOptions.CreateVerbosityOption(),
            CommonOptions.CreateNoLogoOption())
            .CloneWithNoLogo(true);

        TestCommand testCommand = new(
            parsedMSBuildArgs,
            noRestore,
            msbuildPath);

        // Apply environment variables provided by the user via --environment (-e) option, if present
        if (result.GetValue(definition.TestEnvOption) is { } environmentVariables)
        {
            foreach (var (name, value) in environmentVariables)
            {
                testCommand.EnvironmentVariable(name, value);
            }
        }


        Dictionary<string, string> variables = VSTestForwardingApp.GetVSTestRootVariables();
        foreach (var (rootVariableName, rootValue) in variables)
        {
            testCommand.EnvironmentVariable(rootVariableName, rootValue);
            VSTestTrace.SafeWriteTrace(() => $"Root variable set {rootVariableName}:{rootValue}");
        }

        VSTestTrace.SafeWriteTrace(() => $"Starting test using MSBuild with arguments '{testCommand.GetArgumentsToMSBuild()}' custom MSBuild path '{msbuildPath}' norestore '{noRestore}'");
        return testCommand;
    }

    internal static int RunArtifactPostProcessingIfNeeded(string testSessionCorrelationId, string? diag, FeatureFlag disableFeatureFlag)
    {
        if (disableFeatureFlag.IsSet(FeatureFlag.DISABLE_ARTIFACTS_POSTPROCESSING))
        {
            return 0;
        }

        // VSTest runner will save artifacts inside a temp folder if needed.
        string expectedArtifactDirectory = Path.Combine(Path.GetTempPath(), testSessionCorrelationId);
        if (!Directory.Exists(expectedArtifactDirectory))
        {
            VSTestTrace.SafeWriteTrace(() => "No artifact found, post-processing won't run.");
            return 0;
        }

        VSTestTrace.SafeWriteTrace(() => $"Artifacts directory found '{expectedArtifactDirectory}', running post-processing.");

        var artifactsPostProcessArgs = new List<string> { "--artifactsProcessingMode-postprocess", $"--testSessionCorrelationId:{testSessionCorrelationId}" };

        if (diag != null)
        {
            artifactsPostProcessArgs.Add($"--diag:{diag}");
        }

        try
        {
            return new VSTestForwardingApp(artifactsPostProcessArgs).Execute();
        }
        finally
        {
            if (Directory.Exists(expectedArtifactDirectory))
            {
                VSTestTrace.SafeWriteTrace(() => $"Cleaning artifact directory '{expectedArtifactDirectory}'.");
                try
                {
                    Directory.Delete(expectedArtifactDirectory, true);
                }
                catch (Exception ex)
                {
                    VSTestTrace.SafeWriteTrace(() => $"Exception during artifact cleanup: \n{ex}");
                }
            }
        }
    }

    internal /*internal for testing*/ static bool ContainsBuiltTestSources(ParseResult parseResult, int settingsLength)
    {
        for (int i = 0; i < parseResult.UnmatchedTokens.Count - settingsLength; i++)
        {
            string arg = parseResult.UnmatchedTokens[i];
            if (!arg.StartsWith("-") &&
                (arg.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || arg.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)))
            {
                return true;
            }
        }

        return false;
    }

    /// <returns>A case-insensitive dictionary of any properties passed from the user and their values.</returns>
    private static Dictionary<string, string> GetUserSpecifiedExplicitMSBuildProperties(ParseResult parseResult)
    {
        Dictionary<string, string> globalProperties = new(StringComparer.OrdinalIgnoreCase);
        IEnumerable<string> globalPropEnumerable = parseResult.UnmatchedTokens;
        foreach (var unmatchedToken in globalPropEnumerable)
        {
            var propertyPairs = MSBuildPropertyParser.ParseProperties(unmatchedToken);
            foreach (var propertyKeyValue in propertyPairs)
            {
                string propertyName;
                if (propertyKeyValue.key.StartsWith("--property:", StringComparison.OrdinalIgnoreCase)
                    || propertyKeyValue.key.StartsWith("/property:", StringComparison.OrdinalIgnoreCase))
                {
                    propertyName = propertyKeyValue.key.RemovePrefix().Substring("property:".Length);
                }
                else if (propertyKeyValue.key.StartsWith("-p:", StringComparison.OrdinalIgnoreCase)
                    || propertyKeyValue.key.StartsWith("/p:", StringComparison.OrdinalIgnoreCase))
                {
                    propertyName = propertyKeyValue.key.RemovePrefix().Substring("p:".Length);
                }
                else
                {
                    continue;
                }

                globalProperties[propertyName] = propertyKeyValue.value;
            }
        }
        return globalProperties;
    }
}

public class TerminalLoggerDetector
{
    public static TerminalLoggerMode ProcessTerminalLoggerConfiguration(ParseResult parseResult)
    {
        string? terminalLoggerArg;
        if (!TryFromCommandLine(parseResult.UnmatchedTokens, out terminalLoggerArg) && !TryFromEnvironmentVariables(out terminalLoggerArg))
        {
            terminalLoggerArg = FindDefaultValue(parseResult.UnmatchedTokens) ?? "auto";
        }

        terminalLoggerArg = NormalizeIntoBooleanValues(terminalLoggerArg!);

        TerminalLoggerMode useTerminalLogger;
        if (bool.TryParse(terminalLoggerArg, out bool boolOption))
        {
            // When true, terminal logger will be forced, when false it won't be used.
            useTerminalLogger = boolOption ? TerminalLoggerMode.On : TerminalLoggerMode.Off;
        }
        else
        {
            //  When we could not parse the value to bool. It can be either "auto" or invalid.
            if (!terminalLoggerArg.Equals("auto", StringComparison.OrdinalIgnoreCase))
            {
                // Value is not one of: true (or on), false (or off) or auto, MSBuild should fail.
                // We should not return false, because that will suppress TerminalLogger from trying to setup.
                useTerminalLogger = TerminalLoggerMode.Invalid;
            }
            else
            {
                useTerminalLogger = CheckIfTerminalIsSupportedAndTryEnableAnsiColorCodes() ? TerminalLoggerMode.On : TerminalLoggerMode.Off;
            }
        }

        return useTerminalLogger;

        static bool CheckIfTerminalIsSupportedAndTryEnableAnsiColorCodes()
        {
            if (Environment.GetEnvironmentVariable("MSBUILDENSURESTDOUTFORTASKPROCESSES") == "1")
            {
                return false;
            }

            (var acceptAnsiColorCodes, var outputIsScreen, var originalConsoleMode) = NativeMethods.QueryIsScreenAndTryEnableAnsiColorCodes();
            if (originalConsoleMode != null)
            {
                // Restore to previous state, so MSBuild can set it themselves.
                NativeMethods.RestoreConsoleMode(originalConsoleMode);
            }

            if (!outputIsScreen)
            {
                return false;
            }

            // TerminalLogger is not used if the terminal does not support ANSI/VT100 escape sequences.
            if (!acceptAnsiColorCodes)
            {
                return false;
            }

            return true;
        }

        string? FindDefaultValue(IReadOnlyList<string> unmatchedTokens)
        {
            // Find default configuration so it is part of telemetry even when default is not used.
            // Default can be stored in /tlp:default=true|false|on|off|auto
            Switch? terminalLoggerDefault = TryFind(unmatchedTokens, "tlp", "terminalloggerparameters");
            if (terminalLoggerDefault == null)
            {
                return null;
            }

            if (terminalLoggerDefault.Value == null)
            {
                return null;
            }

            foreach (string parameter in terminalLoggerDefault.Value.Split(':'))
            {
                if (string.IsNullOrWhiteSpace(parameter))
                {
                    continue;
                }

                string[] parameterAndValue = parameter.Split('=');
                if (parameterAndValue[0].Equals("default", StringComparison.InvariantCultureIgnoreCase) && parameterAndValue.Length > 1)
                {
                    return parameterAndValue[1];
                }
            }

            return null;
        }

        bool TryFromCommandLine(IReadOnlyList<string> unmatchedTokens, [NotNullWhen(true)] out string? value)
        {
            Switch? terminalLogger = TryFind(unmatchedTokens, ["tl", "terminalLogger", "ll", "livelogger"]);
            if (terminalLogger == null)
            {
                value = null;
                return false;
            }

            if (terminalLogger.Value == null)
            {
                // if the switch was set but not to an explicit value, the value is "auto"
                value = "auto";
                return true;
            }

            value = terminalLogger.Value;
            return true;
        }

        bool TryFromEnvironmentVariables([NotNullWhen(true)] out string? terminalLoggerArg)
        {
            // Keep MSBUILDLIVELOGGER supporting existing use. But MSBUILDTERMINALLOGGER takes precedence.
            string? liveLoggerArg = Environment.GetEnvironmentVariable("MSBUILDLIVELOGGER");
            terminalLoggerArg = Environment.GetEnvironmentVariable("MSBUILDTERMINALLOGGER");
            if (!string.IsNullOrEmpty(terminalLoggerArg))
            {
                return true;
            }
            else if (!string.IsNullOrEmpty(liveLoggerArg))
            {
                terminalLoggerArg = liveLoggerArg;
                return true;
            }
            else
            {
                return false;
            }
        }

        string NormalizeIntoBooleanValues(string terminalLoggerArg)
        {
            // We now have a string`. It can be "true" or "false" which means just that:
            if (terminalLoggerArg.Equals("on", StringComparison.InvariantCultureIgnoreCase))
            {
                terminalLoggerArg = bool.TrueString;
            }
            else if (terminalLoggerArg.Equals("off", StringComparison.InvariantCultureIgnoreCase))
            {
                terminalLoggerArg = bool.FalseString;
            }

            return terminalLoggerArg;
        }
    }

    private static Switch? TryFind(IReadOnlyList<string> unmatchedTokens, params string[] names)
    {
        foreach (string prefix in new string[] { "-", "--", "/" })
        {
            foreach (var name in names)
            {
                var found = unmatchedTokens.FirstOrDefault(t => t.StartsWith(prefix + name, StringComparison.OrdinalIgnoreCase));
                if (found != null)
                {
                    var param = found.Substring(prefix.Length);
                    if (!param.Contains(":"))
                    {
                        return new Switch(param, null);
                    }
                    else
                    {
                        var parts = param.Split(":", 2);
                        return new Switch(parts[0], parts[1]);
                    }
                }
            }
        }

        return null;
    }

    internal static class NativeMethods
    {
        internal const uint FILE_TYPE_CHAR = 0x0002;
        internal const int STD_OUTPUT_HANDLE = -11;
        internal const int STD_ERROR_HANDLE = -12;
        internal const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;

        private static bool? s_isWindows;

        /// <summary>
        /// Gets a value indicating whether we are running under some version of Windows.
        /// </summary>
        [SupportedOSPlatformGuard("windows")]
        internal static bool IsWindows
        {
            get
            {
                s_isWindows ??= RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
                return s_isWindows.Value;
            }
        }

        internal static (bool AcceptAnsiColorCodes, bool OutputIsScreen, uint? OriginalConsoleMode) QueryIsScreenAndTryEnableAnsiColorCodes(StreamHandleType handleType = StreamHandleType.StdOut)
        {
            if (Console.IsOutputRedirected)
            {
                // There's no ANSI terminal support if console output is redirected.
                return (AcceptAnsiColorCodes: false, OutputIsScreen: false, OriginalConsoleMode: null);
            }

            bool acceptAnsiColorCodes = false;
            bool outputIsScreen = false;
            uint? originalConsoleMode = null;
            if (IsWindows)
            {
                try
                {
                    nint outputStream = GetStdHandle((int)handleType);
                    if (GetConsoleMode(outputStream, out uint consoleMode))
                    {
                        if ((consoleMode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) == ENABLE_VIRTUAL_TERMINAL_PROCESSING)
                        {
                            // Console is already in required state.
                            acceptAnsiColorCodes = true;
                        }
                        else
                        {
                            originalConsoleMode = consoleMode;
                            consoleMode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
                            if (SetConsoleMode(outputStream, consoleMode) && GetConsoleMode(outputStream, out consoleMode))
                            {
                                // We only know if vt100 is supported if the previous call actually set the new flag, older
                                // systems ignore the setting.
                                acceptAnsiColorCodes = (consoleMode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) == ENABLE_VIRTUAL_TERMINAL_PROCESSING;
                            }
                        }

                        uint fileType = GetFileType(outputStream);
                        // The std out is a char type (LPT or Console).
                        outputIsScreen = fileType == FILE_TYPE_CHAR;
                        acceptAnsiColorCodes &= outputIsScreen;
                    }
                }
                catch
                {
                    // In the unlikely case that the above fails we just ignore and continue.
                }
            }
            else
            {
                // On posix OSes detect whether the terminal supports VT100 from the value of the TERM environment variable.
#pragma warning disable RS0030 // Do not use banned APIs
                acceptAnsiColorCodes = AnsiDetector.IsAnsiSupported(Environment.GetEnvironmentVariable("TERM"));
#pragma warning restore RS0030 // Do not use banned APIs
                // It wasn't redirected as tested above so we assume output is screen/console
                outputIsScreen = true;
            }

            return (acceptAnsiColorCodes, outputIsScreen, originalConsoleMode);
        }

        internal static void RestoreConsoleMode(uint? originalConsoleMode, StreamHandleType handleType = StreamHandleType.StdOut)
        {
            if (IsWindows && originalConsoleMode is not null)
            {
                nint stdOut = GetStdHandle((int)handleType);
                _ = SetConsoleMode(stdOut, originalConsoleMode.Value);
            }
        }

        [DllImport("kernel32.dll")]
        [SupportedOSPlatform("windows")]
        internal static extern nint GetStdHandle(int nStdHandle);

        [DllImport("kernel32.dll")]
        [SupportedOSPlatform("windows")]
        internal static extern uint GetFileType(nint hFile);

        internal enum StreamHandleType
        {
            /// <summary>
            /// StdOut.
            /// </summary>
            StdOut = STD_OUTPUT_HANDLE,

            /// <summary>
            /// StdError.
            /// </summary>
            StdErr = STD_ERROR_HANDLE,
        }

        [DllImport("kernel32.dll")]
        internal static extern bool GetConsoleMode(nint hConsoleHandle, out uint lpMode);

        [DllImport("kernel32.dll")]
        internal static extern bool SetConsoleMode(nint hConsoleHandle, uint dwMode);
    }

    internal static class AnsiDetector
    {
        private static readonly Regex[] TerminalsRegexes =
        [
            new("^xterm"), // xterm, PuTTY, Mintty
            new("^rxvt"), // RXVT
            new("^(?!eterm-color).*eterm.*"), // Accepts eterm, but not eterm-color, which does not support moving the cursor, see #9950.
            new("^screen"), // GNU screen, tmux
            new("tmux"), // tmux
            new("^vt100"), // DEC VT series
            new("^vt102"), // DEC VT series
            new("^vt220"), // DEC VT series
            new("^vt320"), // DEC VT series
            new("ansi"), // ANSI
            new("scoansi"), // SCO ANSI
            new("cygwin"), // Cygwin, MinGW
            new("linux"), // Linux console
            new("konsole"), // Konsole
            new("bvterm"), // Bitvise SSH Client
            new("^st-256color"), // Suckless Simple Terminal, st
            new("alacritty"), // Alacritty
        ];

        public static bool IsAnsiSupported(string? termType)
            => !string.IsNullOrEmpty(termType) && TerminalsRegexes.Any(regex => regex.IsMatch(termType));
    }

    private record class Switch(string Name, string? Value);
}

public enum TerminalLoggerMode
{
    Off,
    On,
    Invalid
}