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

using Microsoft.DotNet.Cli.Utils.Extensions;

namespace Microsoft.DotNet.Cli.Utils;

public class Command(Process? process, bool trimTrailingNewlines = false, IDictionary<string, string?>? customEnvironmentVariables = null) : ICommand
{
    private readonly Process _process = process ?? throw new ArgumentNullException(nameof(process));

    private readonly Dictionary<string, string?>? _customEnvironmentVariables =
        // copy the dictionary to avoid mutating the original
        customEnvironmentVariables == null ? null : new(customEnvironmentVariables);

    private StreamForwarder? _stdOut;

    private StreamForwarder? _stdErr;

    private bool _running = false;

    private readonly bool _trimTrailingNewlines = trimTrailingNewlines;

    public CommandResult Execute()
    {
        return Execute(null);
    }
    public CommandResult Execute(Action<Process>? processStarted)
    {
        Reporter.Verbose.WriteLine(string.Format(
            LocalizableStrings.RunningFileNameArguments,
            _process.StartInfo.FileName,
            _process.StartInfo.Arguments));

        ThrowIfRunning();

        _running = true;

        _process.EnableRaisingEvents = true;

        Stopwatch? sw = null;
        if (CommandLoggingContext.IsVerbose)
        {
            sw = Stopwatch.StartNew();

            Reporter.Verbose.WriteLine($"> {Command.FormatProcessInfo(_process.StartInfo)}".White());
        }

        using (var reaper = ProcessReaper.Create(_process))
        {
            _process.Start();
            processStarted?.Invoke(_process);
            reaper.NotifyProcessStarted();

            Reporter.Verbose.WriteLine(string.Format(
                LocalizableStrings.ProcessId,
                _process.Id));

            var taskOut = _stdOut?.BeginRead(_process.StandardOutput);
            var taskErr = _stdErr?.BeginRead(_process.StandardError);
            _process.WaitForExit();

            taskOut?.Wait();
            taskErr?.Wait();
        }

        var exitCode = _process.ExitCode;

        if (CommandLoggingContext.IsVerbose)
        {
            Debug.Assert(sw is not null);
            var message = string.Format(
                LocalizableStrings.ProcessExitedWithCode,
                Command.FormatProcessInfo(_process.StartInfo),
                exitCode,
                sw.ElapsedMilliseconds);
            if (exitCode == 0)
            {
                Reporter.Verbose.WriteLine(message.Green());
            }
            else
            {
                Reporter.Verbose.WriteLine(message.Red().Bold());
            }
        }

        return new CommandResult(
            _process.StartInfo,
            exitCode,
            _stdOut?.CapturedOutput,
            _stdErr?.CapturedOutput);
    }

    public ICommand WorkingDirectory(string? projectDirectory)
    {
        _process.StartInfo.WorkingDirectory = projectDirectory;
        return this;
    }

    public ICommand EnvironmentVariable(string name, string? value)
    {
        _process.StartInfo.Environment[name] = value;
        _customEnvironmentVariables?[name] = value;
        return this;
    }

    public ICommand StandardOutputEncoding(Encoding encoding)
    {
        _process.StartInfo.StandardOutputEncoding = encoding;
        return this;
    }

    public ICommand CaptureStdOut()
    {
        ThrowIfRunning();
        EnsureStdOut();
        _stdOut?.Capture(_trimTrailingNewlines);
        return this;
    }

    public ICommand CaptureStdErr()
    {
        ThrowIfRunning();
        EnsureStdErr();
        _stdErr?.Capture(_trimTrailingNewlines);
        return this;
    }

    public ICommand ForwardStdOut(TextWriter? to = null, bool onlyIfVerbose = false, bool ansiPassThrough = true)
    {
        ThrowIfRunning();
        if (!onlyIfVerbose || CommandLoggingContext.IsVerbose)
        {
            EnsureStdOut();

            if (to == null)
            {
                _stdOut?.ForwardTo(writeLine: Reporter.Output.WriteLine);
                EnvironmentVariable(CommandLoggingContext.Variables.AnsiPassThru, ansiPassThrough.ToString());
            }
            else
            {
                _stdOut?.ForwardTo(writeLine: to.WriteLine);
            }
        }
        return this;
    }

    public ICommand ForwardStdErr(TextWriter? to = null, bool onlyIfVerbose = false, bool ansiPassThrough = true)
    {
        ThrowIfRunning();
        if (!onlyIfVerbose || CommandLoggingContext.IsVerbose)
        {
            EnsureStdErr();

            if (to == null)
            {
                _stdErr?.ForwardTo(writeLine: Reporter.Error.WriteLine);
                EnvironmentVariable(CommandLoggingContext.Variables.AnsiPassThru, ansiPassThrough.ToString());
            }
            else
            {
                _stdErr?.ForwardTo(writeLine: to.WriteLine);
            }
        }
        return this;
    }

    public ICommand OnOutputLine(Action<string> handler)
    {
        ThrowIfRunning();
        EnsureStdOut();

        _stdOut?.ForwardTo(writeLine: handler);
        return this;
    }

    public ICommand OnErrorLine(Action<string> handler)
    {
        ThrowIfRunning();
        EnsureStdErr();

        _stdErr?.ForwardTo(writeLine: handler);
        return this;
    }

    public string CommandName => _process.StartInfo.FileName;

    public string CommandArgs => _process.StartInfo.Arguments;

    public ProcessStartInfo StartInfo => _process.StartInfo;

    /// <summary>
    /// If set in the constructor, it's used to keep track of environment variables modified via <see cref="EnvironmentVariable"/>
    /// unlike <see cref="ProcessStartInfo.Environment"/> which includes all environment variables of the current process.
    /// </summary>
    public IReadOnlyDictionary<string, string?>? CustomEnvironmentVariables => _customEnvironmentVariables;

    public ICommand SetCommandArgs(string commandArgs)
    {
        _process.StartInfo.Arguments = commandArgs;
        return this;
    }

    private static string FormatProcessInfo(ProcessStartInfo info)
    {
        if (string.IsNullOrWhiteSpace(info.Arguments))
        {
            return info.FileName;
        }

        return $"{info.FileName} {info.Arguments}";
    }

    private void EnsureStdOut()
    {
        _stdOut ??= new StreamForwarder();
        _process.StartInfo.RedirectStandardOutput = true;
    }

    private void EnsureStdErr()
    {
        _stdErr ??= new StreamForwarder();
        _process.StartInfo.RedirectStandardError = true;
    }

    private void ThrowIfRunning([CallerMemberName] string? memberName = null)
    {
        if (_running)
        {
            throw new InvalidOperationException(string.Format(
                LocalizableStrings.UnableToInvokeMemberNameAfterCommand,
                memberName));
        }
    }
}