📌

【HomeBrew】初めてのRustでCLIツール開発

に公開

自己紹介

初めまして、フリーランスエンジニアのたおです!

今まで主にTypeScriptやPythonを使った開発をしてきましたが、最近話題のRustに興味を持ち、学習を兼ねて実用的なCLIツールを作ってみました。

この記事では、Rust初心者の私が実際にCLIツール「addpath」を開発する過程で学んだことや、つまずいたポイントをまとめていきます。

なぜaddpathを作ったのか

みなさんも経験があると思いますが、新しいツールをインストールした後に「PATHを通す」作業って地味に面倒ですよね。

# あれ、どこにインストールされたんだっけ...
$ find /usr -name "新しいツール" 2>/dev/null
# .zshrcを開いて...
$ vim ~/.zshrc
# パスを追加して...
export PATH="$PATH:/usr/local/bin/新しいツール"
# 反映させる
$ source ~/.zshrc

この一連の作業を自動化したい!そう思って作ったのがaddpathです。

addpathの機能

addpathは以下のような機能を持つCLIツールです:

  1. 指定したコマンド名でシステム内を検索
  2. 見つかったパスの候補を一覧表示
  3. ユーザーが選択したパスを自動的にシェルの設定ファイルに追加
  4. すでにPATHに追加されているものは色分けして表示

使い方

# 例:nodeのパスを探して追加する
$ addpath node

# 追加の検索ディレクトリを指定することも可能
$ addpath python --adddir /home/user/local

Rustプロジェクトの作成

まずは新しいRustプロジェクトを作成します。

$ cargo new addpath
$ cd addpath

必要なクレート(ライブラリ)の選定

Rustでは外部ライブラリのことを「クレート」と呼びます。今回使用したクレートをCargo.tomlに追加します:

[package]
name = "addpath"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = "3.0"        # コマンドライン引数のパース
walkdir = "2.3"     # ディレクトリの再帰的な探索
dirs = "3.0"        # ホームディレクトリなどの取得
rayon = "1.10"      # 並列処理(今回は使用していませんが、将来の拡張用)
crossterm = "0.27"  # ターミナル操作
colored = "2.0"     # 文字列の色付け

実装の詳細

1. コマンドライン引数の処理

Rustでコマンドライン引数を扱うにはclapクレートが便利です。

use clap::{App, Arg};

fn main() {
    let matches = App::new("addpath")
        .version("1.0")
        .author("Tao119")
        .about("Automatically adds paths to your shell configuration")
        .arg(
            Arg::with_name("pkgname")
                .help("The package name to search for")
                .required(true)
                .index(1),
        )
        .arg(
            Arg::with_name("adddir")
                .help("Additional directory to include in the search path")
                .long("adddir")
                .takes_value(true)
                .multiple(true),
        )
        .get_matches();

    let pkgname = matches.value_of("pkgname").unwrap();
}

2. すでにPATHに存在するかチェック

whichコマンドを使って、指定されたコマンドがすでにPATHに存在するかチェックします:

use std::process::Command;

if let Ok(output) = Command::new("which").arg(pkgname).output() {
    if !output.stdout.is_empty() {
        println!("{} is already in the PATH.", pkgname);
        return;
    }
}

3. ディレクトリの再帰的な探索

walkdirクレートを使って、指定されたディレクトリを再帰的に探索します:

use walkdir::{DirEntry, WalkDir};

for entry in WalkDir::new(dir)
    .into_iter()
    .filter_entry(is_not_skippable)
    .filter_map(Result::ok)
{
    if entry.file_type().is_dir() && entry.file_name() == "bin" {
        // binディレクトリを見つけたら、その中を探索
        for sub_entry in WalkDir::new(entry.path())
            .max_depth(1)
            .into_iter()
            .filter_map(Result::ok)
        {
            if sub_entry.file_name().to_string_lossy().contains(pkgname) {
                candidates.push(entry.clone().into_path());
            }
        }
    }
}

特定のディレクトリ(/dev/proc/sys)はスキップするようにしています:

fn is_not_skippable(entry: &DirEntry) -> bool {
    let skip_dirs = ["dev", "proc", "sys"];
    !entry
        .path()
        .components()
        .any(|c| skip_dirs.contains(&c.as_os_str().to_str().unwrap()))
}

4. 見つかったパスの表示

coloredクレートを使って、見つかったパスを色分けして表示します:

use colored::*;

for (index, path) in candidates.iter().enumerate() {
    let path_str = format!("{}", path.display());
    if existing_contents.contains(&path.display().to_string()) {
        println!(
            "{}: {} {}",
            index,
            path_str.bright_black(),
            "(already exists)".to_string().red()
        );
    } else {
        println!("{}: {}", index, path_str.bright_yellow());
    }
}

5. シェル設定ファイルへの追加

選択されたパスを、使用しているシェルに応じて適切な設定ファイルに追加します:

let shell_path = env::var("SHELL").unwrap_or_default();
let config_file = if shell_path.ends_with("/bash") {
    "bashrc"
} else if shell_path.ends_with("/zsh") {
    "zshrc"
} else {
    eprintln!("Unsupported shell");
    return;
};

// ファイルに追記
fn append_to_file(file_path: PathBuf, content: &str) {
    let mut file = OpenOptions::new()
        .append(true)
        .open(file_path)
        .expect("Failed to open file");
    if let Err(e) = writeln!(file, "{}", content) {
        eprintln!("Failed to write to file: {}", e);
    }
}

Rust初心者がつまずいたポイント

1. 所有権とライフタイム

Rustの最大の特徴である所有権システムは、最初は理解が難しかったです。例えば、clone()を使わずに値を移動しようとしてコンパイルエラーになることが多々ありました。

// エラーになる例
let path = entry.into_path();
candidates.push(path);
// pathをもう一度使おうとするとエラー

// 解決策
candidates.push(entry.clone().into_path());

2. エラーハンドリング

RustではResult型を使ったエラーハンドリングが基本です。unwrap()を多用するとパニックの原因になるので、適切にエラー処理をする必要があります。

// 危険な例
let config_contents = read_to_string(&config_path).unwrap();

// より安全な例
let config_contents = read_to_string(&config_path).unwrap_or_default();

3. 文字列の扱い

RustにはString&strという2つの文字列型があり、使い分けに苦労しました。

// &str から String への変換
let s: String = "hello".to_string();
// または
let s: String = String::from("hello");

// String から &str への変換
let s: String = String::from("hello");
let s_ref: &str = &s;

HomeBrewでの個人公開

実は、このツールはすでにHomeBrewで個人的に公開しています!HomeBrewには「tap」という仕組みがあり、個人のリポジトリからFormulaを配布することができます。

HomeBrewのtapとは?

tapは、HomeBrewの公式リポジトリ以外からFormulaを配布するための仕組みです。個人や組織が独自のtapを作成して、自作のツールを配布できます。

個人tapの作成手順

  1. GitHubにtapリポジトリを作成

    • リポジトリ名はhomebrew-tapにするのが慣例
    • 例:https://github.com/Tao119/homebrew-tap
  2. Formulaファイルを作成

    • Formula/addpath.rbのようなファイルを作成
    • 以下は基本的なFormulaの例:
class Addpath < Formula
  desc "Automatically adds paths to your shell configuration"
  homepage "https://github.com/Tao119/addpath"
  url "https://github.com/Tao119/addpath/archive/refs/tags/v0.1.2.tar.gz"
  sha256 "実際のSHA256ハッシュ値"
  license "MIT"

  depends_on "rust" => :build

  def install
    system "cargo", "install", *std_cargo_args
  end

  test do
    system "#{bin}/addpath", "--help"
  end
end
  1. GitHubでリリースを作成

    • ソースコードのtarballが自動生成される
    • このURLをFormulaで使用
  2. SHA256ハッシュ値を取得

# リリースのtarballをダウンロードしてハッシュ値を計算
$ curl -L https://github.com/Tao119/addpath/archive/refs/tags/v0.1.2.tar.gz | shasum -a 256

インストール方法

個人のtapからインストールする場合は、以下のようにユーザー名を指定します:

# tapを追加(初回のみ)
$ brew tap Tao119/tap

# インストール
$ brew install Tao119/tap/addpath

# または、tapを追加せずに直接インストール
$ brew install Tao119/tap/addpath

個人tapのメリット

  • 即座に配布可能: 公式リポジトリへのPRを待つ必要がない
  • 自由な更新: 自分のペースでアップデートできる
  • テスト環境として: 公式公開前の動作確認に使える

注意点

  • 個人tapは公式のレビューを受けていないため、利用者は自己責任で使用
  • 依存関係の管理に注意が必要
  • セキュリティアップデートは自分で管理する必要がある

実際に使ってみる

インストール

HomeBrewを使ってインストールできます:

# 私の個人tapからインストール
$ brew install Tao119/tap/addpath

または、Rustの環境がある場合はソースからビルド:

$ git clone https://github.com/Tao119/addpath.git
$ cd addpath
$ cargo install --path .

使用例

例えば、nodeのパスを探して追加する場合:

$ addpath node

Searching in directories: ["/usr", "/opt"]
Checking directory: /usr
Checking directory: /opt
0: /usr/local/bin (already exists)
1: /opt/homebrew/bin
Select the path to add by number: 1

Added the following line to your zshrc file:
export PATH="$PATH:/opt/homebrew/bin"

finished setting the path!
Please run the following command to update your shell environment:
source /Users/username/.zshrc

初めてのRust開発でしたが、以下のような学びがありました:

  • 型安全性: コンパイル時にほとんどのエラーが検出されるので、実行時エラーが少ない
  • パフォーマンス: システムプログラミング言語だけあって、実行速度が速い
  • エコシステム: cargo.ioには豊富なクレートがあり、必要な機能を簡単に追加できる
  • 学習曲線: 所有権やライフタイムなど、独特の概念の理解には時間がかかる

Rustは学習コストは高いですが、一度慣れると安全で高速なプログラムが書けるようになります。CLIツールの開発は、Rustの学習に最適な題材だと感じました。

ソースコード

完全なソースコードはGitHubで公開しています:
https://github.com/Tao119/addpath

リポジトリには以下が含まれています:

  • 完全なソースコード
  • READMEファイル
  • ライセンス情報
  • リリース版のバイナリ

今後の改善点

  • 並列処理を使った検索の高速化
  • より多くのシェル(fish、PowerShellなど)への対応
  • 設定ファイルのバックアップ機能
  • より詳細なログ出力

みなさんもRustでCLIツール開発、始めてみませんか?


この記事が参考になりましたら、いいねやストックをお願いします!
質問やコメントもお待ちしています。

Discussion