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.Serialization;
using Aspire.Cli.Configuration;
using Spectre.Console;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Commands.Sdk;
 
/// <summary>
/// Command for dumping ATS capabilities from Aspire integration libraries.
/// Supports multiple output formats for different use cases.
/// 
/// Usage:
///   aspire sdk dump [integration.csproj]           # Pretty output (default)
///   aspire sdk dump --json                         # Machine-readable JSON
///   aspire sdk dump --ci -o capabilities.txt      # Stable text for git diffing
/// </summary>
internal sealed class SdkDumpCommand : BaseCommand
{
    private readonly IAppHostServerProjectFactory _appHostServerProjectFactory;
    private readonly ILogger<SdkDumpCommand> _logger;
 
    public SdkDumpCommand(
        IAppHostServerProjectFactory appHostServerProjectFactory,
        IFeatures features,
        ICliUpdateNotifier updateNotifier,
        CliExecutionContext executionContext,
        IInteractionService interactionService,
        ILogger<SdkDumpCommand> logger)
        : base("dump", "Dump ATS capabilities from Aspire integration libraries.", features, updateNotifier, executionContext, interactionService)
    {
        _appHostServerProjectFactory = appHostServerProjectFactory;
        _logger = logger;
 
        // The integration project is the main input (optional - defaults to core Aspire.Hosting)
        var integrationArgument = new Argument<FileInfo?>("integration")
        {
            Description = "Path to the integration project (.csproj). If not specified, dumps core Aspire.Hosting capabilities.",
            Arity = ArgumentArity.ZeroOrOne
        };
        Arguments.Add(integrationArgument);
 
        var outputOption = new Option<FileInfo?>("--output", "-o")
        {
            Description = "Output file. If not specified, outputs to stdout."
        };
        Options.Add(outputOption);
 
        var jsonOption = new Option<bool>("--json")
        {
            Description = "Output as JSON for machine consumption."
        };
        Options.Add(jsonOption);
 
        var ciOption = new Option<bool>("--ci")
        {
            Description = "Output stable text format for CI/CD diffing."
        };
        Options.Add(ciOption);
    }
 
    protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        var integrationProject = parseResult.GetValue<FileInfo?>("integration");
        var outputFile = parseResult.GetValue<FileInfo?>("--output");
        var jsonFormat = parseResult.GetValue<bool>("--json");
        var ciFormat = parseResult.GetValue<bool>("--ci");
 
        // Validate the integration project if specified
        if (integrationProject is not null)
        {
            if (!integrationProject.Exists)
            {
                InteractionService.DisplayError($"Integration project not found: {integrationProject.FullName}");
                return ExitCodeConstants.FailedToFindProject;
            }
 
            if (!integrationProject.Extension.Equals(".csproj", StringComparison.OrdinalIgnoreCase))
            {
                InteractionService.DisplayError($"Expected a .csproj file, got: {integrationProject.Extension}");
                return ExitCodeConstants.InvalidCommand;
            }
        }
 
        if (jsonFormat && ciFormat)
        {
            InteractionService.DisplayError("Cannot specify both --json and --ci. Choose one format.");
            return ExitCodeConstants.InvalidCommand;
        }
 
        var format = jsonFormat ? OutputFormat.Json : ciFormat ? OutputFormat.Ci : OutputFormat.Pretty;
 
        // For file output, skip the interactive spinner
        if (outputFile is not null)
        {
            return await DumpCapabilitiesAsync(integrationProject, outputFile, format, cancellationToken);
        }
 
        return await InteractionService.ShowStatusAsync(
            ":magnifying_glass_tilted_right: Scanning capabilities...",
            async () => await DumpCapabilitiesAsync(integrationProject, outputFile, format, cancellationToken));
    }
 
    private async Task<int> DumpCapabilitiesAsync(
        FileInfo? integrationProject,
        FileInfo? outputFile,
        OutputFormat format,
        CancellationToken cancellationToken)
    {
        // Use a temporary directory for the AppHost server
        var tempDir = Path.Combine(Path.GetTempPath(), "aspire-sdk-dump", Guid.NewGuid().ToString("N")[..8]);
        Directory.CreateDirectory(tempDir);
 
        try
        {
            var appHostServerProject = _appHostServerProjectFactory.Create(tempDir);
            var socketPath = appHostServerProject.GetSocketPath();
 
            // Build packages list - empty since we only need core capabilities + optional integration
            var packages = new List<(string Name, string Version)>();
 
            _logger.LogDebug("Building AppHost server for capability scanning");
 
            // Create project files with the integration project reference if specified
            var additionalProjectRefs = integrationProject is not null
                ? new[] { integrationProject.FullName }
                : null;
 
            await appHostServerProject.CreateProjectFilesAsync(
                AppHostServerProject.DefaultSdkVersion,
                packages,
                cancellationToken,
                additionalProjectReferences: additionalProjectRefs);
 
            var (buildSuccess, buildOutput) = await appHostServerProject.BuildAsync(cancellationToken);
 
            if (!buildSuccess)
            {
                InteractionService.DisplayError("Failed to build capability scanner.");
                foreach (var (_, line) in buildOutput.GetLines())
                {
                    InteractionService.DisplayMessage("wrench", line.EscapeMarkup());
                }
                return ExitCodeConstants.FailedToBuildArtifacts;
            }
 
            // Start the server
            var currentPid = Environment.ProcessId;
            var (serverProcess, _) = appHostServerProject.Run(socketPath, currentPid, new Dictionary<string, string>());
 
            try
            {
                // Connect and get capabilities
                await using var rpcClient = await AppHostRpcClient.ConnectAsync(socketPath, cancellationToken);
 
                _logger.LogDebug("Fetching capabilities via RPC");
                var capabilities = await rpcClient.GetCapabilitiesAsync(cancellationToken);
 
                // Output Info-level diagnostics to stderr via logger (shown with -d flag)
                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);
                }
 
                // Remove Info diagnostics from output (they go to stderr only)
                capabilities.Diagnostics.RemoveAll(d => d.Severity == "Info");
 
                // 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
                    Console.WriteLine(output);
                }
 
                // Return error code if there are errors in diagnostics
                var hasErrors = capabilities.Diagnostics.Exists(d => d.Severity == "Error");
                return hasErrors ? ExitCodeConstants.InvalidCommand : ExitCodeConstants.Success;
            }
            finally
            {
                // Stop the server - just try to kill, catch if already exited
                try
                {
                    serverProcess.Kill(entireProcessTree: true);
                }
                catch (InvalidOperationException)
                {
                    // Process already exited - this is fine
                }
                catch (Exception ex)
                {
                    _logger.LogDebug(ex, "Error killing AppHost server process");
                }
            }
        }
        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);
            }
        }
    }
 
    #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 --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))
            {
                sb.AppendLine(t.TypeId);
                foreach (var p in t.Properties.OrderBy(p => p.Name))
                {
                    var optional = p.IsOptional ? "?" : "";
                    sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "  {0}{1}: {2}", p.Name, optional, p.Type?.TypeId ?? "unknown"));
                }
            }
            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();
        }
 
        // 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, "   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));
                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));
                }
            }
            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();
        }
 
        // 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)
 
internal sealed class CapabilitiesInfo
{
    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<DiagnosticInfo> Diagnostics { 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 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; }
}
 
internal sealed class CallbackParameterInfo
{
    public string Name { get; set; } = "";
    public TypeRefInfo? Type { 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 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 List<DtoPropertyInfo> Properties { get; set; } = [];
}
 
internal sealed class DtoPropertyInfo
{
    public string Name { get; set; } = "";
    public TypeRefInfo? Type { get; set; }
    public bool IsOptional { get; set; }
}
 
internal sealed class EnumTypeInfo
{
    public string TypeId { get; set; } = "";
    public string Name { get; set; } = "";
    public List<string> Values { 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(CapabilityInfo))]
[JsonSerializable(typeof(ParameterInfo))]
[JsonSerializable(typeof(CallbackParameterInfo))]
[JsonSerializable(typeof(TypeRefInfo))]
[JsonSerializable(typeof(HandleTypeInfo))]
[JsonSerializable(typeof(DtoTypeInfo))]
[JsonSerializable(typeof(DtoPropertyInfo))]
[JsonSerializable(typeof(EnumTypeInfo))]
[JsonSerializable(typeof(DiagnosticInfo))]
internal partial class CapabilitiesJsonContext : JsonSerializerContext
{
}
 
#endregion