CLIツール — clap、クロスコンパイル
clap による引数解析、カラー出力、プログレスバー、クロスコンパイル/配布まで、実用的な CLI ツール開発の全工程を習得する
CLIツール — clap、クロスコンパイル
clap による引数解析、カラー出力、プログレスバー、クロスコンパイル/配布まで、実用的な CLI ツール開発の全工程を習得する
この章で学ぶこと
- 引数解析 — clap derive API による型安全なCLI定義、サブコマンド、バリデーション
- ユーザー体験 — カラー出力、プログレスバー、対話的プロンプト
- クロスコンパイルと配布 — cross、GitHub Actions、cargo-dist
前提知識
このガイドを読む前に、以下の知識があると理解が深まります:
- 基本的なプログラミングの知識
- 関連する基礎概念の理解
- 組み込み/WASM — no_std、wasm-bindgen の内容を理解していること
1. CLIツールの構成要素
| ┌─ 引数解析 ─────────────────────────────────────┐ | ||
| clap (derive / builder) | ||
| → コマンド、フラグ、オプション、サブコマンド | ||
| └────────────────────────────────────────────────┘ | ||
| ┌─ 出力・UX ─────────────────────────────────────┐ | ||
| colored / owo-colors → カラー出力 | ||
| indicatif → プログレスバー | ||
| dialoguer → 対話的プロンプト | ||
| tabled → テーブル表示 | ||
| └────────────────────────────────────────────────┘ | ||
| ┌─ I/O・設定 ────────────────────────────────────┐ | ||
| serde + toml/json → 設定ファイル | ||
| directories → XDG パス | ||
| tracing → ログ出力 | ||
| └────────────────────────────────────────────────┘ | ||
| ┌─ ビルド・配布 ──────────────────────────────────┐ | ||
| cross → クロスコンパイル | ||
| cargo-dist → バイナリ配布 | ||
| GitHub Actions → CI/CD | ||
| └────────────────────────────────────────────────┘ |
2. clap による引数解析
コード例1: derive API の基本
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
/// ファイル管理ツール — ファイルの検索・変換・分析を行う
#[derive(Parser, Debug)]
#[command(name = "filetool")]
#[command(version, about, long_about = None)]
struct Cli {
/// 詳細出力を有効にする
#[arg(short, long, global = true)]
verbose: bool,
/// 設定ファイルのパス
#[arg(short, long, default_value = "~/.config/filetool/config.toml")]
config: PathBuf,
/// 出力フォーマット
#[arg(short, long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// ファイルを検索する
Search {
/// 検索パターン (正規表現)
pattern: String,
/// 検索対象ディレクトリ
#[arg(short, long, default_value = ".")]
dir: PathBuf,
/// 最大結果数
#[arg(short = 'n', long, default_value_t = 100)]
max_results: usize,
/// ファイル拡張子フィルタ
#[arg(short, long)]
extension: Option<String>,
},
/// ファイルを変換する
Convert {
/// 入力ファイル
input: PathBuf,
/// 出力ファイル
output: PathBuf,
/// 変換先フォーマット
#[arg(short, long)]
to: String,
},
/// ディレクトリを分析する
Analyze {
/// 対象パス
#[arg(default_value = ".")]
path: PathBuf,
/// 深度制限
#[arg(short, long)]
depth: Option<usize>,
},
}
#[derive(ValueEnum, Clone, Debug)]
enum OutputFormat {
Text,
Json,
Csv,
}
fn main() {
let cli = Cli::parse();
if cli.verbose {
eprintln!("設定ファイル: {:?}", cli.config);
eprintln!("出力形式: {:?}", cli.format);
}
match &cli.command {
Commands::Search { pattern, dir, max_results, extension } => {
println!("検索: '{}' in {:?} (最大{}件)", pattern, dir, max_results);
if let Some(ext) = extension {
println!("拡張子フィルタ: .{}", ext);
}
}
Commands::Convert { input, output, to } => {
println!("変換: {:?} -> {:?} ({}形式)", input, output, to);
}
Commands::Analyze { path, depth } => {
println!("分析: {:?} (深度: {:?})", path, depth);
}
}
}
// 使用例:
// $ filetool search "TODO" --dir src/ -n 50 --extension rs
// $ filetool convert input.csv output.json --to json
// $ filetool analyze /var/log --depth 3 --verbose --format jsonコード例2: バリデーション
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser)]
struct Cli {
/// ポート番号 (1024-65535)
#[arg(short, long, default_value_t = 8080)]
#[arg(value_parser = clap::value_parser!(u16).range(1024..=65535))]
port: u16,
/// 入力ファイル (存在確認)
#[arg(value_parser = validate_file_exists)]
input: PathBuf,
/// 並行度 (1-256)
#[arg(short = 'j', long, default_value_t = num_cpus::get())]
#[arg(value_parser = clap::value_parser!(usize).range(1..=256))]
jobs: usize,
}
fn validate_file_exists(s: &str) -> Result<PathBuf, String> {
let path = PathBuf::from(s);
if path.exists() {
Ok(path)
} else {
Err(format!("ファイルが見つかりません: {}", s))
}
}コード例2b: 環境変数との連携
use clap::Parser;
#[derive(Parser, Debug)]
#[command(name = "myapp")]
struct Cli {
/// API キー (環境変数 MY_API_KEY でも設定可能)
#[arg(long, env = "MY_API_KEY")]
api_key: String,
/// ログレベル (環境変数 RUST_LOG でも設定可能)
#[arg(long, env = "RUST_LOG", default_value = "info")]
log_level: String,
/// データベース URL
#[arg(long, env = "DATABASE_URL")]
database_url: Option<String>,
/// デバッグモード
#[arg(long, env = "DEBUG", default_value_t = false)]
debug: bool,
}
// 優先順位: コマンドライン引数 > 環境変数 > デフォルト値
// $ MY_API_KEY=secret myapp --log-level debug
// $ myapp --api-key secret --database-url postgres://localhost/mydbコード例2c: グループ化とフラットン
use clap::{Args, Parser};
#[derive(Parser, Debug)]
struct Cli {
#[command(flatten)]
connection: ConnectionArgs,
#[command(flatten)]
output: OutputArgs,
/// 実行するクエリ
query: String,
}
/// 接続設定
#[derive(Args, Debug)]
struct ConnectionArgs {
/// ホスト名
#[arg(long, default_value = "localhost")]
host: String,
/// ポート番号
#[arg(long, default_value_t = 5432)]
port: u16,
/// ユーザー名
#[arg(long, env = "DB_USER")]
user: String,
/// パスワード
#[arg(long, env = "DB_PASSWORD")]
password: Option<String>,
/// SSL モード
#[arg(long, value_enum, default_value_t = SslMode::Prefer)]
ssl_mode: SslMode,
}
/// 出力設定
#[derive(Args, Debug)]
struct OutputArgs {
/// 出力フォーマット
#[arg(short, long, value_enum, default_value_t = Format::Table)]
format: Format,
/// 出力ファイル (省略時は stdout)
#[arg(short, long)]
output: Option<std::path::PathBuf>,
/// ヘッダーを非表示
#[arg(long)]
no_header: bool,
}
#[derive(clap::ValueEnum, Clone, Debug)]
enum SslMode { Disable, Prefer, Require }
#[derive(clap::ValueEnum, Clone, Debug)]
enum Format { Table, Json, Csv, Tsv }
// $ myapp --host db.example.com --user admin --format json "SELECT * FROM users"コード例2d: カスタム derive とヘルプメッセージ
use clap::Parser;
/// ログ解析ツール — 構造化ログを検索・集計・可視化する
///
/// 例:
/// logana search "ERROR" --since 1h --format json
/// logana stats --group-by level
/// logana tail --follow /var/log/app.log
#[derive(Parser)]
#[command(
name = "logana",
version,
about,
long_about = None,
after_help = "詳細は https://example.com/logana を参照してください。",
arg_required_else_help = true,
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(clap::Subcommand)]
enum Commands {
/// ログを検索する
Search {
/// 検索パターン (正規表現対応)
pattern: String,
/// 期間フィルタ (例: 1h, 30m, 7d)
#[arg(long, value_parser = parse_duration)]
since: Option<std::time::Duration>,
/// ログレベルフィルタ
#[arg(long, value_delimiter = ',')]
level: Vec<String>,
/// コンテキスト行数 (前後)
#[arg(short = 'C', long, default_value_t = 0)]
context: usize,
},
/// 統計情報を表示する
Stats {
/// グループ化キー
#[arg(long)]
group_by: Option<String>,
/// 上位 N 件のみ表示
#[arg(long, default_value_t = 10)]
top: usize,
},
/// リアルタイムでログを追跡する
Tail {
/// 対象ファイル
file: std::path::PathBuf,
/// follow モード (-f 相当)
#[arg(short, long)]
follow: bool,
/// フィルタパターン
#[arg(long)]
filter: Option<String>,
},
}
fn parse_duration(s: &str) -> Result<std::time::Duration, String> {
let len = s.len();
if len < 2 {
return Err("期間の形式が不正です (例: 1h, 30m, 7d)".to_string());
}
let (num_str, unit) = s.split_at(len - 1);
let num: u64 = num_str.parse().map_err(|_| "数値の解析に失敗しました".to_string())?;
match unit {
"s" => Ok(std::time::Duration::from_secs(num)),
"m" => Ok(std::time::Duration::from_secs(num * 60)),
"h" => Ok(std::time::Duration::from_secs(num * 3600)),
"d" => Ok(std::time::Duration::from_secs(num * 86400)),
_ => Err(format!("不明な単位: {}. s/m/h/d を使用してください", unit)),
}
}3. ユーザー体験の向上
コード例3: カラー出力とプログレスバー
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle, MultiProgress};
fn main() {
// カラー出力
println!("{}", "成功!".green().bold());
println!("{}", "警告: ディスク容量低下".yellow());
println!("{}", "エラー: ファイル破損".red().bold());
println!("{}", "情報: 処理開始".blue());
// 単一プログレスバー
let pb = ProgressBar::new(100);
pb.set_style(
ProgressStyle::with_template(
"{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({eta})"
).unwrap()
.progress_chars("=>-")
);
for i in 0..100 {
pb.set_position(i);
std::thread::sleep(std::time::Duration::from_millis(20));
}
pb.finish_with_message("完了!");
// マルチプログレスバー
let multi = MultiProgress::new();
let style = ProgressStyle::with_template(
"{prefix:.bold} [{bar:30}] {pos}/{len}"
).unwrap();
let pb1 = multi.add(ProgressBar::new(50));
let pb2 = multi.add(ProgressBar::new(80));
pb1.set_style(style.clone());
pb1.set_prefix("ダウンロード");
pb2.set_style(style);
pb2.set_prefix("変換 ");
// 並行処理でバーを更新
let h1 = std::thread::spawn(move || {
for i in 0..=50 { pb1.set_position(i); std::thread::sleep(std::time::Duration::from_millis(30)); }
pb1.finish();
});
let h2 = std::thread::spawn(move || {
for i in 0..=80 { pb2.set_position(i); std::thread::sleep(std::time::Duration::from_millis(20)); }
pb2.finish();
});
h1.join().unwrap();
h2.join().unwrap();
}コード例3b: テーブル出力
use tabled::{Table, Tabled, settings::{Style, Modify, object::Columns, Alignment}};
#[derive(Tabled)]
struct ProcessInfo {
#[tabled(rename = "PID")]
pid: u32,
#[tabled(rename = "名前")]
name: String,
#[tabled(rename = "CPU %")]
cpu: f64,
#[tabled(rename = "メモリ (MB)")]
memory_mb: f64,
#[tabled(rename = "状態")]
status: String,
}
fn display_processes(processes: &[ProcessInfo]) {
let table = Table::new(processes)
.with(Style::rounded())
.with(Modify::new(Columns::new(2..=3)).with(Alignment::right()))
.to_string();
println!("{}", table);
}
fn main() {
let processes = vec![
ProcessInfo {
pid: 1234, name: "nginx".into(),
cpu: 2.5, memory_mb: 128.4, status: "running".into(),
},
ProcessInfo {
pid: 5678, name: "postgres".into(),
cpu: 15.3, memory_mb: 512.8, status: "running".into(),
},
ProcessInfo {
pid: 9012, name: "redis".into(),
cpu: 0.8, memory_mb: 64.2, status: "idle".into(),
},
];
display_processes(&processes);
// 出力:
// ╭──────┬──────────┬───────┬──────────────┬─────────╮
// │ PID │ 名前 │ CPU % │ メモリ (MB) │ 状態 │
// ├──────┼──────────┼───────┼──────────────┼─────────┤
// │ 1234 │ nginx │ 2.5 │ 128.4 │ running │
// │ 5678 │ postgres │ 15.3 │ 512.8 │ running │
// │ 9012 │ redis │ 0.8 │ 64.2 │ idle │
// ╰──────┴──────────┴───────┴──────────────┴─────────╯
}コード例3c: スピナーとステータス表示
use indicatif::{ProgressBar, ProgressStyle};
use std::time::Duration;
/// 不定量の処理にはスピナーを使う
fn process_with_spinner() {
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::with_template("{spinner:.green} {msg}")
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
);
spinner.set_message("データベースに接続中...");
// 接続処理のシミュレーション
std::thread::sleep(Duration::from_secs(2));
spinner.set_message("スキーマを検証中...");
std::thread::sleep(Duration::from_secs(1));
spinner.set_message("マイグレーションを実行中...");
std::thread::sleep(Duration::from_secs(3));
spinner.finish_with_message("✓ マイグレーション完了");
}
/// ダウンロードのようなバイトベースの進捗表示
fn download_with_progress(total_bytes: u64) {
let pb = ProgressBar::new(total_bytes);
pb.set_style(
ProgressStyle::with_template(
"{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})"
)
.unwrap()
.progress_chars("█▓▒░ "),
);
let mut downloaded = 0u64;
while downloaded < total_bytes {
let chunk = std::cmp::min(8192, total_bytes - downloaded);
downloaded += chunk;
pb.set_position(downloaded);
std::thread::sleep(Duration::from_millis(10));
}
pb.finish_with_message("ダウンロード完了");
}コード例4: 対話的プロンプト
use dialoguer::{Confirm, Input, Select, MultiSelect, Password};
use console::style;
fn main() -> anyhow::Result<()> {
// テキスト入力
let name: String = Input::new()
.with_prompt("プロジェクト名")
.default("my-project".into())
.interact_text()?;
// 選択
let frameworks = &["Axum", "Actix-web", "Rocket", "Warp"];
let selection = Select::new()
.with_prompt("フレームワークを選択")
.items(frameworks)
.default(0)
.interact()?;
println!("選択: {}", frameworks[selection]);
// 複数選択
let features = &["Database", "Auth", "WebSocket", "GraphQL", "Metrics"];
let selections = MultiSelect::new()
.with_prompt("機能を選択 (スペースキーで選択)")
.items(features)
.interact()?;
for &i in &selections {
println!(" + {}", features[i]);
}
// 確認
if Confirm::new()
.with_prompt("プロジェクトを作成しますか?")
.default(true)
.interact()?
{
println!("{} プロジェクト '{}' を作成しました!", style("✓").green(), name);
}
Ok(())
}4. クロスコンパイルと配布
コード例5: クロスコンパイル設定
# .cargo/config.toml
# macOS (Apple Silicon)
[target.aarch64-apple-darwin]
# ネイティブの場合は設定不要
# Linux (x86_64, musl で静的リンク)
[target.x86_64-unknown-linux-musl]
linker = "x86_64-linux-musl-gcc"
# Windows (MinGW クロスコンパイル)
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"# cross を使ったクロスコンパイル (Docker ベース)
cargo install cross
# Linux (静的リンク)
cross build --release --target x86_64-unknown-linux-musl
# Windows
cross build --release --target x86_64-pc-windows-gnu
# ARM Linux (Raspberry Pi)
cross build --release --target aarch64-unknown-linux-gnuCI/CD パイプライン
| git push --tags v1.0.0 | ||
| ▼ | ||
| ┌──────────────────────────────────┐ | ||
| Matrix Build | ||
| ┌─ ubuntu → x86_64-linux | ||
| ├─ ubuntu → aarch64-linux | ||
| ├─ macos → x86_64-darwin | ||
| ├─ macos → aarch64-darwin | ||
| └─ windows → x86_64-windows | ||
| └──────────────────────────────────┘ | ||
| ▼ | ||
| ┌──────────────────────────────────┐ | ||
| GitHub Release | ||
| - filetool-linux-x86_64.tar.gz | ||
| - filetool-linux-aarch64.tar.gz | ||
| - filetool-darwin-x86_64.tar.gz | ||
| - filetool-darwin-aarch64.tar.gz | ||
| - filetool-windows-x86_64.zip | ||
| └──────────────────────────────────┘ |
コード例6: GitHub Actions ワークフロー
# .github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
jobs:
build:
strategy:
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
archive: tar.gz
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
archive: tar.gz
- target: x86_64-apple-darwin
os: macos-latest
archive: tar.gz
- target: aarch64-apple-darwin
os: macos-latest
archive: tar.gz
- target: x86_64-pc-windows-msvc
os: windows-latest
archive: zip
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Upload
uses: actions/upload-artifact@v4
with:
name: filetool-${{ matrix.target }}
path: target/${{ matrix.target }}/release/filetool*コード例6b: cargo-dist による自動配布
# Cargo.toml に以下を追加
[workspace.metadata.dist]
# CI プロバイダ
ci = ["github"]
# インストーラ生成
installers = ["shell", "powershell", "homebrew"]
# ターゲット
targets = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"x86_64-pc-windows-msvc",
]
# Homebrew tap リポジトリ
tap = "username/homebrew-tap"
# リリース発行
publish-jobs = ["homebrew"]# cargo-dist の初期化
cargo install cargo-dist
cargo dist init
# ローカルでビルドテスト
cargo dist build
# プラン表示 (何がビルドされるか確認)
cargo dist plan
# タグプッシュでリリース自動実行
git tag v1.0.0
git push --tags5. 設定ファイルとデータ永続化
コード例7: XDG 準拠の設定管理
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AppConfig {
/// デフォルトの出力フォーマット
#[serde(default = "default_format")]
pub format: String,
/// 並行度
#[serde(default = "default_jobs")]
pub jobs: usize,
/// カラー出力
#[serde(default = "default_color")]
pub color: ColorMode,
/// エディタコマンド
#[serde(default)]
pub editor: Option<String>,
/// カスタムエイリアス
#[serde(default)]
pub aliases: std::collections::HashMap<String, Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "lowercase")]
pub enum ColorMode {
Auto,
Always,
Never,
}
fn default_format() -> String { "text".into() }
fn default_jobs() -> usize { num_cpus::get() }
fn default_color() -> ColorMode { ColorMode::Auto }
impl AppConfig {
/// 設定ファイルのパスを取得 (XDG 準拠)
pub fn config_path() -> Option<PathBuf> {
ProjectDirs::from("com", "example", "filetool")
.map(|dirs| dirs.config_dir().join("config.toml"))
}
/// データディレクトリのパスを取得
pub fn data_dir() -> Option<PathBuf> {
ProjectDirs::from("com", "example", "filetool")
.map(|dirs| dirs.data_dir().to_path_buf())
}
/// キャッシュディレクトリのパスを取得
pub fn cache_dir() -> Option<PathBuf> {
ProjectDirs::from("com", "example", "filetool")
.map(|dirs| dirs.cache_dir().to_path_buf())
}
/// 設定を読み込む (なければデフォルト値)
pub fn load() -> anyhow::Result<Self> {
let path = Self::config_path()
.ok_or_else(|| anyhow::anyhow!("設定ディレクトリを特定できません"))?;
if path.exists() {
let content = std::fs::read_to_string(&path)?;
let config: Self = toml::from_str(&content)?;
Ok(config)
} else {
Ok(Self::default())
}
}
/// 設定を保存する
pub fn save(&self) -> anyhow::Result<()> {
let path = Self::config_path()
.ok_or_else(|| anyhow::anyhow!("設定ディレクトリを特定できません"))?;
// ディレクトリを作成
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
std::fs::write(&path, content)?;
Ok(())
}
}
impl Default for AppConfig {
fn default() -> Self {
Self {
format: default_format(),
jobs: default_jobs(),
color: default_color(),
editor: None,
aliases: std::collections::HashMap::new(),
}
}
}
// 設定ファイルの例 (~/.config/filetool/config.toml):
// format = "json"
// jobs = 8
// color = "auto"
// editor = "nvim"
//
// [aliases]
// rs = ["search", "--extension", "rs"]
// todo = ["search", "TODO|FIXME", "--extension", "rs"]コード例8: 履歴とキャッシュの管理
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Debug)]
pub struct CommandHistory {
entries: Vec<HistoryEntry>,
#[serde(default = "default_max_entries")]
max_entries: usize,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct HistoryEntry {
pub timestamp: chrono::DateTime<chrono::Utc>,
pub command: String,
pub args: Vec<String>,
pub exit_code: i32,
pub duration_ms: u64,
}
fn default_max_entries() -> usize { 1000 }
impl CommandHistory {
pub fn load(path: &PathBuf) -> anyhow::Result<Self> {
if path.exists() {
let content = std::fs::read_to_string(path)?;
Ok(serde_json::from_str(&content)?)
} else {
Ok(Self {
entries: Vec::new(),
max_entries: default_max_entries(),
})
}
}
pub fn add(&mut self, entry: HistoryEntry) {
self.entries.push(entry);
// 古いエントリを削除
if self.entries.len() > self.max_entries {
let excess = self.entries.len() - self.max_entries;
self.entries.drain(..excess);
}
}
pub fn save(&self, path: &PathBuf) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)?;
std::fs::write(path, content)?;
Ok(())
}
/// 直近 N 件を取得
pub fn recent(&self, n: usize) -> &[HistoryEntry] {
let start = self.entries.len().saturating_sub(n);
&self.entries[start..]
}
/// 特定コマンドの実行回数
pub fn command_count(&self, command: &str) -> usize {
self.entries.iter().filter(|e| e.command == command).count()
}
}6. エラーハンドリングとロギング
コード例9: anyhow + thiserror による構造化エラー
use thiserror::Error;
/// アプリケーション固有のエラー型
#[derive(Error, Debug)]
pub enum AppError {
#[error("設定ファイルの読み込みに失敗しました: {path}")]
ConfigLoad {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("パターン '{pattern}' は無効な正規表現です")]
InvalidPattern {
pattern: String,
#[source]
source: regex::Error,
},
#[error("ファイル '{path}' が見つかりません")]
FileNotFound { path: std::path::PathBuf },
#[error("権限が不足しています: {path}")]
PermissionDenied { path: std::path::PathBuf },
#[error("操作がタイムアウトしました ({timeout_secs}秒)")]
Timeout { timeout_secs: u64 },
#[error("不明なフォーマット: '{format}'. 使用可能: {available}")]
UnknownFormat {
format: String,
available: String,
},
}
/// main 関数での統合
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
// tracing の初期化
setup_logging(cli.verbose)?;
// 実行
let result = run(&cli);
// エラー表示のカスタマイズ
match result {
Ok(()) => std::process::exit(0),
Err(e) => {
// anyhow のエラーチェーンを表示
if cli.verbose {
// 詳細: エラーチェーン全体
eprintln!("{}: {:?}", "エラー".red().bold(), e);
} else {
// 簡潔: 最上位のメッセージのみ
eprintln!("{}: {}", "エラー".red().bold(), e);
}
// エラーの種類に応じた終了コード
let code = match e.downcast_ref::<AppError>() {
Some(AppError::FileNotFound { .. }) => 2,
Some(AppError::PermissionDenied { .. }) => 3,
Some(AppError::InvalidPattern { .. }) => 4,
Some(AppError::ConfigLoad { .. }) => 5,
Some(AppError::Timeout { .. }) => 6,
_ => 1,
};
std::process::exit(code);
}
}
}
use clap::Parser;
use colored::Colorize;
#[derive(Parser)] struct Cli { #[arg(short, long)] verbose: bool }
fn run(_cli: &Cli) -> anyhow::Result<()> { Ok(()) }
fn setup_logging(_verbose: bool) -> anyhow::Result<()> { Ok(()) }コード例10: tracing によるログ出力
use tracing::{debug, error, info, instrument, warn};
use tracing_subscriber::{fmt, EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
/// ロギングのセットアップ
fn setup_logging(verbose: bool, log_file: Option<&std::path::Path>) -> anyhow::Result<()> {
let env_filter = if verbose {
EnvFilter::new("debug")
} else {
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"))
};
let fmt_layer = fmt::layer()
.with_target(false)
.with_thread_ids(false)
.with_timer(fmt::time::ChronoLocal::new("%H:%M:%S%.3f".to_string()));
let subscriber = tracing_subscriber::registry()
.with(env_filter)
.with(fmt_layer);
// ファイル出力を追加
if let Some(log_path) = log_file {
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)?;
let file_layer = fmt::layer()
.with_writer(file)
.with_ansi(false)
.json();
subscriber.with(file_layer).init();
} else {
subscriber.init();
}
Ok(())
}
/// instrument マクロで自動トレーシング
#[instrument(skip(content), fields(content_len = content.len()))]
fn process_file(path: &std::path::Path, content: &str) -> anyhow::Result<usize> {
info!("ファイルを処理中: {:?}", path);
let lines = content.lines().count();
debug!(lines, "行数を計算");
if lines == 0 {
warn!("空のファイルです: {:?}", path);
}
// エラーの場合
if !path.exists() {
error!("ファイルが存在しません: {:?}", path);
anyhow::bail!("ファイルが見つかりません");
}
info!(lines, "処理完了");
Ok(lines)
}
// 使い方:
// RUST_LOG=debug myapp process ← debug 以上を表示
// RUST_LOG=myapp=trace myapp ← 自クレートのみ trace
// myapp --verbose process ← verbose で debug 有効7. CLI テスト
コード例11: assert_cmd による統合テスト
// tests/integration_test.rs
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::TempDir;
use std::fs;
#[test]
fn test_help_flag() {
Command::cargo_bin("filetool")
.unwrap()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("ファイル管理ツール"));
}
#[test]
fn test_version_flag() {
Command::cargo_bin("filetool")
.unwrap()
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn test_search_basic() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.rs");
fs::write(&file, "fn main() {\n // TODO: implement\n println!(\"hello\");\n}").unwrap();
Command::cargo_bin("filetool")
.unwrap()
.args(["search", "TODO", "--dir"])
.arg(dir.path())
.assert()
.success()
.stdout(predicate::str::contains("TODO"));
}
#[test]
fn test_search_no_results() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("empty.txt");
fs::write(&file, "nothing here").unwrap();
Command::cargo_bin("filetool")
.unwrap()
.args(["search", "NONEXISTENT", "--dir"])
.arg(dir.path())
.assert()
.success()
.stdout(predicate::str::contains("0 件"));
}
#[test]
fn test_invalid_pattern() {
Command::cargo_bin("filetool")
.unwrap()
.args(["search", "[invalid", "--dir", "."])
.assert()
.failure()
.stderr(predicate::str::contains("無効な正規表現"));
}
#[test]
fn test_nonexistent_directory() {
Command::cargo_bin("filetool")
.unwrap()
.args(["search", "test", "--dir", "/nonexistent/path"])
.assert()
.failure()
.code(2);
}
#[test]
fn test_json_output_format() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("data.txt");
fs::write(&file, "test data").unwrap();
let output = Command::cargo_bin("filetool")
.unwrap()
.args(["search", "test", "--dir"])
.arg(dir.path())
.args(["--format", "json"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
// JSON として妥当かチェック
let _: serde_json::Value = serde_json::from_str(&stdout)
.expect("出力が有効な JSON であること");
}コード例12: trycmd によるスナップショットテスト
# Cargo.toml
[dev-dependencies]
trycmd = "0.15"// tests/cli_tests.rs
#[test]
fn cli_tests() {
trycmd::TestCases::new().case("tests/cmd/*.toml");
}# tests/cmd/search_help.toml
bin.name = "filetool"
args = ["search", "--help"]
status.code = 0
stdout.is = """
ファイルを検索する
Usage: filetool search [OPTIONS] <PATTERN>
Arguments:
<PATTERN> 検索パターン (正規表現)
Options:
-d, --dir <DIR> 検索対象ディレクトリ [default: .]
-n, --max-results <MAX> 最大結果数 [default: 100]
-e, --extension <EXT> ファイル拡張子フィルタ
-h, --help Print help
"""コード例13: clap のユニットテスト
use clap::Parser;
#[derive(Parser, Debug, PartialEq)]
struct Cli {
#[arg(short, long)]
verbose: bool,
#[arg(short, long, default_value_t = 8080)]
port: u16,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(clap::Subcommand, Debug, PartialEq)]
enum Commands {
Start { #[arg(long)] daemon: bool },
Stop,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_args() {
let cli = Cli::parse_from(["myapp"]);
assert!(!cli.verbose);
assert_eq!(cli.port, 8080);
assert!(cli.command.is_none());
}
#[test]
fn test_verbose_flag() {
let cli = Cli::parse_from(["myapp", "--verbose"]);
assert!(cli.verbose);
}
#[test]
fn test_custom_port() {
let cli = Cli::parse_from(["myapp", "--port", "3000"]);
assert_eq!(cli.port, 3000);
}
#[test]
fn test_subcommand_start() {
let cli = Cli::parse_from(["myapp", "start", "--daemon"]);
assert_eq!(cli.command, Some(Commands::Start { daemon: true }));
}
#[test]
fn test_invalid_port_range() {
let result = Cli::try_parse_from(["myapp", "--port", "99999"]);
assert!(result.is_err());
}
/// ヘルプが正常に生成されることを検証
#[test]
fn test_help_generation() {
// parse_from に --help を渡すとプロセスが終了するため、
// Command を直接使って検証する
use clap::CommandFactory;
let mut cmd = Cli::command();
let help = cmd.render_help().to_string();
assert!(help.contains("--verbose"));
assert!(help.contains("--port"));
}
}8. 実践的な CLI ツール設計パターン
パイプとリダイレクト対応
use std::io::{self, BufRead, Write, IsTerminal};
/// stdin がパイプされているか TTY かを判定して動作を変える
fn main() -> anyhow::Result<()> {
let stdin = io::stdin();
let stdout = io::stdout();
let mut out = io::BufWriter::new(stdout.lock());
if stdin.is_terminal() {
// 対話モード: ユーザーに入力を求める
eprintln!("テキストを入力してください (Ctrl+D で終了):");
}
// パイプでもTTYでも統一的に処理
for line in stdin.lock().lines() {
let line = line?;
let processed = process_line(&line);
writeln!(out, "{}", processed)?;
}
Ok(())
}
fn process_line(line: &str) -> String {
line.to_uppercase()
}
// 使い方:
// $ echo "hello world" | myapp ← パイプ入力
// $ myapp < input.txt > output.txt ← リダイレクト
// $ myapp ← 対話モードシグナルハンドリング (Graceful Shutdown)
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
fn main() -> anyhow::Result<()> {
// Ctrl+C ハンドラ
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
if !r.load(Ordering::SeqCst) {
// 2回目の Ctrl+C で強制終了
eprintln!("\n強制終了します");
std::process::exit(130);
}
eprintln!("\n中断を受信しました。処理を停止しています...");
r.store(false, Ordering::SeqCst);
})?;
// メイン処理ループ
let total = 1000;
for i in 0..total {
if !running.load(Ordering::SeqCst) {
eprintln!("処理を中断しました ({}/{})", i, total);
// クリーンアップ処理
cleanup()?;
std::process::exit(130); // 128 + SIGINT(2)
}
process_item(i)?;
}
Ok(())
}
fn process_item(_i: usize) -> anyhow::Result<()> {
std::thread::sleep(std::time::Duration::from_millis(10));
Ok(())
}
fn cleanup() -> anyhow::Result<()> {
eprintln!("一時ファイルを削除中...");
Ok(())
}カラー出力の自動判定
use std::io::IsTerminal;
/// 出力先に応じてカラーを自動制御
struct Output {
color_enabled: bool,
}
impl Output {
fn new(color_mode: &str) -> Self {
let color_enabled = match color_mode {
"always" => true,
"never" => false,
_ => {
// auto: TTY ならカラー、パイプなら無効
std::io::stdout().is_terminal()
&& std::env::var("NO_COLOR").is_err() // NO_COLOR 規約対応
&& std::env::var("TERM").map_or(true, |t| t != "dumb")
}
};
Self { color_enabled }
}
fn success(&self, msg: &str) {
if self.color_enabled {
println!("\x1b[32m✓\x1b[0m {}", msg);
} else {
println!("[OK] {}", msg);
}
}
fn error(&self, msg: &str) {
if self.color_enabled {
eprintln!("\x1b[31m✗\x1b[0m {}", msg);
} else {
eprintln!("[ERROR] {}", msg);
}
}
fn warning(&self, msg: &str) {
if self.color_enabled {
eprintln!("\x1b[33m⚠\x1b[0m {}", msg);
} else {
eprintln!("[WARN] {}", msg);
}
}
fn info(&self, msg: &str) {
if self.color_enabled {
println!("\x1b[36mℹ\x1b[0m {}", msg);
} else {
println!("[INFO] {}", msg);
}
}
}9. 比較表
CLI クレート一覧
| カテゴリ | クレート | 用途 | Cargo.toml |
|---|---|---|---|
| 引数解析 | clap | コマンドライン引数 | clap = { version = "4", features = ["derive", "env"] } |
| カラー | colored | 色付きターミナル出力 | colored = "2" |
| カラー | owo-colors | 軽量カラー出力 | owo-colors = "4" |
| 進捗 | indicatif | プログレスバー / スピナー | indicatif = "0.17" |
| 対話 | dialoguer | インタラクティブプロンプト | dialoguer = "0.11" |
| テーブル | tabled | テーブル表示 | tabled = "0.16" |
| コンソール | console | ターミナルユーティリティ | console = "0.15" |
| エラー | anyhow | アプリケーションエラー | anyhow = "1" |
| エラー | thiserror | ライブラリエラー定義 | thiserror = "2" |
| ログ | tracing | 構造化ログ | tracing = "0.1" |
| ログ | tracing-subscriber | ログ出力設定 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } |
| 設定 | directories | XDG パス | directories = "5" |
| 設定 | toml | TOML パーサ | toml = "0.8" |
| テスト | assert_cmd | CLI 統合テスト | assert_cmd = "2" |
| テスト | predicates | アサーション | predicates = "3" |
| テスト | trycmd | スナップショットテスト | trycmd = "0.15" |
| シグナル | ctrlc | Ctrl+C ハンドリング | ctrlc = "3" |
| ビルド | cross | クロスコンパイル | CLI ツール |
| 配布 | cargo-dist | バイナリ配布 | CLI ツール |
CLI 引数解析ライブラリ比較
| ライブラリ | API スタイル | コンパイル速度 | 機能 | 特徴 |
|---|---|---|---|---|
| clap (derive) | 構造体マクロ | 遅め | 最も豊富 | 業界標準 |
| clap (builder) | ビルダーパターン | 遅め | 最も豊富 | 動的定義 |
| argh | derive | 高速 | 基本的 | Google 製、軽量 |
| pico-args | 手動 | 最速 | 最小限 | 依存ゼロ |
| bpaf | derive + builder | 中程度 | 豊富 | 合成可能 |
配布方式比較
| 方式 | 対象 | 利点 | 欠点 |
|---|---|---|---|
| GitHub Release | 全OS | 広い到達性 | 手動ダウンロード |
| cargo install | Rust 開発者 | 最も簡単 | rustc 必要 |
| Homebrew (tap) | macOS/Linux | パッケージ管理 | tap 管理が必要 |
| cargo-dist | 全OS | 自動化 | 設定が必要 |
| Docker イメージ | サーバー | 環境独立 | Docker 必要 |
10. アンチパターン
アンチパターン1: エラー出力を stdout に流す
// NG: エラーメッセージを println! (stdout) で出力
fn bad_main() {
let result = process_file("input.txt");
if result.is_err() {
println!("エラー: ファイルが見つかりません");
// パイプ時に正常出力と混ざる!
// $ filetool input.txt | grep pattern ← エラーも grep される
}
}
// OK: エラーは stderr、正常出力は stdout
fn good_main() {
match process_file("input.txt") {
Ok(output) => print!("{}", output), // stdout
Err(e) => {
eprintln!("エラー: {}", e); // stderr
std::process::exit(1); // 非ゼロ終了コード
}
}
}
fn process_file(_: &str) -> Result<String, String> { Ok("data".into()) }アンチパターン2: 終了コードの無視
// NG: 常に exit(0)
fn bad() {
if let Err(e) = run() {
eprintln!("{}", e);
// exit code = 0 (成功扱い) → CI/CD で異常検知できない
}
}
// OK: 適切な終了コード
fn main() {
let result = run();
std::process::exit(match result {
Ok(_) => 0, // 成功
Err(e) => {
eprintln!("エラー: {:#}", e);
match e.downcast_ref::<std::io::Error>() {
Some(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => 2,
Some(_) => 3, // I/O エラー
None => 1, // その他のエラー
}
}
});
}
fn run() -> anyhow::Result<()> { Ok(()) }アンチパターン3: カラーの強制出力
// NG: パイプ先でもカラーコードが出力されてしまう
fn bad_output() {
// colored はデフォルトで TTY を検出するが、
// 環境変数 CLICOLOR_FORCE=1 などで常に有効になる場合がある
println!("{}", "結果".green());
// $ myapp | grep pattern → "\x1b[32m結果\x1b[0m" がそのまま出力
}
// OK: NO_COLOR 規約と TTY 判定を尊重
fn good_output() {
// colored の自動検出を利用
if std::env::var("NO_COLOR").is_ok() {
colored::control::set_override(false);
}
// もしくは手動制御
use std::io::IsTerminal;
if !std::io::stdout().is_terminal() {
colored::control::set_override(false);
}
println!("{}", "結果".green());
}
use colored::Colorize;アンチパターン4: 大量出力のバッファリング不足
use std::io::{self, Write, BufWriter};
// NG: 毎行 println! → 毎回 flush でパフォーマンス劣化
fn bad_output(lines: &[String]) {
for line in lines {
println!("{}", line); // 暗黙の flush が発生
}
}
// OK: BufWriter で一括書き出し
fn good_output(lines: &[String]) -> io::Result<()> {
let stdout = io::stdout();
let mut out = BufWriter::new(stdout.lock());
for line in lines {
writeln!(out, "{}", line)?;
}
out.flush()?; // 最後に明示的に flush
Ok(())
}
// 数万行の出力で 10-100 倍の速度差が出るアンチパターン5: unwrap の乱用
// NG: CLI ツールで unwrap → ユーザーに不親切なパニックメッセージ
fn bad_main() {
let content = std::fs::read_to_string("config.toml").unwrap();
// thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: ...
let config: Config = toml::from_str(&content).unwrap();
}
// OK: エラーを人間が読めるメッセージに変換
fn good_main() -> anyhow::Result<()> {
let content = std::fs::read_to_string("config.toml")
.context("設定ファイル config.toml を開けませんでした")?;
let config: Config = toml::from_str(&content)
.context("設定ファイルの形式が不正です")?;
Ok(())
}
use anyhow::Context;
use serde::Deserialize;
#[derive(Deserialize)] struct Config {}FAQ
Q1: clap の derive API と builder API はどちらを使うべき?
A: ほとんどの場合 derive API が推奨です。構造体に #[derive(Parser)] を付けるだけで型安全な CLI が定義できます。動的にコマンドを構築する必要がある場合(プラグインシステム等)のみ builder API を検討してください。
Q2: musl と glibc の違いは?
A: musl でビルドすると静的リンクされ、libc に依存しないバイナリになります。どの Linux ディストリビューションでもそのまま動作します。glibc は動的リンクのため、実行環境の glibc バージョンに依存します。配布用バイナリには musl が推奨です。
Q3: シェル補完を生成するには?
A: clap の clap_complete クレートで各シェルの補完スクリプトを自動生成できます。
use clap::CommandFactory;
use clap_complete::{generate, Shell};
// ビルド時に生成 (build.rs)
fn main() {
let mut cmd = Cli::command();
let out_dir = std::env::var("OUT_DIR").unwrap();
for shell in [Shell::Bash, Shell::Zsh, Shell::Fish, Shell::PowerShell] {
let mut file = std::fs::File::create(
format!("{}/filetool.{}", out_dir, shell)
).unwrap();
generate(shell, &mut cmd, "filetool", &mut file);
}
}
// サブコマンドで生成するパターン
#[derive(clap::Subcommand)]
enum Commands {
/// シェル補完スクリプトを生成する
Completions {
/// 対象シェル
#[arg(value_enum)]
shell: Shell,
},
// ... 他のコマンド
}
fn handle_completions(shell: Shell) {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "filetool", &mut std::io::stdout());
}
// 使い方:
// $ filetool completions bash > ~/.local/share/bash-completion/completions/filetool
// $ filetool completions zsh > ~/.zfunc/_filetool
// $ filetool completions fish > ~/.config/fish/completions/filetool.fishQ4: マニュアルページ (man page) を生成するには?
A: clap_mangen クレートで man ページを自動生成できます。
// build.rs
use clap::CommandFactory;
use clap_mangen::Man;
fn main() {
let out_dir = std::env::var("OUT_DIR").unwrap();
let cmd = Cli::command();
let man = Man::new(cmd);
let mut buffer = Vec::new();
man.render(&mut buffer).unwrap();
std::fs::write(format!("{}/filetool.1", out_dir), buffer).unwrap();
}Q5: バイナリサイズを小さくするには?
A: 以下の設定を組み合わせることで、バイナリサイズを大幅に削減できます。
# Cargo.toml
[profile.release]
opt-level = "z" # サイズ最適化
lto = true # Link-Time Optimization
codegen-units = 1 # 単一コード生成ユニット
panic = "abort" # unwind テーブル除去
strip = true # デバッグ情報除去| 設定 | サイズ削減効果 | ビルド速度への影響 |
|---|---|---|
| strip = true | 30-50% 削減 | なし |
| lto = true | 10-20% 削減 | 大幅に遅化 |
| opt-level = "z" | 5-15% 削減 | やや遅化 |
| panic = "abort" | 5-10% 削減 | なし |
| codegen-units = 1 | 5-10% 削減 | 遅化 |
さらに cargo install cargo-bloat でバイナリ内の関数サイズを分析できます。
Q6: CLI ツールのバージョニングはどうすべき?
A: Cargo.toml の version をシングルソースオブトゥルースにし、clap の version マクロで自動反映させます。
#[derive(clap::Parser)]
#[command(version)] // Cargo.toml の version を使用
struct Cli {
// ...
}
// git のコミットハッシュも含めたい場合:
// build.rs
fn main() {
// vergen クレートで環境変数を自動設定
println!("cargo:rustc-env=GIT_HASH={}",
std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|_| "unknown".to_string()));
}
// main.rs
#[derive(clap::Parser)]
#[command(version = format!("{} ({})", env!("CARGO_PKG_VERSION"), env!("GIT_HASH")))]
struct Cli {
// ...
}
// $ myapp --version
// myapp 1.2.3 (a1b2c3d)Q7: テスト時に stdin をシミュレートするには?
A: assert_cmd の write_stdin メソッドを使います。
use assert_cmd::Command;
#[test]
fn test_stdin_input() {
Command::cargo_bin("myapp")
.unwrap()
.write_stdin("line1\nline2\nline3\n")
.assert()
.success()
.stdout(predicates::str::contains("3 lines processed"));
}
#[test]
fn test_pipe_simulation() {
// パイプされた入力のシミュレーション
Command::cargo_bin("myapp")
.unwrap()
.write_stdin("hello world\n")
.args(["--format", "json"])
.assert()
.success();
}Q8: Rust CLI は Go の CLI と比べてどう?
A: 両者にそれぞれ長所があります。
| 項目 | Rust (clap) | Go (cobra) |
|---|---|---|
| コンパイル速度 | 遅い | 速い |
| バイナリサイズ | 小さい (strip 時) | やや大きい |
| 実行速度 | 最速クラス | 十分速い |
| メモリ使用量 | 最小 | GC 分やや多い |
| クロスコンパイル | cross が必要 | ネイティブ対応 |
| 型安全性 | 非常に高い | 高い |
| エコシステム | clap + 多数クレート | cobra が標準 |
| 学習コスト | 高い | 低い |
パフォーマンスが最重要な場合や、メモリ安全性が求められる場合は Rust が適しています。迅速な開発やチーム開発では Go が優位な場合もあります。
まとめ
| 項目 | 要点 |
|---|---|
| clap derive | #[derive(Parser)] で型安全な CLI 定義 |
| サブコマンド | #[derive(Subcommand)] で enum ベース |
| 環境変数連携 | #[arg(env = "...")] で環境変数フォールバック |
| バリデーション | value_parser とカスタム関数で入力検証 |
| カラー出力 | colored / owo-colors で見やすい出力 |
| NO_COLOR 対応 | NO_COLOR 環境変数と TTY 判定を尊重 |
| プログレスバー | indicatif で長時間処理の可視化 |
| テーブル表示 | tabled で構造化データの表形式出力 |
| 対話的UI | dialoguer で入力・選択・確認 |
| 設定ファイル | directories + toml で XDG 準拠の設定管理 |
| エラーハンドリング | anyhow + thiserror で構造化エラー |
| ログ出力 | tracing で構造化ログ |
| クロスコンパイル | cross で Docker ベースの簡単クロスビルド |
| 静的リンク | musl ターゲットで単一バイナリ配布 |
| 配布 | cargo-dist で自動バイナリ配布 |
| テスト | assert_cmd + trycmd で CLI 統合テスト |
| シグナル | ctrlc で Ctrl+C の Graceful Shutdown |
| 終了コード | 0=成功、非0=エラー。stderr にエラー出力 |
| パイプ対応 | BufWriter で高速出力、is_terminal() で判定 |
次に読むべきガイド
- Cargo/ワークスペース — パッケージ管理と公開
- テスト — CLI の統合テスト
- ベストプラクティス — API設計とエラーハンドリング
参考文献
- clap documentation: https://docs.rs/clap/latest/clap/
- Command Line Applications in Rust: https://rust-cli.github.io/book/
- cross (cross-compilation): https://github.com/cross-rs/cross