Rustの実践:ファイル検索ツールの構築(grepの簡略版)
これはRustチュートリアルの最終回です。最初の30回のレッスンで学んだことをすべて活用して、実際に動作するコマンドライン用のファイル検索ツール(
grepの簡略版)を作成します。要件定義からコード記述、テストから実行に至るまで、どのステップも省略することなく、すべてを網羅して解説します。
最初の30回のレッスンが「動きを覚える」ことだったとすれば、今回のレッスンは「それを実践する」ことについてです。それは、あらゆる調理技術を習得した後、実際にゲストのために本格的な食事を振る舞うようなものです。まだタイミングが完璧ではないとか、包丁さばきがまだ洗練されていないと感じるかもしれませんが、この料理を完成させた時点で、あなたは「Rustを学んだ」状態から「Rustでコードを書ける」状態へと、真の意味でステップアップしたことになるでしょう。
1. 学習内容
- Rustの基礎知識(所有権、文字列操作、Vec、Resultによるエラー処理)の包括的な応用
- CLIツールの機能アーキテクチャの設計:パラメータの解析 → ファイルの読み込み → データ処理 → 結果の出力
std::env::args()を使用してコマンドライン引数を解析するstd::fs::read_to_string()およびBufReadを使用して、ファイルを行単位で読み込む- 検索ロジックの正確性を検証するためのユニットテストを作成する
2. ストーリー:丸太の海から針を探す
(1) ログを手作業で確認する手間
トムは、あるECプラットフォームの注文サービスの維持管理を担当している。ある水曜日の深夜、アラートシステムが作動した。多数の注文処理に失敗し、エラー率が30%まで急上昇していたのだ。
- 運用部門から 200MB のログファイル
order-service.logが送信されました - キーワード
ERRORを含むすべての行を検索する - トムは検索するためにエディタを開いたところ、エディタがクラッシュしてしまった(ファイルが大きすぎたため)。
- システムに組み込まれている
grepを使う — ただし、トムの Windows マシンにはそれがインストールされていない - PowerShellの
Select-Stringを使う — 構文を思い出せなかったので、ドキュメントで調べるのにかなり時間がかかってしまった
30分後、トムはようやくエラーの原因を突き止めた。データベースの接続プールが枯渇していたのだ。しかし、その貴重な30分はすでに「適切なツールを探す」ことに費やされてしまっていた。
「自分で作った検索ツールがあれば、ほんの数秒でできるんだけど……」
(2) 当方の提案
今日は、Rustでコマンドライン用のファイル検索ツール(grep-lite)を作成します。このツールは、以下の機能を備えています:
- コマンドラインから検索語句やファイルパスを受け付ける
- 行ごとに検索し、一致した行とその行番号を出力する
- 大文字と小文字を区別するモードと区別しないモードの両方をサポートしています
- 堅牢なエラー処理機能を備えている
# Basic Usage
cargo run -- "ERROR" order-service.log
# Case-insensitive mode
IGNORE_CASE=1 cargo run -- "error" order-service.log
# Error message indicating that the file does not exist
cargo run -- "hello" nonexistent.txt
# Output: minigrep: error reading file: The system cannot find the file specified. (os error 2)
3. プロジェクト要件
(1) 機能一覧
| # | 機能 | 説明 |
|---|---|---|
| 1 | コマンドライン引数 | 2つの引数を受け付けます:検索語 (query) とファイルパス (file_path) |
| 2 | ファイルの読み込み | 指定されたファイルの内容全体を読み込む |
| 3 | 行単位で検索 | 各行を検索語句と照合し、一致する行(行番号付き)を出力します |
| 4 | 大文字と小文字の区別 | IGNORE_CASE 環境変数を使用して、大文字と小文字の区別を無効にするかどうかを制御します |
| 5 | エラー処理 | ファイルが見つからない、権限不足、パラメータの欠落などのエラーを適切に処理する |
(2) プロジェクト・モジュールと主要概念の比較
| モジュール/関数 | 対応するRustの概念 | 出典コース |
|---|---|---|
parse_args() |
コマンドライン引数 env::args, Result |
29—標準ライブラリ |
search() |
文字列検索 contains、イテレータ enumerate |
03-文字列、22-イテレータ |
run() |
ファイル入出力 fs::read_to_string、? 演算子 |
27-ファイル入出力、09-エラー処理 |
main() |
プロセス制御 match, process::exit |
05-プロセス制御 |
IGNORE_CASE |
環境変数 env::var |
29-標準ライブラリ |
#[cfg(test)] |
モジュールの単体テスト | 12-テスト |
&str / String |
所有と借用 | 02-所有、03-弦 |
Vec<(usize, &str)> |
ベクトルのタプル、ジェネリクス | 14-ベクトル、19-ジェネリクス |
(3) コマンドライン引数の設計
| パラメータの位置 | 変数名 | 型 | 必須 | 説明 |
|---|---|---|---|---|
args[0] |
プログラム名 | String |
自動 | minigrep |
args[1] |
query |
&str |
はい | 検索キーワード |
args[2] |
file_path |
&str |
はい | 対象ファイルのパス |
| 環境変数 | IGNORE_CASE |
String |
なし | 1 に設定されている場合は大文字小文字を区別しない |
(4) プロジェクトのアーキテクチャ
graph TB
A[main] --> B[parse_args]
A --> C[run]
C --> D[read file via fs::read_to_string]
C --> E[read env var IGNORE_CASE]
C --> F[search lines]
F --> G[print matching lines]
C --> H[return Result for error handling]
B --> I{args.len() < 3?}
I -->|Yes| J[return Err]
I -->|No| K[return (query, file_path)]
subgraph search
F1[query + content + case_sensitive] --> F2[iterate lines with enumerate]
F2 --> F3{line contains query?}
F3 -->|Yes| F4[push (line_no, line)]
F3 -->|No| F5[skip]
end
style A fill:#4a90d9,color:#fff
style C fill:#67c23a,color:#fff
style F fill:#e6a23c,color:#fff
style H fill:#f56c6c,color:#fff
4. プロジェクトのコード全体
(1) ▶ サンプル:grep-lite の完全なコード実装
(1) プロジェクトの構成
minigrep/
├── Cargo.toml
└── src/
└── main.rs # All the code is in main.rs (Standard Library Version)
(2) Cargo.toml
[package]
name = "minigrep"
version = "0.1.0"
edition = "2021"
# This course uses only the standard library.,No external dependencies required
[dependencies]
(3) src/main.rs (完全なコード)
// ============================================
// minigrep - Command-Line File Search Tool (grep Simplified Version)
// Features:
// 1. Accept two command-line arguments: Search Terms + File Path
// 2. Read the contents of a file,Search by Line
// 3. Print matching lines (With line numbers)
// 4. Via environment variables IGNORE_CASE Case Sensitivity
// 5. Elegant Error Handling
// ============================================
use std::env;
use std::fs;
use std::process;
/// Parsing Command-Line Arguments,Back (query, file_path)
///
/// Expected to receive 2 parameters (Excluding program name):
/// Parameters 1: Search Terms (query)
/// Parameters 2: File Path (file_path)
///
/// Return an error message when the number of parameters is insufficient。
fn parse_args(args: &[String]) -> Result<(&str, &str), &'static str> {
if args.len() < 3 {
return Err("usage: minigrep <query> <file_path>");
}
let query = &args[1];
let file_path = &args[2];
Ok((query, file_path))
}
/// Search for matches line by line in the document content
///
/// # Arguments
/// * `query` - Keywords to search for
/// * `contents` - String Slicing in File Content
/// * `case_sensitive` - Is it case-sensitive?
///
/// # Returns
/// Returns a Vec,Includes all matching rows (Branch Number, Row Content)
fn search<'a>(
query: &str,
contents: &'a str,
case_sensitive: bool,
) -> Vec<(usize, &'a str)> {
let mut results = Vec::new();
for (line_no, line) in contents.lines().enumerate() {
let matched = if case_sensitive {
line.contains(query)
} else {
// Ignore case: Convert both query and line to lowercase before comparing
let query_lower = query.to_lowercase();
let line_lower = line.to_lowercase();
line_lower.contains(&query_lower)
};
if matched {
// Line number starts counting from 1 (Better aligned with user habits)
results.push((line_no + 1, line));
}
}
results
}
/// Core Operational Logic: Parsing Parameters → Read a File → Search → Print Results
///
/// IO Error handling is centralized in this function; main is responsible only for invoking and handling final errors.
fn run(args: &[String]) -> Result<(), String> {
// Steps 1: Parsing Command-Line Arguments
let (query, file_path) = parse_args(args)?;
// Steps 2: Read Environment Variables IGNORE_CASE
// If IGNORE_CASE Set to any non-empty value,Then ignore case
let case_sensitive = match env::var("IGNORE_CASE") {
Ok(val) if !val.is_empty() => false, // Ignore case
_ => true, // Case-sensitive by default
};
// Steps 3: Read the contents of a file
let contents = fs::read_to_string(file_path)
.map_err(|e| format!("minigrep: error reading file: {}", e))?;
// Steps 4: Perform a search
let matches = search(query, &contents, case_sensitive);
// Steps 5: Print Results
if matches.is_empty() {
println!("minigrep: no matches found for '{}'", query);
} else {
println!(
"minigrep: found {} match(es) for '{}'{}",
matches.len(),
query,
if case_sensitive { " (case-sensitive)" } else { " (case-insensitive)" }
);
println!("---");
for (line_no, line) in &matches {
println!("{:>4}: {}", line_no, line);
}
}
Ok(())
}
/// Program Entry Point
///
/// Collect Command-Line Arguments → Call run → Handling Errors
fn main() {
let args: Vec<String> = env::args().collect();
// Call run,If an error occurs, print a message and exit.
if let Err(err_msg) = run(&args) {
eprintln!("{}", err_msg);
process::exit(1);
}
}
// ============================================
// Unit Testing
// ============================================
#[cfg(test)]
mod tests {
use super::*;
/// Test parse_args: Under normal circumstances
#[test]
fn test_parse_args_ok() {
let args = vec![
"program".to_string(),
"hello".to_string(),
"test.txt".to_string(),
];
let result = parse_args(&args);
assert!(result.is_ok());
let (query, path) = result.unwrap();
assert_eq!(query, "hello");
assert_eq!(path, "test.txt");
}
/// Test parse_args: Insufficient parameters
#[test]
fn test_parse_args_missing() {
let args = vec!["program".to_string()];
let result = parse_args(&args);
assert!(result.is_err());
}
/// Test search: Case-sensitive
#[test]
fn test_search_case_sensitive() {
let contents = "\
Rust is safe and fast.
rust is a systems language.
I love Rust programming.
RUST is awesome!";
let query = "Rust";
let results = search(query, contents, true);
assert_eq!(results.len(), 2);
assert_eq!(results[0], (1, "Rust is safe and fast."));
assert_eq!(results[1], (3, "I love Rust programming."));
}
/// Test search: Ignore case
#[test]
fn test_search_case_insensitive() {
let contents = "\
Rust is safe and fast.
rust is a systems language.
I love Rust programming.
RUST is awesome!";
let query = "rust";
let results = search(query, contents, false);
// When case is ignored, all 4 lines should match (All contain rust/Rust/RUST)
assert_eq!(results.len(), 4);
assert_eq!(results[0], (1, "Rust is safe and fast."));
assert_eq!(results[1], (2, "rust is a systems language."));
assert_eq!(results[2], (3, "I love Rust programming."));
assert_eq!(results[3], (4, "RUST is awesome!"));
}
/// Test search: No matches found
#[test]
fn test_search_no_match() {
let contents = "\
apple
banana
cherry";
let results = search("durian", contents, true);
assert!(results.is_empty());
}
/// Test search: Empty content
#[test]
fn test_search_empty_contents() {
let contents = "";
let results = search("hello", contents, true);
assert!(results.is_empty());
}
/// Test search: Empty query
#[test]
fn test_search_empty_query() {
let contents = "line one\nline two\nline three";
let results = search("", contents, true);
// An empty string matches any line (contains("") is always true)
assert_eq!(results.len(), 3);
}
/// Integration Testing: Simulate run Function (Using Temporary Files)
#[test]
fn test_run_with_temp_file() {
use std::io::Write;
// Create a temporary file
let mut temp_file = tempfile::NamedTempFile::new().unwrap();
write!(temp_file, "hello world\nrust is great\nHELLO everyone\n").unwrap();
let temp_path = temp_file.path().to_str().unwrap().to_string();
// Construction Parameters
let args = vec![
"minigrep".to_string(),
"hello".to_string(),
temp_path.clone(),
];
// Set Environment Variables: Case-sensitive
// Note: This test depends on tempfile crate; if you don't want to import from an external source crate You can skip this
// This is for reference only.,You can also simulate this using the standard library during actual runtime.
let result = run(&args);
assert!(result.is_ok());
}
}
// ============================================
// Note:
// 1. You can copy the code above directly into src/main.rs Compile and Run
// 2. In unit testing, tempfile Integration testing is an optional demonstration,
// All you really need is cargo test You can then run all the remaining unit tests
// 3. Running Mode:
// cargo run -- "search_term" "file_path"
// IGNORE_CASE=1 cargo run -- "search_term" "file_path"
// ============================================
(2) ▶ サンプル:コンパイルと実行
# 1. Create a Project
cargo new minigrep
cd minigrep
# 2. Copy the code above to src/main.rs
# 3. Compilation
cargo build
# 4. Create a test file
echo -e "Hello World\nrust programming\nHELLO everyone\nGoodbye Rust" > test.txt
# 5. Run (Case-sensitive)
cargo run -- "rust" test.txt
# 6. Run (Ignore case)
IGNORE_CASE=1 cargo run -- "rust" test.txt
# 7. Run Test
cargo test
(3) ▶ サンプル:例のプログラムの実行
例 1:基本検索(大文字と小文字を区別)
テストファイル test.txt を作成:
Hello World
Rust is awesome
rust is fast
I love Rust
RUST is powerful
Goodbye
コマンド:
cargo run -- "Rust" test.txt
出力:
minigrep: found 2 match(es) for 'Rust' (case-sensitive)
---
1: Hello World
3: I love Rust
例 2:大文字・小文字を区別しない
コマンド:
IGNORE_CASE=1 cargo run -- "rust" test.txt
出力:
minigrep: found 4 match(es) for 'rust' (case-insensitive)
---
1: Hello World
2: Rust is awesome
3: rust is fast
4: I love Rust
例 3: ファイルが存在しない
コマンド:
cargo run -- "hello" nonexistent.txt
出力:
minigrep: error reading file: The system cannot find the file specified. (os error 2)
例 4:一致なし
コマンド:
cargo run -- "python" test.txt
出力:
minigrep: no matches found for 'python'
例 5:引数が不足している
コマンド:
cargo run -- "hello"
出力:
usage: minigrep <query> <file_path>
(4) テスト出力
$ cargo test
Compiling minigrep v0.1.0
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.23s
Running unittests src/main.rs
running 7 tests
test tests::test_parse_args_ok ... ok
test tests::test_parse_args_missing ... ok
test tests::test_search_case_sensitive ... ok
test tests::test_search_case_insensitive ... ok
test tests::test_search_no_match ... ok
test tests::test_search_empty_contents ... ok
test tests::test_search_empty_query ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
7つのユニットテストすべてが成功しました!これらのテストは、通常の検索(大文字小文字を区別する場合と区別しない場合)、一致する結果がない場合、コンテンツが空の場合、クエリが空の場合、パラメータの解析など、エッジケースを網羅しています。これこそがRustのテストシステム
#[cfg(test)]および#[test]の真価です。テストコードは本番コードと同じファイルに配置され、条件付きコンパイルによって、テストコードが本番ビルドに含まれないように保証されています。
5. コードの解説
(1) parse_args 機能
fn parse_args(args: &[String]) -> Result<(&str, &str), &'static str> {
if args.len() < 3 {
return Err("usage: minigrep <query> <file_path>");
}
let query = &args[1];
let file_path = &args[2];
Ok((query, file_path))
}
&[String]のスライスを受け取りました(所有しているわけではなく、単に借りているだけです)- パラメータの数を確認し、数が不足している場合は
Err(&'static str)を返す - 成功した場合は
Ok((&str, &str))を返す—2つの文字列スライス - 統一的なエラー処理には、
Resultタイプを使用してください
(2) search 関数
fn search<'a>(
query: &str,
contents: &'a str,
case_sensitive: bool,
) -> Vec<(usize, &'a str)> {
let mut results = Vec::new();
for (line_no, line) in contents.lines().enumerate() {
let matched = if case_sensitive {
line.contains(query)
} else {
let query_lower = query.to_lowercase();
let line_lower = line.to_lowercase();
line_lower.contains(&query_lower)
};
if matched {
results.push((line_no + 1, line));
}
}
results
}
- ライフサイクルアノテーション
'aを使用すると、返される&strがcontentsパラメータと同じライフサイクルを持つことを示します。 contents.lines()各行(改行文字を除く)に対するイテレータを返す.enumerate()各行にインデックスを追加する(0から開始。出力時は1を加えて1から開始するようにする)- 大文字と小文字を区別しない場合は、比較する前に
queryとlineの両方を小文字に変換する
(3) run 関数
fn run(args: &[String]) -> Result<(), String> {
let (query, file_path) = parse_args(args)?;
// ...
let case_sensitive = match env::var("IGNORE_CASE") {
Ok(val) if !val.is_empty() => false,
_ => true,
};
let contents = fs::read_to_string(file_path)
.map_err(|e| format!("minigrep: error reading file: {}", e))?;
// ...
}
?演算子:parse_argsがErrを返す場合、エラーは自動的に伝播されるenv::var("IGNORE_CASE"): 環境変数を読み込むfs::read_to_string: 1回の操作でファイルをStringに読み込む.map_err(): 標準ライブラリのエラータイプを、わかりやすい文字列に変換する
(4) main 関数
fn main() {
let args: Vec<String> = env::args().collect();
if let Err(err_msg) = run(&args) {
eprintln!("{}", err_msg);
process::exit(1);
}
}
env::args().collect():Vec<String>へのコマンドライン引数を収集するif let Err(...):runの戻り値をパターンマッチングを用いて処理するeprintln!: エラーメッセージは stdout ではなく stderr (標準エラー出力) に出力されますprocess::exit(1): 0以外の終了コード(異常終了を示す)でプログラムを終了する
(1) ▶ サンプル:高度な機能—行番号のカウントと検索結果の文脈
// ============================================
// Expand grep-lite:Show line numbers and surrounding lines
// ============================================
use std::env;
use std::fs;
use std::process;
fn search_with_context<'a>(query: &str, contents: &'a str, context: usize, ignore_case: bool) -> Vec<(usize, &'a str)> {
let pattern = if ignore_case { query.to_lowercase() } else { query.to_string() };
contents.lines()
.enumerate()
.filter(|(_, line)| {
let haystack = if ignore_case { line.to_lowercase() } else { line.to_string() };
haystack.contains(&pattern)
})
.map(|(i, line)| (i + 1, line))
.collect()
}
fn search_surrounding<'a>(query: &str, contents: &'a str, context: usize, ignore_case: bool) -> Vec<(usize, &'a str, bool)> {
let matches: Vec<usize> = contents.lines().enumerate()
.filter(|(_, line)| {
let haystack = if ignore_case { line.to_lowercase() } else { line.to_string() };
haystack.contains(&if ignore_case { query.to_lowercase() } else { query.to_string() })
})
.map(|(i, _)| i)
.collect();
let mut result = Vec::new();
let mut printed = std::collections::HashSet::new();
for &match_idx in &matches {
let start = match_idx.saturating_sub(context);
let end = (match_idx + context + 1).min(contents.lines().count());
for i in start..end {
if printed.insert(i) {
let is_match = i == match_idx;
result.push((i + 1, contents.lines().nth(i).unwrap_or(""), is_match));
}
}
}
result
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("Usage: {} <Search> <Documents> [--context N] [--ignore-case]", args[0]);
process::exit(1);
}
let query = &args[1];
let filename = &args[2];
let context = args.iter().position(|a| a == "--context")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0);
let ignore_case = args.iter().any(|a| a == "--ignore-case") ||
env::var("IGNORE_CASE").is_ok();
let contents = fs::read_to_string(filename).unwrap_or_else(|err| {
eprintln!("Read a File '{}' Failure: {}", filename, err);
process::exit(1);
});
println!("=== Search '{}' (Ignore case: {}) ===\n", query, ignore_case);
if context > 0 {
let results = search_surrounding(query, &contents, context, ignore_case);
for (line_num, line, is_match) in &results {
let prefix = if *is_match { ">>>" } else { " " };
println!("{} {}: {}", prefix, line_num, line);
}
} else {
let results = search_with_context(query, &contents, context, ignore_case);
for (line_num, line) in &results {
println!("{}: {}", line_num, line);
}
println!("\nTotal {} matching lines", results.len());
}
}
拡張版では、行番号の表示(
enumerate)とコンテキスト行(--context Nパラメータ)が追加されています。search_surroundingはHashSetを使用して重複を削除し、一致する行が隣接している場合の重複出力を防ぎます。>>>は一致する行にマークを付け、通常の行はインデントして表示されます。
(2) ▶ サンプル:高度な機能—複数ファイル検索と再帰的なディレクトリ検索
// ============================================
// Expand grep-lite:Supports multiple files and recursive directories
// ============================================
use std::env;
use std::fs;
use std::path::Path;
use std::process;
fn search_in_file(query: &str, filename: &str, ignore_case: bool) -> Vec<(usize, String)> {
let contents = match fs::read_to_string(filename) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let pattern = if ignore_case { query.to_lowercase() } else { query.to_string() };
contents.lines()
.enumerate()
.filter(|(_, line)| {
let haystack = if ignore_case { line.to_lowercase() } else { line.to_string() };
haystack.contains(&pattern)
})
.map(|(i, line)| (i + 1, line.to_string()))
.collect()
}
fn find_files(path: &Path, extension: &str) -> Vec<String> {
let mut files = Vec::new();
if path.is_file() {
files.push(path.to_string_lossy().to_string());
} else if path.is_dir() {
if let Ok(entries) = fs::read_dir(path) {
for entry in entries.flatten() {
let sub_path = entry.path();
if sub_path.is_dir() {
files.extend(find_files(&sub_path, extension));
} else if sub_path.extension().map(|e| e == extension).unwrap_or(false) {
files.push(sub_path.to_string_lossy().to_string());
}
}
}
}
files
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("Usage: {} <Search> <Path...> [--ignore-case] [--ext rs]", args[0]);
process::exit(1);
}
let query = &args[1];
let ignore_case = args.iter().any(|a| a == "--ignore-case");
let extension = args.iter().position(|a| a == "--ext")
.and_then(|i| args.get(i + 1))
.map(|s| s.as_str())
.unwrap_or("rs");
let mut all_files = Vec::new();
for path_str in args[2..].iter().filter(|a| !a.starts_with('-')) {
let path = Path::new(path_str);
all_files.extend(find_files(path, extension));
}
println!("Search '{}' in {} file(s) (Extension: .{})\n", query, all_files.len(), extension);
let mut total_matches = 0;
let mut files_with_matches = 0;
for filename in &all_files {
let results = search_in_file(query, filename, ignore_case);
if !results.is_empty() {
println!("--- {} ---", filename);
for (line_num, line) in &results {
println!(" {}: {}", line_num, line);
}
total_matches += results.len();
files_with_matches += 1;
}
}
println!("\n=== Statistics ===");
println!("Scanned Documents: {}, Matches found: {}, Total Matching Rows: {}", all_files.len(), files_with_matches, total_matches);
}
複数ファイルの検索機能:
find_filesはディレクトリを再帰的に探索し、ファイル拡張子でフィルタリングします。search_in_fileは単一のファイル内を検索します。出力形式では、--- filename ---を使用して各ファイルごとの結果を区切り、最後にスキャン結果と一致結果の概要を表示します。
❓ よくある質問
std::env::args_os() ではなく env::args() を使うのですか?args() は Args イテレータを返し、String 型を生成します。これはほとんどのシナリオに適しています。 args_os()はOsStringを返します。これはUTF-8以外のパラメータも処理できますが、使用方法がより複雑です。このプロジェクトの要件においては、args()で十分です。fs::read_to_string()とBufReaderによる行単位の読み込みにはどのような違いがありますか?fs::read_to_string()はファイル全体を一度にメモリに読み込むため、中小規模のファイルに適しています。 BufReaderは1行ずつ読み込むため、大容量のファイル(数百MB規模のログファイルなど)に適しており、メモリ使用量も抑えられます。このレッスンでは、コードを簡潔に保つためにread_to_stringを使用していますが、非常に大容量のファイルを処理する場合は、BufReaderとlines()のイテレータを組み合わせて、1行ずつ処理することができます。search 関数にはライフタイムパラメータ 'a が必要なのでしょうか?Vec<(usize, &str)> 内の &str が、contents パラメータ内のデータを参照しているためです。 ライフタイムアノテーション 'a は、返される参照が contents パラメータと同じライフタイムを持つことをコンパイラに伝えます。これにより、コンパイラは検索結果が使用される時点で contents のデータがまだ有効であることを保証し、ダングリング参照を防ぐことができます。IGNORE_CASE の値に関するルールはどのようなものですか?IGNORE_CASE が空でない値に設定されていれば、大文字小文字は区別されません。 IGNORE_CASE=1、IGNORE_CASE=true、またはIGNORE_CASE=yesを設定しても同様の効果があります。環境変数が設定されていない場合(つまり、env::varがErrを返す場合)、または空の文字列に設定されている場合は、大文字と小文字が区別されます。process::exit(1) を使うのですか?process::exit(1) は指定された終了コードでプログラムを終了させます。これは CLI ツールに適しています。親プロセス(シェルスクリプトなど)は、この終了コードに基づいて実行が成功したかどうかを判断できるからです。 panicはスタックトレースを出力しますが、これはエンドユーザーにとって分かりにくいものです。CLIツールにおける標準的な慣習は、成功時には終了コード0を返し、エラー時には0以外の終了コードを返すことです。📖 まとめ
- プロジェクトのアーキテクチャ:このCLIツールは
main → run → searchの3層アーキテクチャを採用しており、mainはエントリポイントとして機能し、エラー処理のみを担当し、runはオーケストレーションロジックを担当し、searchは純粋関数で構成されています。 - コマンドライン引数:
std::env::args()を使用して引数を収集し、parse_args関数をカスタマイズして引数の数を検証し、Resultを使用して統一的なエラー処理を行います - ファイルの読み込み:
std::fs::read_to_string()は小さなファイルを 1 回のパスで読み込みます。BufReader+lines()は、大きなファイルを 1 行ずつ処理するのに適しています - 環境変数の制御:
std::env::var("IGNORE_CASE")は環境変数を読み取り、matchのパターンマッチングを用いて、それらが設定されているかどうかを判断します - エラー処理:戻り値の型として
Result<(), String>を使用します。?はエラーを伝播します。eprintln!は stderr に出力します。process::exit(1)は終了コードを設定します - ユニットテスト:
#[cfg(test)]および#[test]を使用して、通常、境界、エラーの 3 つのシナリオを網羅するテストを作成し、cargo testを使ってワンクリックで実行する
📝 練習問題
-
難易度 ⭐:検索機能に「一致件数」機能を追加する:
search関数を基に、具体的な行の内容を返さず、一致した行の数のみを返す新しいcount_matches関数を追加する。例えば、count_matches("Rust", contents, true)は3を返す。mainに--countフラグを追加する(3番目のパラメータで決定)。これが--countに設定されている場合、一致した行数のみを出力する。 -
難易度 ⭐⭐:ツールに「コンテキスト行」機能を追加する:
search関数を修正し、一致した行の前後にある所定の行数を返すようにする(grep -Cと同様)。一致した行の前後に表示する行数を指定するための新しいパラメータcontext_lines: usizeを追加する。例えば、context_lines=1を指定すると、一致した行の直前の行と直後の行が併せて表示される。エッジケース(ファイルの先頭や末尾にある行にはコンテキストがない)への対応を確実に行うこと。 -
難易度 ⭐⭐⭐:プロジェクトを「複数ファイル検索ツール」に拡張する:複数のファイルを横断して検索できるようプログラムを修正してください。新しいコマンドライン形式は
minigrep <query> <file1> <file2> ...です(ファイル名の数は可変です)。出力形式を変更し、一致した各行の先頭にファイル名を付加するようにします(例:file1.txt:5: Hello World)。ヒント:ループを使用して複数のファイルを処理してください。$()*モードはマクロではありませんが、その概念は似ています。「ロジックを一度処理し、それを複数の入力に適用する」というものです。



