CLI приложения #11. Двойной вывод

Вернёмся к вопросу использования CLI приложений. Глобально существует два параллельно идущих сценария: когда приложением пользуется человек и когда приложение запускается скриптом или внешним приложением. Принципиальная разница только в том, в каком виде приложение будет отдавать данные. Для человека важно, чтобы результат выполнения команд и программы в целом был наглядным, понятным человеку. Для этого мы и занимаемся в последних постах цветом, таблицами и прочими украшениями.
Во втором сценарии важно отдавать данные в понятном и строго определенном формате, который сможет разобрать другая программа. Под строго определённым тут понимается один формат, заданный для конкретного вывода.
Например, если CLI приложение при запросе отдает данные в json формате, то менять этот формат на csv в процессе обновления не стоит - это поломает использование приложения там, где оно используется. Это касается и всего API приложения в целом - его стоит продумывать заранее и стараться максимально не изменять имеющиеся команды.
Но вернёмся к выводу. Поскольку у нас есть два возможных потребителя и им требуется два различных формата отдаваемых данных, значит надо предусмотреть это заранее.

Сделаем команду check, которая осматривает папку и сообщает о найденных файлах. Полный код:

using System.CommandLine;
using System.Text.Json;
using Spectre.Console;

internal record FileStatus(string Name, long Size, bool Ok);

internal class Program
{
    private static int Main(string[] args)
    {
        var pathArgument = new Argument<string>("path")
        {
            Description = "Папка для проверки",
            DefaultValueFactory = _ => "."
        };

        var formatOption = new Option<string>("--format")
        {
            Description = "Формат вывода: table, json или auto",
            DefaultValueFactory = _ => "auto"
        };
        formatOption.AcceptOnlyFromAmong("table", "json", "auto");

        var checkCommand = new Command("check", "Проверить файлы в папке");
        checkCommand.Arguments.Add(pathArgument);
        checkCommand.Options.Add(formatOption);

        checkCommand.SetAction(parseResult =>
        {
            var path = parseResult.GetValue(pathArgument)!;
            var format = parseResult.GetValue(formatOption)!;

            if (!Directory.Exists(path))
            {
                Console.Error.WriteLine($"Папка не найдена: {path}");
                return 2;
            }

            var files = Directory.EnumerateFiles(path)
                .Select(p => new FileInfo(p))
                .Select(f => new FileStatus(f.Name, f.Length, f.Length > 0))
                .ToList();

            var useTable = format switch
            {
                "table" => true,
                "json" => false,
                _ => AnsiConsole.Profile.Capabilities.Interactive
            };

            if (useTable)
            {
                var table = new Table();
                table.AddColumn("Файл");
                table.AddColumn("Размер");
                table.AddColumn("Статус");

                foreach (var f in files)
                {
                    table.AddRow(
                        Markup.Escape(f.Name),
                        $"{f.Size} б",
                        f.Ok ? "[green]OK[/]" : "[red]пусто[/]");
                }

                AnsiConsole.Write(table);
            }
            else
            {
                Console.Out.WriteLine(JsonSerializer.Serialize(files));
            }

            Console.Error.WriteLine($"Обработано файлов: {files.Count}");
            return 0;
        });

        var rootCommand = new RootCommand("Учебное CLI приложение");
        rootCommand.Subcommands.Add(checkCommand);

        return rootCommand.Parse(args).Invoke();
    }
}

Здесь files — обычный список записей FileStatus, собранный из содержимого папки. Файл считается в порядке, если он не пустой. Проверка условная, но нам сейчас важно не что мы проверяем, а как показываем результат.
Ключевых мест два.

Первое — опция --format. Метод AcceptOnlyFromAmong из System.CommandLine сам отвергнет --format xml, покажет список допустимых значений и перечислит их в справке. Руками ничего проверять не нужно.

Второе — режим auto, где программа сама решает, что показать. Нужно понять, есть ли по ту сторону живой человек, и Spectre.Console отвечает на это одним свойством:

AnsiConsole.Profile.Capabilities.Interactive

Оно вернёт false, если вывод перенаправлен в файл или передан другой программе, а также в не интерактивном сеансе — например, на сервере сборки. Это тот самый случай, когда вывод должен быть в четком формате и без дополнительных украшений.
Проверяем:

# человек за терминалом — рисуется таблица
dotnet run -- check

# перенаправили в файл — внутри чистый JSON
dotnet run -- check > report.json

# а так можно потребовать таблицу принудительно
dotnet run -- check --format table > table.txt
Три вида вывода: терминал, report.json, table.txt

Все три вывода: в терминал, json и текст с форматированием.

Обратите внимание на предпоследнюю строку кода: количество обработанных файлов уходит в stderr. Поэтому в report.json попадёт только JSON, а сообщение останется на экране — о таком формате разделения вывода мы говорили ранее.