これは、なにをしたくて書いたもの?
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();
これで存在しないファイルを指定してアプリケーションを実行すると、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!を呼び出すのではなく、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倍くらい時間をかけていました…。
まあ、こういうお題の方がいいですね。途中でちょっと疲れてしまったので後半がさらっとになっていますが、こうやって慣れていければ
いいなと思います。