これは、なにをしたくて書いたもの?
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
で取得するようです。
とりあえず書き写してみます。
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
トレイトのもののようです。
そしてIterator#nth
の戻り値はOption
列挙型であり、expect
はOption
がNone
の時にはパニックとするようです。
ちなみに、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.
またイテレーターというのであれば、ループして取得もできそうです。
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というライブラリーを使うことになっています。クレート、でよいんでしょうか?
追加してみましょう。
$ 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)]
を使うために必要なようです。
ソースコードはこう変更。
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
結果、こうなりました。
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_string
とstd::string::String::contains
を呼び出す時に、変数そのものを渡すのではなく参照を渡しています。
References and Borrowing - The Rust Programming Language
それぞれのAPIのドキュメントを見ると、確かに参照を期待していますね。
read_to_string in std::fs - 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
は、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();
これで存在しないファイルを指定してアプリケーションを実行すると、called
Result::unwrap()on an
Errvalue
と表示してパニックになります。
$ 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!
を呼び出すのではなく、Ok
やError
を返すようにしてもよいみたいです。その場合は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
は、Result
がErr
だった場合の変換関数のようです。
let content = std::fs::read_to_string(&args.path) .map_err(|err| CustomError(format!("Error reading `{:?}`: {}", args.path, err)))?;
ここで独自の構造体に変換しています。
この結果、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
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()))?;
しかも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)]
の説明などもありました。
進捗バーについてはこちらのクレートが、
https://crates.io/crates/indicatif
ログについてはこのあたりのクレートが紹介されています。
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(()) }
ドキュメントではこの関数に戻り値はないのですが、コンパイルすると「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
トレイトを実装しています。
実行結果。
$ 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
はマクロですね。
テストは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.rs
とsrc/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倍くらい時間をかけていました…。
まあ、こういうお題の方がいいですね。途中でちょっと疲れてしまったので後半がさらっとになっていますが、こうやって慣れていければ
いいなと思います。