第5章 実践:CLI ツールの開発
Rust は、起動速度の速さ、シングルバイナリへのコンパイル、低メモリ消費といった特徴から、実用的な CLI(コマンドラインインターフェース)ツールの開発に最適な言語です。
本章では、指定したファイルの中からキーワードを高速に検索し、該当する行を行番号やカラーハイライト付きで表示するミニ grep ツール「rfind」を一から作成します。
5.1 CLI ツールの仕様と設計
作成するツール「rfind」の要件は以下のとおりです。
- 検索対象のキーワードとファイルパスをコマンドライン引数として受け取る。
- 大文字と小文字を区別しない検索オプション(
-i/--ignore-case)をサポートする。 - 行番号の表示・非表示を切り替えるオプション(
-n/--line-number)をサポートする。 - 一致したキーワード部分をターミナル上で見やすく色付け(ハイライト)して表示する。
- 引数が不正な場合やファイルが存在しない場合に、分かりやすいエラーメッセージを表示する。
5.2 プロジェクトの作成と依存クレートの設定
最初に、新しいバイナリプロジェクトを作成します。
cargo new rfind
cd rfind
Cargo.toml の編集
Rust のエコシステムには、高品質な CLI 開発用クレートが豊富に存在します。Cargo.toml の [dependencies] に以下を追加します。
[package]
name = "rfind"
version = "0.1.0"
edition = "2021"
[dependencies]
# コマンドライン引数を構造体にパースする標準ライブラリ
clap = { version = "4.5", features = ["derive"] }
# ターミナル出力をカラフルにするライブラリ
colored = "2.1"
# 簡潔で実用的なエラーハンドリングを提供するライブラリ
anyhow = "1.0"
各クレートの役割は以下のとおりです。
-
clap: Rust で最も広く使われている引数パーサーです。構造体にアトリビュートを付与するだけで、引数の解析や
--help表示を自動生成します。 -
colored: 文字列に
.red()や.green()などのメソッドを追加し、ターミナルで直感的にカラー装飾を行えます。 -
anyhow: アプリケーション開発における汎用的なエラー処理を、少ない記述量で安全に行えるようにします。
5.3 コマンドライン引数の定義
src/main.rs を開き、clap の Derive 機能を使ってコマンドライン引数を定義します。
#![allow(unused)]
fn main() {
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "rfind")]
#[command(author = "Your Name <you@example.com>")]
#[command(version = "1.0")]
#[command(about = "指定したファイルからキーワードを高速に検索する CLI ツール")]
struct Cli {
/// 検索対象の文字列
#[arg(short, long)]
query: String,
/// 検索するファイルのパス
#[arg(short, long)]
path: PathBuf,
/// 大文字と小文字を区別せずに検索する
#[arg(short = 'i', long = "ignore-case")]
ignore_case: bool,
/// 行番号を表示する
#[arg(short = 'n', long = "line-number", default_value_t = true)]
line_number: bool,
}
}
このように構造体を定義するだけで、Cli::parse() を呼べばコマンドライン引数が自動的にバリデーションされ、構造体のフィールドに値が格納されます。
5.4 検索ロジックの実装とテスト
検索処理はテストがしやすいように、独立した関数として切り出します。
#![allow(unused)]
fn main() {
/// 検索結果を保持する構造体
#[derive(Debug, PartialEq)]
pub struct SearchResult<'a> {
pub line_number: usize,
pub line_content: &'a str,
}
/// 検索ロジック本体
pub fn search<'a>(query: &str, content: &'a str, ignore_case: bool) -> Vec<SearchResult<'a>> {
let mut results = Vec::new();
let query_lower = query.to_lowercase();
for (index, line) in content.lines().enumerate() {
let is_matched = if ignore_case {
line.to_lowercase().contains(&query_lower)
} else {
line.contains(query)
};
if is_matched {
results.push(SearchResult {
line_number: index + 1,
line_content: line,
});
}
}
results
}
}
ユニットテストの作成
検索ロジックが意図通りに動作するかを検証するテストコードを記述します。
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_TEXT: &str = "\
Rust is fast and safe.
Memory safety without GC.
Trust the compiler.
";
#[test]
fn test_case_sensitive() {
let results = search("Rust", SAMPLE_TEXT, false);
assert_eq!(results.len(), 1);
assert_eq!(results[0].line_number, 1);
}
#[test]
fn test_case_insensitive() {
let results = search("rust", SAMPLE_TEXT, true);
// "Rust" と "Trust" の 2 行にマッチする
assert_eq!(results.len(), 2);
}
}
}
テストは cargo test コマンドでいつでも即座に実行できます。
5.5 完全なコードの実装
ファイルの読み込み、検索結果の装飾表示、およびエラーハンドリングを組み合わせた src/main.rs の全貌は以下のとおりです。
use anyhow::{Context, Result};
use clap::Parser;
use colored::*;
use std::fs;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "rfind")]
#[command(version = "1.0")]
#[command(about = "Rust 製の高速ファイル検索 CLI ツール")]
struct Cli {
/// 検索対象の文字列
query: String,
/// 検索するファイルのパス
path: PathBuf,
/// 大文字と小文字を区別しない
#[arg(short = 'i', long = "ignore-case")]
ignore_case: bool,
/// 行番号を表示する
#[arg(short = 'n', long = "line-number", default_value_t = true)]
line_number: bool,
}
#[derive(Debug, PartialEq)]
pub struct SearchResult<'a> {
pub line_number: usize,
pub line_content: &'a str,
}
pub fn search<'a>(query: &str, content: &'a str, ignore_case: bool) -> Vec<SearchResult<'a>> {
let mut results = Vec::new();
let query_lower = query.to_lowercase();
for (index, line) in content.lines().enumerate() {
let is_matched = if ignore_case {
line.to_lowercase().contains(&query_lower)
} else {
line.contains(query)
};
if is_matched {
results.push(SearchResult {
line_number: index + 1,
line_content: line,
});
}
}
results
}
fn main() -> Result<()> {
let args = Cli::parse();
// ファイル内容の読み込み(失敗時は親切なエラーメッセージを付与)
let content = fs::read_to_string(&args.path)
.with_context(|| format!("ファイルを開けませんでした: {:?}", args.path))?;
let results = search(&args.query, &content, args.ignore_case);
if results.is_empty() {
println!("{}", "一致する行は見つかりませんでした。".yellow());
return Ok(());
}
println!(
"{} 件の一致が見つかりました(対象: {:?}):\n",
results.len().to_string().green().bold(),
args.path
);
for item in results {
let line_prefix = if args.line_number {
format!("{}: ", item.line_number.to_string().cyan())
} else {
String::new()
};
// 一致したキーワードを赤色太字でハイライト
let highlighted_line = if args.ignore_case {
// 大文字小文字無視の簡易置換(表示用)
item.line_content.replace(&args.query, &args.query.red().bold().to_string())
} else {
item.line_content.replace(&args.query, &args.query.red().bold().to_string())
};
println!("{line_prefix}{highlighted_line}");
}
Ok(())
}
5.6 実行確認とリリースビルド
動作確認
まずは自動生成されたヘルプ画面を表示してみましょう。
cargo run -- --help
引数を渡して実際に検索を実行します。
# 大文字小文字を区別して検索
cargo run -- Rust Cargo.toml
# 大文字小文字を無視して検索
cargo run -- -i rust Cargo.toml
本番用シングルバイナリのビルド
開発が完了したら、コンパイラの最高レベルの最適化を効かせたリリースバイナリを出力します。
cargo build --release
ビルドが完了すると、target/release/rfind(Windows では target/release/rfind.exe)という単一の実行可能ファイルが生成されます。依存関係がすべて 1 つのバイナリに静的にリンクされているため、このファイル単体を他の PC やサーバーに配置するだけで即座に高速動作します。