File: Utils\ConsoleActivityLogger.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.csproj (aspire)
// 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;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using Spectre.Console;
 
namespace Aspire.Cli.Utils;
 
/// <summary>
/// Lightweight, spec-aligned console logger for aligned colored task output without
/// rewriting the entire existing publishing pipeline. Integrates by mapping publish
/// step/task events to Start/Progress/Success/Warning/Failure calls.
/// </summary>
internal sealed class ConsoleActivityLogger
{
    private readonly bool _enableColor;
    private readonly ICliHostEnvironment _hostEnvironment;
    private readonly object _lock = new();
    private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
    private readonly Dictionary<string, string> _stepColors = new();
    private readonly Dictionary<string, ActivityState> _stepStates = new(); // Track final state per step for summary
    private readonly Dictionary<string, string> _displayNames = new(); // Optional friendly display names for step keys
    private List<StepDurationRecord>? _durationRecords; // Optional per-step duration breakdown
    private readonly string[] _availableColors = ["blue", "cyan", "yellow", "magenta", "purple", "orange3"];
    private int _colorIndex;
 
    private int _successCount;
    private int _warningCount;
    private int _failureCount;
    private volatile bool _spinning;
    private Task? _spinnerTask;
    private readonly char[] _spinnerChars = ['|', '/', '-', '\\'];
    private int _spinnerIndex;
 
    // No raw ANSI escape codes; rely on Spectre.Console markup tokens.
 
    private const string SuccessSymbol = "✓";
    private const string FailureSymbol = "✗";
    private const string WarningSymbol = "⚠";
    private const string InProgressSymbol = "→";
    private const string InfoSymbol = "i";
 
    public ConsoleActivityLogger(ICliHostEnvironment hostEnvironment, bool? forceColor = null)
    {
        _hostEnvironment = hostEnvironment;
        _enableColor = forceColor ?? _hostEnvironment.SupportsAnsi;
        
        // Disable spinner in non-interactive environments
        if (!_hostEnvironment.SupportsInteractiveOutput)
        {
            _spinning = false;
        }
    }
 
    public enum ActivityState
    {
        InProgress,
        Success,
        Warning,
        Failure,
        Info
    }
 
    public void StartTask(string taskKey, string? startingMessage = null)
    {
        lock (_lock)
        {
            // Initialize step state as InProgress if first time seen
            if (!_stepStates.ContainsKey(taskKey))
            {
                _stepStates[taskKey] = ActivityState.InProgress;
            }
        }
        WriteLine(taskKey, InProgressSymbol, startingMessage ?? "Starting...", ActivityState.InProgress);
    }
 
    public void StartTask(string taskKey, string displayName, string? startingMessage = null)
    {
        lock (_lock)
        {
            if (!_stepStates.ContainsKey(taskKey))
            {
                _stepStates[taskKey] = ActivityState.InProgress;
            }
            _displayNames[taskKey] = displayName;
        }
        WriteLine(taskKey, InProgressSymbol, startingMessage ?? ($"Starting {displayName}..."), ActivityState.InProgress);
    }
 
    public void StartSpinner()
    {
        // Skip spinner in non-interactive environments
        if (!_hostEnvironment.SupportsInteractiveOutput || _spinning)
        {
            return;
        }
        _spinning = true;
        _spinnerTask = Task.Run(async () =>
        {
            // Spinner sits at bottom; we write spinner char then backspace.
            while (_spinning)
            {
                AnsiConsole.Write(CultureInfo.InvariantCulture, _spinnerChars[_spinnerIndex % _spinnerChars.Length]);
                AnsiConsole.Write(CultureInfo.InvariantCulture, "\b");
                _spinnerIndex++;
                await Task.Delay(120).ConfigureAwait(false);
            }
            // Clear spinner character
            AnsiConsole.Write(CultureInfo.InvariantCulture, ' ');
            AnsiConsole.Write(CultureInfo.InvariantCulture, "\b");
        });
    }
 
    public async Task StopSpinnerAsync()
    {
        _spinning = false;
        if (_spinnerTask is not null)
        {
            await _spinnerTask.ConfigureAwait(false);
            _spinnerTask = null;
        }
    }
 
    public void Progress(string taskKey, string message)
    {
        WriteLine(taskKey, InProgressSymbol, message, ActivityState.InProgress);
    }
 
    public void Success(string taskKey, string message, double? seconds = null)
    {
        lock (_lock)
        {
            _successCount++;
            _stepStates[taskKey] = ActivityState.Success;
        }
        WriteCompletion(taskKey, SuccessSymbol, message, ActivityState.Success, seconds);
    }
 
    public void Warning(string taskKey, string message, double? seconds = null)
    {
        lock (_lock)
        {
            _warningCount++;
            _stepStates[taskKey] = ActivityState.Warning;
        }
        WriteCompletion(taskKey, WarningSymbol, message, ActivityState.Warning, seconds);
    }
 
    public void Failure(string taskKey, string message, double? seconds = null)
    {
        lock (_lock)
        {
            _failureCount++;
            _stepStates[taskKey] = ActivityState.Failure;
        }
        WriteCompletion(taskKey, FailureSymbol, message, ActivityState.Failure, seconds);
    }
 
    public void Info(string taskKey, string message)
    {
        WriteLine(taskKey, InfoSymbol, message, ActivityState.Info);
    }
 
    public void Continuation(string message)
    {
        lock (_lock)
        {
            // Continuation lines: indent with two spaces relative to the symbol column for readability
            const string continuationPrefix = "  ";
            foreach (var line in SplitLinesPreserve(message))
            {
                Console.Write(continuationPrefix);
                Console.WriteLine(line);
            }
        }
    }
 
    public void WriteSummary(string? dashboardUrl = null)
    {
        lock (_lock)
        {
            var totalSeconds = _stopwatch.Elapsed.TotalSeconds;
            var line = new string('-', 60);
            AnsiConsole.MarkupLine(line);
            var totalSteps = _stepStates.Count;
            // Derive per-step outcome counts from _stepStates (not task-level counters) for accurate X/Y display.
            var succeededSteps = _stepStates.Values.Count(v => v == ActivityState.Success);
            var warningSteps = _stepStates.Values.Count(v => v == ActivityState.Warning);
            var failedSteps = _stepStates.Values.Count(v => v == ActivityState.Failure);
            var summaryParts = new List<string>();
            var succeededSegment = totalSteps > 0 ? $"{succeededSteps}/{totalSteps} steps succeeded" : $"{succeededSteps} steps succeeded";
            if (_enableColor)
            {
                summaryParts.Add($"[green]{SuccessSymbol} {succeededSegment}[/]");
                if (warningSteps > 0)
                {
                    summaryParts.Add($"[yellow]{WarningSymbol} {warningSteps} warning{(warningSteps == 1 ? string.Empty : "s")}[/]");
                }
                if (failedSteps > 0)
                {
                    summaryParts.Add($"[red]{FailureSymbol} {failedSteps} failed[/]");
                }
            }
            else
            {
                summaryParts.Add($"{SuccessSymbol} {succeededSegment}");
                if (warningSteps > 0)
                {
                    summaryParts.Add($"{WarningSymbol} {warningSteps} warning{(warningSteps == 1 ? string.Empty : "s")}");
                }
                if (failedSteps > 0)
                {
                    summaryParts.Add($"{FailureSymbol} {failedSteps} failed");
                }
            }
            summaryParts.Add($"Total time: {totalSeconds.ToString("0.0", CultureInfo.InvariantCulture)}s");
            AnsiConsole.MarkupLine(string.Join(" • ", summaryParts));
 
            if (_durationRecords is { Count: > 0 })
            {
                AnsiConsole.WriteLine();
                AnsiConsole.MarkupLine("Steps Summary:");
                foreach (var rec in _durationRecords)
                {
                    var durStr = rec.Duration.TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture).PadLeft(4);
                    var symbol = rec.State switch
                    {
                        ActivityState.Success => _enableColor ? "[green]" + SuccessSymbol + "[/]" : SuccessSymbol,
                        ActivityState.Warning => _enableColor ? "[yellow]" + WarningSymbol + "[/]" : WarningSymbol,
                        ActivityState.Failure => _enableColor ? "[red]" + FailureSymbol + "[/]" : FailureSymbol,
                        _ => _enableColor ? "[cyan]" + InProgressSymbol + "[/]" : InProgressSymbol
                    };
                    var name = rec.DisplayName.EscapeMarkup();
                    var reason = rec.State == ActivityState.Failure && !string.IsNullOrEmpty(rec.FailureReason)
                        ? ( _enableColor ? $" [red]— {HighlightAndEscape(rec.FailureReason!)}[/]" : $"{rec.FailureReason}" )
                        : string.Empty;
                    var lineSb = new StringBuilder();
                    lineSb.Append("  ")
                        .Append(durStr).Append(" s  ")
                        .Append(symbol).Append(' ')
                        .Append("[dim]").Append(name).Append("[/]")
                        .Append(reason);
                    AnsiConsole.MarkupLine(lineSb.ToString());
                }
                AnsiConsole.WriteLine();
            }
 
            // If a caller provided a final status line via SetFinalResult, print it now
            if (!string.IsNullOrEmpty(_finalStatusHeader))
            {
                AnsiConsole.MarkupLine(_finalStatusHeader!);
            }
            if (!string.IsNullOrEmpty(dashboardUrl))
            {
                // Render dashboard URL as clickable link in interactive terminals, plain in non-interactive
                var url = dashboardUrl;
                if (!_hostEnvironment.SupportsInteractiveOutput || !_enableColor)
                {
                    AnsiConsole.MarkupLine($"Dashboard: {url.EscapeMarkup()}");
                }
                else
                {
                    AnsiConsole.MarkupLine($"Dashboard: [link={url}]{url}[/]");
                }
            }
            AnsiConsole.MarkupLine(line);
            AnsiConsole.WriteLine(); // Ensure final newline after deployment summary
        }
    }
 
    private string? _finalStatusHeader;
 
    /// <summary>
    /// Sets the final deployment result lines to be displayed in the summary (e.g., DEPLOYMENT FAILED ...).
    /// Optional usage so existing callers remain compatible.
    /// </summary>
    public void SetFinalResult(bool succeeded)
    {
        // Always show only a single final header line with symbol; no per-step duplication.
        if (succeeded)
        {
            _finalStatusHeader = _enableColor
                ? $"[green]{SuccessSymbol} DEPLOYMENT SUCCEEDED[/]"
                : $"{SuccessSymbol} DEPLOYMENT SUCCEEDED";
        }
        else
        {
            _finalStatusHeader = _enableColor
                ? $"[red]{FailureSymbol} DEPLOYMENT FAILED[/]"
                : $"{FailureSymbol} DEPLOYMENT FAILED";
        }
    }
 
    /// <summary>
    /// Provides per-step duration data (already sorted) for inclusion in the summary.
    /// </summary>
    public void SetStepDurations(IEnumerable<StepDurationRecord> records)
    {
        _durationRecords = records.ToList();
    }
 
    public readonly record struct StepDurationRecord(string Key, string DisplayName, ActivityState State, TimeSpan Duration, string? FailureReason);
 
    private void WriteCompletion(string taskKey, string symbol, string message, ActivityState state, double? seconds)
    {
        var text = seconds.HasValue ? $"{message} ({seconds.Value.ToString("0.0", CultureInfo.InvariantCulture)}s)" : message;
        WriteLine(taskKey, symbol, text, state);
    }
 
    private void WriteLine(string taskKey, string symbol, string message, ActivityState state)
    {
        lock (_lock)
        {
            var time = DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture);
            var stepColor = GetOrAssignStepColor(taskKey);
            var displayKey = _displayNames.TryGetValue(taskKey, out var dn) ? dn : taskKey;
            var coloredSymbol = _enableColor ? ColorizeSymbol(symbol, state) : symbol;
 
            foreach (var line in SplitLinesPreserve(message))
            {
                // Format: dim timestamp, colored step tag, symbol, escaped message
                var escapedLine = HighlightAndEscape(line);
                var escapedTask = displayKey.EscapeMarkup();
                var markup = new StringBuilder();
                markup.Append("[dim]").Append(time).Append("[/] ");
                markup.Append('[').Append(stepColor).Append(']').Append('(').Append(escapedTask).Append(')').Append("[/] ");
                if (_enableColor)
                {
                    if (state == ActivityState.Failure)
                    {
                        // Make the entire failure segment (symbol + message) red, not just the symbol
                        markup.Append("[red]").Append(symbol).Append(' ').Append(escapedLine).Append("[/]");
                    }
                    else if (state == ActivityState.Warning)
                    {
                        // Optionally color whole warning message (improves scanability)
                        markup.Append("[yellow]").Append(symbol).Append(' ').Append(escapedLine).Append("[/]");
                    }
                    else
                    {
                        markup.Append(coloredSymbol).Append(' ').Append(escapedLine);
                    }
                }
                else
                {
                    markup.Append(symbol).Append(' ').Append(escapedLine);
                }
                AnsiConsole.MarkupLine(markup.ToString());
            }
        }
    }
 
    private string GetOrAssignStepColor(string taskKey)
    {
        if (!_stepColors.TryGetValue(taskKey, out var color))
        {
            color = _availableColors[_colorIndex % _availableColors.Length];
            _stepColors[taskKey] = color;
            _colorIndex++;
        }
        return color;
    }
 
    private static IEnumerable<string> SplitLinesPreserve(string message)
    {
        if (message.IndexOf('\n') < 0)
        {
            yield return message;
            yield break;
        }
        var lines = message.Replace("\r\n", "\n").Split('\n');
        foreach (var l in lines)
        {
            yield return l;
        }
    }
 
    private static string ColorizeSymbol(string symbol, ActivityState state) => state switch
    {
        ActivityState.Success => $"[green]{symbol}[/]",
        ActivityState.Warning => $"[yellow]{symbol}[/]",
        ActivityState.Failure => $"[red]{symbol}[/]",
        ActivityState.InProgress => $"[cyan]{symbol}[/]",
        ActivityState.Info => $"[dim]{symbol}[/]",
        _ => symbol
    };
 
    private static readonly Regex s_urlRegex = new(
        pattern: @"(?:(?:https?|ftp)://)[^\s]+",
        options: RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
 
    // Escapes non-URL portions for Spectre markup while preserving injected [link] markup unescaped.
    private string HighlightAndEscape(string input)
    {
        if (string.IsNullOrEmpty(input))
        {
            return string.Empty;
        }
 
        var matches = s_urlRegex.Matches(input);
        if (matches.Count == 0)
        {
            return input.EscapeMarkup();
        }
 
        // In non-interactive environments, just output URLs as-is without [link] markup
        if (!_hostEnvironment.SupportsInteractiveOutput)
        {
            return input.EscapeMarkup();
        }
 
        var sb = new StringBuilder(input.Length + 32);
        var lastIndex = 0;
        foreach (Match match in matches)
        {
            if (match.Index > lastIndex)
            {
                var segment = input.Substring(lastIndex, match.Index - lastIndex);
                sb.Append(segment.EscapeMarkup());
            }
            var url = match.Value; // Do not EscapeMarkup inside [link] to keep it functional.
            sb.Append("[link=").Append(url).Append(']').Append(url).Append("[/]");
            lastIndex = match.Index + match.Length;
        }
        if (lastIndex < input.Length)
        {
            sb.Append(input.Substring(lastIndex).EscapeMarkup());
        }
        return sb.ToString();
    }
 
    // Note: DetectColorSupport is no longer needed as we use _hostEnvironment.SupportsAnsi directly
}