File: Commands\Sdk\SdkDumpCommand.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;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Shared.Json;
using Microsoft.Extensions.Logging;
using Semver;
using Spectre.Console;
using StreamJsonRpc;
 
namespace Aspire.Cli.Commands.Sdk;
 
/// <summary>
/// Command for dumping ATS capabilities and exported values from Aspire integration libraries.
/// Supports multiple output formats for different use cases.
/// 
/// Usage:
///   aspire sdk dump                                                               # Core Aspire.Hosting only
///   aspire sdk dump Aspire.Hosting.Redis@13.2.0                                   # Single package
///   aspire sdk dump Aspire.Hosting.Redis@13.2.0 Aspire.Hosting.PostgreSQL@13.2.0  # Multiple packages
///   aspire sdk dump ./MyIntegration.csproj Aspire.Hosting.Redis@13.2.0            # Mix of project and packages
///   aspire sdk dump --format json                                                 # Machine-readable JSON
///   aspire sdk dump --format ci -o capabilities.txt                               # Stable text for git diffing
/// </summary>
internal sealed class SdkDumpCommand : BaseCommand
{
    private readonly IAppHostServerProjectFactory _appHostServerProjectFactory;
    private readonly IAppHostServerSessionFactory _serverSessionFactory;
    private readonly ILogger<SdkDumpCommand> _logger;
 
    private static readonly Argument<string[]> s_integrationArgument = new("integrations")
    {
        Description = "Integrations to scan. Each can be a .csproj path or a NuGet package in PackageName@Version format. If not specified, dumps core Aspire.Hosting capabilities.",
        Arity = ArgumentArity.ZeroOrMore
    };
    private static readonly Option<FileInfo?> s_outputOption = new("--output", "-o")
    {
        Description = "Output file. If not specified, outputs to stdout."
    };
    private static readonly Option<DirectoryInfo?> s_outputDirectoryOption = new("--output-directory")
    {
        Description = "Output directory for one CI ATS file per integration. Requires --format ci and at least one integration."
    };
    private static readonly Option<OutputFormat> s_formatOption = new("--format")
    {
        Description = "Output format: Pretty (default), Json (machine-readable), or Ci (stable text for diffing)."
    };
 
    public SdkDumpCommand(
        IAppHostServerProjectFactory appHostServerProjectFactory,
        IAppHostServerSessionFactory serverSessionFactory,
        ILogger<SdkDumpCommand> logger,
        CommonCommandServices services)
        : base("dump", "Dump ATS capabilities from Aspire integration libraries.", services)
    {
        _appHostServerProjectFactory = appHostServerProjectFactory;
        _serverSessionFactory = serverSessionFactory;
        _logger = logger;
 
        Arguments.Add(s_integrationArgument);
        Options.Add(s_outputOption);
        Options.Add(s_outputDirectoryOption);
        Options.Add(s_formatOption);
    }
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        var integrationArgs = parseResult.GetValue(s_integrationArgument) ?? [];
        var outputFile = parseResult.GetValue(s_outputOption);
        var outputDirectory = parseResult.GetValue(s_outputDirectoryOption);
        var format = parseResult.GetValue(s_formatOption);
 
        if (outputFile is not null && outputDirectory is not null)
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand, "The --output and --output-directory options cannot be used together.");
        }
 
        if (outputDirectory is not null && format != OutputFormat.Ci)
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand, "The --output-directory option requires --format ci.");
        }
 
        if (outputDirectory is not null && integrationArgs.Length == 0)
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand, "The --output-directory option requires at least one integration.");
        }
 
        // Parse each integration argument: either a .csproj path or PackageName@Version
        var integrations = new List<IntegrationReference>();
 
        foreach (var arg in integrationArgs)
        {
            if (arg.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase))
            {
                var projectFile = new FileInfo(arg);
                if (!projectFile.Exists)
                {
                    return CommandResult.Failure(CliExitCodes.FailedToFindProject, $"Integration project not found: {projectFile.FullName}");
                }
 
                integrations.Add(IntegrationReference.FromProject(
                    IntegrationAssemblyNameResolver.Resolve(projectFile),
                    projectFile.FullName));
            }
            else if (arg.Contains('@'))
            {
                var atIndex = arg.LastIndexOf('@');
                var packageName = arg[..atIndex];
                var packageVersion = arg[(atIndex + 1)..];
 
                if (string.IsNullOrWhiteSpace(packageName) || string.IsNullOrWhiteSpace(packageVersion) || packageName.Contains('@'))
                {
                    return CommandResult.Failure(CliExitCodes.InvalidCommand, $"Invalid package format '{arg}'. Expected PackageName@Version (e.g. Aspire.Hosting.Redis@9.2.0).");
                }
 
                if (!SemVersion.TryParse(packageVersion, SemVersionStyles.Any, out _))
                {
                    return CommandResult.Failure(CliExitCodes.InvalidCommand, $"Invalid version '{packageVersion}' in '{arg}'. Expected a valid NuGet version (e.g. 9.2.0).");
                }
 
                _logger.LogDebug("Parsed package reference {PackageName} version {Version}", packageName, packageVersion);
                integrations.Add(IntegrationReference.FromPackage(packageName, packageVersion));
            }
            else
            {
                return CommandResult.Failure(CliExitCodes.InvalidCommand, $"Invalid integration argument '{arg}'. Expected a .csproj path or PackageName@Version format.");
            }
        }
 
        var duplicateIntegration = integrations
            .GroupBy(integration => integration.Name, StringComparer.OrdinalIgnoreCase)
            .FirstOrDefault(group => group.Count() > 1);
        if (duplicateIntegration is not null)
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand, $"Multiple integrations resolve to assembly name '{duplicateIntegration.Key}'.");
        }
 
        if (outputDirectory is not null)
        {
            return CommandResult.FromExitCode(await DumpCapabilitiesToDirectoryAsync(integrations, outputDirectory, format, cancellationToken));
        }
 
        // For file output, skip the interactive spinner
        if (outputFile is not null)
        {
            return CommandResult.FromExitCode(await DumpCapabilitiesAsync(integrations, outputFile, format, cancellationToken));
        }
 
        return CommandResult.FromExitCode(await InteractionService.ShowStatusAsync(
            "Scanning capabilities...",
            async () => await DumpCapabilitiesAsync(integrations, outputFile, format, cancellationToken),
            emoji: KnownEmojis.MagnifyingGlassTiltedLeft));
    }
 
    private async Task<int> DumpCapabilitiesAsync(
        List<IntegrationReference> integrations,
        FileInfo? outputFile,
        OutputFormat format,
        CancellationToken cancellationToken)
    {
        var tempDirectory = Directory.CreateTempSubdirectory("aspire-sdk-dump-");
        var tempDir = tempDirectory.FullName;
 
        try
        {
            var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken);
 
            _logger.LogDebug("Building AppHost server for capability scanning with {Count} integrations", integrations.Count);
 
            var prepareResult = await appHostServerProject.PrepareAsync(
                ExecutionContext.IdentityVersion,
                integrations,
                cancellationToken: cancellationToken);
 
            if (!prepareResult.Success)
            {
                InteractionService.DisplayError("Failed to build capability scanner.");
                if (prepareResult.Output is not null)
                {
                    foreach (var (_, line) in prepareResult.Output.GetLines())
                    {
                        InteractionService.DisplayMessage(KnownEmojis.Wrench, line);
                    }
                }
                return CliExitCodes.FailedToBuildArtifacts;
            }
 
            await using var serverSession = _serverSessionFactory.Create(appHostServerProject, environmentVariables: null, debug: false, gracefulShutdownSignaler: null, shutdownService: null, isolateConsole: false, cancellationToken);
            // Short-lived RPC session: StartAsync() spawns the server. We never observe the
            // exit-code task (WaitForExitAsync) because disposal flows the exit code through the
            // activity scope and the only failure mode we care about surfaces via the RPC call below.
            await serverSession.StartAsync();
 
            // Connect and get capabilities
            var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken);
 
            var exportAssemblyNames = integrations.Count > 0
                ? integrations.Select(i => i.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToArray()
                : null;
 
            _logger.LogDebug("Fetching capabilities via RPC");
            var capabilities = exportAssemblyNames is not null
                ? await rpcClient.GetCapabilitiesForAssembliesAsync(exportAssemblyNames, cancellationToken)
                : await rpcClient.GetCapabilitiesAsync(cancellationToken);
 
            PrepareCapabilitiesForOutput(capabilities, integrations);
 
            // Format the output
            var output = format switch
            {
                OutputFormat.Json => FormatJson(capabilities),
                OutputFormat.Ci => FormatCi(capabilities),
                _ => FormatPretty(capabilities)
            };
 
            // Write output
            if (outputFile is not null)
            {
                var outputDir = outputFile.Directory;
                if (outputDir is not null && !outputDir.Exists)
                {
                    outputDir.Create();
                }
                await File.WriteAllTextAsync(outputFile.FullName, output, cancellationToken);
                InteractionService.DisplaySuccess($"Capabilities written to {outputFile.FullName}");
            }
            else
            {
                // Output to stdout
                InteractionService.DisplayRawText(output, consoleOverride: ConsoleOutput.Standard);
            }
 
            // Return error code if there are errors in diagnostics
            var hasErrors = capabilities.Diagnostics.Exists(d => d.Severity == "Error");
            return hasErrors ? CliExitCodes.InvalidCommand : CliExitCodes.Success;
        }
        finally
        {
            // Clean up temp directory
            try
            {
                if (Directory.Exists(tempDir))
                {
                    Directory.Delete(tempDir, recursive: true);
                }
            }
            catch (Exception ex)
            {
                _logger.LogDebug(ex, "Failed to clean up temp directory {TempDir}", tempDir);
            }
        }
    }
 
    private async Task<int> DumpCapabilitiesToDirectoryAsync(
        List<IntegrationReference> integrations,
        DirectoryInfo outputDirectory,
        OutputFormat format,
        CancellationToken cancellationToken)
    {
        var tempDirectory = Directory.CreateTempSubdirectory("aspire-sdk-dump-");
        var tempDir = tempDirectory.FullName;
 
        try
        {
            var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken);
 
            _logger.LogDebug("Building AppHost server for batched capability scanning with {Count} integrations", integrations.Count);
 
            var prepareResult = await appHostServerProject.PrepareAsync(
                ExecutionContext.IdentityVersion,
                integrations,
                cancellationToken: cancellationToken);
 
            if (!prepareResult.Success)
            {
                InteractionService.DisplayError("Failed to build capability scanner.");
                if (prepareResult.Output is not null)
                {
                    foreach (var (_, line) in prepareResult.Output.GetLines())
                    {
                        InteractionService.DisplayMessage(KnownEmojis.Wrench, line);
                    }
                }
                return CliExitCodes.FailedToBuildArtifacts;
            }
 
            await using var serverSession = _serverSessionFactory.Create(appHostServerProject, environmentVariables: null, debug: false, gracefulShutdownSignaler: null, shutdownService: null, isolateConsole: false, cancellationToken);
            // Short-lived RPC session: StartAsync() spawns the server. We never observe the
            // exit-code task (WaitForExitAsync) because disposal flows the exit code through the
            // activity scope and the only failure mode we care about surfaces via the RPC call below.
            await serverSession.StartAsync();
 
            var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken);
            outputDirectory.Create();
 
            var dumpTasks = integrations
                .Select(integration => DumpIntegrationCapabilitiesAsync(rpcClient, integration, outputDirectory, format, cancellationToken))
                .ToArray();
 
            var dumpResults = await Task.WhenAll(dumpTasks);
            var failures = dumpResults.Where(result => !result.Success).ToArray();
            if (failures.Length > 0)
            {
                InteractionService.DisplayError("Failed to dump capabilities for one or more integrations.");
                foreach (var failure in failures)
                {
                    InteractionService.DisplayMessage(KnownEmojis.CrossMark, $"{failure.IntegrationName}: {failure.ErrorMessage}");
                }
 
                return CliExitCodes.FailedToBuildArtifacts;
            }
 
            return dumpResults.Any(result => result.HasErrors)
                ? CliExitCodes.InvalidCommand
                : CliExitCodes.Success;
        }
        finally
        {
            try
            {
                if (Directory.Exists(tempDir))
                {
                    Directory.Delete(tempDir, recursive: true);
                }
            }
            catch (Exception ex)
            {
                _logger.LogDebug(ex, "Failed to clean up temp directory {TempDir}", tempDir);
            }
        }
    }
 
    private async Task<IntegrationDumpResult> DumpIntegrationCapabilitiesAsync(
        IAppHostRpcClient rpcClient,
        IntegrationReference integration,
        DirectoryInfo outputDirectory,
        OutputFormat format,
        CancellationToken cancellationToken)
    {
        try
        {
            var capabilities = await rpcClient.GetCapabilitiesForAssembliesAsync(new[] { integration.Name }, cancellationToken);
            PrepareCapabilitiesForOutput(capabilities, [integration]);
 
            var output = format switch
            {
                OutputFormat.Json => FormatJson(capabilities),
                OutputFormat.Ci => FormatCi(capabilities),
                _ => FormatPretty(capabilities)
            };
 
            var outputPath = Path.Combine(outputDirectory.FullName, $"{integration.Name}.ats.txt");
            await File.WriteAllTextAsync(outputPath, output, cancellationToken);
            InteractionService.DisplaySuccess($"Capabilities written to {outputPath}");
 
            return new IntegrationDumpResult(integration.Name, Success: true, HasErrors: capabilities.Diagnostics.Exists(d => d.Severity == "Error"));
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException or RemoteInvocationException or AppHostCodeGenerationException)
        {
            _logger.LogWarning(ex, "Failed to dump capabilities for integration {IntegrationName}", integration.Name);
            return new IntegrationDumpResult(integration.Name, Success: false, HasErrors: false, ex.Message);
        }
    }
 
    private void PrepareCapabilitiesForOutput(CapabilitiesInfo capabilities, IEnumerable<IntegrationReference> integrations)
    {
        var infoDiagnostics = capabilities.Diagnostics.Where(d => d.Severity == "Info").ToList();
        foreach (var diag in infoDiagnostics)
        {
            var location = string.IsNullOrEmpty(diag.Location) ? "" : $" [{diag.Location}]";
            _logger.LogDebug("{Message}{Location}", diag.Message, location);
        }
 
        capabilities.Diagnostics.RemoveAll(d => d.Severity == "Info");
 
        var packageVersions = integrations
            .Where(i => i.IsPackageReference)
            .Select(i => new PackageInfo { Name = i.Name, Version = i.Version! })
            .ToList();
        if (packageVersions.Count > 0)
        {
            capabilities.Packages = packageVersions;
        }
    }
 
    private sealed record IntegrationDumpResult(
        string IntegrationName,
        bool Success,
        bool HasErrors,
        string? ErrorMessage = null);
 
    #region Output Formatters
 
    private static string FormatJson(CapabilitiesInfo capabilities)
    {
        return JsonSerializer.Serialize(capabilities, CapabilitiesJsonContext.Default.CapabilitiesInfo);
    }
 
    private static string FormatCi(CapabilitiesInfo capabilities)
    {
        var sb = new StringBuilder();
 
        // Header (no timestamp for stable diffs)
        sb.AppendLine("# Aspire Type System Capabilities");
        sb.AppendLine("# Generated by: aspire sdk dump --format ci");
        sb.AppendLine();
 
        // Diagnostics
        if (capabilities.Diagnostics.Count > 0)
        {
            sb.AppendLine("# Diagnostics");
            foreach (var d in capabilities.Diagnostics.OrderBy(d => d.Severity).ThenBy(d => d.Location))
            {
                var loc = string.IsNullOrEmpty(d.Location) ? "" : string.Format(CultureInfo.InvariantCulture, " [{0}]", d.Location);
                sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0}: {1}{2}", d.Severity.ToLowerInvariant(), d.Message, loc));
            }
            sb.AppendLine();
        }
 
        // Handle Types
        sb.AppendLine("# Handle Types");
        foreach (var t in capabilities.HandleTypes.OrderBy(t => t.AtsTypeId))
        {
            var flags = new List<string>();
            if (t.IsInterface)
            {
                flags.Add("interface");
            }
            if (t.ExposeProperties)
            {
                flags.Add("ExposeProperties");
            }
            if (t.ExposeMethods)
            {
                flags.Add("ExposeMethods");
            }
            var flagStr = flags.Count > 0 ? string.Format(CultureInfo.InvariantCulture, " [{0}]", string.Join(", ", flags)) : "";
            sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0}{1}", t.AtsTypeId, flagStr));
        }
        sb.AppendLine();
 
        // DTO Types
        if (capabilities.DtoTypes.Count > 0)
        {
            sb.AppendLine("# DTO Types");
            foreach (var t in capabilities.DtoTypes.OrderBy(t => t.TypeId))
            {
                if (!string.IsNullOrEmpty(t.Description))
                {
                    sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0} # {1}", t.TypeId, t.Description));
                }
                else
                {
                    sb.AppendLine(t.TypeId);
                }
                foreach (var p in t.Properties.OrderBy(p => p.Name))
                {
                    var optional = p.IsOptional ? "?" : "";
                    var desc = !string.IsNullOrEmpty(p.Description) ? string.Format(CultureInfo.InvariantCulture, " # {0}", p.Description) : "";
                    sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "  {0}{1}: {2}{3}", p.Name, optional, p.Type?.TypeId ?? "unknown", desc));
                }
            }
            sb.AppendLine();
        }
 
        // Enum Types
        if (capabilities.EnumTypes.Count > 0)
        {
            sb.AppendLine("# Enum Types");
            foreach (var t in capabilities.EnumTypes.OrderBy(t => t.TypeId))
            {
                sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0} = {1}", t.TypeId, string.Join(" | ", t.Values)));
            }
            sb.AppendLine();
        }
 
        if (capabilities.ExportedValues.Count > 0)
        {
            sb.AppendLine("# Exported Values");
            foreach (var value in capabilities.ExportedValues
                .OrderBy(value => string.Join(".", value.PathSegments), StringComparer.Ordinal))
            {
                var descriptionSuffix = string.IsNullOrEmpty(value.Description)
                    ? ""
                    : string.Format(CultureInfo.InvariantCulture, " # {0}", value.Description);
                sb.AppendLine(string.Format(
                    CultureInfo.InvariantCulture,
                    "{0}: {1} = {2}{3}",
                    string.Join(".", value.PathSegments),
                    value.Type.TypeId,
                    value.Value?.ToRelaxedJsonString() ?? "null",
                    descriptionSuffix));
            }
            sb.AppendLine();
        }
 
        // Capabilities
        sb.AppendLine("# Capabilities");
        foreach (var c in capabilities.Capabilities.OrderBy(c => c.CapabilityId))
        {
            var paramStr = string.Join(", ", c.Parameters.Select(p =>
            {
                var optional = p.IsOptional ? "?" : "";
                return string.Format(CultureInfo.InvariantCulture, "{0}{1}: {2}", p.Name, optional, p.Type?.TypeId ?? "unknown");
            }));
            var returnStr = c.ReturnType?.TypeId ?? "void";
            sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0}({1}) -> {2}", c.CapabilityId, paramStr, returnStr));
        }
 
        return sb.ToString();
    }
 
    private static string FormatPretty(CapabilitiesInfo capabilities)
    {
        var sb = new StringBuilder();
 
        // Header
        sb.AppendLine("================================================================================");
        sb.AppendLine("                    Aspire Type System Capabilities                             ");
        sb.AppendLine("================================================================================");
        sb.AppendLine();
 
        // Summary
        var errorCount = capabilities.Diagnostics.Count(d => d.Severity == "Error");
        var warningCount = capabilities.Diagnostics.Count(d => d.Severity == "Warning");
        sb.AppendLine("Summary");
        sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "   Handle Types:  {0}", capabilities.HandleTypes.Count));
        sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "   DTO Types:     {0}", capabilities.DtoTypes.Count));
        sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "   Enum Types:    {0}", capabilities.EnumTypes.Count));
        sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "   Exported Values:  {0}", capabilities.ExportedValues.Count));
        sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "   Capabilities:  {0}", capabilities.Capabilities.Count));
        if (errorCount > 0 || warningCount > 0)
        {
            sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "   Diagnostics:   {0} errors, {1} warnings", errorCount, warningCount));
        }
        sb.AppendLine();
 
        // Diagnostics
        if (capabilities.Diagnostics.Count > 0)
        {
            sb.AppendLine("Diagnostics");
            sb.AppendLine("--------------------------------------------------------------------------------");
            foreach (var d in capabilities.Diagnostics.OrderBy(d => d.Severity).ThenBy(d => d.Location))
            {
                var icon = d.Severity == "Error" ? "[ERROR]" : "[WARN]";
                sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "   {0} {1}", icon, d.Message));
                if (!string.IsNullOrEmpty(d.Location))
                {
                    sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "      -> {0}", d.Location));
                }
            }
            sb.AppendLine();
        }
 
        // Handle Types
        sb.AppendLine("Handle Types (passed by reference)");
        sb.AppendLine("--------------------------------------------------------------------------------");
        foreach (var t in capabilities.HandleTypes.OrderBy(t => t.AtsTypeId))
        {
            var flags = new List<string>();
            if (t.IsInterface)
            {
                flags.Add("interface");
            }
            if (t.ExposeProperties)
            {
                flags.Add("properties");
            }
            if (t.ExposeMethods)
            {
                flags.Add("methods");
            }
            var flagStr = flags.Count > 0 ? string.Format(CultureInfo.InvariantCulture, " ({0})", string.Join(", ", flags)) : "";
 
            // Extract short name from AtsTypeId
            var shortName = t.AtsTypeId.Contains('/')
                ? t.AtsTypeId.Split('/')[1]
                : t.AtsTypeId;
            sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "   {0}{1}", shortName, flagStr));
        }
        sb.AppendLine();
 
        // DTO Types
        if (capabilities.DtoTypes.Count > 0)
        {
            sb.AppendLine("DTO Types (serialized as JSON)");
            sb.AppendLine("--------------------------------------------------------------------------------");
            foreach (var t in capabilities.DtoTypes.OrderBy(t => t.Name))
            {
                sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "   {0}", t.Name));
                if (!string.IsNullOrEmpty(t.Description))
                {
                    sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "      {0}", t.Description));
                }
                foreach (var p in t.Properties.OrderBy(p => p.Name))
                {
                    var optional = p.IsOptional ? "?" : "";
                    var typeId = p.Type?.TypeId ?? "unknown";
                    // Simplify type display
                    var simpleType = SimplifyTypeName(typeId);
                    sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "      - {0}{1}: {2}", p.Name, optional, simpleType));
                    if (!string.IsNullOrEmpty(p.Description))
                    {
                        sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "         {0}", p.Description));
                    }
                }
            }
            sb.AppendLine();
        }
 
        // Enum Types
        if (capabilities.EnumTypes.Count > 0)
        {
            sb.AppendLine("Enum Types");
            sb.AppendLine("--------------------------------------------------------------------------------");
            foreach (var t in capabilities.EnumTypes.OrderBy(t => t.Name))
            {
                sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "   {0}", t.Name));
                sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "      {0}", string.Join(" | ", t.Values)));
            }
            sb.AppendLine();
        }
 
        if (capabilities.ExportedValues.Count > 0)
        {
            sb.AppendLine("Exported Values (copied into guest SDKs)");
            sb.AppendLine("--------------------------------------------------------------------------------");
            foreach (var value in capabilities.ExportedValues
                .OrderBy(value => string.Join(".", value.PathSegments), StringComparer.Ordinal))
            {
                sb.AppendLine(string.Format(
                    CultureInfo.InvariantCulture,
                    "   {0}: {1}",
                    string.Join(".", value.PathSegments),
                    SimplifyTypeName(value.Type.TypeId)));
                sb.AppendLine(string.Format(
                    CultureInfo.InvariantCulture,
                    "      {0}",
                    value.Value?.ToRelaxedJsonString() ?? "null"));
                if (!string.IsNullOrEmpty(value.Description))
                {
                    sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "      {0}", value.Description));
                }
            }
            sb.AppendLine();
        }
 
        // Capabilities (grouped by category if available)
        sb.AppendLine("Capabilities");
        sb.AppendLine("--------------------------------------------------------------------------------");
 
        var capsByTarget = capabilities.Capabilities
            .GroupBy(c => c.OwningTypeName ?? "Extension Methods")
            .OrderBy(g => g.Key is null or "Extension Methods") // Sort nulls/extension methods last
            .ThenBy(g => g.Key);
 
        foreach (var group in capsByTarget)
        {
            sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "   [{0}]", group.Key));
            foreach (var c in group.OrderBy(c => c.MethodName))
            {
                var paramStr = string.Join(", ", c.Parameters.Select(p =>
                {
                    var optional = p.IsOptional ? "?" : "";
                    var simpleType = SimplifyTypeName(p.Type?.TypeId ?? "unknown");
                    return string.Format(CultureInfo.InvariantCulture, "{0}{1}: {2}", p.Name, optional, simpleType);
                }));
                var returnType = SimplifyTypeName(c.ReturnType?.TypeId ?? "void");
                sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "      {0}({1}) -> {2}", c.MethodName, paramStr, returnType));
                if (!string.IsNullOrEmpty(c.Description))
                {
                    sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "         {0}", c.Description));
                }
            }
        }
 
        return sb.ToString();
    }
 
    private static string SimplifyTypeName(string typeId)
    {
        // Remove assembly prefix
        if (typeId.Contains('/'))
        {
            typeId = typeId.Split('/')[1];
        }
        // Remove namespace
        var lastDot = typeId.LastIndexOf('.');
        if (lastDot > 0)
        {
            typeId = typeId[(lastDot + 1)..];
        }
        return typeId;
    }
 
    #endregion
 
    private enum OutputFormat
    {
        Pretty,
        Json,
        Ci
    }
}
 
#region Response DTOs (matching server response)
 
// `aspire sdk dump --format json` uses these shapes; keep
// docs/specs/cli-output-formats.md in sync when changing them.
 
internal sealed class CapabilitiesInfo
{
    public List<PackageInfo> Packages { get; set; } = [];
    public List<CapabilityInfo> Capabilities { get; set; } = [];
    public List<HandleTypeInfo> HandleTypes { get; set; } = [];
    public List<DtoTypeInfo> DtoTypes { get; set; } = [];
    public List<EnumTypeInfo> EnumTypes { get; set; } = [];
    public List<ExportedValueInfo> ExportedValues { get; set; } = [];
    public List<DiagnosticInfo> Diagnostics { get; set; } = [];
}
 
internal sealed class PackageInfo
{
    public string Name { get; set; } = "";
    public string Version { get; set; } = "";
}
 
internal sealed class CapabilityInfo
{
    public string CapabilityId { get; set; } = "";
    public string MethodName { get; set; } = "";
    public string? OwningTypeName { get; set; }
    public string QualifiedMethodName { get; set; } = "";
    public string? Description { get; set; }
    public DocumentationInfo? Documentation { get; set; }
    public string CapabilityKind { get; set; } = "";
    public string? TargetTypeId { get; set; }
    public string? TargetParameterName { get; set; }
    public bool ReturnsBuilder { get; set; }
    public List<ParameterInfo> Parameters { get; set; } = [];
    public TypeRefInfo? ReturnType { get; set; }
    public TypeRefInfo? TargetType { get; set; }
    public List<TypeRefInfo> ExpandedTargetTypes { get; set; } = [];
}
 
internal sealed class ParameterInfo
{
    public string Name { get; set; } = "";
    public TypeRefInfo? Type { get; set; }
    public bool IsOptional { get; set; }
    public bool IsNullable { get; set; }
    public bool IsCallback { get; set; }
    public List<CallbackParameterInfo>? CallbackParameters { get; set; }
    public TypeRefInfo? CallbackReturnType { get; set; }
    public string? DefaultValue { get; set; }
    public DocumentationInfo? Documentation { get; set; }
}
 
internal sealed class CallbackParameterInfo
{
    public string Name { get; set; } = "";
    public TypeRefInfo? Type { get; set; }
    public DocumentationInfo? Documentation { get; set; }
}
 
internal sealed class TypeRefInfo
{
    public string TypeId { get; set; } = "";
    public string Category { get; set; } = "";
    public bool IsInterface { get; set; }
    public bool IsReadOnly { get; set; }
    public TypeRefInfo? ElementType { get; set; }
    public TypeRefInfo? KeyType { get; set; }
    public TypeRefInfo? ValueType { get; set; }
    public List<TypeRefInfo>? UnionTypes { get; set; }
}
 
internal sealed class HandleTypeInfo
{
    public string AtsTypeId { get; set; } = "";
    public bool IsInterface { get; set; }
    public bool ExposeProperties { get; set; }
    public bool ExposeMethods { get; set; }
    public DocumentationInfo? Documentation { get; set; }
    public List<TypeRefInfo> ImplementedInterfaces { get; set; } = [];
    public List<TypeRefInfo> BaseTypeHierarchy { get; set; } = [];
}
 
internal sealed class DtoTypeInfo
{
    public string TypeId { get; set; } = "";
    public string Name { get; set; } = "";
    public string? Description { get; set; }
    public DocumentationInfo? Documentation { get; set; }
    public List<DtoPropertyInfo> Properties { get; set; } = [];
}
 
internal sealed class DtoPropertyInfo
{
    public string Name { get; set; } = "";
    public TypeRefInfo? Type { get; set; }
    public bool IsOptional { get; set; }
    public string? Description { get; set; }
    public DocumentationInfo? Documentation { get; set; }
}
 
internal sealed class EnumTypeInfo
{
    public string TypeId { get; set; } = "";
    public string Name { get; set; } = "";
    public List<string> Values { get; set; } = [];
    public List<EnumValueInfo> ValueInfos { get; set; } = [];
    public DocumentationInfo? Documentation { get; set; }
}
 
internal sealed class EnumValueInfo
{
    public string Name { get; set; } = "";
    public DocumentationInfo? Documentation { get; set; }
}
 
internal sealed class ExportedValueInfo
{
    public List<string> PathSegments { get; set; } = [];
    public TypeRefInfo Type { get; set; } = null!;
    public JsonNode? Value { get; set; }
    public string? Description { get; set; }
    public DocumentationInfo? Documentation { get; set; }
}
 
internal sealed class DocumentationInfo
{
    public string? Summary { get; set; }
    public string? Remarks { get; set; }
    public string? Returns { get; set; }
    public List<ParameterDocumentationInfo> Parameters { get; set; } = [];
}
 
internal sealed class ParameterDocumentationInfo
{
    public string Name { get; set; } = "";
    public string Description { get; set; } = "";
}
 
internal sealed class DiagnosticInfo
{
    public string Severity { get; set; } = "";
    public string Message { get; set; } = "";
    public string? Location { get; set; }
}
 
#endregion
 
#region JSON Source Generation Context
 
[JsonSourceGenerationOptions(WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(CapabilitiesInfo))]
[JsonSerializable(typeof(PackageInfo))]
[JsonSerializable(typeof(CapabilityInfo))]
[JsonSerializable(typeof(ParameterInfo))]
[JsonSerializable(typeof(CallbackParameterInfo))]
[JsonSerializable(typeof(TypeRefInfo))]
[JsonSerializable(typeof(HandleTypeInfo))]
[JsonSerializable(typeof(DtoTypeInfo))]
[JsonSerializable(typeof(DtoPropertyInfo))]
[JsonSerializable(typeof(EnumTypeInfo))]
[JsonSerializable(typeof(EnumValueInfo))]
[JsonSerializable(typeof(ExportedValueInfo))]
[JsonSerializable(typeof(DocumentationInfo))]
[JsonSerializable(typeof(ParameterDocumentationInfo))]
[JsonSerializable(typeof(DiagnosticInfo))]
internal partial class CapabilitiesJsonContext : JsonSerializerContext
{
}
 
#endregion