Rust CLI工具開發實戰:從引數解析到終端UI的5種生產模式
编程语言
寫CLI工具,為什麼Rust是2026年的最優解
你用Python寫了個CLI,打包後使用者說「我電腦沒裝Python」;你用Node.js寫了個CLI,node_modules比工具本身還大;你用Go寫了個CLI,交叉編譯倒是方便,但錯誤處理一坨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大挑戰
- 引數解析複雜:子命令巢狀、引數驗證、互斥引數、預設值,手寫解析器容易出bug
- 配置管理混亂:CLI引數、配置檔案、環境變數三者的優先順序和合併策略不清晰
- 進度回饋缺失:長時間任務沒有進度指示,使用者以為程式卡死了
- 終端互動簡陋:只有print輸出,無法實現選擇選單、表格展示、即時重新整理
- 分發困難:Windows/macOS/Linux三平臺建構、簽名、套件管理器發布流程繁瑣
分步實操: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巨集引數名與欄位名不一致
// ❌ 錯誤:欄位名和引數名不匹配,導致--output-dir無法識別
#[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 panic後終端狀態未恢復
// ❌ 錯誤:panic後終端留在raw模式,輸入無回顯
fn main() -> Result<()> {
let terminal = ratatui::init();
run_app(terminal)?; // 如果panic,終端不會恢復
ratatui::restore();
Ok(())
}
// ✅ 正確:使用自訂panic hook確保終端恢復
fn main() -> Result<()> {
let terminal = ratatui::init();
let result = run_app(terminal);
ratatui::restore(); // 即使panic也會執行(透過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 |
配置檔案缺少必填欄位 | 設定default值或檢查配置檔案格式 |
| 3 | Cannot read termios |
終端不支援raw模式(如CI環境) | 偵測is_term()後再啟用Ratatui |
| 4 | ProgressBar overflow |
進度條inc超過total | 使用pb.set_position()替代pb.inc() |
| 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環境變數格式錯誤 | 檢查prefix、separator、巢狀層級 |
| 10 | the trait FromStr is not implemented |
ValueEnum列舉缺少派生 | 確保列舉derive了ValueEnum |
進階最佳化
1. Shell補全與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 = "生成Shell補全指令碼")]
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-rs三層配置合併→indicatif多進度條→Ratatui互動式TUI→cargo-dist一鍵分發五平臺。關鍵是把每個環節都做到極致,讓使用者拿到你的CLI工具時,第一反應是「這工具真專業」。
線上工具推薦
- JSON格式化:/zh-TW/json/format
- Base64編解碼:/zh-TW/encode/base64
- 程式碼格式化:/zh-TW/dev/code-formatter
- Hash計算:/zh-TW/encode/hash
本站提供瀏覽器本地工具,免註冊即可試用 →
#Rust CLI#clap#命令行工具#终端UI#Ratatui#2026#编程语言