Next level console apps with Spectre.Console

Intro

Even if the main things I spend time building are web applications and APIs, I often also create some console applications: sometimes to do some benchmarks, others to test some features without messing up the main projects, or lastly, to implement some small tools (I prefer creating a small app and using a traditional programming language over creating a bunch of scripts).

What I never try to do however, is make said console applications pretty πŸ˜›. Most of the times, it's not worth it, but even when it is, the further I went was using some helper libraries to parse command line arguments.

This is where Spectre.Console comes in. Came across this library not too long ago and was intrigued, so added it to my ever-growing "to explore" list, and having finally had some time to play a bit with it, here comes the obligatory blog post to share that this is a pretty cool library!

From the docs:

Spectre.Console is a .NET Standard 2.0 library that makes it easier to create beautiful console applications.

And it doesn't disappoint!

Besides the ability to easily handle input arguments (much like the CommandLineParser library I've used before), it also provides support to render complex elements in the command line, like tables, trees or progress bars, as well as ways to prompt the user for information, be it in text form or selecting an option.

Enough with the chitchat, let's look at a sample with a bunch of these features in play. To have a relatable context for the sample, I went with one of the last types of console applications I worked on: a migration tool.

The gist of it is, we have some data we want to migrate to a new format, for example from one database to another, so we want to create a tool to help us do this, targeting multiple environments.

At the end, we'll get this:

Application context

To make the sample a bit more relatable than just showing random things happen in the console, came up with this data migration context.

Before diving into Spectre.Console, let's quickly get up to speed with the context, so it all makes some sense.

We have a SampleMigrator class, abstracting the actual migration work (which will actually just be a bunch of Task.Delays to simulate stuff). It provides a way to connect to an environment, gather some initial information, do the actual migration and then disconnect.

The migration process reports after each item is migrated, so we can provide feedback on the status.

The overall API for this feature set looks like the following (implementation removed for brevity):

public class SampleMigrator : IAsyncDisposable
{
    Task ConnectAsync(string username, string password, Environment environment);

    Task<MigrationInformation> GatherMigrationInformationAsync();

    IAsyncEnumerable<MigrationResult> MigrateAsync();

    ValueTask DisposeAsync();
}

public enum Environment { Development, Staging, Production }

public record MigrationInformation(int ThingsToMigrate);

public record MigrationResult(int ThingsId, bool IsMigrationSuccessful);

ConnectAsync and DisposeAsync handle the connection lifetime, GatherMigrationInformationAsync gets some initial information, while MigrateAsync handles the migration process, returning an IAsyncEnumerable to report the status.

This is simpler than an actual data migration would likely be, but it should be complex enough to make the sample console application interesting πŸ™‚.

Setup application and available commands

With all this intro and context out of the way, let's build the console application!

First thing, install Spectre.Console. In the command line, on the root of the project, this would be:

dotnet add package Spectre.Console

Now in our application, we want to expose a couple of commands, migrate and rollback (even if we're implementing only the former).

We set them up as follows:

var app = new CommandApp();
app.Configure(config =>
{
    config.AddCommand<MigrateCommand>("migrate");
    config.AddCommand<RollbackCommand>("rollback");
});

The AddCommand method requires us to specify a generic type that implements ICommand, getting as a parameter the name of the command, i.e. what we'll write in the command line to execute it.

We can implement ICommand directly, or we can inherit from some abstract classes that already have some boilerplate code in place. These are Command and AsyncCommand, but also Command<TSettings> and AsyncCommand<TSettings>, in which TSettings allow us to specify some settings, like the parameters and options the command accepts.

The migrate command is defined as follows:

public class MigrateCommand : AsyncCommand<MigrateCommand.Settings>
{
    public class Settings : CommandSettings
    {
        [CommandOption("-u|--username")]
        [Description("Username to access the environment for migration")]
        public string? Username { get; init; }

        [CommandOption("-p|--password")]
        [Description("Password to access the environment for migration")]
        public string? Password { get; init; }

        [CommandOption("-e|--environment")]
        [Description("Target environment for the migration")]
        public Environment? Environment { get; init; }
    }

    public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
    {
        // ...
    }
}

If you recall from the video I embedded above, I execute the application with the line:

./SpectreConsoleSample.App migrate -u SomeUser

So, migrate selects the command, then I'm using options, namely the -u/--username option to immediately provide the username to the application. Could do the same with the password and environment, but could't show off all of the things happening in the demo.

None of the options is required, nor am I adding validation (which I could, by overriding Validate in the Settingsclass), cause we're going to ask the user for any missing/incorrect value.

As for those description attributes, they're used if we use the help option:

Prompting user for information

As just mentioned, none of the command options is required, so let's ask the user for anything missing. We'll make use of Spectre.Console's prompts to make these requests.

At the top of ExecuteAsync, started with the following, asking for any missing options:

var username = AskUsernameIfMissing(settings.Username);
var password = AskPasswordIfMissing(settings.Password);
var environment = AskEnvironmentIfMissing(settings.Environment);

And implemented these AskXYZ using the following local functions:

static string AskUsernameIfMissing(string? current)
    => !string.IsNullOrWhiteSpace(current)
        ? current
        : AnsiConsole.Prompt(
            new TextPrompt<string>("What's the username?")
                .Validate(username
                    => !string.IsNullOrWhiteSpace(username)
                        ? ValidationResult.Success()
                        : ValidationResult.Error("[yellow]Invalid username[/]")));

static string AskPasswordIfMissing(string? current)
    => TryGetValidPassword(current, out var validPassword)
        ? validPassword
        : AnsiConsole.Prompt(
            new TextPrompt<string>("What's the password?")
                .Secret()
                .Validate(password
                    => TryGetValidPassword(password, out _)
                        ? ValidationResult.Success()
                        : ValidationResult.Error("[yellow]Invalid password[/]")));

static bool TryGetValidPassword(string? password, [NotNullWhen(true)] out string? validPassword)
{
    var isValidPassword = !string.IsNullOrWhiteSpace(password) && password.Length > 2;
    validPassword = password;
    return isValidPassword;
}

static Environment AskEnvironmentIfMissing(Environment? current)
    => current ?? AnsiConsole.Prompt(
        new SelectionPrompt<Environment>()
            .Title("What's the target environment?")
            .AddChoices(
                Environment.Development,
                Environment.Staging,
                Environment.Production)
    );

As we'll see through the rest of the post, AnsiConsole is the entry point for interactions with the console.

For username and password, we're calling AnsiConsole.Prompt and passing in a TextPromp, where we provide the text to show the user and expect a string in return. We can define a validation function, so if the user enters incorrect information, it'll be refused (as you can see in the video at the top of the post). For the password, we also call the Secret extension method, so this prompt is treated as such, not showing the value on the screen.

For the environment, as we have an enum, instead of having the user type the value, we can use a SelectionPrompt, so the user needs only to move through the options and select the desired one.

Presenting information and reporting progress

So, asking the user for some inputs in a couple of different ways? Check! Now let's look at some ways to present information, as well as provide progress feedback, so the user knows things are happening, instead of just hoping the application is actually doing something πŸ˜›.

After we gather this initial information from the user, it's probably a good idea to get the user to double check things, to ensure things are well configured before starting a migration. We could, for example, present the summary in a table format.

With a small number of lines, we can get that:

AnsiConsole.Render(
    new Table()
        .AddColumn(new TableColumn("Setting").Centered())
        .AddColumn(new TableColumn("Value").Centered())
        .AddRow("Username", username)
        .AddRow("Password", "REDACTED")
        .AddRow(new Text("Environment"), new Markup($"[{GetEnvironmentColor(environment)}]{environment}[/]")));

// ...

static string GetEnvironmentColor(Environment environment)
    => environment switch
    {
        Environment.Development => "green",
        Environment.Staging => "yellow",
        Environment.Production => "red",
        _ => throw new ArgumentOutOfRangeException()
    };

Then, we can ask the user for confirmation (resorting again to a selection prompt) and get on with it.

With all info in hand, it's time to begin performing the migration steps, using the previously introduced SampleMigrator class.

As you might have noticed, all of SampleMigrator's methods are async (in multiple variations), which immediately gives us an hint that they're good candidates to make use of Spectre.Console's progress reporting features.

For ConnectAsync, GatherMigrationInformationAsync and DisposeAsync, which return Task or ValueTask, we can use a simple spinner, just to signal things are happening. We can use the Status component for that.

await AnsiConsole
    .Status()
    .StartAsync("Connecting...", _ => migrator.ConnectAsync(username, password, environment));

var migrationInformation = await AnsiConsole
    .Status()
    .StartAsync(
        "Gathering migration information...",
        _ => migrator.GatherMigrationInformationAsync());

// ...

await AnsiConsole
    .Status()
    .StartAsync("Disconnecting...", _ => migrator.DisposeAsync().AsTask());

Nice and easy! But it gets better πŸ™‚. Spinners are an improvement over no feedback at all, but even better is to actually have an idea of the amount of work done and the amount remaining. As we know the amount of... "things" to migrate (returned by GatherMigrationInformationAsync) and MigrateAsync returns an IAsyncEnumerable with information about each migrated item, we can make use of the Progress component as follows.

var migrationResults = await AnsiConsole.Progress().StartAsync(async ctx =>
{
    var migrationTask = ctx.AddTask("Migrating...", maxValue: migrationInformation.ThingsToMigrate);

    var successes = 0;
    var failures = 0;

    await foreach (var migration in migrator.MigrateAsync())
    {
        if (migration.IsMigrationSuccessful)
        {
            ++successes;
        }
        else
        {
            ++failures;
        }

        migrationTask.Increment(1);
    }

    return (successes, failures);
});

We start a progress task associated with the migration process. To have the progress accurately displayed without messing with math to find the percentage of work done, we can pass the maximum value to the AddTask call on the context. Then, for each processed item, we call Increment on the progress task.

When our migration finally finishes, we can present the results to the user. As we have two possibilities, success or failure, we can present it with a simple bar chart.

AnsiConsole.Render(
    new BarChart()
        .Label("Migration results")
        .AddItem("Succeeded", migrationResults.successes, Color.Green)
        .AddItem("Failed", migrationResults.failures, Color.Red));

Outro

Don't know about you, but I've never built such a pretty console application. As I mentioned in the beginning, the most I've done was using some helper libraries to parse command line arguments, and maybe hide passwords or adding a couple of colors here and there, but what I've played with for this post is just next level stuff! πŸ˜ƒ

It goes without saying that there are more features I didn't go into, but just this brief look has me sold on Spectre.Console, which I'll certainly consider next time I need to build a console application that could use better usability.

Links in the post:

The source code for this post is in the SpectreConsoleSample repository.

Thanks for stopping by, cyaz!

Thanks for stopping by, cyaz!

23