第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 まとめ

    学んだこと

  • アーキテクチャ設計
- レイヤードアーキテクチャ - 依存性注入

  • エラー処理
- カスタムエラー型 - thiserror / anyhow

  • CLI設計
- clap による引数パース - 設定ファイル

  • 実装テクニック
- 並列処理 - カラー出力 - パフォーマンス最適化

完成形

これで、以下を備えた実用的なCLIツールが完成します:

  • ✅ 高速な検索(並列処理)
  • ✅ 使いやすいCLI
  • ✅ 美しい出力(カラーリング)
  • ✅ 堅牢なエラー処理
  • ✅ 包括的なテスト
  • ✅ 最適化されたパフォーマンス
  • ---

    参考資料

  • Command Line Applications in Rust
  • clap
  • anyhow
  • thiserror