はじめに

CLIツールの開発言語として、Rustは非常に優れた選択肢です。高速な実行速度、シングルバイナリでの配布、強力な型システムによる安全性、そしてクロスプラットフォーム対応が容易という特徴があります。

本記事では、Rustを使って実用的なCLIツールを開発する手順を、プロジェクトのセットアップからクロスプラットフォーム配布まで一貫して解説します。

プロジェクトのセットアップ

Cargoプロジェクトの作成

1
2
3
4
5
6
# 新規プロジェクト作成
cargo new mytools --name mytools
cd mytools

# または既存ディレクトリで初期化
cargo init

基本的な依存関係

Cargo.tomlに必要なクレートを追加します:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
[package]
name = "mytools"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <[email protected]>"]
description = "A collection of useful CLI tools"
license = "MIT"
repository = "https://github.com/yourname/mytools"
keywords = ["cli", "tools", "utility"]
categories = ["command-line-utilities"]

[dependencies]
# 引数パース
clap = { version = "4.5", features = ["derive", "env", "wrap_help"] }
# 非同期ランタイム
tokio = { version = "1.36", features = ["full"] }
# HTTP クライアント
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
# JSON処理
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# エラーハンドリング
anyhow = "1.0"
thiserror = "1.0"
# ログ出力
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# 設定ファイル
config = "0.14"
# プログレスバー
indicatif = "0.17"
# カラー出力
colored = "2.1"
# ファイルシステム操作
walkdir = "2.4"
# 日時処理
chrono = { version = "0.4", features = ["serde"] }

[dev-dependencies]
assert_cmd = "2.0"
predicates = "3.1"
tempfile = "3.10"

[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true

clapを使った引数パース

基本的なCLI構造

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// src/main.rs
use clap::{Parser, Subcommand, Args};
use anyhow::Result;

/// A collection of useful CLI tools
#[derive(Parser)]
#[command(name = "mytools")]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
struct Cli {
    /// Enable verbose output
    #[arg(short, long, global = true)]
    verbose: bool,

    /// Configuration file path
    #[arg(short, long, global = true, env = "MYTOOLS_CONFIG")]
    config: Option<std::path::PathBuf>,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Fetch data from API
    Fetch(FetchArgs),
    /// Process files
    Process(ProcessArgs),
    /// Show configuration
    Config(ConfigArgs),
}

#[derive(Args)]
struct FetchArgs {
    /// API endpoint URL
    #[arg(short, long)]
    url: String,

    /// Output file path
    #[arg(short, long)]
    output: Option<std::path::PathBuf>,

    /// Request timeout in seconds
    #[arg(short, long, default_value = "30")]
    timeout: u64,

    /// Number of retry attempts
    #[arg(long, default_value = "3")]
    retries: u32,
}

#[derive(Args)]
struct ProcessArgs {
    /// Input files or directories
    #[arg(required = true)]
    inputs: Vec<std::path::PathBuf>,

    /// Output directory
    #[arg(short, long, default_value = ".")]
    output_dir: std::path::PathBuf,

    /// Process files in parallel
    #[arg(short, long)]
    parallel: bool,

    /// Number of worker threads
    #[arg(short = 'j', long, default_value = "4")]
    jobs: usize,
}

#[derive(Args)]
struct ConfigArgs {
    /// Show current configuration
    #[arg(long)]
    show: bool,

    /// Initialize configuration file
    #[arg(long)]
    init: bool,
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();

    // ログの初期化
    init_logging(cli.verbose)?;

    // 設定の読み込み
    let config = load_config(cli.config.as_deref())?;

    match cli.command {
        Commands::Fetch(args) => cmd_fetch(args, &config).await,
        Commands::Process(args) => cmd_process(args, &config).await,
        Commands::Config(args) => cmd_config(args, &config),
    }
}

ログ出力の設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// src/logging.rs
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use anyhow::Result;

pub fn init_logging(verbose: bool) -> Result<()> {
    let filter = if verbose {
        EnvFilter::new("debug")
    } else {
        EnvFilter::try_from_default_env()
            .unwrap_or_else(|_| EnvFilter::new("info"))
    };

    tracing_subscriber::registry()
        .with(filter)
        .with(fmt::layer().with_target(false).with_thread_ids(false))
        .init();

    Ok(())
}

設定ファイルの扱い

設定構造体の定義

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
// src/config.rs
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use anyhow::{Context, Result};

#[derive(Debug, Deserialize, Serialize, Default)]
pub struct AppConfig {
    #[serde(default)]
    pub api: ApiConfig,

    #[serde(default)]
    pub processing: ProcessingConfig,

    #[serde(default)]
    pub output: OutputConfig,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct ApiConfig {
    pub base_url: String,
    pub api_key: Option<String>,
    pub timeout_seconds: u64,
    pub max_retries: u32,
}

impl Default for ApiConfig {
    fn default() -> Self {
        Self {
            base_url: "https://api.example.com".to_string(),
            api_key: None,
            timeout_seconds: 30,
            max_retries: 3,
        }
    }
}

#[derive(Debug, Deserialize, Serialize)]
pub struct ProcessingConfig {
    pub parallel: bool,
    pub max_workers: usize,
    pub chunk_size: usize,
}

impl Default for ProcessingConfig {
    fn default() -> Self {
        Self {
            parallel: true,
            max_workers: num_cpus::get(),
            chunk_size: 1024 * 1024, // 1MB
        }
    }
}

#[derive(Debug, Deserialize, Serialize)]
pub struct OutputConfig {
    pub format: OutputFormat,
    pub color: bool,
    pub quiet: bool,
}

#[derive(Debug, Deserialize, Serialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
    #[default]
    Text,
    Json,
    Csv,
}

impl Default for OutputConfig {
    fn default() -> Self {
        Self {
            format: OutputFormat::Text,
            color: true,
            quiet: false,
        }
    }
}

pub fn load_config(path: Option<&std::path::Path>) -> Result<AppConfig> {
    let builder = config::Config::builder();

    // デフォルト設定
    let builder = builder.add_source(config::File::from_str(
        include_str!("../config/default.toml"),
        config::FileFormat::Toml,
    ));

    // ユーザー設定ファイル
    let config_path = path
        .map(PathBuf::from)
        .or_else(|| {
            dirs::config_dir().map(|p| p.join("mytools").join("config.toml"))
        });

    let builder = if let Some(ref path) = config_path {
        if path.exists() {
            builder.add_source(config::File::from(path.as_path()))
        } else {
            builder
        }
    } else {
        builder
    };

    // 環境変数からの上書き
    let builder = builder.add_source(
        config::Environment::with_prefix("MYTOOLS")
            .separator("__")
            .try_parsing(true),
    );

    let config = builder
        .build()
        .context("Failed to build configuration")?
        .try_deserialize()
        .context("Failed to deserialize configuration")?;

    Ok(config)
}

エラーハンドリング

カスタムエラー型の定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// src/error.rs
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("API error: {message} (status: {status})")]
    Api {
        status: u16,
        message: String,
    },

    #[error("File not found: {path}")]
    FileNotFound {
        path: std::path::PathBuf,
    },

    #[error("Invalid input: {0}")]
    InvalidInput(String),

    #[error("Configuration error: {0}")]
    Config(String),

    #[error("Network error: {0}")]
    Network(#[from] reqwest::Error),

    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("JSON error: {0}")]
    Json(#[from] serde_json::Error),
}

pub type AppResult<T> = Result<T, AppError>;

エラーハンドリングの実践

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// src/commands/fetch.rs
use crate::config::AppConfig;
use crate::error::{AppError, AppResult};
use anyhow::{Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
use std::time::Duration;
use tracing::{debug, info, warn};

pub async fn cmd_fetch(args: FetchArgs, config: &AppConfig) -> Result<()> {
    info!("Fetching data from: {}", args.url);

    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(args.timeout))
        .build()
        .context("Failed to create HTTP client")?;

    let mut last_error = None;

    for attempt in 1..=args.retries {
        debug!("Attempt {}/{}", attempt, args.retries);

        match fetch_with_progress(&client, &args.url).await {
            Ok(data) => {
                // 出力処理
                if let Some(ref output_path) = args.output {
                    std::fs::write(output_path, &data)
                        .with_context(|| format!("Failed to write to {:?}", output_path))?;
                    info!("Data saved to {:?}", output_path);
                } else {
                    println!("{}", data);
                }
                return Ok(());
            }
            Err(e) => {
                warn!("Attempt {} failed: {}", attempt, e);
                last_error = Some(e);

                if attempt < args.retries {
                    let delay = Duration::from_secs(2u64.pow(attempt));
                    debug!("Retrying in {:?}...", delay);
                    tokio::time::sleep(delay).await;
                }
            }
        }
    }

    Err(last_error.unwrap().into())
}

async fn fetch_with_progress(client: &reqwest::Client, url: &str) -> AppResult<String> {
    let response = client.get(url).send().await?;

    if !response.status().is_success() {
        return Err(AppError::Api {
            status: response.status().as_u16(),
            message: response.text().await.unwrap_or_default(),
        });
    }

    let total_size = response.content_length().unwrap_or(0);

    let pb = ProgressBar::new(total_size);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")?
            .progress_chars("#>-"),
    );

    let mut downloaded = 0u64;
    let mut content = Vec::new();

    let mut stream = response.bytes_stream();
    use futures_util::StreamExt;

    while let Some(chunk) = stream.next().await {
        let chunk = chunk?;
        downloaded += chunk.len() as u64;
        pb.set_position(downloaded);
        content.extend_from_slice(&chunk);
    }

    pb.finish_with_message("Download complete");

    String::from_utf8(content)
        .map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8: {}", e)))
}

ファイル処理コマンドの実装

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
// src/commands/process.rs
use crate::config::AppConfig;
use anyhow::{Context, Result};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use rayon::prelude::*;
use std::path::PathBuf;
use tracing::info;
use walkdir::WalkDir;

pub async fn cmd_process(args: ProcessArgs, config: &AppConfig) -> Result<()> {
    // 処理対象ファイルの収集
    let files = collect_files(&args.inputs)?;
    info!("Found {} files to process", files.len());

    if files.is_empty() {
        println!("No files to process");
        return Ok(());
    }

    // 出力ディレクトリの作成
    std::fs::create_dir_all(&args.output_dir)
        .with_context(|| format!("Failed to create output directory: {:?}", args.output_dir))?;

    if args.parallel {
        process_parallel(&files, &args.output_dir, args.jobs)?;
    } else {
        process_sequential(&files, &args.output_dir)?;
    }

    Ok(())
}

fn collect_files(inputs: &[PathBuf]) -> Result<Vec<PathBuf>> {
    let mut files = Vec::new();

    for input in inputs {
        if input.is_file() {
            files.push(input.clone());
        } else if input.is_dir() {
            for entry in WalkDir::new(input)
                .follow_links(true)
                .into_iter()
                .filter_map(|e| e.ok())
            {
                if entry.file_type().is_file() {
                    files.push(entry.into_path());
                }
            }
        }
    }

    Ok(files)
}

fn process_parallel(files: &[PathBuf], output_dir: &PathBuf, jobs: usize) -> Result<()> {
    let pool = rayon::ThreadPoolBuilder::new()
        .num_threads(jobs)
        .build()
        .context("Failed to create thread pool")?;

    let mp = MultiProgress::new();
    let pb = mp.add(ProgressBar::new(files.len() as u64));
    pb.set_style(
        ProgressStyle::default_bar()
            .template("{spinner:.green} Processing [{bar:40}] {pos}/{len} ({percent}%)")?
            .progress_chars("=> "),
    );

    let results: Vec<Result<(), anyhow::Error>> = pool.install(|| {
        files
            .par_iter()
            .map(|file| {
                let result = process_single_file(file, output_dir);
                pb.inc(1);
                result
            })
            .collect()
    });

    pb.finish_with_message("Processing complete");

    // エラーの集計
    let errors: Vec<_> = results.into_iter().filter_map(|r| r.err()).collect();
    if !errors.is_empty() {
        eprintln!("Errors occurred during processing:");
        for err in &errors {
            eprintln!("  - {}", err);
        }
    }

    Ok(())
}

fn process_sequential(files: &[PathBuf], output_dir: &PathBuf) -> Result<()> {
    let pb = ProgressBar::new(files.len() as u64);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("{spinner:.green} Processing [{bar:40}] {pos}/{len}")?
            .progress_chars("=> "),
    );

    for file in files {
        process_single_file(file, output_dir)?;
        pb.inc(1);
    }

    pb.finish_with_message("Processing complete");
    Ok(())
}

fn process_single_file(input: &PathBuf, output_dir: &PathBuf) -> Result<()> {
    // ファイル処理のロジック
    let content = std::fs::read_to_string(input)
        .with_context(|| format!("Failed to read: {:?}", input))?;

    // 何らかの処理(例:行数カウント、変換など)
    let processed = content.lines().count();

    let output_name = input
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_else(|| "output".to_string());

    let output_path = output_dir.join(format!("{}.processed", output_name));
    std::fs::write(&output_path, format!("Lines: {}", processed))
        .with_context(|| format!("Failed to write: {:?}", output_path))?;

    Ok(())
}

テストの実装

単体テスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/lib.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_config_default() {
        let config = AppConfig::default();
        assert_eq!(config.api.timeout_seconds, 30);
        assert!(config.processing.parallel);
    }

    #[test]
    fn test_collect_files_single() {
        let temp = tempfile::tempdir().unwrap();
        let file_path = temp.path().join("test.txt");
        std::fs::write(&file_path, "content").unwrap();

        let files = collect_files(&[file_path.clone()]).unwrap();
        assert_eq!(files.len(), 1);
        assert_eq!(files[0], file_path);
    }
}

統合テスト(CLIテスト)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// tests/cli.rs
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::tempdir;

#[test]
fn test_help() {
    let mut cmd = Command::cargo_bin("mytools").unwrap();
    cmd.arg("--help")
        .assert()
        .success()
        .stdout(predicate::str::contains("A collection of useful CLI tools"));
}

#[test]
fn test_version() {
    let mut cmd = Command::cargo_bin("mytools").unwrap();
    cmd.arg("--version")
        .assert()
        .success()
        .stdout(predicate::str::contains(env!("CARGO_PKG_VERSION")));
}

#[test]
fn test_process_command() {
    let input_dir = tempdir().unwrap();
    let output_dir = tempdir().unwrap();

    // テストファイルの作成
    std::fs::write(input_dir.path().join("test1.txt"), "line1\nline2\nline3").unwrap();
    std::fs::write(input_dir.path().join("test2.txt"), "single line").unwrap();

    let mut cmd = Command::cargo_bin("mytools").unwrap();
    cmd.args([
        "process",
        input_dir.path().to_str().unwrap(),
        "-o",
        output_dir.path().to_str().unwrap(),
    ])
    .assert()
    .success();

    // 出力ファイルの確認
    assert!(output_dir.path().join("test1.txt.processed").exists());
    assert!(output_dir.path().join("test2.txt.processed").exists());
}

#[test]
fn test_fetch_invalid_url() {
    let mut cmd = Command::cargo_bin("mytools").unwrap();
    cmd.args(["fetch", "--url", "invalid-url"])
        .assert()
        .failure();
}

クロスプラットフォームビルド

GitHub Actionsでの自動ビルド

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - 'v*'

permissions:
  contents: write

jobs:
  build:
    strategy:
      matrix:
        include:
          - target: x86_64-unknown-linux-gnu
            os: ubuntu-latest
            name: linux-x64
          - target: x86_64-unknown-linux-musl
            os: ubuntu-latest
            name: linux-x64-musl
          - target: aarch64-unknown-linux-gnu
            os: ubuntu-latest
            name: linux-arm64
          - target: x86_64-apple-darwin
            os: macos-latest
            name: macos-x64
          - target: aarch64-apple-darwin
            os: macos-latest
            name: macos-arm64
          - target: x86_64-pc-windows-msvc
            os: windows-latest
            name: windows-x64

    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v4

      - name: Install Rust
        uses: dtolnay/rust-action@stable
        with:
          targets: ${{ matrix.target }}

      - name: Install cross-compilation tools
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        run: |
          sudo apt-get update
          sudo apt-get install -y gcc-aarch64-linux-gnu

      - name: Install musl tools
        if: matrix.target == 'x86_64-unknown-linux-musl'
        run: |
          sudo apt-get update
          sudo apt-get install -y musl-tools

      - name: Build
        run: cargo build --release --target ${{ matrix.target }}

      - name: Package (Unix)
        if: runner.os != 'Windows'
        run: |
          cd target/${{ matrix.target }}/release
          tar czf ../../../mytools-${{ matrix.name }}.tar.gz mytools
          cd ../../..

      - name: Package (Windows)
        if: runner.os == 'Windows'
        run: |
          cd target/${{ matrix.target }}/release
          7z a ../../../mytools-${{ matrix.name }}.zip mytools.exe
          cd ../../..

      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: mytools-${{ matrix.name }}
          path: mytools-${{ matrix.name }}.*

  release:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Download all artifacts
        uses: actions/download-artifact@v4

      - name: Create Release
        uses: softprops/action-gh-release@v1
        with:
          files: |
            mytools-*/mytools-*
          draft: false
          prerelease: false

ローカルでのクロスコンパイル

1
2
3
4
5
6
7
8
# クロスツールのインストール
cargo install cross

# Linux ARM64向けビルド
cross build --release --target aarch64-unknown-linux-gnu

# Windows向けビルド(Linux/macOSから)
cross build --release --target x86_64-pc-windows-gnu

まとめ

RustでCLIツールを開発する際の重要なポイントをまとめます:

  1. clapによる引数パース:deriveマクロで型安全かつ保守しやすいCLI定義
  2. 設定ファイルの階層化:デフォルト、ユーザー設定、環境変数の優先順位
  3. 適切なエラーハンドリング:thiserrorとanyhowの使い分け
  4. プログレス表示:indicatifで長時間処理の進捗を可視化
  5. 並列処理:rayonで簡単にマルチスレッド化
  6. テスト:assert_cmdでCLI統合テストを記述
  7. クロスプラットフォーム配布:GitHub Actionsで自動ビルド

Rustの学習曲線は急ですが、一度習得すれば高品質なCLIツールを効率的に開発できます。ぜひ本記事のコードをベースに、自分だけのツールを作ってみてください。