128
109

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Rust 2Advent Calendar 2020

Day 7

2020 年版 Command Line Tool を作ってみる in Rust

Last updated at Posted at 2020-12-06

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 を使って次のように記述します。

Cargo.toml
[dependencies]
...
anyhow = "1.0"
src/main.rs
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

すごい便利ですね :tada:

failure で用意されていたマクロも、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 というファイル名で作成します。

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 が書けるようにアプリケーションを作ってくぞー :cat:

128
109
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
128
109

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?