header image

枝折

Rust CLI チュートリアルをやってみる

CREATED: 2023 / 08 / 27 Mon

UPDATED: 2023 / 08 / 27 Mon

Rust CLI のドキュメントを辿ってみたので簡単にまとめてみる。

Rust CLI チュートリアル

このページ、日本語訳がなさそうな感じだったので抄訳的に解説してみます。 Command Line Applications in Rust

チュートリアルでは grep のクローンを作ります。 クローンでは、特定のファイルに指定された文字列が存在するかを確認し、存在する場合はその行を返却します。

こんな具合で呼び出せば、foobar を含んだ行がターミナルに表示されるイメージです。

$ grrs foobar test.txt
foobar

セットアップから

Rust のインストールとかは端折ります、調べればすぐ出てくるので。 ちなみに私の環境では rustc --version の出力は rustc 1.69.0 (84c898d65 2023-04-16) です。

まずはプロジェクトを作成します。 好きな場所で cargo new してください。

$ cargo new grrs
$ cd grrs/

上記のようにディレクトリに入ってから cargo run すると、コンパイルが始まって Hello, world! が出力されるかと思います。

コマンドライン引数を取り扱う

Rust でコマンドライン引数を認識するために、std::env::args() を利用します。 これは与えられた引数のイテレーターを提供します。

この中に入っている一番最初の要素はこのプログラムの名前、つまり今回でいえば grrs となります。 したがって、コマンドの利用者が引数として渡した値は添字0以降から取得することができます。

以下のようなコマンドを想定しているので、一つ目の引数はパターン、二つ目の引数は検査対象のファイルパスとなります。

$ grrs foobar test.txt

この引数を取得するには以下のように書くことになります。

let pattern = std::env::args().nth(1).expect("パターンが提供されていません");
let path = std::env::args().nth(2).expect("パスが提供されていません");

expect は値が取得できなかった場合に表示されます。 で、これをプログラムの中で利用するために struct にまとめると、以下のようになりそうです。

struct Cli {
    pattern: String,
    path: std::path::PathBuf,
}

これから引数をこねくり回していくことになるのですが、自力で引数をパースするより既存のライブラリを使えた方が楽ですよね。 なので clap というライブラリを使って引数をいい感じにパースしてもらいます。

cargo.toml に clap を追加してあげます

[dependencies]
clap = { version = "4.0", features = ["derive"] }

もしくは cargo add clap --features derive を実行してあげれば自動で cargo.toml に追加されるかと思います。 すると rust ファイルで use clap::Parser; を使えるようになります。

use clap::Parser;

#[derive(Parser)]
struct Cli {
    pattern: String,
    path: std::path::PathBuf,
}

Cli 構造体の上に #[derive(Parser)] と記述することで、引数のパースなどの細かい作業をすっ飛ばして必要な値だけを抽出できるようになります。 後は Cli::parse() の結果にその値が含まれるのでそれを使うだけです。

ここまでをまとめると概ね以下のような感じになります。 現状は args を使ってないので warning が表示されますが、#[allow(unused_variables)] を記述することで warning が表示されないようにしています。 unused_variables は lint と呼ばれるもので、コンパイル時に実行されエラーや警告をどのように表示するのかといったことを管理しています。 Lints - The rustc book

// src/main.rc
use clap::Parser;

#[derive(Parser)]
struct Cli {
    pattern: String,
    path: std::path::PathBuf,
}

#[allow(unused_variables)]
fn main() {
    let args = Cli::parse();
}

この状態で cargo run だけを実行すると、the following required arguments were not provided とエラーが表示されることでしょう。 引数を指定してくれよ、と。

バイナリにする前の cargo run での実行時では、-- の後に引数を渡すことができます。 したがって、cargo run -- first-arg second-arg としてあげれば、何も起きませんがとりあえず正常に実行され正常に終了することと思います。

機能の実装

これで CLI ツールを作成する前準備は整いました。 コマンドライン引数を受け取れるようになったので、この先の実装を書いていきます。 上述の通り、引数は let args = Cli::parse() で取得することができます。

それでは、第1引数で与えた文字列が第2引数で与えたファイルに存在するかどうかを確認してみましょう。 今回の CLI のコアとなる機能ですね。

fn main() {
    let args = Cli::parse();

    // ファイルの内容を取得
    let content = std::fs::read_to_string(&args.path).expect("ファイルが読み込めませんでした");

    // pattern に合致する行をファイルの中から取得して表示
    for line in content.lines() {
        if line.contains(&args.pattern) {
            println!("{}", line);
        }
    }
}

src と同じディレクトリ内に sample.txt などを作成し、好きな文字を入れておいてください。 で、cargo run -- hoge sample.txt といった感じでターミナルで実行すると、sample.txt に一致する文字列があれば其の行が返却されるかと思います。

これで機能はできました! あとは最後にテストをしたいと思います。

テストを書く

先に書いたコードの中でビジネスロジックとして存在しているのは以下の部分かと思います。

for line in content.lines() {
    if line.contains(&args.pattern) {
        println!("{}", line);
    }
}

この部分は現在 main() の中にあるので、テストしやすいように関数 find_matches に置き換えます。

fn find_matches(content: &str, pattern: &str) {
  for line in content.lines() {
      if line.contains(pattern) {
            println!("{}", line);
      }
  }
}

すると、main() 内での呼び出しはこんな感じになるかと思います。

fn main() {
    let args = Cli::parse();
    let content = std::fs::read_to_string(&args.path).expect("ファイルが読み込めませんでした");
    find_matches(&content, &args.pattern)
}

それでは find_matches をテストしてみましょう! と行きたいところですが、find_matches は現状何も戻り値を返さないのでこの関数の結果のアサーションを書くことができないです。

じゃあどうするかというと、この関数の外に結果を残せるようにします。 具体的には、println! の代わりに writeln! を利用することで、その出力を与えられた変数に保持します。

writeln!println! より抽象的な関数であり、writeln! の出力を標準出力に指定しているのが println! と考えれば良いかと思います。

println!("Hello world");

// 以下の writeln! は println! と同じ
use std::io::{self, BufRead, BufWriter, Write};
let mut stdout = io::stdout();
writeln!(stdout, "Hello world");

で、テストの時には標準出力ではなく、ベクターに出力を記録すれば、実行後にその中身を検証することができそうです。 まずは現状のコードにおける println!writeln! に置き換えてみましょう。

fn find_matches(content: &str, pattern: &str, mut writer: impl std::io::Write) {
  for line in content.lines() {
      if line.contains(pattern) {
          writeln!(writer, "{}", line);
      }
  }
}

fn main() {
    let args = Cli::parse();
    let content = std::fs::read_to_string(&args.path).expect("ファイルが読み込めませんでした");
    find_matches(&content, &args.pattern, &mut std::io::stdout())
}

これで cargo run しても以前と同じように動くようになっているかと思います。 では、テストの方に移ります。 ベクターを result 変数に代入し、これを find_matches の第3引数に指定します。 実行後はその result の中身を検証することでテストを行うわけです。

fn find_matches(content: &str, pattern: &str, mut writer: impl std::io::Write) {
  for line in content.lines() {
      if line.contains(pattern) {
          writeln!(writer, "{}", line);
      }
  }
}

#[test]
fn find_a_match() {
    let mut result = Vec::new();
    find_matches("lorem ipsum\ndolor sit amet", "lorem", &mut result);
    assert_eq!(result, b"lorem ipsum\n");
}

cargo test で実行するとテストが通るようになっているかと思います。

関数を別ファイルへ移動する

現状 find_matches 関数を main.rs に書いていますが、このようなビジネスロジックが増えるとファイルが肥大化して見通しが悪くなってしまう可能性があります。 こういう場合は関数を別ファイルへ移動してみましょう。

まず、main.rs がある src ディレクトリ内に lib.rs を作成します。 この lib.rspub を付与した find_matches を定義します。 pub を付与することで、他のファイルからも呼び出せるようにするわけです。

// src/lib.rs
pub fn find_matches(content: &str, pattern: &str, mut writer: impl std::io::Write) {
  for line in content.lines() {
      if line.contains(pattern) {
          writeln!(writer, "{}", line);
      }
  }
}

すると、src/main.rs からは grrs::find_matches でこの関数を呼び出すことができます。

// src/main.rs
fn main() {
    let args = Cli::parse();
    let content = std::fs::read_to_string(&args.path).expect("ファイルが読み込めませんでした");
    grrs::find_matches(&content, &args.pattern, &mut std::io::stdout())
}

同様にテストでも呼び出しを修正して、テストが通るか確認しておきましょう。

より複雑なテストを行う

CLI の呼び出しからテストする

CLI 自体を呼び出し、その出力を確認するにはどうすれば良いでしょう? assert_cmd というクレートを使えばこれを実現できます。

以下を cargo.toml に記載してみます。

[dev-dependencies]
assert_cmd = "2.0.12"
predicates = "3.0.3"

predicates はアサーションを書くために必要なクレートです。 テストは以下のように書きます。 tests/cli.rs にテストを書けば、cargo test を実行することでこれまでと同じようにテストすることができます。

// tests/cli.rs
use assert_cmd::prelude::*;
use predicates::prelude::*;
use std::process::Command;

#[test]
fn file_doesnt_exist() -> Result<(), Box<dyn std::error::Error>> {
    let mut cmd = Command::cargo_bin("grrs")?;

    cmd.arg("foobar").arg("test/file/doesnt/exist");
    cmd.assert()
        .failure()
        .stderr(predicate::str::contains("ファイルが読み込めませんでした"));

    Ok(())
}

これでテスト内から grrs CLI を呼び出し、その第1引数に 'foobar' を、第2引数に存在しないパスを入れることで、 「ファイルが読み込めませんでした」というメッセージを含んだ結果が返ってきているかどうかをテストしています。

テスト用のファイルを作成する

今回作成した CLI ではテストのためにファイルを用意する必要がありますが、ファイルを用意する手段としては以下が考えられるかと思います。

  • 自分でファイルを作成する
  • テスト内でファイルを作成する
  • これまでは自分でファイルを作って動作確認を行なっていましたが、ここではテストでファイルを作成する方法についても触れます。

    cargo.tomlassert_fs を追加してください。 このクレートはテストで利用するファイルのフィクスチャーやそのアサーションを提供します。

    [dev-dependencies]
    assert_fs = "1.0.13"
    use assert_fs::prelude::*;
    
    #[test]
    fn find_content_in_file() -> Result<(), Box<dyn std::error::Error>> {
        let file = assert_fs::NamedTempFile::new("sample.txt")?;
        file.write_str("hoge\nfoo\nbar")?;
    
        let mut cmd = Command::cargo_bin("grrs")?;
        cmd.arg("hoge").arg(file.path());
        cmd.assert()
            .success()
            .stdout(predicate::str::contains("hoge"));
    
        Ok(())
    }

    このテストではまず最初に sample.txt を作成し、その中に 'hoge\nfoo\nbar' を記載しています。 で、そのパスを第2引数に設定し、第1引数には 'hoge' を指定しています。 結果に 'hoge' が存在することを確認しテストを終了します。

    このように assert_fs を利用することでテスト内でファイルを作成し、これをテストに利用することができました。 テストに利用可能な便利なクレートは他にも様々ありますので、実際にテストを書きながら、あれができないかな、これができないかな、と調べてみるのが良いかと思います。

    を仕舞い

    Rust での CLI 作成についてでした。 チュートリアルの内容を簡単にまとめただけですが、これで仕舞いにします。 https://rust-cli.github.io/book/index.html

    リポジトリも公開しています。 https://github.com/yutaro1204/grrs_example