Back to list
dev_to 2026年3月21日

Rust で CLI ツールを構築する:ゼロから crates.io に公開まで

Building a CLI Tool in Rust: From Zero to Published on crates.io

Translated: 2026/3/21 5:01:37

Japanese Translation

多くの CLI ツールは Python や Node.js で書かれています。動作することはできますが、起動に遅い、ランタイムが必要で、配布も難しいという課題があります。Rust は単一バイナリの配布、ミリ秒未満の起動、そして無謀な並行性を提供します。ここでは、ゼロからファイル検索ツール「hunt」を構築し、crates.io に公開する過程を紹介します。 まずはプロジェクトをセットアップします: ```bash cargo new hunt && cd hunt ``` `Cargo.toml` の内容は以下の通りです: [package] name = "hunt" version = "0.1.0" edition = "2021" description = "A fast file content searcher" license = "MIT" [dependencies] clap = { version = "4", features = ["derive"] } regex = "1" colored = "2" anyhow = "1" ignore = "0.4" Clap の派生 API を使用して構体を完全な CLI パーサーに変換します: ```rust use clap::Parser; use std::path::PathBuf; #[derive(Parser, Debug)] #[command(name = "hunt", about = "Search file contents, fast")] pub struct Args { /// Regex pattern to search for pub pattern: String, /// Directory or file to search #[arg(default_value = ".")] pub path: PathBuf, /// Case-insensitive search #[arg(short, long)] pub ignore_case: bool, /// Filter by file extension #[arg(short = 't', long = "type")] pub file_type: Option<String>, /// Show match counts only #[arg(short, long)] pub count: bool, /// Max results to display #[arg(short = 'n', long, default_value = "1000")] pub max_results: usize, } ``` この構体により、`--help`、`--version`、検証、およびシェル補完がゼロの手動引数解析で提供されます。 `ignore` クライアント(ripgrep の著者によって提供)は`.gitignore` ルールを自動的に処理します: ```rust use anyhow::Result; use ignore::WalkBuilder; use std::path::Path; pub fn walk_files(root: &Path, file_type: Option<&str>, mut cb: impl FnMut(&Path) -> Result<()>) -> Result<()> { let builder = WalkBuilder::new(root); for entry in builder.build() { let entry = entry?; if entry.file_type().map_or(true, |ft| ft.is_dir()) { continue; } let path = entry.path(); if let Some(ext) = file_type { let matches = path.extension() .and_then(|e| e.to_str()) .map_or(false, |e| e == ext); if !matches { continue; } } if !cb(path)? { break; } } Ok(()) } ``` キーとなる無料機能:`.gitignore` パーシング、隠しファイルのフィルタリング、シンボリックリンクの扱い、クロスプラットフォームパス。` 検索ロジックを実装します: ```rust use colored::Colorize; use regex::Regex; use std::fs; use std::path::Path; pub struct Match { pub path: String, pub line_num: usize, pub line: String, pub start: usize, pub end: usize, } pub fn search_file(path: &Path, regex: &Regex) -> Vec<Match> { let content = match fs::read_to_string(path) { Ok(c) => c, Err(_) => return vec![], // スキップバイナリ/読み取れないファイル }; let path_str = path.display().to_string(); content.lines().enumerate().filter_map(|(i, line)| { regex.find(line).map(|m| Match { path: path_str.clone(), line_num: i + 1, line: line.to_string(), start: m.start(), end: m.end(), }) }).collect() } pub fn display(results: &[Match], max: usize) { let mut current_file: Option<String> = None; for m in results.iter().take(max) { if m.path != current_file.as_ref().map(|s| s.as_str()) { if let Some(c) = current_file { println!(); } println!("{}:{}{}", m.path.purple().bold(), ",", ";"); // Simplified display logic current_file = Some(m.path.clone()); } println!("{}:{}{}", m.line_num.to_string().green(), ".", "."); // Simplified line display } } ``` メイン関数をまとめます: ```rust use anyhow::Result; use clap::Parser; use regex::Regex; fn main() -> Result<()> { let args = Args::parse(); let pattern = if args.ignore_case { format!("(?i){}", args.pattern) } else { args.pattern.clone() }; let regex = Regex::new(&pattern)?; let mut results = Vec::new(); let max = args.max_results; walk_files(&args.path, args.file_type.as_deref(), |path| { results.extend(search_file(path, &regex)); Ok(results.len() < max) })?; if results.is_empty() { std::process::exit(1); } display(&results, max); Ok(()) } ``` `anyhow` はコンテキストチェーン付きで使い勝手の良いエラーハンドリングを提供します: ```rust use anyhow::{Context, Result, bail}; fn validate_path(path: &Path) -> Result<()> { if !path.exists() { bail!("Path does not exist: {}", path.display()); } Ok(()) } ``` エラーが完全な文脈で上層にバブルアップします: ``` Error: Failed to read /tmp/missing.txt Caused by: No such file or directory (os error 2) ```

Original Content

Most CLI tools are written in Python or Node. They work, but they are slow to start, need a runtime, and distribute poorly. Rust gives you single-binary distribution, sub-millisecond startup, and fearless concurrency. Here is how to build a file searcher called hunt from scratch to published crate. cargo new hunt && cd hunt [package] name = "hunt" version = "0.1.0" edition = "2021" description = "A fast file content searcher" license = "MIT" [dependencies] clap = { version = "4", features = ["derive"] } regex = "1" colored = "2" anyhow = "1" ignore = "0.4" Clap derive API turns a struct into a full CLI parser: use clap::Parser; use std::path::PathBuf; #[derive(Parser, Debug)] #[command(name = "hunt", about = "Search file contents, fast")] pub struct Args { /// Regex pattern to search for pub pattern: String, /// Directory or file to search #[arg(default_value = ".")] pub path: PathBuf, /// Case-insensitive search #[arg(short, long)] pub ignore_case: bool, /// Filter by file extension #[arg(short = 't', long = "type")] pub file_type: Option, /// Show match counts only #[arg(short, long)] pub count: bool, /// Max results to display #[arg(short = 'n', long, default_value = "1000")] pub max_results: usize, } That struct gives you --help, --version, validation, and shell completions with zero manual argument parsing. The ignore crate (from the ripgrep author) handles .gitignore rules automatically: use anyhow::Result; use ignore::WalkBuilder; use std::path::Path; pub fn walk_files(root: &Path, file_type: Option<&str>, mut cb: F) -> Result<()> where F: FnMut(&Path) -> Result { let builder = WalkBuilder::new(root); for entry in builder.build() { let entry = entry?; if entry.file_type().map_or(true, |ft| ft.is_dir()) { continue; } let path = entry.path(); if let Some(ext) = file_type { let matches = path.extension() .and_then(|e| e.to_str()) .map_or(false, |e| ext == e); if !matches { continue; } } if !cb(path)? { break; } } Ok(()) } Key features you get for free: .gitignore parsing, hidden file filtering, symlink handling, cross-platform paths. use colored::Colorize; use regex::Regex; use std::fs; use std::path::Path; pub struct Match { pub path: String, pub line_num: usize, pub line: String, pub start: usize, pub end: usize, } pub fn search_file(path: &Path, regex: &Regex) -> Vec { let content = match fs::read_to_string(path) { Ok(c) => c, Err(_) => return vec![], // Skip binary/unreadable files }; let path_str = path.display().to_string(); content.lines().enumerate().filter_map(|(i, line)| { regex.find(line).map(|m| Match { path: path_str.clone(), line_num: i + 1, line: line.to_string(), start: m.start(), end: m.end(), }) }).collect() } pub fn display(results: &[Match], max: usize) { let mut current_file = ""; for m in results.iter().take(max) { if m.path != current_file { if !current_file.is_empty() { println!(); } println!("{}", m.path.purple().bold()); current_file = &m.path; } println!("{}:{}{}{}", m.line_num.to_string().green(), &m.line[..m.start], m.line[m.start..m.end].red().bold(), &m.line[m.end..]); } } use anyhow::Result; use clap::Parser; use regex::Regex; fn main() -> Result<()> { let args = cli::Args::parse(); let pattern = if args.ignore_case { format!("(?i){}", args.pattern) } else { args.pattern.clone() }; let regex = Regex::new(&pattern)?; let mut results = Vec::new(); let max = args.max_results; walker::walk_files(&args.path, args.file_type.as_deref(), |path| { results.extend(searcher::search_file(path, ®ex)); Ok(results.len() < max * 2) })?; if results.is_empty() { std::process::exit(1); } searcher::display(&results, max); Ok(()) } anyhow gives you ergonomic error handling with context chains: use anyhow::{Context, Result, bail}; fn validate_path(path: &Path) -> Result<()> { if !path.exists() { bail!("Path does not exist: {}", path.display()); } Ok(()) } // Errors bubble up with full context: // Error: Failed to read /tmp/missing.txt // Caused by: No such file or directory (os error 2) let content = fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; Rules: anyhow::Result for apps, thiserror for libraries. Add .context() at every I/O boundary. Unit tests: #[cfg(test)] mod tests { use super::*; use std::io::Write; use tempfile::NamedTempFile; #[test] fn finds_pattern() { let mut f = NamedTempFile::new().unwrap(); writeln!(f, "hello world").unwrap(); writeln!(f, "goodbye").unwrap(); writeln!(f, "hello again").unwrap(); let re = Regex::new("hello").unwrap(); let results = search_file(f.path(), &re); assert_eq!(results.len(), 2); assert_eq!(results[0].line_num, 1); } #[test] fn case_insensitive() { let mut f = NamedTempFile::new().unwrap(); writeln!(f, "Hello World").unwrap(); writeln!(f, "HELLO").unwrap(); let re = Regex::new("(?i)hello").unwrap(); assert_eq!(search_file(f.path(), &re).len(), 2); } } Integration tests with assert_cmd: [dev-dependencies] tempfile = "3" assert_cmd = "2" predicates = "3" use assert_cmd::Command; use predicates::prelude::*; use std::fs; use tempfile::TempDir; #[test] fn finds_pattern_in_files() { let dir = TempDir::new().unwrap(); fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap(); Command::cargo_bin("hunt").unwrap() .args(["fn", dir.path().to_str().unwrap()]) .assert() .success() .stdout(predicate::str::contains("fn main")); } #[test] fn exits_1_on_no_match() { let dir = TempDir::new().unwrap(); fs::write(dir.path().join("a.txt"), "nothing").unwrap(); Command::cargo_bin("hunt").unwrap() .args(["xyz123", dir.path().to_str().unwrap()]) .assert() .failure() .code(1); } Optimize binary size: [profile.release] strip = true lto = true codegen-units = 1 panic = "abort" Goes from around 8MB to around 2.5MB. Publish: cargo test && cargo clippy -- -D warnings cargo publish --dry-run cargo login && cargo publish Anyone can now cargo install hunt. Rust CLI tools give you single-binary distribution, sub-millisecond startup, and correct error handling via Result types. The ecosystem is mature: clap for args, colored for output, anyhow for errors, ignore for file walking, assert_cmd for integration testing. Ship a production tool in a few hundred lines. If this was helpful, you can support my work at ko-fi.com/nopkt ☕