Rust CLIツール開発実践:引数解析からターミナルUIまでの5つのプロダクションパターン
CLIツール開発、なぜRustが2026年の最適解なのか
PythonでCLIを書いたら、ユーザーが「Pythonがインストールされていない」と言う。Node.jsで書いたら、node_modulesがツール自体より大きい。Goで書いたら、クロスコンパイルは便利だが、エラー処理がtry-catchの塊。2026年、Rust CLIがついにコマンドラインツール開発の第一選択に——clap v4の型安全な引数解析、indicatifの美しいプログレスバー、RatatuiのターミナルUI、そして最も重要な:単一バイナリ、ゼロ依存、ミリ秒起動。
本記事は引数解析から出発し、clap引数解析→config-rs設定管理→indicatifプログレス表示→RatatuiターミナルUI→クロスプラットフォーム配布の5つのプロダクションパターンを完了し、Rust CLIを「動く」から「優れた」ものにします。
Rust CLIコアエコシステム
| ライブラリ | 用途 | 2026バージョン |
|---|---|---|
| clap | 引数解析とサブコマンド | v4.5+ |
| config-rs | マルチフォーマット設定管理 | v0.14+ |
| indicatif | プログレスバーとステータス表示 | v0.17+ |
| Ratatui | ターミナルUIフレームワーク | v0.29+ |
| crossterm | クロスプラットフォームターミナル制御 | v0.28+ |
| anyhow/thiserror | エラー処理 | v1.0+/v2.0+ |
| cargo-dist | クロスプラットフォーム配布 | v0.22+ |
| dialoguer | インタラクティブプロンプト | v0.11+ |
| console | ターミナルスタイルとカラー | v0.15+ |
| serde | シリアライズ/デシリアライズ | v1.0+ |
CLIツールアーキテクチャフロー
CLIツール処理フロー:
1. ユーザーがコマンドライン指令を実行
2. clapが引数とサブコマンドを解析、Cli構造体を生成
3. config-rsが設定ファイル(TOML/YAML/ENV)を読み込み、CLI引数とマージ
4. ビジネスロジックを実行、indicatifがプログレスバーを表示
5. オプション:RatatuiがインタラクティブターミナルUIをレンダリング
6. 結果を出力、終了コードを返す
7. cargo-distがクロスプラットフォームバイナリをビルド
問題分析:Rust CLIプロダクション開発の5つの課題
- 引数解析の複雑さ:ネストされたサブコマンド、引数検証、相互排他引数、デフォルト値——手書きパーサーはバグが入りやすい
- 設定管理の混乱:CLI引数、設定ファイル、環境変数間の優先度とマージ戦略が不明確
- プログレスフィードバックの欠如:長時間タスクにプログレス表示がなく、ユーザーがプログラムがフリーズしたと思う
- ターミナルインタラクションの原始的さ:print出力のみ、選択メニューやテーブル表示、リアルタイム更新ができない
- 配布の困難さ:Windows/macOS/Linuxの3プラットフォームビルド、署名、パッケージマネージャー公開プロセスが煩雑
ステップバイステップ:5つのプロダクションパターン
パターン1:clap v4引数解析とサブコマンド
use clap::{Parser, Subcommand, Args, ValueEnum};
#[derive(Parser)]
#[command(name = "toolkit")]
#[command(about = "ToolsKu CLI - 開発者ツールキット", long_about = None)]
#[command(version, author)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(long, global = true, help = "詳細ログを有効化")]
verbose: bool,
#[arg(long, global = true, value_name = "FILE", help = "設定ファイルパスを指定")]
config: Option<String>,
}
#[derive(Subcommand)]
enum Commands {
#[command(about = "JSON処理ツール")]
Json(JsonCommand),
#[command(about = "エンコード変換ツール")]
Encode(EncodeCommand),
#[command(about = "コードフォーマット")]
Format(FormatCommand),
#[command(about = "ファイルハッシュ計算")]
Hash(HashCommand),
#[command(about = "バッチ処理")]
Batch(BatchCommand),
}
#[derive(Args)]
struct JsonCommand {
#[command(subcommand)]
action: JsonAction,
#[arg(short, long, default_value = "stdin", help = "入力ファイルパス")]
input: String,
#[arg(short, long, help = "出力ファイルパス")]
output: Option<String>,
}
#[derive(Subcommand)]
enum JsonAction {
#[command(about = "JSONフォーマット")]
Format {
#[arg(short, long, default_value_t = 2, help = "インデントスペース数")]
indent: usize,
},
#[command(about = "JSON検証")]
Validate,
#[command(about = "JSON Pathクエリ")]
Query {
#[arg(help = "JSON Path式")]
expression: String,
},
#[command(about = "JSON圧縮")]
Minify,
}
#[derive(Args)]
struct EncodeCommand {
#[command(subcommand)]
action: EncodeAction,
}
#[derive(Subcommand)]
enum EncodeAction {
#[command(about = "Base64エンコード/デコード")]
Base64 {
#[arg(help = "入力文字列")]
input: String,
#[arg(short, long, help = "デコードモード")]
decode: bool,
#[arg(short, long, default_value_t = Encoding::Standard)]
encoding: Encoding,
},
#[command(about = "URLエンコード/デコード")]
Url {
#[arg(help = "入力文字列")]
input: String,
#[arg(short, long, help = "デコードモード")]
decode: bool,
},
}
#[derive(Clone, ValueEnum)]
enum Encoding {
Standard,
UrlSafe,
}
#[derive(Args)]
struct HashCommand {
#[arg(help = "ファイルパス")]
file: String,
#[arg(short, long, default_value_t = HashAlgorithm::Sha256)]
algorithm: HashAlgorithm,
}
#[derive(Clone, ValueEnum)]
enum HashAlgorithm {
Md5,
Sha1,
Sha256,
Sha512,
Blake3,
}
#[derive(Args)]
struct BatchCommand {
#[arg(short, long, help = "入力ディレクトリ")]
input_dir: String,
#[arg(short, long, help = "出力ディレクトリ")]
output_dir: Option<String>,
#[arg(short, long, default_value_t = 4, help = "並列数")]
parallel: usize,
#[arg(long, help = "サブディレクトリを再帰処理")]
recursive: bool,
#[arg(long, help = "ファイルマッチパターン", default_value = "*.json")]
pattern: String,
}
#[derive(Args)]
struct FormatCommand {
#[arg(help = "ファイルパス")]
file: String,
#[arg(short, long, default_value_t = 2, help = "インデントスペース数")]
indent: usize,
#[arg(long, help = "ファイルを直接修正")]
write: bool,
}
use clap::Parser;
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
if cli.verbose {
std::env::set_var("RUST_LOG", "debug");
}
env_logger::init();
match cli.command {
Commands::Json(cmd) => handle_json(cmd)?,
Commands::Encode(cmd) => handle_encode(cmd)?,
Commands::Format(cmd) => handle_format(cmd)?,
Commands::Hash(cmd) => handle_hash(cmd)?,
Commands::Batch(cmd) => handle_batch(cmd)?,
}
Ok(())
}
fn handle_json(cmd: JsonCommand) -> anyhow::Result<()> {
let input = read_input(&cmd.input)?;
match cmd.action {
JsonAction::Format { indent } => {
let value: serde_json::Value = serde_json::from_str(&input)?;
let formatted = serde_json::to_string_pretty(&value)?;
write_output(&formatted, cmd.output.as_deref())?;
}
JsonAction::Validate => {
match serde_json::from_str::<serde_json::Value>(&input) {
Ok(_) => println!("✓ 有効なJSON"),
Err(e) => {
eprintln!("✗ 無効なJSON: {}", e);
std::process::exit(1);
}
}
}
JsonAction::Query { expression } => {
let value: serde_json::Value = serde_json::from_str(&input)?;
let results = jsonpath_lib::select(&value, &expression)?;
let output = serde_json::to_string_pretty(&results)?;
write_output(&output, cmd.output.as_deref())?;
}
JsonAction::Minify => {
let value: serde_json::Value = serde_json::from_str(&input)?;
let minified = serde_json::to_string(&value)?;
write_output(&minified, cmd.output.as_deref())?;
}
}
Ok(())
}
fn read_input(path: &str) -> anyhow::Result<String> {
if path == "stdin" {
use std::io::Read;
let mut buffer = String::new();
std::io::stdin().read_to_string(&mut buffer)?;
Ok(buffer)
} else {
Ok(std::fs::read_to_string(path)?)
}
}
fn write_output(content: &str, path: Option<&str>) -> anyhow::Result<()> {
match path {
Some(p) => std::fs::write(p, content)?,
None => print!("{}", content),
}
Ok(())
}
パターン2:config-rsマルチソース設定管理
use config::{Config, ConfigBuilder, Environment, File, FileFormat};
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
pub general: GeneralConfig,
pub output: OutputConfig,
pub network: NetworkConfig,
pub processing: ProcessingConfig,
}
#[derive(Debug, Deserialize, Clone)]
pub struct GeneralConfig {
pub verbose: bool,
pub color: bool,
pub pager: bool,
pub log_level: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct OutputConfig {
pub format: OutputFormat,
pub indent: usize,
pub line_width: usize,
}
#[derive(Debug, Deserialize, Clone)]
pub struct NetworkConfig {
pub timeout_secs: u64,
pub max_retries: u32,
pub proxy: Option<String>,
pub user_agent: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ProcessingConfig {
pub parallel: usize,
pub batch_size: usize,
pub max_file_size_mb: u64,
pub temp_dir: PathBuf,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
Json,
Yaml,
Toml,
Table,
Plain,
}
impl AppConfig {
pub fn load(cli_config: Option<&str>) -> anyhow::Result<Self> {
let config_path = cli_config
.map(|s| PathBuf::from(s))
.or_else(|| {
dirs::home_dir().map(|h| h.join(".toolkit").join("config.toml"))
});
let mut builder = Config::builder()
.set_default("general.verbose", false)?
.set_default("general.color", true)?
.set_default("general.pager", false)?
.set_default("general.log_level", "info")?
.set_default("output.format", "json")?
.set_default("output.indent", 2)?
.set_default("output.line_width", 80)?
.set_default("network.timeout_secs", 30)?
.set_default("network.max_retries", 3)?
.set_default("network.user_agent", "ToolsKu-CLI/1.0")?
.set_default("processing.parallel", 4)?
.set_default("processing.batch_size", 100)?
.set_default("processing.max_file_size_mb", 50)?
.set_default("processing.temp_dir", "/tmp/toolkit")?;
if let Some(path) = &config_path {
if path.exists() {
builder = builder.add_source(File::from(path.as_path()));
}
}
builder = builder.add_source(
Environment::with_prefix("TOOLKIT")
.separator("_")
.try_parsing(true)
);
let config: AppConfig = builder.build()?.try_deserialize()?;
Ok(config)
}
}
# ~/.toolkit/config.toml
[general]
verbose = false
color = true
pager = true
log_level = "debug"
[output]
format = "table"
indent = 4
line_width = 120
[network]
timeout_secs = 60
max_retries = 5
proxy = "http://proxy.example.com:8080"
user_agent = "ToolsKu-CLI/1.0"
[processing]
parallel = 8
batch_size = 200
max_file_size_mb = 100
temp_dir = "/home/user/.toolkit/tmp"
use crate::config::AppConfig;
fn handle_batch(cmd: BatchCommand) -> anyhow::Result<()> {
let config = AppConfig::load(None)?;
let parallel = if cmd.parallel > 0 { cmd.parallel } else { config.processing.parallel };
let max_size = config.processing.max_file_size_mb * 1024 * 1024;
println!("並列数: {}", parallel);
println!("最大ファイルサイズ: {}MB", config.processing.max_file_size_mb);
println!("出力フォーマット: {:?}", config.output.format);
Ok(())
}
パターン3:indicatifプログレス表示とマルチタスク
use indicatif::{ProgressBar, ProgressStyle, MultiProgress, ProgressDrawTarget};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Semaphore;
fn create_progress_bar(total: u64, message: &str) -> ProgressBar {
let pb = ProgressBar::new(total);
pb.set_style(
ProgressStyle::with_template(
"{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}"
)
.unwrap()
.progress_chars("█▓░")
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
);
pb.set_message(message.to_string());
pb.enable_steady_tick(Duration::from_millis(100));
pb
}
fn create_spinner(message: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template("{spinner:.green} {msg} ({elapsed})")
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
);
pb.set_message(message.to_string());
pb.enable_steady_tick(Duration::from_millis(80));
pb
}
async fn process_files_with_progress(
files: Vec<PathBuf>,
parallel: usize,
) -> anyhow::Result<Vec<ProcessResult>> {
let multi = Arc::new(MultiProgress::new());
let semaphore = Arc::new(Semaphore::new(parallel));
let total_pb = multi.add(create_progress_bar(
files.len() as u64,
"全体進捗",
));
let mut handles = vec![];
for (idx, file) in files.into_iter().enumerate() {
let multi = multi.clone();
let semaphore = semaphore.clone();
let total_pb = total_pb.clone();
let handle = tokio::spawn(async move {
let _permit = semaphore.acquire().await.unwrap();
let file_name = file.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let file_pb = multi.add(create_spinner(&format!("[{}] 処理中", file_name)));
let result = process_single_file(&file).await;
match &result {
Ok(_) => {
file_pb.finish_with_message(format!("[{}] ✓ 完了", file_name));
}
Err(e) => {
file_pb.finish_with_message(format!("[{}] ✗ 失敗: {}", file_name, e));
}
}
total_pb.inc(1);
result
});
handles.push(handle);
}
total_pb.finish_with_message("全件完了");
let results: Vec<ProcessResult> = handles
.into_iter()
.filter_map(|h| h.ok().and_then(|r| r.ok()))
.collect();
Ok(results)
}
async fn process_single_file(path: &PathBuf) -> anyhow::Result<ProcessResult> {
let content = tokio::fs::read_to_string(path).await?;
let processed = transform_content(&content)?;
let output_path = path.with_extension("processed.json");
tokio::fs::write(&output_path, &processed).await?;
Ok(ProcessResult {
input: path.clone(),
output: output_path,
size: processed.len(),
})
}
struct ProcessResult {
input: PathBuf,
output: PathBuf,
size: usize,
}
use indicatif::{ProgressBar, ProgressStyle};
fn download_with_progress(url: &str, dest: &str) -> anyhow::Result<()> {
let response = reqwest::blocking::Client::new()
.get(url)
.send()?;
let total_size = response.content_length().unwrap_or(0);
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::with_template(
"{msg}\n{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})"
)
.unwrap()
.progress_chars("█▓░")
);
pb.set_message(format!("ダウンロード {}", url));
let mut file = std::fs::File::create(dest)?;
let mut downloaded: u64 = 0;
use std::io::Write;
for chunk in response.chunk() {
let chunk = chunk?;
file.write_all(&chunk)?;
let new = downloaded + chunk.len() as u64;
pb.set_position(new);
downloaded = new;
}
pb.finish_with_message(format!("✓ ダウンロード完了: {}", dest));
Ok(())
}
パターン4:RatatuiインタラクティブターミナルUI
use ratatui::{
crossterm::event::{self, Event, KeyCode, KeyEventKind},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, Cell, Gauge, HighlightSpacing, Row, Table, TableState, Tabs},
Frame, DefaultTerminal,
};
use std::time::Duration;
struct App {
should_quit: bool,
current_tab: usize,
table_state: TableState,
tasks: Vec<Task>,
progress: u16,
}
#[derive(Clone)]
struct Task {
name: String,
status: TaskStatus,
progress: u16,
size: String,
}
#[derive(Clone, PartialEq)]
enum TaskStatus {
Pending,
Running,
Completed,
Failed,
}
impl App {
fn new() -> Self {
let tasks = vec![
Task { name: "config.json".into(), status: TaskStatus::Completed, progress: 100, size: "2.3KB".into() },
Task { name: "data.csv".into(), status: TaskStatus::Running, progress: 67, size: "15.7MB".into() },
Task { name: "output.yaml".into(), status: TaskStatus::Pending, progress: 0, size: "—".into() },
Task { name: "report.md".into(), status: TaskStatus::Failed, progress: 34, size: "8.1KB".into() },
Task { name: "schema.toml".into(), status: TaskStatus::Running, progress: 89, size: "1.2KB".into() },
];
let mut table_state = TableState::default();
table_state.select(Some(0));
Self {
should_quit: false,
current_tab: 0,
table_state,
tasks,
progress: 0,
}
}
fn run(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
while !self.should_quit {
terminal.draw(|frame| self.draw(frame))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
self.handle_key(key.code);
}
}
}
self.update();
}
Ok(())
}
fn handle_key(&mut self, key: KeyCode) {
match key {
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
KeyCode::Char('1') => self.current_tab = 0,
KeyCode::Char('2') => self.current_tab = 1,
KeyCode::Char('3') => self.current_tab = 2,
KeyCode::Down => self.select_next(),
KeyCode::Up => self.select_previous(),
KeyCode::Enter => self.toggle_task(),
_ => {}
}
}
fn select_next(&mut self) {
let i = match self.table_state.selected() {
Some(i) => {
if i >= self.tasks.len() - 1 { 0 } else { i + 1 }
}
None => 0,
};
self.table_state.select(Some(i));
}
fn select_previous(&mut self) {
let i = match self.table_state.selected() {
Some(i) => {
if i == 0 { self.tasks.len() - 1 } else { i - 1 }
}
None => 0,
};
self.table_state.select(Some(i));
}
fn toggle_task(&mut self) {
if let Some(i) = self.table_state.selected() {
let task = &mut self.tasks[i];
match task.status {
TaskStatus::Pending => {
task.status = TaskStatus::Running;
}
TaskStatus::Running => {
task.status = TaskStatus::Completed;
task.progress = 100;
}
_ => {}
}
}
}
fn update(&mut self) {
for task in &mut self.tasks {
if task.status == TaskStatus::Running && task.progress < 100 {
task.progress = (task.progress + 1).min(100);
if task.progress == 100 {
task.status = TaskStatus::Completed;
}
}
}
}
fn draw(&self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let titles = vec!["タスク", "システム", "設定"];
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL).title(" ToolsKu CLI "))
.select(self.current_tab)
.style(Style::default().fg(Color::White))
.highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
frame.render_widget(tabs, chunks[0]);
match self.current_tab {
0 => self.draw_task_table(frame, chunks[1]),
1 => self.draw_system_status(frame, chunks[1]),
2 => self.draw_config_panel(frame, chunks[1]),
_ => {}
}
let help = Line::from(vec![
Span::styled(" ↑↓ ", Style::default().bg(Color::DarkGray)),
Span::raw(" 選択 "),
Span::styled(" Enter ", Style::default().bg(Color::DarkGray)),
Span::raw(" 切替 "),
Span::styled(" 1-3 ", Style::default().bg(Color::DarkGray)),
Span::raw(" タブ "),
Span::styled(" q ", Style::default().bg(Color::DarkGray)),
Span::raw(" 終了 "),
]);
frame.render_widget(
Block::default().borders(Borders::ALL).title(help),
chunks[2],
);
}
fn draw_task_table(&self, frame: &mut Frame, area: Rect) {
let header = Row::new(vec![
Cell::from("ファイル名").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("ステータス").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("進捗").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("サイズ").style(Style::default().add_modifier(Modifier::BOLD)),
])
.style(Style::default().bg(Color::DarkGray))
.height(1)
.bottom_margin(1);
let rows: Vec<Row> = self.tasks.iter().map(|task| {
let status_str = match task.status {
TaskStatus::Pending => Span::styled("● 待機", Style::default().fg(Color::Yellow)),
TaskStatus::Running => Span::styled("● 実行中", Style::default().fg(Color::Cyan)),
TaskStatus::Completed => Span::styled("● 完了", Style::default().fg(Color::Green)),
TaskStatus::Failed => Span::styled("● 失敗", Style::default().fg(Color::Red)),
};
let progress_str = format!("{:>3}%", task.progress);
let progress_style = if task.progress == 100 {
Style::default().fg(Color::Green)
} else if task.progress > 50 {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::Yellow)
};
Row::new(vec![
Cell::from(Span::raw(&task.name)),
Cell::from(status_str),
Cell::from(Span::styled(progress_str, progress_style)),
Cell::from(Span::raw(&task.size)),
])
.height(1)
}).collect();
let table = Table::new(
rows,
[
Constraint::Percentage(40),
Constraint::Percentage(20),
Constraint::Percentage(20),
Constraint::Percentage(20),
],
)
.header(header)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_spacing(HighlightSpacing::Always)
.block(Block::default().borders(Borders::ALL).title(" タスクリスト "));
frame.render_stateful_widget(table, area, &mut self.table_state.clone());
}
fn draw_system_status(&self, frame: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
])
.split(area);
let cpu_gauge = Gauge::default()
.block(Block::default().borders(Borders::ALL).title(" CPU "))
.gauge_style(Style::default().fg(Color::Cyan))
.percent(45);
frame.render_widget(cpu_gauge, chunks[0]);
let mem_gauge = Gauge::default()
.block(Block::default().borders(Borders::ALL).title(" メモリ "))
.gauge_style(Style::default().fg(Color::Magenta))
.percent(62);
frame.render_widget(mem_gauge, chunks[1]);
let disk_gauge = Gauge::default()
.block(Block::default().borders(Borders::ALL).title(" ディスク "))
.gauge_style(Style::default().fg(Color::Yellow))
.percent(78)
.label("78% (156GB/200GB)");
frame.render_widget(disk_gauge, chunks[2]);
}
fn draw_config_panel(&self, frame: &mut Frame, area: Rect) {
let config_text = vec![
Line::from(vec![
Span::styled(" verbose: ", Style::default().fg(Color::Yellow)),
Span::raw("false"),
]),
Line::from(vec![
Span::styled(" color: ", Style::default().fg(Color::Yellow)),
Span::raw("true"),
]),
Line::from(vec![
Span::styled(" format: ", Style::default().fg(Color::Yellow)),
Span::raw("json"),
]),
Line::from(vec![
Span::styled(" parallel: ", Style::default().fg(Color::Yellow)),
Span::raw("4"),
]),
Line::from(vec![
Span::styled(" timeout: ", Style::default().fg(Color::Yellow)),
Span::raw("30s"),
]),
];
let block = Block::default()
.borders(Borders::ALL)
.title(" 現在の設定 (~/.toolkit/config.toml) ");
frame.render_widget(
ratatui::widgets::Paragraph::new(config_text).block(block),
area,
);
}
}
fn main() -> anyhow::Result<()> {
let mut terminal = ratatui::init();
let mut app = App::new();
let result = app.run(&mut terminal);
ratatui::restore();
result
}
パターン5:クロスプラットフォーム配布とビルド
# Cargo.toml
[package]
name = "toolkit"
version = "1.0.0"
edition = "2021"
description = "ToolsKu CLI - 開発者ツールキット"
license = "MIT"
repository = "https://github.com/toolsku/cli"
[[bin]]
name = "toolkit"
path = "src/main.rs"
[dependencies]
clap = { version = "4.5", features = ["derive", "env", "unicode"] }
config = "0.14"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
thiserror = "2.0"
indicatif = "0.17"
ratatui = "0.29"
crossterm = "0.28"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
dialoguer = "0.11"
console = "0.15"
dirs = "5.0"
env_logger = "0.11"
log = "0.4"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
panic = "abort"
# .github/workflows/release.yml
name: Release
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
release:
name: Release ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
- os: macos-latest
target: x86_64-apple-darwin
- os: macos-latest
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: cargo-dist/setup-cargo-dist@v0
- run: cargo dist build --target=${{ matrix.target }} --artifacts=global
- uses: actions/upload-artifact@v4
with:
name: toolkit-${{ matrix.target }}
path: target/dist/
publish:
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cargo-dist/setup-cargo-dist@v0
- run: cargo dist publish
# [dist] section in Cargo.toml or dist.toml
[dist]
installers = ["shell", "powershell", "homebrew"]
targets = [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
]
merge-scripts = false
install-path = "CARGO_HOME"
[dist.dependencies.homebrew]
name = "toolkit"
[dist.dependencies.apt]
name = "toolkit"
[dist.dependencies.chocolatey]
name = "toolkit"
# Windows MSIビルドスクリプト
# build-msi.ps1
$version = "1.0.0"
$cargoTarget = "x86_64-pc-windows-msvc"
Write-Host "Building toolkit v$version for Windows..."
cargo build --release --target $cargoTarget
$binaryPath = "target\$cargoTarget\release\toolkit.exe"
if (-not (Test-Path $binaryPath)) {
Write-Error "Binary not found: $binaryPath"
exit 1
}
Write-Host "Binary size: $((Get-Item $binaryPath).Length / 1MB) MB"
Write-Host "Creating MSI installer..."
wix build `
--arch x64 `
--define "Version=$version" `
--output "toolkit-$version-x64.msi" `
toolkit.wxs
Write-Host "MSI installer created: toolkit-$version-x64.msi"
<!-- toolkit.wxs -->
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
Name="ToolsKu CLI"
Manufacturer="ToolsKu"
Version="$(var.Version)"
UpgradeCode="E9F8A7B6-C5D4-3E2F-1A0B-9C8D7E6F5A4B">
<MajorUpgrade DowngradeErrorMessage="新しいバージョンが既にインストールされています" />
<Feature Id="MainFeature" Title="ToolsKu CLI">
<ComponentRef Id="MainExecutable" />
<ComponentRef Id="PathEnvironment" />
</Feature>
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="ToolsKu">
<Component Id="MainExecutable" Guid="*">
<File Id="ToolkitExe"
Source="target\x86_64-pc-windows-msvc\release\toolkit.exe"
KeyPath="yes" />
</Component>
<Component Id="PathEnvironment" Guid="*">
<Environment
Id="PATH"
Name="PATH"
Value="[INSTALLFOLDER]"
Permanent="no"
Part="last"
Action="set"
System="yes" />
</Component>
</Directory>
</StandardDirectory>
</Package>
</Wix>
落とし穴ガイド
落とし穴1:clap deriveマクロ引数名の不一致
// ❌ 間違い:フィールド名と引数名が不一致
#[derive(Args)]
struct BadCmd {
#[arg(long)]
output_dir: String, // --output-dirを生成
}
// ✅ 正しい:引数名を明示的に指定
#[derive(Args)]
struct GoodCmd {
#[arg(long = "output-dir")]
output_dir: String, // 明示的マッピング
}
落とし穴2:config-rs環境変数オーバーライドが機能しない
// ❌ 間違い:プレフィックスとセパレータの設定が不適切
Environment::with_prefix("TOOLKIT")
// TOOLKIT_NETWORK_TIMEOUTを期待するが実際はTOOLKIT_NETWORKTIMEOUTを検索
// ✅ 正しい:セパレータを明示的に設定
Environment::with_prefix("TOOLKIT")
.separator("_") // TOOLKIT_NETWORK_TIMEOUT_SECS
.try_parsing(true)
落とし穴3:indicatifマルチプログレスバー出力の混乱
// ❌ 間違い:複数のProgressBarが直接println、出力が交錯
let pb1 = ProgressBar::new(100);
let pb2 = ProgressBar::new(200);
pb1.println("task1 message"); // pb2の出力と交錯
// ✅ 正しい:MultiProgressで統一管理
let mp = MultiProgress::new();
let pb1 = mp.add(ProgressBar::new(100));
let pb2 = mp.add(ProgressBar::new(200));
// すべての出力がMultiProgressを通じて調整、交錯なし
落とし穴4:Ratatuiパニック後のターミナル状態未復元
// ❌ 間違い:パニック後ターミナルがrawモードのまま、入力エコーなし
fn main() -> Result<()> {
let terminal = ratatui::init();
run_app(terminal)?; // パニック時、ターミナルが復元されない
ratatui::restore();
Ok(())
}
// ✅ 正しい:カスタムパニックフックでターミナル復元を保証
fn main() -> Result<()> {
let terminal = ratatui::init();
let result = run_app(terminal);
ratatui::restore(); // パニック時でも実行(Dropまたはdefer経由)
result
}
fn setup_panic_hook() {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
ratatui::restore();
default_hook(info);
}));
}
落とし穴5:WindowsでANSIカラーが表示されない
// ❌ 間違い:WindowsはデフォルトでANSIエスケープシーケンスをサポートしない
fn main() {
println!("\x1b[32m緑色テキスト\x1b[0m"); // Windows CMDで色が表示されない
}
// ✅ 正しい:Windows ANSIサポートを有効化
fn main() {
#[cfg(windows)]
{
let _ = enable_ansi_support();
}
println!("\x1b[32m緑色テキスト\x1b[0m");
}
#[cfg(windows)]
fn enable_ansi_support() -> std::io::Result<()> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::System::Console::{
GetConsoleMode, SetConsoleMode, ENABLE_VIRTUAL_TERMINAL_PROCESSING,
STD_OUTPUT_HANDLE,
};
unsafe {
let handle = windows_sys::Win32::System::Console::GetStdHandle(STD_OUTPUT_HANDLE);
let mut mode: u32 = 0;
GetConsoleMode(handle, &mut mode);
SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
}
Ok(())
}
エラートラブルシューティング
| # | エラーメッセージ | 原因 | 解決方法 |
|---|---|---|---|
| 1 | error: unexpected argument '--xxx' |
clap引数名がderiveフィールドと不一致 | #[arg(long = "xxx")]で明示的に指定 |
| 2 | missing field 'xxx' in config |
設定ファイルに必須フィールドが欠落 | デフォルト値を設定または設定ファイル形式を確認 |
| 3 | Cannot read termios |
ターミナルがrawモードをサポートしない(CI等) | Ratatui有効化前にis_term()を確認 |
| 4 | ProgressBar overflow |
プログレスバーのincがtotalを超過 | pb.inc()の代わりにpb.set_position()を使用 |
| 5 | terminal is not fully interactive |
非インタラクティブターミナルでTUIを実行 | std::io::stdout().is_term()を確認 |
| 6 | cannot find cargo-dist |
cargo-distがインストールされていない | cargo install cargo-dist |
| 7 | linker 'cc' not found |
Linuxクロスコンパイルツールチェーンが不足 | gcc-aarch64-linux-gnuをインストール |
| 8 | MSI build failed |
WiX Toolsetがインストールされていない | wixをインストールしてPATHに追加 |
| 9 | Environment variable not found |
config-rs環境変数形式が不正 | プレフィックス、セパレータ、ネストレベルを確認 |
| 10 | the trait FromStr is not implemented |
ValueEnum列挙型にderiveが不足 | 列挙型にValueEnumをderive |
高度な最適化
1. シェル補完とMan Page生成
use clap::{CommandFactory, Parser};
use clap_complete::{generate, Shell};
use clap_mangen::Man;
#[derive(Parser)]
struct Cli {
#[arg(long, value_name = "SHELL", help = "シェル補完スクリプトを生成")]
completions: Option<Shell>,
#[arg(long, help = "Man Pageを生成")]
manpage: bool,
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
if let Some(shell) = cli.completions {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
generate(shell, &mut cmd, &name, &mut std::io::stdout());
return Ok(());
}
if cli.manpage {
let cmd = Cli::command();
let man = Man::new(cmd);
man.render(&mut std::io::stdout())?;
return Ok(());
}
Ok(())
}
2. インタラクティブウィザードモード
use dialoguer::{Select, MultiSelect, Confirm, Input, theme::ColorfulTheme};
fn interactive_mode() -> anyhow::Result<()> {
println!("🛠 ToolsKu CLI インタラクティブモード\n");
let action = Select::with_theme(&ColorfulTheme::default())
.with_prompt("操作を選択")
.items(&["JSON処理", "エンコード", "ファイルハッシュ", "バッチ処理", "設定管理"])
.default(0)
.interact()?;
match action {
0 => {
let sub_action = Select::with_theme(&ColorfulTheme::default())
.with_prompt("JSON操作")
.items(&["フォーマット", "検証", "クエリ", "圧縮"])
.default(0)
.interact()?;
let input_path: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("入力ファイルパス")
.default("stdin".into())
.interact()?;
let indent: usize = Input::with_theme(&ColorfulTheme::default())
.with_prompt("インデントスペース数")
.default(2)
.interact()?;
println!("実行: JSON {:?}, 入力: {}, インデント: {}", sub_action, input_path, indent);
}
1 => {
let encode_type = Select::with_theme(&ColorfulTheme::default())
.with_prompt("エンコードタイプ")
.items(&["Base64", "URL", "Hex"])
.default(0)
.interact()?;
let decode = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("デコードモード?")
.default(false)
.interact()?;
let input: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("入力文字列")
.interact()?;
println!("実行: {:?} {}, 入力: {}", encode_type, if decode { "デコード" } else { "エンコード" }, input);
}
_ => {}
}
Ok(())
}
3. プラグインシステムアーキテクチャ
use std::collections::HashMap;
use std::path::PathBuf;
use libloading::{Library, Symbol};
type PluginInitFn = extern "C" fn() -> Box<dyn Plugin>;
pub trait Plugin {
fn name(&self) -> &str;
fn version(&self) -> &str;
fn execute(&self, args: &[String]) -> anyhow::Result<String>;
}
pub struct PluginManager {
plugins: HashMap<String, Box<dyn Plugin>>,
libraries: Vec<Library>,
}
impl PluginManager {
pub fn new() -> Self {
Self {
plugins: HashMap::new(),
libraries: Vec::new(),
}
}
pub fn load_plugin(&mut self, path: &PathBuf) -> anyhow::Result<()> {
unsafe {
let lib = Library::new(path)?;
let init: Symbol<PluginInitFn> = lib.get(b"plugin_init")?;
let plugin = init();
let name = plugin.name().to_string();
println!("プラグイン読み込み: {} v{}", name, plugin.version());
self.plugins.insert(name, plugin);
self.libraries.push(lib);
}
Ok(())
}
pub fn execute(&self, name: &str, args: &[String]) -> anyhow::Result<String> {
let plugin = self.plugins.get(name)
.ok_or_else(|| anyhow::anyhow!("プラグインが見つかりません: {}", name))?;
plugin.execute(args)
}
pub fn list_plugins(&self) -> Vec<(&str, &str)> {
self.plugins.values()
.map(|p| (p.name(), p.version()))
.collect()
}
}
比較分析
| 次元 | Rust CLI | Go CLI | Python CLI | Node.js CLI |
|---|---|---|---|---|
| バイナリサイズ | 2-10MB | 5-20MB | Python環境が必要 | Node環境が必要 |
| 起動速度 | <5ms | 5-20ms | 100-500ms | 50-200ms |
| メモリ使用量 | 1-10MB | 5-30MB | 20-100MB | 30-150MB |
| 引数解析 | ✅clap(derive) | ✅cobra | ✅click/typer | ✅commander/yargs |
| プログレス表示 | ✅indicatif | ✅progressbar | ✅rich/tqdm | ✅ora/cli-progress |
| ターミナルUI | ✅Ratatui | ⚠️tview/bubbletea | ✅textual/rich | ⚠️blessed/ink |
| クロスコンパイル | ✅cargo+target | ✅GOOS/GOARCH | ❌PyInstaller必要 | ❌pkg/nexe必要 |
| エラー処理 | ✅コンパイル時 | ⚠️ランタイムpanic | ❌ランタイム例外 | ❌ランタイム例外 |
| エコシステム成熟度 | ⭐高 | ⭐非常に高い | ⭐非常に高い | ⭐高 |
| 学習曲線 | ⭐急 | ⭐緩やか | ⭐非常に緩やか | ⭐緩やか |
まとめ:Rust CLIのコアアドバンテージは「速い」ことではなく(もちろん速いですが)、信頼性にあります——コンパイル時型チェックがランタイムクラッシュを排除し、clap deriveが引数定義を自己文書化し、config-rsが設定優先度を透明にし、indicatifが長時間タスクを「ブラックボックス」から解放し、RatatuiがCLIを「動く」から「優れた」ものにします。2026年のプロダクションプラクティス:clap deriveで引数定義→config-rs3層設定マージ→indicatifマルチプログレスバー→RatatuiインタラクティブTUI→cargo-distワンクリック5プラットフォーム配布。鍵は各环节を極限まで高めることで、ユーザーがあなたのCLIツールを手にした時、最初の反応が「このツールは本当にプロだ」になることです。
オンラインツール推奨
- JSONフォーマッター:/ja/json/format
- Base64エンコード/デコード:/ja/encode/base64
- コードフォーマッター:/ja/dev/code-formatter
- ハッシュ計算:/ja/encode/hash
ブラウザローカルツールを無料で試す →