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つの課題

  1. 引数解析の複雑さ:ネストされたサブコマンド、引数検証、相互排他引数、デフォルト値——手書きパーサーはバグが入りやすい
  2. 設定管理の混乱:CLI引数、設定ファイル、環境変数間の優先度とマージ戦略が不明確
  3. プログレスフィードバックの欠如:長時間タスクにプログレス表示がなく、ユーザーがプログラムがフリーズしたと思う
  4. ターミナルインタラクションの原始的さ:print出力のみ、選択メニューやテーブル表示、リアルタイム更新ができない
  5. 配布の困難さ: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ツールを手にした時、最初の反応が「このツールは本当にプロだ」になることです。


オンラインツール推奨

ブラウザローカルツールを無料で試す →

#Rust CLI#clap#命令行工具#终端UI#Ratatui#2026#编程语言