快轉到主要內容
  1. 教學文章/

Rust CLI 實戰:用 clap 打造命令列工具(Python Typer 對照版)

·5 分鐘· loading · loading · ·
Rust Cli Clap Typer Python
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Rust 入門 - 本文屬於一個選集。
§ 1: 本文

一、前言
#

嗨,這裡是拍拍君!🦀

如果你跟拍拍君一樣,是個寫慣 Python 的工程師,那你一定用過 Typer 來做 CLI 工具——三行程式碼就有漂亮的 --help,超方便。

但有沒有遇過這種情況:你的 CLI 工具要處理幾十萬個檔案、或是要打包給沒裝 Python 的同事用?這時候 Rust 就是你的好朋友了。今天拍拍君帶你用 Rust 的 clap 套件來打造 CLI 工具,而且會一路跟 Python Typer 對照,讓你無痛上手!

二、安裝 Rust 與建立專案
#

如果你還沒裝 Rust,一行搞定:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

安裝完成後,建立新專案:

cargo new pypy-cli
cd pypy-cli

加入 clap 依賴:

cargo add clap --features derive

這會在 Cargo.toml 加上:

[dependencies]
clap = { version = "4", features = ["derive"] }

💡 cargo add 就像 Python 的 uv addpip install,但它直接改 Cargo.toml(相當於 pyproject.toml)。

三、Hello World:最簡單的 CLI
#

Python Typer 版
#

# hello.py
import typer

app = typer.Typer()

@app.command()
def hello(name: str):
    """跟你打招呼!"""
    print(f"哈囉,{name}!拍拍君向你問好 🎉")

if __name__ == "__main__":
    app()
python hello.py 拍拍醬
# 哈囉,拍拍醬!拍拍君向你問好 🎉

Rust clap 版
#

// src/main.rs
use clap::Parser;

/// 拍拍君的 CLI 工具
#[derive(Parser)]
#[command(name = "pypy-cli", version, about)]
struct Cli {
    /// 你的名字
    name: String,
}

fn main() {
    let cli = Cli::parse();
    println!("哈囉,{}!拍拍君向你問好 🎉", cli.name);
}
cargo run -- 拍拍醬
# 哈囉,拍拍醬!拍拍君向你問好 🎉

看出來了嗎?結構超像的:

概念 Python Typer Rust clap
定義參數 函式參數 + type hint struct 欄位 + type
說明文字 docstring /// 文件註解
執行解析 app() Cli::parse()

四、選擇性參數與旗標
#

Python 版
#

@app.command()
def greet(
    name: str,
    greeting: str = typer.Option("哈囉", help="問候語"),
    excited: bool = typer.Option(False, "--excited", "-e", help="加驚嘆號"),
):
    """自訂問候語"""
    msg = f"{greeting}{name}"
    if excited:
        msg += "!!!🔥"
    print(msg)

Rust 版
#

use clap::Parser;

#[derive(Parser)]
#[command(name = "pypy-cli", version, about = "拍拍君的 CLI 工具")]
struct Cli {
    /// 你的名字
    name: String,

    /// 問候語
    #[arg(short, long, default_value = "哈囉")]
    greeting: String,

    /// 加驚嘆號
    #[arg(short, long)]
    excited: bool,
}

fn main() {
    let cli = Cli::parse();
    let mut msg = format!("{}{}", cli.greeting, cli.name);
    if cli.excited {
        msg.push_str("!!!🔥");
    }
    println!("{msg}");
}
cargo run -- 拍拍醬 --greeting 嗨嗨 -e
# 嗨嗨,拍拍醬!!!🔥

重點整理:

  • #[arg(short, long)] → 自動產生 -g--greeting
  • bool 型別自動變成旗標(flag),不用給值
  • default_value 就是 Typer 的 typer.Option("預設值")

五、子命令(Subcommands)
#

真正的 CLI 工具通常有子命令,像 git adddocker run。這是 clap 最強大的地方。

Python 版
#

import typer

app = typer.Typer()

@app.command()
def count(path: str, extension: str = typer.Option(".py", help="副檔名")):
    """計算檔案數量"""
    from pathlib import Path
    files = list(Path(path).rglob(f"*{extension}"))
    print(f"找到 {len(files)}{extension} 檔案 📂")

@app.command()
def search(path: str, pattern: str):
    """搜尋檔案內容"""
    from pathlib import Path
    for f in Path(path).rglob("*.py"):
        content = f.read_text(errors="ignore")
        if pattern in content:
            print(f"✅ {f}")

if __name__ == "__main__":
    app()

Rust 版
#

use clap::{Parser, Subcommand};
use std::fs;
use std::path::Path;

#[derive(Parser)]
#[command(name = "pypy-cli", version, about = "拍拍君的檔案工具")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// 計算檔案數量
    Count {
        /// 目標路徑
        path: String,
        /// 副檔名
        #[arg(short, long, default_value = ".py")]
        extension: String,
    },
    /// 搜尋檔案內容
    Search {
        /// 目標路徑
        path: String,
        /// 搜尋關鍵字
        pattern: String,
    },
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Commands::Count { path, extension } => {
            let count = count_files(&path, &extension);
            println!("找到 {count}{extension} 檔案 📂");
        }
        Commands::Search { path, pattern } => {
            search_files(&path, &pattern);
        }
    }
}

fn count_files(dir: &str, ext: &str) -> usize {
    let mut count = 0;
    if let Ok(entries) = fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                count += count_files(path.to_str().unwrap_or(""), ext);
            } else if path.to_str().map_or(false, |s| s.ends_with(ext)) {
                count += 1;
            }
        }
    }
    count
}

fn search_files(dir: &str, pattern: &str) {
    if let Ok(entries) = fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                search_files(path.to_str().unwrap_or(""), pattern);
            } else if path.to_str().map_or(false, |s| s.ends_with(".py")) {
                if let Ok(content) = fs::read_to_string(&path) {
                    if content.contains(pattern) {
                        println!("✅ {}", path.display());
                    }
                }
            }
        }
    }
}
cargo run -- count ./src --extension .rs
# 找到 1 個 .rs 檔案 📂

cargo run -- search ./src "fn main"
# ✅ ./src/main.rs

子命令在 Rust 裡用 enum 表示——每個 variant 就是一個子命令,超直覺!

六、錯誤處理:Result 與 anyhow
#

Python 裡我們習慣 try/except,Rust 用 Result 型別。搭配 anyhow 套件可以讓錯誤處理跟 Python 一樣輕鬆。

cargo add anyhow
use anyhow::{Context, Result};
use clap::Parser;
use std::fs;
use std::path::PathBuf;

#[derive(Parser)]
#[command(name = "pypy-cli")]
struct Cli {
    /// 要讀取的檔案
    file: PathBuf,

    /// 搜尋關鍵字
    pattern: String,
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    let content = fs::read_to_string(&cli.file)
        .with_context(|| format!("讀不到檔案:{}", cli.file.display()))?;

    let matches: Vec<(usize, &str)> = content
        .lines()
        .enumerate()
        .filter(|(_, line)| line.contains(&cli.pattern))
        .collect();

    if matches.is_empty() {
        println!("找不到「{}」😢", cli.pattern);
    } else {
        println!("找到 {} 筆符合的行:", matches.len());
        for (i, line) in matches {
            println!("  L{}: {}", i + 1, line);
        }
    }

    Ok(())
}
cargo run -- Cargo.toml "clap"
# 找到 1 筆符合的行:
#   L7: clap = { version = "4", features = ["derive"] }

? 運算子就像 Python 的 raise——遇到錯誤就往上拋,但 Rust 在編譯時就會檢查你有沒有處理所有可能的錯誤,安全感滿滿。

七、打包與發佈
#

Python CLI 工具要發佈,你得搞 setup.pypyproject.tomlentry_points⋯⋯

Rust?一行:

cargo build --release

產出的二進位檔在 target/release/pypy-cli,直接丟給別人就能用!不需要裝 Rust、不需要虛擬環境。

# 檔案大小比較
$ ls -lh target/release/pypy-cli
# -rwxr-xr-x  1 user  staff  1.2M  pypy-cli

# Python 打包成執行檔(用 PyInstaller)
$ ls -lh dist/pypy-cli
# -rwxr-xr-x  1 user  staff   45M  pypy-cli

1.2 MB vs 45 MB,差距超級大!

八、Typer vs clap 完整比較
#

功能 Python Typer Rust clap
參數定義 type hints struct + derive
選項 typer.Option() #[arg()]
子命令 @app.command() enum + Subcommand
自動補全 ✅ 內建 clap_complete
錯誤處理 try/except Result + ?
打包 PyInstaller (~45MB) cargo build (~1MB)
啟動速度 ~100ms ~1ms
學習曲線 🟢 低 🟡 中

結語
#

今天我們從 Python Typer 的角度出發,學了 Rust clap 的核心功能:

  1. 基本參數 — struct 欄位就是參數
  2. 選項與旗標#[arg()] 標註
  3. 子命令 — enum 表達不同命令
  4. 錯誤處理Result + anyhow
  5. 打包cargo build --release 一行搞定

如果你已經會 Typer,學 clap 的門檻真的不高。而且 Rust CLI 的啟動速度和檔案大小,真的會讓你回不去 😏

下次我們來看 Rust 的 Ownership 和 Borrowing——這是 Rust 最獨特(也最讓人抓狂)的概念。拍拍君會用 Python 工程師聽得懂的方式來講,敬請期待!

Happy coding!🦀✨

延伸閱讀
#

Rust 入門 - 本文屬於一個選集。
§ 1: 本文

相關文章

用 Typer 打造專業 CLI 工具:Python 命令列框架教學
·10 分鐘· loading · loading
Python Typer Cli 開發工具
Polars:比 Pandas 快 10 倍的 DataFrame 新選擇
·6 分鐘· loading · loading
Python Polars Dataframe 資料分析 Rust
超快速 Python 套件管理:uv 完全教學
·6 分鐘· loading · loading
Python Uv Package Manager Rust
讓你的終端機華麗變身:Rich 套件教學
·2 分鐘· loading · loading
Python Rich Cli
MLX 入門教學:在 Apple Silicon 上跑機器學習
·4 分鐘· loading · loading
Python Mlx Apple-Silicon Machine-Learning Deep-Learning
FastAPI:Python 最潮的 Web API 框架
·5 分鐘· loading · loading
Python Fastapi Web Api Async