System.CommandLine コマンドラインパーサーを利用する

C# を使っているときに、コマンドラインツールを作るときに便利なコマンドラインパーサーないかな？と思って調査したところ、最もオフィシャルに近い `System.CommandLine` を試してみることにしました。自分がしたいことを中心に調べたことをまとめてみたいと思います。

* [System.CommandLine を使用してコマンド ラインを解析する](https://docs.microsoft.com/ja-jp/archive/msdn-magazine/2019/march/net-parse-the-command-line-with-system-commandline)
* [GitHub Project](https://github.com/dotnet/command-line-api)

# 基本的な使い方

`RootCommand` というクラスをインスタンス化して、そこに、`AddArgument`, `AddOption` で引数やオプションを渡します。最後に `Invoke` または、`InvokeAsync` を実行してあげるとよいです。`Main` メソッドの中で呼ばれるようにしてみてください。

```csharp:Program.cs
        static async Task<int> Main(string[] args)
        {
            var rootCommand = new RootCommand
            {
                Description = "Update the configuration file of the LinuxPoolConfig"
            };

            rootCommand.AddArgument(new Argument<int>("stage", "Stage Number for update e.g. 1"));
            Option sourceOption = new Option<string>(
                new string[] {"--source", "-s"},
                "Source Version for update. e.g. 3.0.17892 By default, this tool update update version that matches the major version of target version."
                );
            rootCommand.AddOption(sourceOption);
            rootCommand.AddArgument(new Argument<string>("target", "Target Version for update. e.g. 3.0.17893"));

            Option configFileOption = new Option<FileInfo>(
                aliases: new string[] {"--config-file", "-c"},
                description: "Config file to update");
               
            rootCommand.AddOption(configFileOption);
            rootCommand.Handler = CommandHandler.Create<int, string, string, FileInfo, IConsole>(new UpdateAction().Execute);
            return await rootCommand.InvokeAsync(args);
        }
```
すると、Helpを自動で作成してれます。`MyConfigCmd` はプロジェクト名です。

```bash:Help
MyConfigCmd
  Update the configuration file of the MyConfig

Usage:
  MyConfigCmd [options] <stage> <target>

Arguments:
  <stage>   Stage Number for update e.g. 1
  <target>  Target Version for update. e.g. 3.0.17893

Options:
  -s, --source <source>            Source Version for update. e.g. 3.0.17892 By default, this tool update update
                                   version that matches the major version of target version.
  -c, --config-file <config-file>  Config file to update
  --version                        Show version information
  -?, -h, --help                   Show help and usage information
```

実行の例は次のような感じです。

```bash
$ MyConfigCmd.exe 0 3.0.15829 --source 3.0.15828 --config-file .\Hello\config.json 
```

# 必須・オプションパラメータの表現

## 必須項目

必須項目は、`Argument` で表現します、必須項目の型、名前、概要を定義できます。概要は、`help` でも使われます。

```csharp:Argument
rootCommand.AddArgument(new Argument<int>("stage", "Stage Number for update e.g. 1"));
```

## オプション
オプションの項目は、次のように書くことで、 追加することができます。`alias` を定義することで、オプションの書き方や、省略形の書き方も設定できます。また、`getDefaultValue()` に対して関数を渡すことでデフォルト値を設定することができます。

```csharp:Option
            Option configFileOption = new Option<FileInfo>(
                aliases: new string[] {"--config-file", "-c"},
                description: "Config file to update",
                getDefaultValue: () => new FileInfo(Path.Combine(".", "config")));
            rootCommand.AddOption(configFileOption);
```

# Sub Command 

Sub Command は、今回必要なかったので試していませんが [Add SubCommand](https://github.com/dotnet/command-line-api/blob/main/docs/How-To.md#Add-a-subcommand) を見ると、`Command` というクラスをインスタンス化して、`RootCommand` に足してあげるとよいだけのようです。

# ハンドラの実行

さて、このコマンドアプリが実行されたら、何らかのアクションを実行したいと思います。このように設定します。

```csharp:Handler
           rootCommand.Handler = CommandHandler.Create<int, string, string, FileInfo, IConsole>(new UpdateAction().Execute);
 
```

ここで、ポイントは、ここで渡しているハンドラの名前です。定義した`Argument` や、`Option` で定義した名前と同じ引数名にする必要があります。`Argument` は名前が明確なので、わかりやすいですが、`Option` は、`alias`しかないし、`--config-file` とかの場合どうなるの？と思うと思いますがこの場合は、`configFile` というキャメルケースになるようです。私はここが最初わからなくて、パラメータが渡ってこないという問題に遭遇しました。

```csharp:UpdateAction.Execute
        public int Execute(int stage, string source, string target, FileInfo configFile, IConsole console)
        {  
            :
```

# 終了ステータス

コマンドラインアプリを作っていると、終了ステータスを定義したいと思います。実は、先ほどのハンドラは戻り値無しでもかけるのですが、先ほど紹介した通り戻り値を`int` として定義しています。そうすると、この部分が終了ステータスになるので、`return 1;` とか返すと、エラーの終了ステータスになりますので便利です。


# ユニットテストで便利な機能

 先ほどのハンドラの部分で、`IConsole` という謎のパラメータがあります。これをつけてあげると、Console のオブジェクトを引き取ることができます。コンソールへの出力は次のように標準出力にも、エラー出力にも書くことができます。

```csharp:Console
            if (configFile == null || !File.Exists(configFile?.FullName))
            {
                console.Error.WriteLine($"config file : {configFile} does not exists.");
                return 1;
            }
            console.Out.WriteLine($"executing.... Stage: {stage}, SourceVersion: {source}, TargetVersion : {target}, ConfigFile : {configFile?.FullName}");
     :

```

これが何が良いか？というと、このハンドラのユニットテストを書くと、次のような感じで書くことができます。つまり、コンソールのMockを渡して、エラーの時のメッセージを簡単にテストすることができます。

```csharp:TestSomething.cs
TestConsole console = new TestConsole();
var status = new UpdateAction().Execute(0, "3.0.15828", "3.0.15829", new FileInfo(wrongConfigPath), console);
console.StdErr().Flush();
string output = Encoding.UTF8.GetString(console.StdErr().ToArray());
            Assert.Contains($"config file : {wrongConfigPath} does not exists.", output);
```


```csharp:TestConsole.cs
    public class TestConsole :IConsole
    {
        public IStandardStreamWriter Out { get; } = new TestConsoleStandardOutputWriter();
        public bool IsOutputRedirected { get; }
        public IStandardStreamWriter Error { get; } = new TestConsoleStandardErrorWriter();
        public bool IsErrorRedirected { get; }
        public bool IsInputRedirected { get; }

        public MemoryStream StdOut()
        {
            return ((TestConsoleStandardOutputWriter) Out).Message;
        }

        public MemoryStream StdErr()
        {
            return ((TestConsoleStandardErrorWriter) Error).Message;
        }
    }

    public class TestConsoleStandardOutputWriter : IStandardStreamWriter
    {
        public MemoryStream Message { get; } = new MemoryStream();
        public void Write(string value)
        {
            Message.Write(Encoding.UTF8.GetBytes(value));
        }
    }

    public class TestConsoleStandardErrorWriter : IStandardStreamWriter
    {
        public MemoryStream Message { get; } = new MemoryStream();
        public void Write(string value)
        {
            Message.Write(Encoding.UTF8.GetBytes(value));
        }
    }
```

# おわりに

私のやりたいことは、大体 `System.CommandLine`で出来る感じなので、今後も使っていこうと思います。ちなみに、まだβなので、はよGAになって欲しいですね。