Split View: Rust로 CLI 만들기: clap과 ripgrep·fd·bat의 비결
Rust로 CLI 만들기: clap과 ripgrep·fd·bat의 비결
- 들어가며 — 조용한 CLI 르네상스
- 첫 CLI — clap의 derive API
- 서브커맨드 — git 스타일 도구
- 왜 지금 Rust인가 — GC 없는 성능
- 배포 — 단일 정적 바이너리
- 있으면 좋은 크레이트들
- 좋은 CLI가 지키는 관행
- 마치며
- 참고 자료
들어가며 — 조용한 CLI 르네상스
지난 몇 년 사이, 여러분이 매일 쓰는 명령줄 도구들의 상당수가 조용히 Rust로 다시 쓰였습니다. grep은 ripgrep(rg)으로, find는 fd로, cat은 bat으로, ls는 eza로, cd는 zoxide(z)로. 여기에 파일 변경을 감지해 테스트를 다시 돌려 주는 bacon, 디스크 사용량을 보는 dust, 프로세스 뷰어 bottom(btm)까지 목록은 계속 늘어납니다.
이건 우연이 아닙니다. 명령줄 도구는 Rust의 강점이 거의 완벽하게 맞아떨어지는 영역입니다. 시작이 빨라야 하고(GC 워밍업이 없다), 예측 가능하게 빨라야 하고(GC 일시정지가 없다), 배포가 쉬워야 하고(런타임 의존성 없는 단일 바이너리), 안전해야 합니다(수많은 파일과 인코딩을 다루면서도 크래시하지 않아야 한다). 이 글은 그 르네상스의 배경을 짚고, 여러분이 직접 CLI를 만들 때 실제로 무엇이 필요한지를 코드로 보여 줍니다.
Rust 문법이 낯설다면, 글을 읽으면서 이 사이트의 Rust 학습 실습실에서 소유권·트레잇 같은 기초를 병행해 보세요.
첫 CLI — clap의 derive API
Rust 생태계에서 인자 파싱의 사실상 표준은 clap입니다. 예전에는 빌더 스타일로 인자를 하나씩 등록했지만, 요즘 권장되는 방식은 derive API입니다. 구조체를 하나 정의하고 #[derive(Parser)]를 붙이면, 필드가 곧 명령줄 인자가 됩니다.
Cargo.toml에 의존성부터 추가합니다.
[dependencies]
clap = { version = "4", features = ["derive"] }
이제 파일 안의 패턴을 찾는 아주 작은 grep 클론을 만들어 봅니다.
use clap::Parser;
use std::fs;
/// 파일에서 패턴을 찾는 작은 도구
#[derive(Parser)]
#[command(version, about)]
struct Cli {
/// 찾을 패턴
pattern: String,
/// 검색할 파일 경로
path: std::path::PathBuf,
/// 대소문자를 무시할지 여부
#[arg(short, long)]
ignore_case: bool,
}
fn main() {
let cli = Cli::parse();
let content = fs::read_to_string(&cli.path).expect("파일을 읽을 수 없습니다");
for (num, line) in content.lines().enumerate() {
let found = if cli.ignore_case {
line.to_lowercase().contains(&cli.pattern.to_lowercase())
} else {
line.contains(&cli.pattern)
};
if found {
println!("{}: {}", num + 1, line);
}
}
}
여기서 눈여겨볼 점이 여럿입니다. 필드 pattern과 path는 위치 인자(positional argument)가 됩니다. #[arg(short, long)]가 붙은 ignore_case는 -i 또는 --ignore-case 플래그가 됩니다. 필드 위의 /// 문서 주석은 그대로 도움말 텍스트가 됩니다. 그리고 #[command(version, about)] 덕분에 --version과 --help가 공짜로 생깁니다.
빌드해서 --help를 실행하면, 직접 한 줄도 쓰지 않은 깔끔한 사용법 화면이 나옵니다.
$ minigrep --help
파일에서 패턴을 찾는 작은 도구
Usage: minigrep [OPTIONS] <PATTERN> <PATH>
Arguments:
<PATTERN> 찾을 패턴
<PATH> 검색할 파일 경로
Options:
-i, --ignore-case 대소문자를 무시할지 여부
-h, --help Print help
-V, --version Print version
타입이 곧 계약이라는 점이 핵심입니다. path를 PathBuf로 선언했으니 clap이 문자열을 경로로 변환해 주고, ignore_case를 bool로 선언했으니 플래그의 유무가 자동으로 참·거짓이 됩니다. 인자가 빠지거나 형식이 틀리면 clap이 알아서 오류 메시지를 내고 종료합니다.
서브커맨드 — git 스타일 도구
git commit, git push처럼 하나의 도구가 여러 하위 명령을 갖는 구조는 아주 흔합니다. clap에서는 열거형(enum)으로 표현합니다. 각 변형(variant)이 하나의 서브커맨드가 됩니다.
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// 새 항목을 추가한다
Add {
/// 추가할 내용
text: String,
},
/// 항목 목록을 보여 준다
List {
/// 완료된 항목도 포함할지
#[arg(short, long)]
all: bool,
},
/// 항목을 완료 처리한다
Done {
/// 완료할 항목 번호
id: u32,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Add { text } => {
println!("추가: {}", text);
}
Commands::List { all } => {
println!("목록 (완료 포함: {})", all);
}
Commands::Done { id } => {
println!("완료 처리: 항목 {}", id);
}
}
}
이제 이 도구는 todo add "우유 사기", todo list --all, todo done 3처럼 호출됩니다. 각 서브커맨드는 자기만의 인자와 플래그를 가지며, match로 분기하면 컴파일러가 모든 변형을 빠짐없이 처리했는지 검사해 줍니다. 서브커맨드를 하나 추가하면 match도 함께 고쳐야 하므로, 처리를 빠뜨릴 수가 없습니다.
왜 지금 Rust인가 — GC 없는 성능
이 도구들이 빠른 데는 여러 이유가 있지만, 가장 근본적인 것은 가비지 컬렉터가 없다는 점입니다.
Go, Java, Node 같은 런타임은 편리하지만 두 가지 세금을 물립니다. 첫째, 프로그램 시작 시 런타임을 초기화하는 지연이 있습니다. 명령줄 도구는 수천 번 반복 호출되기 때문에, 매 호출의 시작 지연이 쌓이면 체감이 됩니다. 둘째, 실행 중 GC가 개입해 예측 불가능한 일시정지를 일으킵니다. Rust는 컴파일 시점의 소유권 규칙으로 메모리를 관리하므로 런타임 GC 자체가 없습니다. 프로그램이 즉시 시작하고, 멈춤 없이 일정한 속도로 돌아갑니다.
여기에 두 가지 저수준 무기가 더해집니다.
- SIMD — 현대 CPU는 한 명령으로 여러 바이트를 동시에 처리하는 벡터 명령을 제공합니다. ripgrep이 의존하는
memchr크레이트는 SIMD로 한 번에 16·32·64바이트를 훑으며 특정 바이트를 찾습니다. 순진한 바이트별 루프보다 훨씬 빠릅니다. - mmap — 파일을 커널 버퍼에서 복사해 오는 대신, 프로세스의 가상 주소 공간에 직접 매핑해 거대한 바이트 배열처럼 다룹니다. 큰 파일 하나를 훑을 때 복사 단계가 사라집니다.
다만 오해는 말아야 합니다. "Rust라서 빠르다"는 절반만 맞습니다. Rust는 무겁지 않은 추상화와 안전한 동시성이라는 토대를 줄 뿐, 속도의 본질은 알고리즘과 설계에 있습니다. ripgrep이 빠른 진짜 이유가 궁금하다면 이 블로그의 ripgrep은 왜 그렇게 빠른가 글에서 정규식 엔진 선택부터 병렬 순회까지 자세히 다뤘습니다.
배포 — 단일 정적 바이너리
CLI 도구의 성패는 성능만이 아니라 배포의 편의성에서 갈립니다. 여기서 Rust는 결정적인 이점을 가집니다.
cargo build --release를 실행하면 하나의 실행 파일이 나옵니다. 이 파일은 인터프리터도, 런타임도, node_modules도 필요로 하지 않습니다. 사용자는 파이썬 버전을 맞추거나 JVM을 설치할 필요 없이, 그냥 바이너리 하나를 내려받아 실행 권한을 주고 실행하면 됩니다.
# 릴리스 빌드 (최적화 켜짐)
cargo build --release
# 결과물은 여기 하나뿐
./target/release/mytool
# 완전 정적 링크가 필요하면 musl 타깃을 쓴다
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
특히 musl 타깃으로 빌드하면 C 표준 라이브러리까지 정적으로 포함되어, 배포 대상의 시스템 라이브러리 버전에 전혀 의존하지 않는 완전 정적 바이너리가 나옵니다. 이런 바이너리는 최소 도커 이미지(scratch나 alpine)에 그대로 넣어도 동작합니다. 배포가 "파일 하나 복사"로 끝나는 것, 이것이 CLI 저자와 사용자 모두에게 큰 매력입니다.
있으면 좋은 크레이트들
인자 파싱이 clap이라면, 나머지 흔한 필요를 채워 주는 크레이트들이 있습니다. 좋은 CLI는 대개 이 조합으로 만들어집니다.
- anyhow — 애플리케이션 레벨의 오류 처리를 편하게 해 줍니다. 다양한 오류 타입을 하나로 묶고,
?연산자로 전파하며, 실패 경로에 맥락을 덧붙일 수 있습니다. - indicatif — 진행 표시줄(progress bar)과 스피너를 그려 줍니다. 파일을 많이 처리하는 도구라면 사용자에게 진행 상황을 보여 주는 것이 큰 차이를 만듭니다.
- crossterm — 터미널을 크로스 플랫폼으로 제어합니다. 색상, 커서 이동, 화면 지우기, 키 입력 감지 등을 윈도우·맥·리눅스에서 동일하게 다룰 수 있습니다.
anyhow로 오류에 맥락을 붙이는 예를 보겠습니다.
use anyhow::{Context, Result};
use std::fs;
fn load_config(path: &str) -> Result<String> {
let content = fs::read_to_string(path)
.with_context(|| format!("설정 파일을 읽지 못했습니다: {}", path))?;
Ok(content)
}
fn main() -> Result<()> {
let config = load_config("config.toml")?;
println!("설정 {} 바이트를 읽었습니다", config.len());
Ok(())
}
main이 Result를 반환하도록 하면, 오류가 발생했을 때 Rust가 알아서 메시지를 출력하고 0이 아닌 종료 코드로 끝냅니다. with_context로 붙인 설명 덕분에, 사용자는 "파일 없음" 같은 저수준 오류가 아니라 "설정 파일을 읽지 못했습니다"라는 이해 가능한 메시지를 봅니다.
indicatif로 진행 표시줄을 그리는 예도 간단합니다.
use indicatif::ProgressBar;
use std::thread;
use std::time::Duration;
fn main() {
let total = 1000;
let bar = ProgressBar::new(total);
for _ in 0..total {
// 실제로는 여기서 파일 하나를 처리한다
thread::sleep(Duration::from_millis(2));
bar.inc(1);
}
bar.finish_with_message("완료");
}
좋은 CLI가 지키는 관행
빠르고 안전한 것만으로는 부족합니다. 앞서 본 도구들이 사랑받는 이유는 사용자 경험을 세심하게 챙기기 때문입니다. 여러분이 도구를 만들 때도 다음을 기억하면 좋습니다.
- 표준 스트림을 존중하라. 정상 출력은 stdout으로, 오류와 진단은 stderr로 보냅니다. 그래야 사용자가
mytool | other처럼 파이프로 연결할 때 진단 메시지가 데이터를 오염시키지 않습니다. - 파이프를 감지하라. 출력이 터미널이 아니라 파이프로 연결되면 색상 코드를 끄는 것이 예의입니다. 대부분의 도구가 이렇게 동작하고, 필요하면
--color always같은 옵션으로 강제하게 둡니다. - 종료 코드를 지켜라. 성공은 0, 실패는 0이 아닌 값. 셸 스크립트가 여러분의 도구를 조건문에 넣을 수 있어야 합니다.
- 좋은 기본값을 골라라. 사용자가 대개 원하는 동작을 설정 없이 기본으로 삼고, 예외적 상황만 플래그로 열어 줍니다. 이것이 ripgrep이
.gitignore를 기본으로 존중하는 이유입니다. - 셸 자동완성을 제공하라. clap은 bash·zsh·fish용 자동완성 스크립트를 생성해 줍니다. 사소해 보여도 사용성을 크게 끌어올립니다.
버전 관리 도구를 다뤄 보며 CLI 감각을 익히고 싶다면, 이 사이트의 Git 플레이그라운드에서 명령들의 동작을 직접 실험해 볼 수 있습니다.
마치며
CLI 르네상스는 언어 팬덤이 아니라 적합성의 결과입니다. 빠른 시작, 예측 가능한 성능, 단일 바이너리 배포, 메모리 안전. 이 네 가지가 명령줄 도구라는 문제에 정확히 들어맞았고, 그 위에서 ripgrep·fd·bat 같은 도구들이 태어났습니다.
여러분의 첫 도구는 거창할 필요가 없습니다. #[derive(Parser)]를 붙인 구조체 하나로 시작해, 서브커맨드를 붙이고, anyhow로 오류를 다듬고, 필요하면 indicatif로 진행을 보여 주세요. 그리고 마지막으로 표준 스트림과 종료 코드 같은 작은 관행을 지키면, 여러분의 도구도 사람들이 매일 손에 익혀 쓰는 그 목록에 들어갈 수 있습니다.
참고 자료
- clap 공식 문서 — https://docs.rs/clap/
- clap derive 튜토리얼 — https://docs.rs/clap/latest/clap/_derive/_tutorial/index.html
- The Rust Programming Language, 12장 (minigrep 만들기) — https://doc.rust-lang.org/book/ch12-00-an-io-project.html
- Command Line Applications in Rust (책) — https://rust-cli.github.io/book/
- anyhow 크레이트 — https://docs.rs/anyhow/
- indicatif 크레이트 — https://docs.rs/indicatif/
- crossterm 크레이트 — https://docs.rs/crossterm/
- ripgrep 저장소 — https://github.com/BurntSushi/ripgrep
- fd 저장소 — https://github.com/sharkdp/fd
- bat 저장소 — https://github.com/sharkdp/bat
Building CLIs in Rust: clap and the Secrets of ripgrep, fd, bat
- Introduction — The Quiet CLI Renaissance
- Your First CLI — clap's derive API
- Subcommands — A git-Style Tool
- Why Rust Now — Performance Without a GC
- Distribution — A Single Static Binary
- The Crates You'll Want
- The Habits Good CLIs Keep
- Wrapping Up
- References
Introduction — The Quiet CLI Renaissance
Over the past few years, a surprising number of the command-line tools you use daily have been quietly rewritten in Rust. grep became ripgrep (rg), find became fd, cat became bat, ls became eza, cd became zoxide (z). Add bacon, which watches your files and reruns tests; dust, which shows disk usage; and bottom (btm), a process viewer — the list keeps growing.
This is not an accident. Command-line tools sit squarely in Rust's sweet spot. They need to start fast (no GC warmup), stay predictably fast (no GC pauses), ship easily (a single binary with no runtime dependencies), and be safe (handle countless files and encodings without crashing). This post traces the background of that renaissance and then shows, in code, what you actually need to build a CLI of your own.
If Rust syntax is new to you, follow along in this site's Rust Learning Lab to build up the basics of ownership and traits as you read.
Your First CLI — clap's derive API
The de facto standard for argument parsing in the Rust ecosystem is clap. It used to register arguments one by one in a builder style, but the recommended approach today is the derive API. You define a struct and annotate it with #[derive(Parser)], and each field becomes a command-line argument.
Start by adding the dependency to Cargo.toml.
[dependencies]
clap = { version = "4", features = ["derive"] }
Now let us build a tiny grep clone that searches for a pattern inside a file.
use clap::Parser;
use std::fs;
/// A tiny tool that searches for a pattern in a file
#[derive(Parser)]
#[command(version, about)]
struct Cli {
/// The pattern to look for
pattern: String,
/// The path of the file to search
path: std::path::PathBuf,
/// Whether to ignore case
#[arg(short, long)]
ignore_case: bool,
}
fn main() {
let cli = Cli::parse();
let content = fs::read_to_string(&cli.path).expect("could not read the file");
for (num, line) in content.lines().enumerate() {
let found = if cli.ignore_case {
line.to_lowercase().contains(&cli.pattern.to_lowercase())
} else {
line.contains(&cli.pattern)
};
if found {
println!("{}: {}", num + 1, line);
}
}
}
Several things are worth noticing. The fields pattern and path become positional arguments. The ignore_case field, annotated with #[arg(short, long)], becomes a -i or --ignore-case flag. The /// doc comment above each field becomes its help text. And thanks to #[command(version, about)], --version and --help come for free.
Build it and run --help, and you get a clean usage screen you never wrote a line of.
$ minigrep --help
A tiny tool that searches for a pattern in a file
Usage: minigrep [OPTIONS] <PATTERN> <PATH>
Arguments:
<PATTERN> The pattern to look for
<PATH> The path of the file to search
Options:
-i, --ignore-case Whether to ignore case
-h, --help Print help
-V, --version Print version
The key idea is that the type is the contract. Because you declared path as a PathBuf, clap converts the string into a path for you; because you declared ignore_case as a bool, the presence or absence of the flag becomes true or false automatically. If an argument is missing or malformed, clap prints an error and exits on its own.
Subcommands — A git-Style Tool
A single tool with several subcommands, like git commit and git push, is a very common shape. In clap you express it with an enum: each variant becomes a subcommand.
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Add a new item
Add {
/// The text to add
text: String,
},
/// List the items
List {
/// Whether to include completed items
#[arg(short, long)]
all: bool,
},
/// Mark an item as done
Done {
/// The id of the item to complete
id: u32,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Add { text } => {
println!("added: {}", text);
}
Commands::List { all } => {
println!("list (include done: {})", all);
}
Commands::Done { id } => {
println!("marked done: item {}", id);
}
}
}
Now the tool is invoked as todo add "buy milk", todo list --all, todo done 3. Each subcommand has its own arguments and flags, and when you branch with match, the compiler checks that you handled every variant. Add a subcommand and you must update the match too, so you cannot forget to handle it.
Why Rust Now — Performance Without a GC
There are several reasons these tools are fast, but the most fundamental is that there is no garbage collector.
Runtimes like Go, Java, and Node are convenient but levy two taxes. First, there is a delay at startup to initialize the runtime. Command-line tools are invoked thousands of times, so a per-invocation startup delay adds up and becomes noticeable. Second, the GC intervenes while the program runs and causes unpredictable pauses. Rust manages memory with compile-time ownership rules, so there is no runtime GC at all. The program starts instantly and runs at a steady speed without stalls.
Two low-level weapons build on top of that.
- SIMD — Modern CPUs provide vector instructions that process several bytes with a single instruction. The
memchrcrate that ripgrep relies on uses SIMD to scan 16, 32, or 64 bytes at once looking for a specific byte. That is far faster than a naive byte-by-byte loop. - mmap — Instead of copying a file out of the kernel buffer, you map it directly into the process's virtual address space and treat it like a giant byte array. When scanning one large file, a whole copy step disappears.
Do not misread this, though. "It is fast because it is Rust" is only half true. Rust gives you the foundation — lightweight abstractions and safe concurrency — but the essence of the speed lies in algorithms and design. If you want the real reasons ripgrep is fast, this blog covers everything from regex-engine choice to parallel traversal in Why ripgrep Is So Fast.
Distribution — A Single Static Binary
The success of a CLI tool hinges not only on performance but on the ease of distribution, and here Rust has a decisive edge.
Run cargo build --release and you get a single executable. That file needs no interpreter, no runtime, no node_modules. Your users do not have to match a Python version or install a JVM; they just download one binary, give it execute permission, and run it.
# release build (optimizations on)
cargo build --release
# the one and only artifact lives here
./target/release/mytool
# for fully static linking, use the musl target
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
Building for the musl target in particular statically includes even the C standard library, producing a fully static binary that does not depend at all on the system library versions of the target machine. Such a binary works even when dropped into a minimal Docker image (scratch or alpine). Distribution ending at "copy one file" is a big draw for both CLI authors and their users.
The Crates You'll Want
If argument parsing is clap, there is a set of crates that fill the other common needs. Good CLIs are usually built from this combination.
- anyhow — Makes application-level error handling pleasant. It unifies diverse error types into one, propagates them with the
?operator, and lets you attach context along the failure path. - indicatif — Draws progress bars and spinners. For a tool that processes many files, showing the user how far along it is makes a real difference.
- crossterm — Controls the terminal cross-platform. Colors, cursor movement, clearing the screen, detecting key input — all handled identically on Windows, macOS, and Linux.
Here is an example of attaching context to an error with anyhow.
use anyhow::{Context, Result};
use std::fs;
fn load_config(path: &str) -> Result<String> {
let content = fs::read_to_string(path)
.with_context(|| format!("failed to read config file: {}", path))?;
Ok(content)
}
fn main() -> Result<()> {
let config = load_config("config.toml")?;
println!("read {} bytes of config", config.len());
Ok(())
}
By letting main return a Result, Rust prints the message and exits with a nonzero code when an error occurs. Thanks to the description added with with_context, the user sees an understandable message like "failed to read config file" rather than a low-level "file not found."
Drawing a progress bar with indicatif is just as simple.
use indicatif::ProgressBar;
use std::thread;
use std::time::Duration;
fn main() {
let total = 1000;
let bar = ProgressBar::new(total);
for _ in 0..total {
// in reality you would process one file here
thread::sleep(Duration::from_millis(2));
bar.inc(1);
}
bar.finish_with_message("done");
}
The Habits Good CLIs Keep
Being fast and safe is not enough. The tools above are loved because they attend carefully to the user experience. Keep these in mind when you build your own.
- Respect the standard streams. Send normal output to stdout, and errors and diagnostics to stderr. That way, when a user pipes with
mytool | other, diagnostic messages do not pollute the data. - Detect pipes. When output is connected to a pipe rather than a terminal, it is courteous to turn off color codes. Most tools behave this way and let you force it with an option like
--color alwaysif needed. - Honor exit codes. Zero for success, nonzero for failure. A shell script must be able to put your tool in a conditional.
- Choose good defaults. Make the behavior users usually want the default with no configuration, and open the exceptional cases behind flags. This is why ripgrep respects
.gitignoreby default. - Provide shell completions. clap can generate completion scripts for bash, zsh, and fish. It seems minor, but it lifts usability a lot.
If you want to develop a feel for CLIs by working with a version-control tool, you can experiment with how commands behave in this site's Git Playground.
Wrapping Up
The CLI renaissance is the result of fit, not language fandom. Fast startup, predictable performance, single-binary distribution, and memory safety — these four match the problem of command-line tools precisely, and on top of that fit were born tools like ripgrep, fd, and bat.
Your first tool does not need to be grand. Start with a single struct annotated with #[derive(Parser)], add subcommands, polish your errors with anyhow, and show progress with indicatif when it helps. And finally, keep the small habits like standard streams and exit codes, and your tool too can join the list people keep at their fingertips every day.
References
- clap official documentation — https://docs.rs/clap/
- clap derive tutorial — https://docs.rs/clap/latest/clap/_derive/_tutorial/index.html
- The Rust Programming Language, Chapter 12 (building minigrep) — https://doc.rust-lang.org/book/ch12-00-an-io-project.html
- Command Line Applications in Rust (book) — https://rust-cli.github.io/book/
- anyhow crate — https://docs.rs/anyhow/
- indicatif crate — https://docs.rs/indicatif/
- crossterm crate — https://docs.rs/crossterm/
- ripgrep repository — https://github.com/BurntSushi/ripgrep
- fd repository — https://github.com/sharkdp/fd
- bat repository — https://github.com/sharkdp/bat