File: Commands\GroupedHelpWriter.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.CommandLine;
using Aspire.Cli.Resources;
 
namespace Aspire.Cli.Commands;
 
/// <summary>
/// Writes grouped help output for the root command, organizing subcommands into logical categories.
/// Groups are determined by each command's <see cref="BaseCommand.HelpGroup"/> property.
/// </summary>
internal static class GroupedHelpWriter
{
    /// <summary>
    /// The well-known group ordering. Groups not listed here appear after these, in alphabetical order.
    /// </summary>
    private static readonly HelpGroup[] s_groupOrder =
    [
        HelpGroup.AppCommands,
        HelpGroup.ResourceManagement,
        HelpGroup.Monitoring,
        HelpGroup.Deployment,
        HelpGroup.ToolsAndConfiguration,
    ];
 
    /// <summary>
    /// Writes grouped help output for the given root command.
    /// </summary>
    /// <param name="command">The root command to generate help for.</param>
    /// <param name="writer">The text writer to write help output to.</param>
    /// <param name="maxWidth">The maximum console width. When null, defaults to 80.</param>
    public static void WriteHelp(Command command, TextWriter writer, int? maxWidth = null)
    {
        var width = maxWidth ?? 80;
 
        // Description
        if (!string.IsNullOrEmpty(command.Description))
        {
            writer.WriteLine(command.Description);
            writer.WriteLine();
        }
 
        // Usage
        writer.WriteLine(HelpGroupStrings.Usage);
        writer.WriteLine(GetIndent() + HelpGroupStrings.UsageSyntax);
        writer.WriteLine();
 
        // Collect visible subcommands and organize by group.
        var grouped = new Dictionary<HelpGroup, List<BaseCommand>>();
        var ungroupedCommands = new List<Command>();
 
        foreach (var sub in command.Subcommands)
        {
            if (sub.Hidden)
            {
                continue;
            }
 
            if (sub is BaseCommand baseCmd && baseCmd.HelpGroup is not HelpGroup.None)
            {
                if (!grouped.TryGetValue(baseCmd.HelpGroup, out var list))
                {
                    list = [];
                    grouped[baseCmd.HelpGroup] = list;
                }
 
                list.Add(baseCmd);
            }
            else
            {
                ungroupedCommands.Add(sub);
            }
        }
 
        // Sort commands within each group by name.
        foreach (var list in grouped.Values)
        {
            list.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
        }
 
        // Compute the first-column width across all commands for consistent alignment.
        var columnWidth = 0;
        foreach (var list in grouped.Values)
        {
            foreach (var cmd in list)
            {
                var label = FormatCommandLabel(cmd);
                if (label.Length > columnWidth)
                {
                    columnWidth = label.Length;
                }
            }
        }
 
        foreach (var cmd in ungroupedCommands)
        {
            var label = FormatCommandLabel(cmd);
            if (label.Length > columnWidth)
            {
                columnWidth = label.Length;
            }
        }
 
        // Padding: 2 spaces indent + label + at least 2 spaces gap before description
        columnWidth += 4;
 
        // Write groups in the defined order, then any additional groups alphabetically.
        var writtenGroups = new HashSet<HelpGroup>();
 
        foreach (var group in s_groupOrder)
        {
            if (grouped.TryGetValue(group, out var commands))
            {
                WriteGroup(writer, GetGroupHeading(group), commands, columnWidth, width);
                writtenGroups.Add(group);
            }
        }
 
        // Write any groups not in the well-known order (future-proofing).
        foreach (var (group, commands) in grouped.OrderBy(kvp => kvp.Key))
        {
            if (!writtenGroups.Contains(group))
            {
                WriteGroup(writer, GetGroupHeading(group), commands, columnWidth, width);
            }
        }
 
        // Catch-all: show any registered commands not assigned to a group.
        if (ungroupedCommands.Count > 0)
        {
            writer.WriteLine(HelpGroupStrings.OtherCommands);
            foreach (var cmd in ungroupedCommands.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))
            {
                var label = FormatCommandLabel(cmd);
                var description = cmd.Description ?? string.Empty;
                WriteTwoColumnRow(writer, label, description, columnWidth, width);
            }
            writer.WriteLine();
        }
 
        // Options
        var visibleOptions = command.Options.Where(o => !o.Hidden).ToList();
        if (visibleOptions.Count > 0)
        {
            writer.WriteLine(HelpGroupStrings.Options);
 
            var optionColumnWidth = 0;
            foreach (var opt in visibleOptions)
            {
                var label = FormatOptionLabel(opt);
                if (label.Length > optionColumnWidth)
                {
                    optionColumnWidth = label.Length;
                }
            }
 
            optionColumnWidth += 4;
 
            foreach (var opt in visibleOptions)
            {
                var label = FormatOptionLabel(opt);
                var desc = opt.Description ?? string.Empty;
                WriteTwoColumnRow(writer, label, desc, optionColumnWidth, width);
            }
 
            writer.WriteLine();
        }
 
        // Help hint
        writer.WriteLine(HelpGroupStrings.HelpHint);
    }
 
    private static void WriteGroup(TextWriter writer, string heading, List<BaseCommand> commands, int columnWidth, int width)
    {
        writer.WriteLine(heading);
        foreach (var cmd in commands)
        {
            var label = FormatCommandLabel(cmd);
            var description = cmd.Description ?? string.Empty;
            WriteTwoColumnRow(writer, label, description, columnWidth, width);
        }
        writer.WriteLine();
    }
 
    /// <summary>
    /// Gets the localized heading string for a help group.
    /// </summary>
    internal static string GetGroupHeading(HelpGroup group) => group switch
    {
        HelpGroup.AppCommands => HelpGroupStrings.AppCommands,
        HelpGroup.ResourceManagement => HelpGroupStrings.ResourceManagement,
        HelpGroup.Monitoring => HelpGroupStrings.Monitoring,
        HelpGroup.Deployment => HelpGroupStrings.Deployment,
        HelpGroup.ToolsAndConfiguration => HelpGroupStrings.ToolsAndConfiguration,
        _ => group.ToString(),
    };
 
    /// <summary>
    /// Writes a two-column row with word-wrapping on the description column.
    /// Continuation lines are indented to align with the description start.
    /// </summary>
    private const int IndentWidth = 2;
 
    private static string GetIndent(int extra = 0) => new(' ', IndentWidth + extra);
 
    private static void WriteTwoColumnRow(TextWriter writer, string label, string description, int columnWidth, int maxWidth)
    {
        var paddedLabel = label.PadRight(columnWidth);
        var descriptionWidth = maxWidth - columnWidth - IndentWidth;
 
        // If the terminal is too narrow to wrap meaningfully, just write it all on one line.
        if (descriptionWidth < 20)
        {
            writer.WriteLine($"{GetIndent()}{paddedLabel}{description}");
            return;
        }
 
        var remaining = description.AsSpan();
        var firstLine = true;
 
        while (remaining.Length > 0)
        {
            if (firstLine)
            {
                writer.Write(GetIndent());
                writer.Write(paddedLabel);
                firstLine = false;
            }
            else
            {
                // Continuation line: indent to align with description column.
                writer.Write(GetIndent(columnWidth));
            }
 
            if (remaining.Length <= descriptionWidth)
            {
                writer.WriteLine(remaining);
                break;
            }
 
            // Find the last space within the allowed width for a clean word break.
            var breakAt = remaining[..descriptionWidth].LastIndexOf(' ');
            if (breakAt <= 0)
            {
                // No space found — hard break at the width limit.
                breakAt = descriptionWidth;
            }
 
            writer.WriteLine(remaining[..breakAt]);
            remaining = remaining[breakAt..].TrimStart();
        }
    }
 
    private static string FormatCommandLabel(Command cmd)
    {
        var args = GetArgumentSyntax(cmd);
        return string.IsNullOrEmpty(args) ? cmd.Name : $"{cmd.Name} {args}";
    }
 
    private static string GetArgumentSyntax(Command cmd)
    {
        if (cmd.Arguments.Count == 0)
        {
            return string.Empty;
        }
 
        var parts = new List<string>();
        foreach (var arg in cmd.Arguments)
        {
            if (arg.Hidden)
            {
                continue;
            }
 
            var name = $"<{arg.Name}>";
 
            // Optional if minimum arity is 0.
            if (arg.Arity.MinimumNumberOfValues == 0)
            {
                name = $"[{name}]";
            }
 
            parts.Add(name);
        }
 
        return string.Join(" ", parts);
    }
 
    private static string FormatOptionLabel(Option option)
    {
        // Collect all identifiers: Name may not be in Aliases in System.CommandLine 2.0.
        var allNames = new HashSet<string>(option.Aliases, StringComparer.Ordinal);
        if (!string.IsNullOrEmpty(option.Name))
        {
            allNames.Add(option.Name);
        }
 
        var sorted = allNames.OrderBy(a => a.Length).ToList();
        return sorted.Count > 1
            ? $"{sorted[0]}, {sorted[1]}"
            : sorted.Count > 0 ? sorted[0] : option.Name;
    }
}