Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第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 やサーバーに配置するだけで即座に高速動作します。