第24章: 実践プロジェクト - 完全なCLIアプリケーション
学習目標
- アーキテクチャ設計の実践
- エラー設計パターンの習得
- CLIアプリケーションのベストプラクティス
- テスト戦略の実装
- 実用的なツールの完成
---
24.1 プロジェクト概要
24.1.1 構築するもの
プロジェクト名: rgrep - Rust版grepツール
機能:
- ファイル/ディレクトリから文字列検索
- 正規表現サポート
- カラー出力
- 再帰検索
- 除外パターン
- パフォーマンス最適化
24.1.2 アーキテクチャ概要
rgrep/
├── Cargo.toml
├── src/
│ ├── main.rs # エントリーポイント
│ ├── lib.rs # ライブラリルート
│ ├── cli/
│ │ ├── mod.rs
│ │ └── parser.rs # CLIパーサ
│ ├── search/
│ │ ├── mod.rs
│ │ ├── matcher.rs # パターンマッチング
│ │ └── walker.rs # ディレクトリ走査
│ ├── output/
│ │ ├── mod.rs
│ │ └── formatter.rs # 出力フォーマット
│ └── error.rs # エラー定義
├── tests/
│ └── integration_tests.rs
└── benches/
└── search_bench.rs
---
24.2 アーキテクチャ設計
24.2.1 レイヤードアーキテクチャ
┌─────────────────────────────────────────────────────────┐
│ CLI層 │
│ コマンドライン引数のパース、バリデーション │
├─────────────────────────────────────────────────────────┤
│ ビジネスロジック層 │
│ 検索アルゴリズム、ファイル走査 │
├─────────────────────────────────────────────────────────┤
│ 出力層 │
│ 結果のフォーマット、カラーリング │
├─────────────────────────────────────────────────────────┤
│ インフラ層 │
│ ファイルシステム、標準入出力 │
└─────────────────────────────────────────────────────────┘
24.2.2 依存性注入
// src/lib.rs
pub trait FileSystem {
fn read_to_string(&self, path: &Path) -> io::Result<String>;
fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>>;
}
pub trait Output {
fn print_match(&self, path: &Path, line_num: usize, line: &str);
fn print_error(&self, error: &Error);
}
pub struct Searcher<FS: FileSystem, O: Output> {
fs: FS,
output: O,
}
impl<FS: FileSystem, O: Output> Searcher<FS, O> {
pub fn new(fs: FS, output: O) -> Self {
Searcher { fs, output }
}
pub fn search(&self, pattern: &str, path: &Path) -> Result<()> {
// 検索ロジック
}
}
テストでのモック:
#[cfg(test)]
mod tests {
use super::*;
struct MockFileSystem {
files: HashMap<PathBuf, String>,
}
impl FileSystem for MockFileSystem {
fn read_to_string(&self, path: &Path) -> io::Result<String> {
self.files.get(path)
.cloned()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found"))
}
fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
Ok(self.files.keys().cloned().collect())
}
}
#[test]
fn test_search() {
let mut files = HashMap::new();
files.insert(PathBuf::from("test.txt"), "hello world".to_string());
let fs = MockFileSystem { files };
let output = MockOutput::new();
let searcher = Searcher::new(fs, output);
searcher.search("hello", Path::new("test.txt")).unwrap();
}
}
---
24.3 エラー設計
24.3.1 カスタムエラー型
// src/error.rs
use std::fmt;
use std::io;
use regex;
#[derive(Debug)]
pub enum Error {
Io(io::Error),
Regex(regex::Error),
InvalidPattern(String),
NoMatchesFound,
PermissionDenied(PathBuf),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Error::Io(err) => write!(f, "IO error: {}", err),
Error::Regex(err) => write!(f, "Regex error: {}", err),
Error::InvalidPattern(pattern) => {
write!(f, "Invalid pattern: {}", pattern)
}
Error::NoMatchesFound => write!(f, "No matches found"),
Error::PermissionDenied(path) => {
write!(f, "Permission denied: {}", path.display())
}
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Io(err) => Some(err),
Error::Regex(err) => Some(err),
_ => None,
}
}
}
impl From<io::Error> for Error {
fn from(err: io::Error) -> Self {
Error::Io(err)
}
}
impl From<regex::Error> for Error {
fn from(err: regex::Error) -> Self {
Error::Regex(err)
}
}
pub type Result<T> = std::result::Result<T, Error>;
24.3.2 anyhowとthiserrorの使用
thiserror版(ライブラリ向け):
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Regex error: {0}")]
Regex(#[from] regex::Error),
#[error("Invalid pattern: {0}")]
InvalidPattern(String),
#[error("No matches found")]
NoMatchesFound,
#[error("Permission denied: {0}")]
PermissionDenied(PathBuf),
}
anyhow版(アプリケーション向け):
use anyhow::{Context, Result};
fn read_config(path: &Path) -> Result<Config> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read config from {:?}", path))?;
let config: Config = toml::from_str(&content)
.context("Failed to parse config")?;
Ok(config)
}
---
24.4 CLI設計
24.4.1 clap を使った引数パース
// src/cli/parser.rs
use clap::{Parser, ValueEnum};
#[derive(Parser)]
#[command(name = "rgrep")]
#[command(about = "A fast grep implementation in Rust")]
#[command(version)]
pub struct Cli {
/// Search pattern (supports regex)
#[arg(value_name = "PATTERN")]
pub pattern: String,
/// Files or directories to search
#[arg(value_name = "PATH", default_value = ".")]
pub paths: Vec<PathBuf>,
/// Use case-insensitive search
#[arg(short, long)]
pub ignore_case: bool,
/// Recursive search in directories
#[arg(short, long)]
pub recursive: bool,
/// Show line numbers
#[arg(short = 'n', long)]
pub line_number: bool,
/// Use colored output
#[arg(long, default_value = "auto")]
pub color: ColorMode,
/// Exclude patterns (can be specified multiple times)
#[arg(long = "exclude", value_name = "GLOB")]
pub exclude: Vec<String>,
/// Maximum depth for recursive search
#[arg(long, value_name = "NUM")]
pub max_depth: Option<usize>,
/// Number of threads (0 = auto)
#[arg(short = 'j', long, default_value = "0")]
pub threads: usize,
}
#[derive(ValueEnum, Clone, Debug)]
pub enum ColorMode {
Auto,
Always,
Never,
}
pub fn parse_args() -> Cli {
Cli::parse()
}
24.4.2 設定ファイルのサポート
// src/cli/config.rs
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Default)]
pub struct Config {
pub ignore_case: Option<bool>,
pub color: Option<String>,
pub exclude: Option<Vec<String>>,
pub max_depth: Option<usize>,
}
impl Config {
pub fn load() -> Result<Self> {
let config_path = dirs::config_dir()
.ok_or_else(|| Error::ConfigNotFound)?
.join("rgrep")
.join("config.toml");
if !config_path.exists() {
return Ok(Config::default());
}
let content = fs::read_to_string(&config_path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
pub fn merge_with_cli(&mut self, cli: &Cli) {
if cli.ignore_case {
self.ignore_case = Some(true);
}
// 他のフィールドも同様にマージ
}
}
---
24.5 検索エンジン実装
24.5.1 パターンマッチャー
// src/search/matcher.rs
use regex::Regex;
pub enum Matcher {
Literal {
pattern: String,
ignore_case: bool,
},
Regex {
regex: Regex,
},
}
impl Matcher {
pub fn new(pattern: &str, ignore_case: bool, use_regex: bool) -> Result<Self> {
if use_regex {
let regex = if ignore_case {
Regex::new(&format!("(?i){}", pattern))?
} else {
Regex::new(pattern)?
};
Ok(Matcher::Regex { regex })
} else {
Ok(Matcher::Literal {
pattern: pattern.to_string(),
ignore_case,
})
}
}
pub fn is_match(&self, line: &str) -> bool {
match self {
Matcher::Literal { pattern, ignore_case } => {
if *ignore_case {
line.to_lowercase().contains(&pattern.to_lowercase())
} else {
line.contains(pattern)
}
}
Matcher::Regex { regex } => regex.is_match(line),
}
}
pub fn find_matches<'a>(&self, line: &'a str) -> Vec<(usize, usize)> {
match self {
Matcher::Literal { pattern, ignore_case } => {
// リテラルマッチの位置を返す
self.find_literal_matches(line, pattern, *ignore_case)
}
Matcher::Regex { regex } => {
regex.find_iter(line)
.map(|m| (m.start(), m.end()))
.collect()
}
}
}
fn find_literal_matches(&self, text: &str, pattern: &str, ignore_case: bool) -> Vec<(usize, usize)> {
// 実装
}
}
24.5.2 ディレクトリウォーカー
// src/search/walker.rs
use walkdir::WalkDir;
use globset::{Glob, GlobSet, GlobSetBuilder};
pub struct Walker {
exclude: GlobSet,
max_depth: Option<usize>,
}
impl Walker {
pub fn new(exclude_patterns: &[String], max_depth: Option<usize>) -> Result<Self> {
let mut builder = GlobSetBuilder::new();
for pattern in exclude_patterns {
builder.add(Glob::new(pattern)?);
}
let exclude = builder.build()?;
Ok(Walker { exclude, max_depth })
}
pub fn walk(&self, root: &Path) -> impl Iterator<Item = PathBuf> + '_ {
let walker = WalkDir::new(root)
.max_depth(self.max_depth.unwrap_or(usize::MAX))
.into_iter()
.filter_entry(|e| !self.is_excluded(e.path()))
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file());
walker.map(|e| e.path().to_path_buf())
}
fn is_excluded(&self, path: &Path) -> bool {
self.exclude.is_match(path)
}
}
24.5.3 並列検索
// src/search/parallel.rs
use rayon::prelude::*;
pub struct ParallelSearcher {
matcher: Arc<Matcher>,
walker: Arc<Walker>,
}
impl ParallelSearcher {
pub fn search(&self, root: &Path) -> Vec<SearchResult> {
self.walker
.walk(root)
.par_bridge() // 並列化
.filter_map(|path| self.search_file(&path).ok())
.flatten()
.collect()
}
fn search_file(&self, path: &Path) -> Result<Vec<SearchResult>> {
let content = fs::read_to_string(path)?;
let mut results = Vec::new();
for (line_num, line) in content.lines().enumerate() {
if self.matcher.is_match(line) {
results.push(SearchResult {
path: path.to_path_buf(),
line_num: line_num + 1,
line: line.to_string(),
matches: self.matcher.find_matches(line),
});
}
}
Ok(results)
}
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub path: PathBuf,
pub line_num: usize,
pub line: String,
pub matches: Vec<(usize, usize)>,
}
---
24.6 出力フォーマット
24.6.1 カラー出力
// src/output/formatter.rs
use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
pub struct Formatter {
stdout: StandardStream,
color_enabled: bool,
}
impl Formatter {
pub fn new(color_mode: ColorMode) -> Self {
let choice = match color_mode {
ColorMode::Always => ColorChoice::Always,
ColorMode::Never => ColorChoice::Never,
ColorMode::Auto => {
if atty::is(atty::Stream::Stdout) {
ColorChoice::Auto
} else {
ColorChoice::Never
}
}
};
Formatter {
stdout: StandardStream::stdout(choice),
color_enabled: !matches!(choice, ColorChoice::Never),
}
}
pub fn print_match(&mut self, result: &SearchResult, show_line_num: bool) {
// ファイル名(緑)
self.set_color(Color::Green, true);
write!(self.stdout, "{}", result.path.display()).unwrap();
self.reset_color();
if show_line_num {
// 行番号(黄色)
self.set_color(Color::Yellow, false);
write!(self.stdout, ":{}", result.line_num).unwrap();
self.reset_color();
}
write!(self.stdout, ":").unwrap();
// マッチ部分をハイライト
self.print_highlighted_line(&result.line, &result.matches);
writeln!(self.stdout).unwrap();
}
fn print_highlighted_line(&mut self, line: &str, matches: &[(usize, usize)]) {
let mut last_end = 0;
for &(start, end) in matches {
// マッチ前の部分
write!(self.stdout, "{}", &line[last_end..start]).unwrap();
// マッチ部分(赤、太字)
self.set_color(Color::Red, true);
write!(self.stdout, "{}", &line[start..end]).unwrap();
self.reset_color();
last_end = end;
}
// 残りの部分
write!(self.stdout, "{}", &line[last_end..]).unwrap();
}
fn set_color(&mut self, color: Color, bold: bool) {
if self.color_enabled {
let mut spec = ColorSpec::new();
spec.set_fg(Some(color));
spec.set_bold(bold);
self.stdout.set_color(&spec).unwrap();
}
}
fn reset_color(&mut self) {
if self.color_enabled {
self.stdout.reset().unwrap();
}
}
}
---
24.7 テスト戦略
24.7.1 単体テスト
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_literal_matcher() {
let matcher = Matcher::new("hello", false, false).unwrap();
assert!(matcher.is_match("hello world"));
assert!(!matcher.is_match("Hello world"));
}
#[test]
fn test_regex_matcher() {
let matcher = Matcher::new(r"\d+", false, true).unwrap();
assert!(matcher.is_match("test 123"));
assert!(!matcher.is_match("test"));
}
#[test]
fn test_case_insensitive() {
let matcher = Matcher::new("hello", true, false).unwrap();
assert!(matcher.is_match("Hello World"));
assert!(matcher.is_match("HELLO"));
}
}
24.7.2 統合テスト
// tests/integration_tests.rs
use std::process::Command;
use assert_cmd::Command as AssertCommand;
use predicates::prelude::*;
#[test]
fn test_basic_search() {
let mut cmd = AssertCommand::cargo_bin("rgrep").unwrap();
cmd.arg("hello")
.arg("tests/fixtures/sample.txt")
.assert()
.success()
.stdout(predicate::str::contains("hello"));
}
#[test]
fn test_case_insensitive() {
let mut cmd = AssertCommand::cargo_bin("rgrep").unwrap();
cmd.arg("-i")
.arg("HELLO")
.arg("tests/fixtures/sample.txt")
.assert()
.success()
.stdout(predicate::str::contains("hello"));
}
#[test]
fn test_no_matches() {
let mut cmd = AssertCommand::cargo_bin("rgrep").unwrap();
cmd.arg("nonexistent")
.arg("tests/fixtures/sample.txt")
.assert()
.failure();
}
---
24.8 パフォーマンス最適化
24.8.1 ベンチマーク
// benches/search_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn bench_search(c: &mut Criterion) {
let mut group = c.benchmark_group("search");
// 小さいファイル
group.bench_function("small_file", |b| {
let content = include_str!("../tests/fixtures/small.txt");
let matcher = Matcher::new("pattern", false, false).unwrap();
b.iter(|| {
for line in content.lines() {
black_box(matcher.is_match(line));
}
});
});
// 大きいファイル
group.bench_function("large_file", |b| {
let content = include_str!("../tests/fixtures/large.txt");
let matcher = Matcher::new("pattern", false, false).unwrap();
b.iter(|| {
for line in content.lines() {
black_box(matcher.is_match(line));
}
});
});
group.finish();
}
criterion_group!(benches, bench_search);
criterion_main!(benches);
---
24.9 まとめ
学んだこと
- エラー処理
- CLI設計
- 実装テクニック
完成形
これで、以下を備えた実用的なCLIツールが完成します:
- ✅ 高速な検索(並列処理)
- ✅ 使いやすいCLI
- ✅ 美しい出力(カラーリング)
- ✅ 堅牢なエラー処理
- ✅ 包括的なテスト
- ✅ 最適化されたパフォーマンス
- Command Line Applications in Rust
- clap
- anyhow
- thiserror
---