Trang chủ

Một CLI Rust có cảm giác hoàn chỉnh

Rust là lựa chọn mặc định của tôi cho các công cụ dòng lệnh, chủ yếu vì một binary tĩnh duy nhất không cần runtime chính là thứ mà một CLI nên là. clap lo phần phân tích tham số, còn phần còn lại của bài này nói về những thứ khiến một công cụ có cảm giác hoàn chỉnh thay vì giống một cái script.

API derive

use clap::{Parser, Subcommand};
use std::path::PathBuf;

#[derive(Parser)]
#[command(name = "notes", version, about = "Một công cụ ghi chú")]
struct Cli {
    /// Đường dẫn tới thư mục ghi chú
    #[arg(short, long, env = "NOTES_DIR", default_value = "~/notes")]
    directory: PathBuf,

    /// Tăng mức độ chi tiết của đầu ra
    #[arg(short, long, action = clap::ArgAction::Count)]
    verbose: u8,

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

#[derive(Subcommand)]
enum Command {
    /// Tạo một ghi chú mới
    New {
        title: String,
        #[arg(short, long)]
        tags: Vec<String>,
    },
    /// Tìm trong các ghi chú có sẵn
    Search {
        query: String,
        #[arg(short, long, default_value_t = 10)]
        limit: usize,
    },
}

Ba thứ trong đó đáng được chỉ ra:

Doc comment trở thành phần trợ giúp. Dòng /// phía trên mỗi trường chính là thứ mà --help in ra. Tài liệu và phần trợ giúp không thể lệch pha nhau vì chúng là một.

env = "NOTES_DIR" khiến một tham số đặt được từ biến môi trường, với cờ dòng lệnh có quyền ưu tiên. Một thuộc tính, và công cụ trở nên cấu hình được trong CI mà không cần file cấu hình nào.

version không kèm giá trị sẽ đọc nó từ Cargo.toml, nên --version không thể lạc hậu.

Việc phân tích tham số khi ấy là một dòng, và --help, --version, các thông báo lỗi cùng gợi ý cho cờ gõ nhầm đều có sẵn:

let cli = Cli::parse();

Bốn thứ khiến nó có cảm giác hoàn chỉnh

Mã thoát

Một công cụ luôn thoát ra với mã 0 thì không dùng được trong một script.

use std::process::ExitCode;

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("lỗi: {error:#}");
            ExitCode::FAILURE
        }
    }
}

{error:#} với anyhow in ra cả chuỗi lỗi chứ không chỉ thông điệp ngoài cùng, và đó là khác biệt giữa “hỏng” và “hỏng: không mở được ~/notes: không đủ quyền”.

stdout cho dữ liệu, stderr cho mọi thứ khác

Quy tắc khiến một công cụ ghép nối được:

println!("{}", result);              // dữ liệu — đưa qua pipe được
eprintln!("Đang tìm trong {} ghi chú…", count);   // tiến độ — không qua pipe

Các thông điệp tiến độ đặt trên stdout sẽ chui vào file khi ai đó chạy notes search foo > results.txt. Đây là sai lầm phổ biến nhất ở các CLI đầu tay và nó vô hình cho tới khi có người đưa đầu ra của bạn qua pipe.

Phát hiện xem bạn có đang ở trong terminal không

Mã màu và thanh tiến độ là nhiễu khi đầu ra là một cái pipe:

use std::io::IsTerminal;

let use_colour = std::io::stdout().is_terminal();

Hãy tôn trọng cả NO_COLOR — đó là một dòng kiểm tra và nó là quy ước.

Bổ sung tự động cho shell

Được sinh ra lúc build, và chúng là thứ khiến một công cụ có cảm giác thuộc về hệ thống:

use clap_complete::{generate, Shell};

Command::Completions { shell } => {
    generate(shell, &mut Cli::command(), "notes", &mut std::io::stdout());
}

notes completions zsh > ~/.zfunc/_notes và việc bổ sung bằng phím tab chạy được cho mọi lệnh con và mọi cờ, tất cả suy ra từ chính cái struct đó.

Mẹo

Hãy thêm --dry-run cho mọi thứ mang tính phá hủy, và làm cho nó in ra chính xác điều gì sẽ xảy ra. Nó tốn một tiếng và nó là khác biệt giữa một công cụ mà người ta tin tưởng dùng với --force và một công cụ mà lần nào họ cũng phải chạy dè dặt.

Cấu trúc để kiểm thử được

Hãy giữ main mỏng và đặt phần việc vào một thư viện:

src/
  main.rs        — phân tích tham số, mã thoát
  lib.rs         — mọi thứ khác

main.rs trở thành hai mươi dòng và mọi thứ thật đều kiểm thử được mà không phải sinh ra một tiến trình. Với các trường hợp đầu-cuối, assert_cmd chạy cái binary rồi khẳng định về đầu ra và mã thoát:

#[test]
fn search_with_no_results_exits_zero() {
    Command::cargo_bin("notes").unwrap()
        .args(["search", "nonexistent"])
        .assert()
        .success()
        .stdout(predicate::str::contains("Không có kết quả"));
}

Bạn có cần async không

Thường là không, và điều đó đáng được cưỡng lại.

Một CLI làm dăm ba thao tác tuần tự thì đơn giản hơn, biên dịch nhanh hơn và dễ gỡ lỗi hơn với I/O chặn. ureq cho HTTP và std::fs cho file lo được phần lớn các công cụ, và cái binary nhỏ hơn đáng kể khi không có runtime.

Hãy với tới tokio khi công cụ thật sự có tính đồng thời — tải năm mươi URL, theo dõi nhiều file cùng lúc — chứ không phải vì async là mặc định hiện đại. Với một chương trình làm ba việc theo thứ tự, đó là chi phí thừa mà chẳng được lợi gì.