rustc(cargo)の出力をannotationに乗せる

GitHub Actionsで使える「Workflow Commands」に、コード中に警告などの表示を追加できる機能があるのでこれを活用してみました。

## おおまかなアクションの流れ

1. Rustコンパイラの出力をjsonで横取りする
1. Workflow Commandsの形式に変換する
1. 上記をかわりに出力する

## Rustコンパイラの出力をjsonで受けとる

https://doc.rust-lang.org/cargo/reference/external-tools.html#json-messages

rustc(cargo)にはコンパイラからのメッセージをjson形式で出力することができる不思議なオプションが存在します。
普段遣いではまず間違いなく使うことのないオプションですが、IDEプラグインだったり今回のようなCIなどの自動化パイプラインに組み込む際に役に立ちます。
このjsonフォーマットについて詳細に解説されたドキュメントは見当たりませんが、Rustでこれらをハンドリングする場合には[cargo_metadata](https://crates.io/crates/cargo_metadata)が使えます。他言語で実装する場合はここからスキーマを逆算する必要がありそうです。

rustcから出力されるjsonデータには、エラーメッセージやエラータイプのほかファイル内の該当箇所の範囲や補足情報といった部分についても細かく記載されています。
cargoから出力されるjsonデータはrustcのものに加えて、ビルド完了であったりアーティファクトについての詳細だったりの情報が含まれています。

## GitHubのAnnotationsとActionsのWorkflow Commands

![スクリーンショット 2021-08-13 220332.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94115/f9b616bc-7f8e-5829-bf60-3bed7f34b45a.png)

GitHub Actionsでは、特定のフォーマットで出力を行うとワークフロー上でのアクションの出力を指定したり上記画像のような自動生成コメントを追加したりすることができます。下記ドキュメントでは、warningとerrorコマンドの説明には「ログにそのメッセージを出力します」までしか書かれていませんが、Checks APIで使えるのとほぼ同じ形のアノテーションも付加されます。

https://docs.github.com/ja/actions/reference/workflow-commands-for-github-actions

## 変換レイヤーを作る

ここまでで必要な前提知識が揃いました。あとはrustcのjson形式からWorkflow Commandsの出力形式に変換するプログラムを作ればrustcの出力をGitHub上でAnnotationsとして表示させることができます。
これはstdoutの内容を変換するプログラムになるので、パイプして使う形でよいでしょう。

できあがったものがこちらになります。

https://github.com/Pctg-x8/cargo-json-gha-translator

ReleasesにはDockerのrustイメージ用（x86-64 GNU Linux向け）にビルドしたバイナリがおいてあります。アクション内でマルチステージビルドとかさせてもいいかなと思いますが、Rustのコンパイルはちょっと時間がかかる上にめったに更新されるものでもないのでバイナリを落としてくる形にしています。
（例: https://github.com/Pctg-x8/peridot/blob/2381df7bb780727cf793f78603af1bb35a765572/.github/actions/checkbuild-subdir/Dockerfile#L4）

内部でやっていることは単純で、`cargo_metadata::Message`を`gha::WorkflowCommand`に変換して出力しているだけです。`cargo_metadata::Message::parse_stream`という便利関数がありまして、これに`stdin`を食わせるだけでcargoの出力を逐次変換してくれます。

`gha::WorkflowCommand`は単にワークフローコマンドの構文をそのままモデリングしたものです。`Display`で実際の書式に変換しています。

```rust
pub enum WorkflowCommand {
    Warning {
        file: PathBuf,
        line: usize,
        col: usize,
        message: String,
    },
    Error {
        file: PathBuf,
        line: usize,
        col: usize,
        message: String,
    },
}
impl std::fmt::Display for WorkflowCommand {
    fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            Self::Warning {
                file,
                line,
                col,
                message,
            } => write!(
                fmt,
                "::warning file={},line={},col={}::{}",
                file.display(),
                line,
                col,
                message
            ),
            Self::Error {
                file,
                line,
                col,
                message,
            } => write!(
                fmt,
                "::error file={},line={},col={}::{}",
                file.display(),
                line,
                col,
                message
            ),
        }
    }
}
```

今回は`warning`と`error`だけ出力できれば十分なのでこの2つだけ用意しました。`cargo_metadata::Message`から`WorkflowCommand`に変換できないものがあるので`TryFrom`で変換するようにしています。

```rust
impl TryFrom<Message> for WorkflowCommand {
    type Error = ();

    fn try_from(base: Message) -> Result<Self, ()> {
        match base {
            Message::CompilerMessage(CompilerMessage {
                message:
                    Diagnostic {
                        level: DiagnosticLevel::Warning,
                        message,
                        mut spans,
                        ..
                    },
                ..
            }) if !spans.is_empty() => {
                let span = spans.pop().unwrap();

                Ok(Self::Warning {
                    file: std::env::current_dir()
                        .expect("Failed to get current dir")
                        .join(span.file_name),
                    line: span.line_start,
                    col: span.column_start,
                    message,
                })
            }
            Message::CompilerMessage(CompilerMessage {
                message:
                    Diagnostic {
                        level: DiagnosticLevel::Error,
                        message,
                        mut spans,
                        ..
                    },
                ..
            }) if !spans.is_empty() => {
                let span = spans.pop().unwrap();

                Ok(Self::Error {
                    file: std::env::current_dir()
                        .expect("Failed to get current dir")
                        .join(span.file_name),
                    line: span.line_start,
                    col: span.column_start,
                    message,
                })
            }
            _ => Err(()),
        }
    }
}
```

エラー型は用意するの面倒だったので`()`にしています。どうせ`.ok()`するし......
ファイルパスをわざわざフルパスにしているのは利用側の都合です[^peridot-ci]。

## おわり

おわりです。

[^peridot-ci]: サブディレクトリのモジュールをテストするのに`cd`で移動してからやっているので、フルパスにしないと正しいファイルにアノテーションがついてくれない