2 年前に書いた記事 Command Line Tool を作ってみる in Rust が今でも参照されることがあるようなので、2020 年版にアップデートした内容を書いていきます。
概要
この記事では Rust で Command Line Tool を作るときに、便利なライブラリ、ツール、そしてサービスを紹介します。主に CLI working group が取り組んでいる Command Line Applications in Rust(以後 Book と呼称)のアップデート内容が中心です。その他にプラスアルファして個人的に便利だと思うツールやサービスを紹介していきます。
こちらに完全なサンプルコードを公開しています。
見やすさの都合上、説明と直接関係のないコードや設定は省略して表示します。手元でビルドして確認したい場合はこちらのソースコードをダウンロードしてご確認ください。
エラーハンドリング
2018 年の時点では failure を紹介していましたが、このライブラリは非推奨となりました。
Book ではエラーハンドリングでよく利用されるライブラリの説明を anyhow に変更しています。
failure を使っている場合は、failure::Error の代わりに anyhow::Result を使って次のように記述します。
[dependencies]
...
anyhow = "1.0"
use percent_encoding::percent_decode;
use anyhow::Result;
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = &args[1];
Ok(println!("{}", decode(input)?))
}
fn decode(input: &str) -> Result<String> {
let decoded = percent_decode(input.as_bytes()).decode_utf8()?;
Ok(decoded.to_string())
}
anyhow::Result は pub type Result<T, E = Error> = core::result::Result<T, E>;
と定義されているため、多くの場合は Result<T, E = Error>
の T
に Return したい型を指定するだけです。
私の場合、failure を利用する際には、pub type Result<T> = std::result::Result<T, Error>;
をプロジェクトごとに定義していました。ですので anyhow を利用するほうが少しだけシンプルになりました。
試しに不正な文字列を引数に渡して実行します。すると次のようなエラーメッセージが表示されます。
❯❯ cargo run '%93%FA%96%7B%8C%EA%0D%0A'
Compiling url v0.2.0 (/Users/watawuwu/dev/src/github.com/watawuwu/qiita-sample-url-tool-2020)
Finished dev [unoptimized + debuginfo] target(s) in 0.55s
Running `target/debug/url '%93%FA%96%7B%8C%EA%0D%0A'`
Error: invalid utf-8 sequence of 1 bytes from index 0
with_context メソッドを利用すると、エラーが発生したときに遅延評価されるコンテキスト(エラーメッセージ)を追加できます。
...
use anyhow::{Context, Result};
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = &args[1];
Ok(println!("{}", decode(input).with_context(|| String::from("Failed decode"))?))
}
...
エラーメッセージは次のようになります。
❯❯ cargo run '%93%FA%96%7B%8C%EA%0D%0A'
Compiling url v0.2.0 (/Users/watawuwu/dev/src/github.com/watawuwu/qiita-sample-url-tool-2020)
Finished dev [unoptimized + debuginfo] target(s) in 0.50s
Running `target/debug/url '%93%FA%96%7B%8C%EA%0D%0A'`
Error: Failed decode
Caused by:
invalid utf-8 sequence of 1 bytes from index 0
percent_decode 関数にも with_context メソッドをチェインしてコンテキストを追加すると、エラーメッセージは次のようになります。
❯❯ cargo run '%93%FA%96%7B%8C%EA%0D%0A'
Finished dev [unoptimized + debuginfo] target(s) in 0.04s
Running `target/debug/url '%93%FA%96%7B%8C%EA%0D%0A'`
Error: Failed decode
Caused by:
0: Failed percent_decode
1: invalid utf-8 sequence of 1 bytes from index 0
すごい便利ですね
failure で用意されていたマクロも、anyhow に同じような機能が用意されています。
- failure::bail -> anyhow::bail
- failure::ensure -> anyhow::ensure
- failure::format_err -> anyhow::anyhow
引数処理
2018 年時点では、 引数を宣言的に定義するライブラリとして structopt を紹介しました。
structopt はもともと clap を内部で利用しており、現在は clap のみで宣言的に引数を定義できるように改修が進められています。
この機能は v3 でリリースされる予定ですが、2020 年 12 月時点では beta.2 のリリースに止まっています。Tracking Issue を見るとまだ課題は多そうです。
Tracking Issue for 3.x · Issue #1037 · clap-rs/clap
ある程度 Rust を知っており、余裕があるかたは clap の beta を積極的に利用し、不具合があればコミュニティに報告したいところです。その他の方は現在も structopt の利用がおすすめです。
この他にも Github の rust-cli Organization 配下に便利なクレートがあります。climake は依存クレートがない軽量な CLI ライブラリです。
ただし継続的にメンテナンスされるかは不明ですので必要に応じて調査をしてください。同じく rust-cli Organization の paw の場合、discord での会話ではメンテナンスの熱量が小さいように見受けられました。同じようにメンテナンスされないクレートが出てくることもあるかと思うので状況に応じて検討が必要です。
ロギング
ロギングについて Book に記載されていることは多くありません。私は現在も pretty_env_logger を便利に利用しています。ですので特に更新内容はないのですが、便利なクレートを見つけたので紹介します。
femme は debug ビルド時はカラー付きの読みやすい形式で出力し、 release ビルド時は ndjson 形式で出力します。機械的にログをパースしたい場合は便利です。
ですが Command Line Tool で必要になることは少ないかもしれません。
# debug モード
❯❯ cargo run 'foo%20bar'
Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/sample 'foo%20bar'`
sample foo bar
# release モード
❯❯ cargo run --release 'foo%20bar'
...
Finished release [optimized] target(s) in 13.09s
Running `target/release/sample 'foo%20bar'`
{"level":30,"time":1605951598313,"msg":"foo bar"}
終了コード(もしくは終了ステータス)
前回のプログラムでは終了コードを std::process::exit
の引数にハードコーディングしていました。
小さいプログラムや main 関数の中で終了コードをハンドリングできている場合はそれで十分だと思います。マジックナンバーを避けたいのであれば定数に定義できます。
Book では終了コードの利用に exitcode が紹介されています。このクレートでは BSD で定義されている sysexits.h
の終了コードが定義されています。
私の場合、BSD を使っていないからなのか、ここで定義された終了コードを見かけることは多くありません。ですので終了コードを合わせるモチベーションは低いです。可能な限り 成功:0
か 失敗:1
で表現し、必要に応じて終了コードを CLI ごとに定義する方針にしています。
Rust 1.26 以降では main 関数の戻り値に Result 型が利用できるようになっており、Ok の場合は EXIT_SUCCESS、Err の場合は EXIT_FAILURE にマップされるため、std::process::exit
を直接利用しない方法もあります。
タスクランナー
build system と package manager の機能を持つ cargo は素晴らしいツールです。ですがプロジェクトごとに RUST_LOG
環境を設定したり、test サブコマンドに --nocapture
などのオプションを追加したりしたくなります。そんな時はやはりタスクランナーがあると便利です。
前回紹介した Makefile は今でも活用しています。しかし Makefile には build system としての機能が多く、タスクランナーとして使うには .PHONY
の指定など不便な点もあります。慣れてしまえば問題ないものの癖が強い規約もあります。そこで前回はリンクだけ紹介した次の 2 つのタスクランナーで Makefile を書き直してみます。
Makefile ファイル版はこちらです。
just
just は make に影響を受けた、プロジェクト固有のコマンドを定義するために作られたタスクランナーです。
Makefile と構文が似ていますが、 .PHONY
の指定が不要になり簡潔になっています。専用に作成していた help タスクもコメント形式で記述でき、jsut --list
でタスクの一覧が表示できます。makefile モードを実装したエディタであれば、同じシンタックスハイライトが利用できます。
VS Code には extension が用意されており、code --install-extension skellock.just
でインストールできます。
just --completions
で補完スクリプトも生成できるため、Makefile と同じ操作感で運用ができそうです。
タスクの定義ファイルは justfile
というファイル名で作成します。ファイル名は case insensitive です。
# Local Variables:
# mode: makefile
# End:
# vim: set ft=make :
set shell := ["/bin/bash", "-c"]
name := "url"
log_level := "debug"
log := name + "=" + log_level
prefix := env_var("HOME") + "/.cargo"
cargo_sub_options := ""
app_args := "foo%20bar"
export RUST_LOG := log
export RUST_BACKTRACE := "1"
alias b := build
alias r := run
alias c := check
alias t := test
alias l := lint
# Execute a main.rs
run args=app_args opt=cargo_sub_options:
cargo run {{ opt }} -- {{ args }}
# Run the tests
test opt=cargo_sub_options:
cargo test {{ opt }} -- --nocapture
# Check syntax, but don't build object files
check opt=cargo_sub_options:
cargo check {{ opt }}
# Build all project
build opt=cargo_sub_options:
cargo build {{ opt }}
# Build all project
release-build:
just build --release
# Remove the target directory
clean:
cargo clean
# Install to $(prefix) directory
install pre=prefix:
cargo install --force --root {{ pre }} --path .
# Run fmt
fmt:
cargo fmt
# Run clippy
clippy:
cargo clippy
# Run fmt and clippy
lint: fmt clippy
操作コマンドも直感的に利用できます。
# タスクの一覧表示
❯❯ just --list
Available recipes:
build opt=cargo_sub_options # Build all project
b opt=cargo_sub_options # alias for `build`
check opt=cargo_sub_options # Check syntax, but don't build object files
c opt=cargo_sub_options # alias for `check`
clean # Remove the target directory
clippy # Run clippy
fmt # Run fmt
install pre=prefix # Install to $(prefix) directory
lint # Run fmt and clippy
l # alias for `lint`
release-build # Build all project
run args=app_args opt=cargo_sub_options # Execute a main.rs
r args=app_args opt=cargo_sub_options # alias for `run`
test opt=cargo_sub_options # Run the tests
t opt=cargo_sub_options # alias for `test`
# テストタスクの実行
❯❯ just test
cargo test -- --nocapture
Compiling url v0.2.0 (/Users/watawuwu/dev/src/github.com/watawuwu/qiita-sample-url-tool-2020)
Finished test [unoptimized + debuginfo] target(s) in 1.28s
Running target/debug/deps/url-135ad7e3b9afd092
running 3 tests
test tests::decode_ascii_ok ... ok
test tests::decode_space_ok ... ok
test tests::decode_invalid_utf8_ng ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
# alias 登録したビルドタスクの実行
❯❯ just b
cargo build
Finished dev [unoptimized + debuginfo] target(s) in 0.12s
# 引数の指定
❯❯ just run baz%20qux
cargo run -- baz%20qux
Finished dev [unoptimized + debuginfo] target(s) in 0.04s
Running `target/debug/url 'baz%20qux'`
DEBUG url > opt: Opt { input: Some("baz%20qux") }
baz qux
Microsoft の Krustlet でも使われています。
cargo-make
cargo-make
はタスクを toml で定義し、マルチプラットフォームに対応できるタスクランナーです。 DSL ではないため toml に対応したエディタであればシンタックスハイライトの表示ができます。ただ、現在のところ bash や zsh の入力補完を生成する機能が見つかりません。補完入力がクセになっている私にとってはつらいところです。
タスクの定義ファイルは Makefile.toml
というファイル名で作成します。
[env]
RUST_LOG = "url=log"
RUST_BACKTRACE = "1"
INSTALL_PREFIX = "${HOME}/.cargo"
APP_ARGS = "foo%20bar"
[tasks.run]
command = "cargo"
args = ["run", "${APP_ARGS}"]
[tasks.install]
command = "cargo"
args = ["install", "--force", "--root", "${INSTALL_PREFIX}", "--path", "."]
[tasks.lint]
dependencies = ["format", "clippy"]
設定しているタスクが少ないことにお気づきでしょうか。cargo-make
では事前に定義されているタスクが用意されています。ですので今回は事前定義されていないタスクだけを設定しています。
事前定義されたタスクの一覧は makers --list-all-steps
で確認できます。またドキュメントからも参照できるので、利用する場合は一度確認することをお薦めします。
数が多すぎて挙動までは確認していませんが、CI 用にタスクが定義されており便利です。この事前に定義されているタスクだけでも cargo-make
を使う理由になりそうです。
# 引数を利用することもできるが、デフォルト値を使いたい場合は環境変数?
❯❯ makers run -e APP_ARGS="baz%20qux"
[cargo-make] INFO - makers 0.32.9
[cargo-make] INFO - Project: url
[cargo-make] INFO - Build File: Makefile.toml
[cargo-make] INFO - Task: run
[cargo-make] INFO - Profile: development
[cargo-make] INFO - Execute Command: "cargo" "run" "baz%20qux"
Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/url 'baz%20qux'`
warning: invalid logging spec 'log', ignoring it
baz qux[cargo-make] INFO - Build Done in 0 seconds.
# もしくは cargo のサブコマンドで実行する
❯❯ cargo make run -e APP_ARGS="baz%20qux"
事前に用意されたタスクだけで十分な場合、または逆にタスクランナーで複雑なことをやりたい場合は cargo-make
が便利だと思います。
以上でタスクランナーの紹介は終わりです。タスクランナーはどれか一択という感じではなく要件に合わせて使いやすいものを選択、または使用しないなどの検討をするのが良さそうです。
CI(Continuous Integration)
CI については Rust のインフラが GitHub Actions に移行しました。今後は Github Actions でのノウハウが増えていくことに期待できます。では Travis CI から GitHub Actions に移行していきます。
Rust's CI is moving to GitHub Actions | Inside Rust Blog
前回紹介したようにクロスプラットフォームであれば、引き続き rust-embedded/cross が便利です。今回は依存する Action が少ないシンプル版と、Action を活用するケースの 2 パターンを紹介します。
シンプル版
Github Actions で利用できる OS イメージにはあらかじめ Rust の Tools がインストールされています。細かいセットアップが必要なければ少ない手順で CI が実現できます。
virtual-environments/Ubuntu2004-README.md
name: Test
on:
push:
paths-ignore:
- '*.md'
- '*.sh'
- 'Dockerfile'
- 'Makefile'
- 'LICENSE-*'
tags-ignore:
- '*.*.*'
# workaround https://github.community/t5/GitHub-Actions/Using-on-push-tags-ignore-and-paths-ignore-together/td-p/38559
branches:
- '**'
jobs:
simple-test:
strategy:
matrix:
target:
- x86_64-unknown-linux-gnu
- x86_64-unknown-linux-musl
- x86_64-apple-darwin
- x86_64-pc-windows-msvc
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: x86_64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
runs-on: ${{ matrix.os }}
steps:
- name: Setup code
uses: actions/checkout@v2
# Work around https://github.com/actions/cache/issues/403 by using GNU tar instead of BSD tar.
- name: Install GNU tar
if: matrix.os == 'macos-latest'
run: |
brew install gnu-tar
echo PATH="/usr/local/opt/gnu-tar/libexec/gnubin:$PATH" >> $GITHUB_ENV
- name: Setup Rust toolchain
run: |
rustup component add rustfmt clippy
rustup target add ${{ matrix.target }}
# https://github.com/actions/cache/blob/master/examples.md#rust---cargo
- name: Cache cargo files
uses: actions/cache@v2
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Test
run: |
cargo test --target=${{ matrix.target }} --all-features
- name: Check format
run: |
cargo fmt --all -- --check
- name: Lint
run: |
cargo clippy --all-features -- -D warnings
現状はこちらの issue により、macOS で cache するとライブラリが破損する可能性があります。GNU の tar を使うことで回避できるので、Install GNU tar
ステップでインストールを実行しましょう。その他に注意することはありません。cargo コマンドを使って test、fmt、clippy を実行するだけのシンプルな CI が簡単に定義できます。
次は非公式な Action である actions-rs を紹介します。
特に cross を使う場合は use-cross: true
などで切り替えられるので、こちらの Action を使うと便利です。
...
jobs:
test:
strategy:
matrix:
target:
- x86_64-unknown-linux-gnu
- x86_64-unknown-linux-musl
- x86_64-apple-darwin
- x86_64-pc-windows-msvc
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: x86_64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
runs-on: ${{ matrix.os }}
steps:
- name: Setup code
uses: actions/checkout@v2
# Work around https://github.com/actions/cache/issues/403 by using GNU tar instead of BSD tar.
- name: Install GNU tar
if: matrix.os == 'macos-latest'
run: |
brew install gnu-tar
echo PATH="/usr/local/opt/gnu-tar/libexec/gnubin:$PATH" >> $GITHUB_ENV
- name: Setup Rust toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
target: ${{ matrix.target }}
override: true
components: rustfmt, clippy
# https://github.com/actions/cache/blob/master/examples.md#rust---cargo
- name: Cache cargo files
uses: actions/cache@v2
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Test
uses: actions-rs/cargo@v1
with:
command: test
args: --target=${{ matrix.target }}
# target によっては use-cross を使うことを検討
#use-cross: true
- name: Check format
uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
- name: Run lint
uses: actions-rs/cargo@v1
with:
command: clippy
args: --all-features -- -D warnings
他にも別のリポジトリで使っている Security check や Coverage、Release などの Workflow もサンプルコードに置いてあるので、興味があれば参考にしてください。
その他
他にも設定ファイルの読み込みには rust-cli/confy が便利そうです。書き込みパスの場所については課題もあるようなので使用する前には issue を確認してみてください。その他にも依存している directories リポジトリが archived になっています。この辺りの仕様も変更になるかもしれません。
最後に
次は Web Application in Rust が書けるようにアプリケーションを作ってくぞー