File: Commands\LsCommand.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.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.CommandLine;
using System.Globalization;
using System.Text.Json;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Spectre.Console;
 
namespace Aspire.Cli.Commands;
 
internal sealed class LsCommand : BaseCommand
{
    internal override HelpGroup HelpGroup => HelpGroup.AppCommands;
 
    private readonly IProjectLocator _projectLocator;
    private readonly CliExecutionContext _executionContext;
    private readonly ICliHostEnvironment _hostEnvironment;
    private readonly ConsoleEnvironment _consoleEnvironment;
    private readonly ProfilingTelemetry _profilingTelemetry;
    private readonly TimeProvider _timeProvider;
 
    private static readonly Option<OutputFormat> s_formatOption = new("--format")
    {
        Description = SharedCommandStrings.LsFormatOptionDescription
    };
 
    private static readonly Option<bool> s_allOption = new("--all")
    {
        Description = SharedCommandStrings.LsAllOptionDescription
    };
 
    private static readonly Option<bool> s_streamOption = new("--stream")
    {
        Description = SharedCommandStrings.LsStreamOptionDescription
    };
 
    public LsCommand(
        IProjectLocator projectLocator,
        ICliHostEnvironment hostEnvironment,
        ConsoleEnvironment consoleEnvironment,
        ProfilingTelemetry profilingTelemetry,
        CommonCommandServices services,
        TimeProvider timeProvider)
        : base("ls", SharedCommandStrings.LsCommandDescription, services)
    {
        _projectLocator = projectLocator;
        _executionContext = services.ExecutionContext;
        _hostEnvironment = hostEnvironment;
        _consoleEnvironment = consoleEnvironment;
        _profilingTelemetry = profilingTelemetry;
        _timeProvider = timeProvider;
 
        Options.Add(s_formatOption);
        Options.Add(s_allOption);
        Options.Add(s_streamOption);
 
        Validators.Add(result =>
        {
            if (result.GetValue(s_streamOption) && result.GetValue(s_formatOption) != OutputFormat.Json)
            {
                result.AddError(SharedCommandStrings.LsStreamRequiresJson);
            }
        });
    }
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        using var activity = Telemetry.StartDiagnosticActivity(Name);
 
        var format = parseResult.GetValue(s_formatOption);
        var includeAll = parseResult.GetValue(s_allOption);
        var stream = parseResult.GetValue(s_streamOption);
        using var profilingActivity = _profilingTelemetry.StartLsCommand(format.ToString().ToLowerInvariant(), includeAll);
 
        // `aspire ls` is ambient discovery from the working directory by default, so
        // it should respect git/default filters. `--all` is the explicit escape hatch
        // for users who intentionally want ignored or generated paths included.
        var scope = includeAll
            ? AppHostDiscoveryScope.AllFiles
            : AppHostDiscoveryScope.DefaultFiltered;
 
        try
        {
            var useJsonStream = format == OutputFormat.Json && stream;
 
            // Status-based progress (with directories searched / AppHosts found) is only useful for the human
            // table view in an interactive terminal. JSON consumers receive structured data instead, and
            // non-interactive hosts may not support spinner status, so they wait for the final table.
            var useInteractiveStatus = format == OutputFormat.Table
                && _hostEnvironment.SupportsInteractiveOutput
                && !_executionContext.DebugMode;
 
            List<AppHostProjectCandidate> appHosts;
            using (var findAppHostsActivity = _profilingTelemetry.StartLsFindAppHosts(scope.ToString()))
            {
                appHosts = (useInteractiveStatus, useJsonStream) switch
                {
                    (true, _) => await FindAppHostsWithStatusAsync(scope, cancellationToken).ConfigureAwait(false),
                    (_, true) => await FindAppHostsWithJsonStreamAsync(scope, cancellationToken).ConfigureAwait(false),
                    _ => await _projectLocator.FindAppHostProjectsAsync(_executionContext.WorkingDirectory, scope, cancellationToken).ConfigureAwait(false)
                };
                findAppHostsActivity.SetAppHostCandidateCount(appHosts.Count);
            }
            profilingActivity.SetAppHostCandidateCount(appHosts.Count);
 
            var appHostInfos = CreateDisplayInfos(appHosts);
 
            if (format == OutputFormat.Json && !useJsonStream)
            {
                var json = JsonSerializer.Serialize(appHostInfos, JsonSourceGenerationContext.RelaxedEscaping.ListCandidateAppHostDisplayInfo);
                InteractionService.DisplayRawText(json, ConsoleOutput.Standard);
            }
            else if (!useJsonStream)
            {
                // Both interactive and non-interactive table output land here so the final table render is
                // identical: the only difference is whether discovery showed a live spinner status above.
                if (appHostInfos.Count == 0)
                {
                    InteractionService.DisplayMessage(KnownEmojis.Information, SharedCommandStrings.LsNoCandidateAppHostsFound);
                }
                else
                {
                    InteractionService.DisplayRenderable(BuildTable(appHostInfos, InteractionService.SupportsLinks));
                }
            }
 
            return CommandResult.Success();
        }
        catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken || cancellationToken.IsCancellationRequested)
        {
            if (format != OutputFormat.Json || !stream)
            {
                InteractionService.DisplayCancellationMessage();
            }
 
            return CommandResult.Success();
        }
    }
 
    private async Task<List<AppHostProjectCandidate>> FindAppHostsWithJsonStreamAsync(AppHostDiscoveryScope scope, CancellationToken cancellationToken)
    {
        var appHosts = new List<AppHostProjectCandidate>();
 
        // `aspire ls --format json --stream` emits each candidate as soon as discovery surfaces
        // it (arrival order from parallel discovery). The contract is documented in
        // docs/specs/cli-output-formats.md. Do NOT sort here:
        // candidates have already been written to stdout via WriteJsonStreamCandidate above, so
        // any post-loop sort would only reorder this in-memory list — which the caller does not
        // use for stream output. Pipe to `sort` / `jq -s 'sort_by(.path)'` for ordered output.
        // See https://github.com/microsoft/aspire/issues/17621.
        await foreach (var candidate in _projectLocator.FindAppHostProjectsStreamAsync(_executionContext.WorkingDirectory, scope, cancellationToken: cancellationToken).ConfigureAwait(false))
        {
            cancellationToken.ThrowIfCancellationRequested();
            appHosts.Add(candidate);
            WriteJsonStreamCandidate(CreateDisplayInfo(candidate));
        }
 
        return appHosts;
    }
 
    private void WriteJsonStreamCandidate(CandidateAppHostDisplayInfo candidate)
    {
        var json = JsonSerializer.Serialize(candidate, JsonSourceGenerationContext.Streaming.CandidateAppHostDisplayInfo);
 
        // `aspire ls --format json --stream` follows the repository-wide NDJSON convention:
        // each line is the same JSON content shape that would otherwise appear as an array
        // item, not a separate event envelope.
        // See https://github.com/ndjson/ndjson-spec for the line-delimited JSON convention.
        // Example:
        //   {"path":"C:\\repo\\AppHost.csproj","language":"C#","status":"buildable"}
        // Keep docs/specs/cli-output-formats.md in sync when changing this shape.
        var writer = _consoleEnvironment.Out.Profile.Out.Writer;
        writer.WriteLine(json);
        writer.Flush();
    }
 
    private async Task<List<AppHostProjectCandidate>> FindAppHostsWithStatusAsync(AppHostDiscoveryScope scope, CancellationToken cancellationToken)
    {
        var appHosts = new List<AppHostProjectCandidate>();
        // Counters are mutated from the discovery worker thread (directory enumeration is single-threaded, and
        // validation runs in parallel via Parallel.ForEachAsync), but read from the status-refresh loop on a
        // different thread. Use Volatile/Interlocked so the refresh loop observes recent values without locking.
        var directoriesSearched = 0;
        var appHostsFound = 0;
 
        // Re-render the status text at most ~once per second so a deep filesystem walk doesn't spam the terminal,
        // but still gives the user a visible heartbeat that work is happening.
        var statusRefreshInterval = TimeSpan.FromSeconds(1);
 
        await InteractionService.ShowDynamicStatusAsync(
            FormatSearchingStatus(directoriesSearched: 0, appHostsFound: 0),
            async updateStatus =>
            {
                using var statusCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
                var refreshTask = RefreshStatusLoopAsync(updateStatus, statusCancellation.Token);
 
                try
                {
                    await foreach (var candidate in _projectLocator
                        .FindAppHostProjectsStreamAsync(
                            _executionContext.WorkingDirectory,
                            scope,
                            onDirectoryEnumerated: count => Volatile.Write(ref directoriesSearched, count),
                            cancellationToken: cancellationToken)
                        .ConfigureAwait(false))
                    {
                        cancellationToken.ThrowIfCancellationRequested();
                        appHosts.Add(candidate);
                        Interlocked.Increment(ref appHostsFound);
                    }
                }
                finally
                {
                    updateStatus(FormatSearchingStatus(Volatile.Read(ref directoriesSearched), Volatile.Read(ref appHostsFound)));
                    statusCancellation.Cancel();
                    try
                    {
                        await refreshTask.ConfigureAwait(false);
                    }
                    catch (OperationCanceledException)
                    {
                    }
                }
 
                return CliExitCodes.Success;
 
                async Task RefreshStatusLoopAsync(Action<string> update, CancellationToken refreshToken)
                {
                    try
                    {
                        while (!refreshToken.IsCancellationRequested)
                        {
                            await Task.Delay(statusRefreshInterval, _timeProvider, refreshToken).ConfigureAwait(false);
                            update(FormatSearchingStatus(Volatile.Read(ref directoriesSearched), Volatile.Read(ref appHostsFound)));
                        }
                    }
                    catch (OperationCanceledException) when (refreshToken.IsCancellationRequested)
                    {
                    }
                }
            }).ConfigureAwait(false);
 
        appHosts.Sort((x, y) => string.Compare(x.AppHostFile.FullName, y.AppHostFile.FullName, StringComparison.Ordinal));
        return appHosts;
    }
 
    private static string FormatSearchingStatus(int directoriesSearched, int appHostsFound)
    {
        return string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.LsSearchingForAppHostsStatus, directoriesSearched, appHostsFound);
    }
 
    private List<CandidateAppHostDisplayInfo> CreateDisplayInfos(IEnumerable<AppHostProjectCandidate> appHosts)
    {
        return appHosts.Select(CreateDisplayInfo).ToList();
    }
 
    private CandidateAppHostDisplayInfo CreateDisplayInfo(AppHostProjectCandidate appHost)
    {
        return new CandidateAppHostDisplayInfo
        {
            Path = appHost.AppHostFile.FullName,
            Language = appHost.Language,
            Status = GetDisplayStatus(appHost.Status)
        };
    }
 
    private static Table BuildTable(List<CandidateAppHostDisplayInfo> appHosts, bool supportsLinks)
    {
        var table = new Table();
        table.AddBoldColumn(SharedCommandStrings.HeaderPath);
        table.AddBoldColumn(SharedCommandStrings.HeaderLanguage);
        table.AddBoldColumn(SharedCommandStrings.HeaderStatus);
 
        foreach (var appHost in appHosts)
        {
            table.AddRow(
                MarkupHelpers.SafeFileLink(supportsLinks, appHost.Path),
                Markup.Escape(appHost.Language),
                GetStatusMarkup(appHost.Status));
        }
 
        return table;
    }
 
    private static string GetDisplayStatus(AppHostProjectCandidateStatus status)
    {
        return status switch
        {
            AppHostProjectCandidateStatus.Buildable => "buildable",
            AppHostProjectCandidateStatus.PossiblyUnbuildable => "possibly-unbuildable",
            _ => status.ToString().ToLowerInvariant()
        };
    }
 
    private static string GetStatusMarkup(string status)
    {
        return status switch
        {
            "buildable" => "[green]buildable[/]",
            "possibly-unbuildable" => "[yellow]possibly-unbuildable[/]",
            _ => Markup.Escape(status)
        };
    }
}
 
// `aspire ls --format json` uses this shape; keep docs/specs/cli-output-formats.md in sync when changing it.
internal sealed class CandidateAppHostDisplayInfo
{
    public required string Path { get; init; }
 
    public required string Language { get; init; }
 
    public required string Status { get; init; }
}