CLOVER🍀

That was when it all began.

RustのGetting Started「Command line apps in Rust」を試す

これは、なにをしたくて書いたもの?

Rustの勉強を進めようとしていてドキュメントを眺めていたら、ちょうど入門によさそうなものがあったのでこちらをやってみることに
しました。

Getting started - Command Line Applications in Rust

CLIなんてとっても自分向きです(笑)。

Command line apps in Rust

今回の対象のドキュメントはこちらです。

Getting started - Command Line Applications in Rust

簡単なCLIを作るということで、コマンドライン引数の解析からエラー処理、テストまで入っています。

これを進めてRustに慣れていきましょう。

環境

今回の環境はこちら。

$ rustup --version
rustup 1.27.1 (54dd3d00f 2024-04-24)
info: This is the version for the rustup toolchain manager, not the rustc compiler.
info: The currently active `rustc` version is `rustc 1.83.0 (90b35a623 2024-11-26)`

進めてみる

こちらに沿って進めていきたいと思います。このドキュメントは「15分でコマンドラインアプリケーションを作る」というセクションと
「詳細なトピック」の2つのセクションで構成されていますが、今回は前半のみを扱いたいと思います。

A command line app in 15 minutes - Command Line Applications in Rust

今回はgrrsというコマンドを作るようです。grepコマンドのようですね。

$ cat test.txt
foo: 10
bar: 20
baz: 30
$ grrs foo test.txt
foo: 10
$ grrs --help
[some help text explaining the available options]

ちなみにRustのドキュメント内にも、grepをテーマにCLIを作成するものがあります。

An I/O Project: Building a Command Line Program - The Rust Programming Language

同じ内容と思いきや、だいぶ内容が違ったりします。こちらも必要に応じて見ていこうと思います。

プロジェクトを作成する

プロジェクトの作成から。

$ cargo new grrs
$ cd grrs

Cargo.toml

[package]
name = "grrs"
version = "0.1.0"
edition = "2021"

[dependencies]

Project setup - Command Line Applications in Rust

コマンドライン引数を解析する

次はコマンドライン引数を解析します。

Parsing command line arguments - Command Line Applications in Rust

コマンドライン引数自体はstd::env::argsで取得するようです。

args in std::env - Rust

とりあえず書き写してみます。

src/main.rs

fn main() {
    let pattern = std::env::args().nth(1).expect("no pattern given");
    let path = std::env::args().nth(2).expect("no path given");

    println!("pattern: {:?}, path {:?}", pattern, path);
}

cargo runで実行する時にアプリケーションにコマンドライン引数を与える時は、--の後に記述するようです。

$ cargo run -- some-pattern some-file

こうなりました。

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/grrs some-pattern some-file`
pattern: "some-pattern", path "some-file"

直接実行する場合はこちら。

$ target/debug/grrs some-pattern some-file
pattern: "some-pattern", path "some-file"

コマンドライン引数を指定しなかったらどうなるんでしょうか?

$ cargo run

panickedになりました。またexpectで指定したメッセージが表示されています。

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/grrs`
thread 'main' panicked at src/main.rs:2:43:
no pattern given
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

これはパニックと呼ぶらしく、Rustでは回復不能なエラーが発生した時にアプリケーションを終了させるようです。

Unrecoverable Errors with panic! - The Rust Programming Language

nthというのはstd::env::argsの戻り値であるstd::env::Argsが実装しているstd::iter::Iteratorトレイトのもののようです。

Args in std::env - Rust

Iterator in std::iter - Rust

そしてIterator#nthの戻り値はOption列挙型であり、expectOptionNoneの時にはパニックとするようです。

Option in std::option - Rust

ちなみに、nthを0にするとどうなるのでしょう?

src/main.rs

fn main() {
    let arg0 = std::env::args().nth(0).expect("no arg?");
    println!("{:?}", arg0);
}

実行ファイルのパスが設定されているようです。

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/grrs`
"target/debug/grrs"

これには依存しない方がよい、とされているようですが。

The first element is traditionally the path of the executable, but it can be set to arbitrary text, and might not even exist. This means this property should not be relied upon for security purposes.

args in std::env - Rust

またイテレーターというのであれば、ループして取得もできそうです。

Control Flow - The Rust Programming Language

src/main.rs

fn main() {
    // let pattern = std::env::args().nth(1).expect("no pattern given");
    // let path = std::env::args().nth(2).expect("no path given");

    // println!("pattern: {:?}, path {:?}", pattern, path);

    for arg in std::env::args() {
        println!("arg: {:?}", arg);
    }
}

できましたね。

$ cargo run -- some-pattern some-file
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/grrs some-pattern some-file`
arg: "target/debug/grrs"
arg: "some-pattern"
arg: "some-file"

完全に脱線しました。話を戻しましょう。

コマンドライン引数は構造体として持つことにするようです。

Defining and Instantiating Structs - The Rust Programming Language

導入してみました。

src/main.rs

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

fn main() {
    let pattern = std::env::args().nth(1).expect("no pattern given");
    let path = std::env::args().nth(2).expect("no path given");

    let args = Cli {
        pattern,
        path: std::path::PathBuf::from(path),
    };

    println!("pattern: {:?}, path: {:?}", args.pattern, args.path);
}

ドキュメントでは、ここでclapというライブラリーを使うことになっています。クレート、でよいんでしょうか?

clap - Rust

追加してみましょう。

$ cargo add clap --features derive

Cargo.tomlはこうなりました。

Cargo.toml

[package]
name = "grrs"
version = "0.1.0"
edition = "2021"

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

--features deriveというのは、この後で構造体に付与する#[derive(Parser)]を使うために必要なようです。

clap::_features - Rust

ソースコードはこう変更。

src/main.rs

use clap::Parser;

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

fn main() {
    /*
    let pattern = std::env::args().nth(1).expect("no pattern given");
    let path = std::env::args().nth(2).expect("no path given");

    let args = Cli {
        pattern,
        path: std::path::PathBuf::from(path),
    };
     */

    let args = Cli::parse();

    println!("pattern: {:?}, path: {:?}", args.pattern, args.path);
}

#[...]の記述は、属性と呼ぶらしいです。

実行結果。

$ cargo run -- some-pattern some-file
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/grrs some-pattern some-file`
pattern: "some-pattern", path: "some-file"

引数を与えなかった場合は、引数が足りないとCLIっぽいエラーになりました。

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/grrs`
error: the following required arguments were not provided:
  <PATTERN>
  <PATH>

Usage: grrs <PATTERN> <PATH>

For more information, try '--help'.
ファイルを読み込む

ファイルの内容を読み込みます。

First implementation - Command Line Applications in Rust

ファイルの読み込みにはstd::fs::read_to_stringを使うようです。

read_to_string in std::fs - Rust

そして読み込んだファイルの各行をループしてstd::string::String::containsで絞り込みます。

Control Flow - The Rust Programming Language

String in std::string - Rust

結果、こうなりました。

src/main.rs

use clap::Parser;

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

fn main() {
    let args = Cli::parse();
    let content = std::fs::read_to_string(&args.path).expect("cound not read file");

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

td::fs::read_to_stringstd::string::String::containsを呼び出す時に、変数そのものを渡すのではなく参照を渡しています。

References and Borrowing - The Rust Programming Language

それぞれのAPIのドキュメントを見ると、確かに参照を期待していますね。

read_to_string in std::fs - Rust

String in std::string - Rust

letを検索キーワードにして動かしてみましょう。

$ cargo run -- let src/main.rs
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/grrs let src/main.rs`
    let args = Cli::parse();
    let content = std::fs::read_to_string(&args.path).expect("cound not read file");

よさそうですね。

ところで試してみると、std::fs::read_to_stringに関しては参照を渡さなくても呼び出せたりします(std::string::String::containsコンパイル
通らなくなります)。

    let content = std::fs::read_to_string(args.path).expect("cound not read file");

チュートリアルに書かれたコードを見ていて、「どういう時に参照を渡すんだろう?」と思ったりもするのですが、ここで参照を渡さずに
後続の処理で同じ変数を使った場合はコンパイルが通らなくなりました。

    let content = std::fs::read_to_string(args.path).expect("cound not read file");

    println!("{:?}", args.path);

こういうエラーメッセージが表示されます。

error[E0382]: borrow of moved value: `args.path`
  --> src/main.rs:13:22
   |
11 |     let content = std::fs::read_to_string(args.path).expect("cound not read file");
   |                                           --------- value moved here
12 |
13 |     println!("{:?}", args.path);
   |                      ^^^^^^^^^ value borrowed here after move
   |
   = note: move occurs because `args.path` has type `PathBuf`, which does not implement the `Copy` trait
   = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0382`.
error: could not compile `grrs` (bin "grrs") due to 1 previous error

「所有権が移った」らしいです。

Understanding Ownership - The Rust Programming Language

What is Ownership? - The Rust Programming Language

ここで参照に戻すと、その後でも使えるようになります。

    let content = std::fs::read_to_string(&args.path).expect("cound not read file");

    println!("{:?}", args.path);

…所有権まわりの話はまたいずれ見ていきましょう。

エラー処理

エラー処理について。

Nicer error reporting - Command Line Applications in Rust

先ほどのコードでは、std::fs::read_to_stringの呼び出し後にexpectを使っていました。

    let content = std::fs::read_to_string(&args.path).expect("cound not read file");

ここで返ってきているのはOption列挙型ではなくResult列挙型です。

Result in std::io - Rust

Result in std::result - Rust

Resultは、Rustでエラーが発生するかもしれない操作を扱うものらしいです。

Recoverable Errors with Result - The Rust Programming Language

こちらのコードは

    let content = std::fs::read_to_string(&args.path).expect("cound not read file");

以下のようにも書けるのですが

    let content = std::fs::read_to_string(&args.path).unwrap();

これで存在しないファイルを指定してアプリケーションを実行すると、calledResult::unwrap()on anErrvalueと表示してパニックになります。

$ cargo run -- let src/not-found.rs
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/grrs let src/not-found.rs`
thread 'main' panicked at src/main.rs:12:55:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

expectを使っていると、この表示が指定した文字列に置き換わるようですね。

$ cargo run -- let src/not-found.rs
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/grrs let src/not-found.rs`
thread 'main' panicked at src/main.rs:11:55:
cound not read file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

で、Resultを直接扱うようにすると以下のようにパターンマッチできるようになり、

    let result = std::fs::read_to_string(&args.path);

    match result {
        Ok(content) => {
            println!("File contnet: {}", content);
        }
        Err(error) => {
            println!("Oh noes: {}", error);
        }
    }

Patterns and Matching - The Rust Programming Language

さらに後続の処理で使おうとするとこうなります。

    let content = match result {
        Ok(content) => content,
        Err(error) => {
            panic!("Can't deal with {}, just exit here", error);
        }
    };

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

なお、Errでマッチしたケースで後続の処理に移るようになっていると(println!を使ったままだと)コンパイルエラーになります。

これで存在しないファイルを指定すると、こうなりました。

$ cargo run -- let src/not-found.rs
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/grrs let src/not-found.rs`
thread 'main' panicked at src/main.rs:16:13:
Can't deal with No such file or directory (os error 2), just exit here
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

明示的にpanic!を呼び出すのではなく、OkErrorを返すようにしてもよいみたいです。その場合はmain関数の戻り値が変わります。

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = Cli::parse();
    let result = std::fs::read_to_string(&args.path);

    let content = match result {
        Ok(content) => content,
        Err(error) => {
            // panic!("Can't deal with {}, just exit here", error);
            return Err(error.into());
        }
    };

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

    return Ok(());
}

結果。

$ cargo run -- let src/not-found.rs
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/grrs let src/not-found.rs`
Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }

ドキュメントでは最後はOk(())のみになっていて、この場合はセミコロンは不要でした…。

    // return Ok(());
    Ok(())

関数の最後の評価値を戻り値にする場合は、returnセミコロンは不要なようです。

Functions - The Rust Programming Language

Rustではこちら(関数の最後の評価値を戻り値にする場合はreturnセミコロンも書かない)が通例になるようです。

このmatchで書いているErrorを返す処理を?演算子を使うことで短縮して書けるようです。

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = Cli::parse();
    // let result = std::fs::read_to_string(&args.path);

    /*
    let content = match result {
        Ok(content) => content,
        Err(error) => {
            // panic!("Can't deal with {}, just exit here", error);
            return Err(error.into());
        }
    };
     */

    let content = std::fs::read_to_string(&args.path)?;

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

    Ok(())
}

Recoverable Errors with Result - The Rust Programming Language

次は、このエラーメッセージを改善します。

Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }

独自のStringを引数に取る構造体を定義。

#[derive(Debug)]
struct CustomError(String);

fn main() -> Result<(), CustomError> {
    let args = Cli::parse();

    let content = std::fs::read_to_string(&args.path)
        .map_err(|err| CustomError(format!("Error reading `{:?}`: {}", args.path, err)))?;

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

    Ok(())
}

Result::map_errは、ResultErrだった場合の変換関数のようです。

    let content = std::fs::read_to_string(&args.path)
        .map_err(|err| CustomError(format!("Error reading `{:?}`: {}", args.path, err)))?;

Result in std::result - Rust

ここで独自の構造体に変換しています。

この結果、main関数の戻り値の型も変わります。

fn main() -> Result<(), CustomError> {

実行結果。

$ cargo run -- let src/not-found.rs
warning: field `0` is never read
  --> src/main.rs:10:20
   |
10 | struct CustomError(String);
   |        ----------- ^^^^^^
   |        |
   |        field in this struct
   |
   = help: consider removing this field
   = note: `CustomError` has a derived impl for the trait `Debug`, but this is intentionally ignored during dead code analysis
   = note: `#[warn(dead_code)]` on by default

warning: `grrs` (bin "grrs") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.07s
     Running `target/debug/grrs let src/not-found.rs`
Error: CustomError("Error reading `\"src/not-found.rs\"`: No such file or directory (os error 2)")

なんか警告されていますが、今はひとまず置いておきます。

この方法ではエラーの原因が失われるので、これを解決するにはanyhowというクレートを使うようです。

$ cargo add anyhow

anyhow - Rust

Cargo.toml

[package]
name = "grrs"
version = "0.1.0"
edition = "2021"

[dependencies]
anyhow = "1.0.95"
clap = { version = "4.5.23", features = ["derive"] }

最終的にこうなりました。

src/main.rs

use anyhow::{Context, Result};
use clap::Parser;

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

fn main() -> Result<()> {
    let args = Cli::parse();

    let content = std::fs::read_to_string(&args.path)
        .with_context(|| format!("could not read file `{}`", args.path.display()))?;

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

    Ok(())
}

with_context関数はanyhowのトレイトの持つもののようなのですが。

    let content = std::fs::read_to_string(&args.path)
        .with_context(|| format!("could not read file `{}`", args.path.display()))?;

Context in anyhow - Rust

しかもanyhow::Contextがないと呼び出すことができません(直接は使っていないのに)。

use anyhow::{Context, Result};

今はちょっと置いておきましょう。

実行結果。

$ cargo run -- let src/not-found.rs
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s
     Running `target/debug/grrs let src/not-found.rs`
Error: could not read file `src/not-found.rs`

Caused by:
    No such file or directory (os error 2)
出力

println!マクロや進捗バー、ログなどについて。

Output for humans and machines - Command Line Applications in Rust

今回はあまり触れないことにします。

{:?}デバッグ表現であることや、#[derive(Debug)]の説明などもありました。

Debug in std::fmt - Rust

進捗バーについてはこちらのクレートが、

https://crates.io/crates/indicatif

ログについてはこのあたりのクレートが紹介されています。

https://crates.io/crates/log

https://crates.io/crates/env_logger

https://crates.io/crates/clap-verbosity-flag

テスト

最後はテストです。

Testing - Command Line Applications in Rust

Rustではテストの仕組みが組み込まれているようです。

Writing Automated Tests - The Rust Programming Language

テスト対象はこの部分にするようです。

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

このままでは、処理がmain関数の中に埋め込まれている、結果を標準出力に直接書き出している、ということでテストしにくいので
以下のように変更します。

src/main.rs

use anyhow::{Context, Result};
use clap::Parser;

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

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

    Ok(())
}

fn main() -> Result<()> {
    let args = Cli::parse();

    let content = std::fs::read_to_string(&args.path)
        .with_context(|| format!("could not read file `{}`", args.path.display()))?;

    find_matches(&content, &args.pattern, &mut std::io::stdout())?;

    Ok(())
}

別関数に処理を切り出しました。mutは可変であること、implは引数がstd::io::Writeトレイトを実装した任意の型、を指すようです。

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

    Ok(())
}

Write in std::io - Rust

ドキュメントではこの関数に戻り値はないのですが、コンパイルすると「Errを無視するな」と言われるのでこのようにしました。

warning: unused `Result` that must be used
  --> src/main.rs:13:13
   |
13 |             writeln!(writer, "{}", line);
   |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
   = note: this warning originates in the macro `writeln` (in Nightly builds, run with -Z macro-backtrace for more info)

呼び出し側はこうなります。

    find_matches(&content, &args.pattern, &mut std::io::stdout())?;

std::io::stdoutから返されるStdout構造体は、std::io::Writeトレイトを実装しています。

stdout in std::io - Rust

Stdout in std::io - Rust

実行結果。

$ cargo run -- let src/main.rs
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/grrs let src/main.rs`
    let args = Cli::parse();
    let content = std::fs::read_to_string(&args.path)


$ cargo run -- let src/not-found.rs
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/grrs let src/not-found.rs`
Error: could not read file `src/not-found.rs`

Caused by:
    No such file or directory (os error 2)

テストコードはこうなりました。

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

#[test]属性をつけるとテストコードとして扱われるようです。

assert_eqはマクロですね。

assert_eq in std - Rust

テストはcargo testで実行します。

$ cargo test

結果。

    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.14s
     Running unittests src/main.rs (target/debug/deps/grrs-6e825b1a2d0a6d74)

running 1 test
test find_a_match ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

間違った結果にした時に、テストが失敗することも確認しておきましょう。

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

結果。

$ cargo test
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running unittests src/main.rs (target/debug/deps/grrs-6e825b1a2d0a6d74)

running 1 test
test find_a_match ... FAILED

failures:

---- find_a_match stdout ----
thread 'find_a_match' panicked at src/main.rs:35:5:
assertion `left == right` failed
  left: [108, 111, 114, 101, 109, 32, 105, 112, 115, 117, 109, 10]
 right: [100, 111, 108, 111, 114, 32, 115, 105, 116, 32, 97, 109, 101, 116]
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    find_a_match

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--bin grrs`

よさそうです。

ところでb"..."ってなんでしょう?って思ったのですが、文字列のバイナリー表現のようです。

Data Types - The Rust Programming Language

全体はこんな感じです。

src/main.rs

use anyhow::{Context, Result};
use clap::Parser;

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

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

    Ok(())
}

fn main() -> Result<()> {
    let args = Cli::parse();

    let content = std::fs::read_to_string(&args.path)
        .with_context(|| format!("could not read file `{}`", args.path.display()))?;

    find_matches(&content, &args.pattern, &mut std::io::stdout())?;

    Ok(())
}

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

Rustでは、単体テストコードをメインのソースコード内に直接書くようです。

Test Organization - The Rust Programming Language

testsディレクトリというものも見えますが、こちらに含まれるテストはインテグレーションテストになります。インテグレーションテストは
このパートの最後に扱います。

ここまですべてmain.rsに書いてきたので、ソースコードsrc/lib.rssrc/main.rsの2つに分割します。

分割結果。

src/lib.rs

use anyhow::Result;

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

    Ok(())
}

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

src/main.rs

use anyhow::{Context, Result};
use clap::Parser;

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

fn main() -> Result<()> {
    let args = Cli::parse();

    let content = std::fs::read_to_string(&args.path)
        .with_context(|| format!("could not read file `{}`", args.path.display()))?;

    grrs::find_matches(&content, &args.pattern, &mut std::io::stdout())?;

    Ok(())
}

単純に移しただけと思いきやfind_matches関数にはpubキーワードを追加し、他のソースコードからアクセスできるようにする必要が
あります。

pub fn find_matches(content: &str, pattern: &str, mut writer: impl std::io::Write) -> Result<()> {

またこの関数を呼び出す時には、パッケージ名で修飾する必要があります。今回のパッケージ名はCargo.tomlに書かれているgrrsです。

    grrs::find_matches(&content, &args.pattern, &mut std::io::stdout())?;

確認。

$ cargo run -- let src/main.rs
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/grrs let src/main.rs`
    let args = Cli::parse();
    let content = std::fs::read_to_string(&args.path)


$ cargo run -- let src/not-found.rs
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/grrs let src/not-found.rs`
Error: could not read file `src/not-found.rs`

Caused by:
    No such file or directory (os error 2)


$ cargo test
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.13s
     Running unittests src/lib.rs (target/debug/deps/grrs-4c58daa702a020b0)

running 1 test
test find_a_match ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/grrs-2b37cf4ecc3ed8f8)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests grrs

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

最後にインテグレーションテストを追加します。こちらはさらっとにしましょう。

クレートを追加。

$ cargo add --dev assert_cmd assert_fs predicates

Cargo.toml

[package]
name = "grrs"
version = "0.1.0"
edition = "2021"

[dependencies]
anyhow = "1.0.95"
clap = { version = "4.5.23", features = ["derive"] }

[dev-dependencies]
assert_cmd = "2.0.16"
assert_fs = "1.1.2"
predicates = "3.1.3"

インテグレーションテストなので、テストコードはtestsディレクトリ内に作成します。

こんな感じですね。

tests/cli.rs

use assert_cmd::Command;
use assert_fs::prelude::FileWriteStr;
use predicates::prelude::predicate;

#[test]
fn file_content_in_file() -> Result<(), Box<dyn std::error::Error>> {
    let file = assert_fs::NamedTempFile::new("sample.txt")?;
    file.write_str("A test\nActual content\nMore content\nAnother test")?;

    let mut cmd = Command::cargo_bin("grrs")?;
    cmd.arg("test").arg(file.path());

    cmd.assert()
        .success()
        .stdout(predicate::str::contains("A test\nAnother test"));

    Ok(())
}

#[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("could not read file"));

    Ok(())
}

ひとつは一時ファイルを作成して正常動作を確認するテスト、もうひとつは指定したファイルが見つからない場合のテストです。

パッケージ化や配布

今回は扱いませんでしたが、こちらにパッケージの公開方法や各種プラットフォーム向けのビルドなどについて書かれています。

Packaging and distributing a Rust tool - Command Line Applications in Rust

おわりに

RustのGetting Started「Command line apps in Rust」を試してみました。

15分のGetting Startedのはずなのですが、ドキュメントなどいろいろ見ていたら30倍くらい時間をかけていました…。

まあ、こういうお題の方がいいですね。途中でちょっと疲れてしまったので後半がさらっとになっていますが、こうやって慣れていければ
いいなと思います。