Rust로 만드는 clap 명령줄 도구
명령줄 인자 페이지에서는 std::env::args()로 인자를 직접 읽었어요. 인자가 한두 개라면 그걸로 충분해요. 하지만 진짜 도구에는 --precision 3 같은 옵션, -p 3 같은 짧은 형태, --help 페이지, 알기 쉬운 에러 메시지, 그리고 git commit, git push 같은 하위 명령어(subcommand)가 필요해요. 이걸 전부 손으로 작성하려면 지루하고 실수하기 쉬운 코드가 잔뜩 생겨요.
clap(영어)(Command Line Argument Parser)은 거의 모든 Rust CLI가 이런 일에 쓰는 크레이트예요. 인자를 평범한 Rust 구조체로 적어 주기만 하면, clap이 파서와 도움말, 에러 메시지를 대신 만들어 줘요. 이 프로젝트에서는 convert라는 작은 단위 변환기를 만들어요. 하위 명령어는 두 개예요. 온도를 바꾸는 temp와 길이를 바꾸는 length예요.
무엇을 만들까요
섹션 제목: “무엇을 만들까요”완성된 도구를 터미널에서 실행하면 이렇게 돼요. 이 페이지의 모든 출력은 프로그램을 실제로 실행해서 얻은 결과예요.
$ cargo run -q -- temp 100 --from celsius --to fahrenheit100 °C = 212.00 °F$ cargo run -q -- temp -40 -f c -t f-40 °C = -40.00 °F$ cargo run -q -- length 5 km mi5 km = 3.11 mi$ cargo run -q -- -p 0 length 1 mi ft1 mi = 5280 ft$ cargo run -q -- temp 20 --from rankine --to cerror: invalid value 'rankine' for '--from <FROM>' [possible values: celsius, fahrenheit, kelvin]
For more information, try '--help'.-- 뒤에 오는 내용은 Cargo가 아니라 여러분의 프로그램에 전달되고, -q(quiet)는 Cargo가 출력하는 “Compiling…” 같은 줄을 숨겨 줘요. 마지막 에러 메시지 보셨나요? 그건 우리가 쓴 게 아니에요. clap이 만들어 준 거예요. (“--from <FROM>에 잘못된 값 ’rankine’이 들어왔고, 가능한 값은 celsius, fahrenheit, kelvin”이라는 뜻이에요.)
1단계: 프로젝트를 만들고 clap 추가하기
섹션 제목: “1단계: 프로젝트를 만들고 clap 추가하기”cargo new convertcd convertcargo add clap --features derive중요한 건 derive 기능(feature)이에요. 이 기능을 켜야 clap을 편하게 쓰는 방법인 #[derive(Parser)]를 쓸 수 있어요. 이제 Cargo.toml에 이런 내용이 들어 있어요.
[dependencies]clap = { version = "4.6.7", features = ["derive"] }(버전 번호는 조금 더 새로울 수도 있어요. 4로 시작하는 버전이면 이 페이지의 내용이 모두 동작해요. cargo add가 처음이라면 Cargo와 크레이트를 참고하세요.)
2단계: 첫 번째 파서
섹션 제목: “2단계: 첫 번째 파서”섭씨를 화씨로만 바꾸는 도구부터 시작해요. 인자 하나에 필드 하나씩 구조체를 만들고, 그 위에 #[derive(Parser)]를 붙여요. src/main.rs를 다음 코드로 바꿔 주세요.
use clap::Parser;
/// 섭씨 온도를 화씨로 변환해요#[derive(Parser, Debug)]#[command(name = "convert", version = "1.0")]struct Cli { /// 섭씨 온도 celsius: f64,
/// 소수점 아래로 표시할 자릿수 #[arg(short, long, default_value_t = 2)] precision: usize,
/// 사용한 공식도 함께 출력 #[arg(short, long)] verbose: bool,}
fn main() { let cli = Cli::parse(); let fahrenheit = cli.celsius * 9.0 / 5.0 + 32.0; println!("{} °C = {:.*} °F", cli.celsius, cli.precision, fahrenheit); if cli.verbose { println!("(사용한 공식: °F = °C × 9/5 + 32)"); }}$ cargo run -q -- 21.521.5 °C = 70.70 °F$ cargo run -q -- 100 --precision 1 --verbose100 °C = 212.0 °F(사용한 공식: °F = °C × 9/5 + 32)clap은 필드를 하나씩 읽고 어떤 종류의 인자인지 정해요.
celsius에는#[arg(...)]옵션이 없으니까 위치 인자(positional argument)가 돼요. 처음 입력한 평범한 값을 말해요. 타입이f64이기 때문에 clap은"21.5"라는 텍스트를 숫자로 파싱하는 일까지 해 줘요.#[arg(short, long)]을 붙이면 필드 이름에서 따온 짧은 이름과 긴 이름을 가진 옵션이 돼요. 여기서는-p/--precision이에요. 옵션을 생략하면default_value_t = 2의 값이 쓰여요.bool필드는 플래그가 돼요.--verbose(또는-v)가 있으면true, 없으면false예요.Option<T>필드(여기서는 쓰지 않았어요)는 아예 생략해도 되는 인자가 돼요.
println!의 {:.*}는 “다음 인자를 소수점 아래 자릿수로 쓴다”는 뜻이에요. 이렇게 해서 --precision 값이 출력까지 전달돼요.
Cli::parse()는 진짜 명령줄을 읽어요. Rust Playground에는 명령줄이 없으니까, 거기서 파서를 시험하려면 가짜 인자 목록을 넘기는 Cli::parse_from을 쓰세요. std::env::args()와 마찬가지로 첫 번째 항목은 항상 프로그램 이름이에요.
use clap::Parser;
/// 섭씨 온도를 화씨로 변환해요#[derive(Parser, Debug)]#[command(name = "convert", version = "1.0")]struct Cli { /// 섭씨 온도 celsius: f64,
/// 소수점 아래로 표시할 자릿수 #[arg(short, long, default_value_t = 2)] precision: usize,
/// 사용한 공식도 함께 출력 #[arg(short, long)] verbose: bool,}
fn main() { // 사용자가 convert 100 --precision 1 --verbose 라고 입력했다고 쳐요. let cli = Cli::parse_from(["convert", "100", "--precision", "1", "--verbose"]); println!("{cli:?}");
let fahrenheit = cli.celsius * 9.0 / 5.0 + 32.0; println!("{} °C = {:.*} °F", cli.celsius, cli.precision, fahrenheit);}실행 결과:
Cli { celsius: 100.0, precision: 1, verbose: true }100 °C = 212.0 °F가짜 인자를 바꿔 보세요. "--verbose"를 빼거나 "--precision"을 "-p"로 바꿔 봐도 좋아요.
3단계: 문서 주석으로 도움말 만들기
섹션 제목: “3단계: 문서 주석으로 도움말 만들기”구조체 안의 /// 줄을 눈치채셨나요? 이건 문서 주석이고, clap은 이걸 도움말 페이지로 바꿔 줘요. 구조체 위의 주석은 프로그램 전체를 설명하고, 각 필드 위의 주석은 그 인자를 설명해요. 다른 건 아무것도 쓰지 않아도 --help(와 -h)가 생겨요.
$ cargo run -q -- --help섭씨 온도를 화씨로 변환해요
Usage: convert [OPTIONS] <CELSIUS>
Arguments: <CELSIUS> 섭씨 온도
Options: -p, --precision <PRECISION> 소수점 아래로 표시할 자릿수 [default: 2] -v, --verbose 사용한 공식도 함께 출력 -h, --help Print help -V, --version Print version$ cargo run -q -- --versionconvert 1.0우리가 쓴 설명은 한국어로 나오지만, Usage:, Options:, Print help 같은 부분은 clap이 미리 준비한 문구라서 영어 그대로예요.
#[command(name = "convert", version = "1.0")] 줄이 여기에 표시되는 이름과 버전을 정해요. #[command(version)]이라고만 쓰면 clap은 Cargo.toml에서 버전을 가져와요.
clap은 사용법이 틀렸을 때도 설명해 줘요.
$ cargo run -q --error: the following required arguments were not provided: <CELSIUS>
Usage: convert <CELSIUS>
For more information, try '--help'.$ cargo run -q -- abcerror: invalid value 'abc' for '<CELSIUS>': invalid float literal
For more information, try '--help'.첫 번째는 “꼭 필요한 인자 <CELSIUS>가 없다”는 뜻이고, 두 번째는 “abc는 올바른 실수가 아니다”라는 뜻이에요.
좋은 도움말은 미래의 나에게 주는 선물이에요. 이 도구를 처음 보는 사람을 위해 쓴다고 생각하고 문서 주석을 작성하세요. 언젠가는 그 사람이 바로 여러분 자신일 테니까요.
4단계: 하위 명령어
섹션 제목: “4단계: 하위 명령어”이제 길이도 지원해 볼게요. git이나 cargo 같은 도구는 서로 다른 일을 하위 명령어(cargo build, cargo test)로 묶어요. clap에서 하위 명령어는 #[derive(Subcommand)]를 붙인 열거형이에요. 배리언트 하나가 하위 명령어 하나이고, 그 필드가 하위 명령어의 인자가 돼요.
use clap::{Parser, Subcommand};
/// 온도와 길이를 바꿔 주는 작은 단위 변환기#[derive(Parser, Debug)]#[command(name = "convert", version = "1.0")]struct Cli { /// 소수점 아래로 표시할 자릿수 #[arg(short, long, default_value_t = 2, global = true)] precision: usize,
#[command(subcommand)] command: Command,}
#[derive(Subcommand, Debug)]enum Command { /// 온도 변환 Temp { /// 변환할 온도 value: f64,
/// 변환 전 단위 #[arg(short, long)] from: String,
/// 변환 후 단위 #[arg(short, long)] to: String, }, /// 길이 변환 Length { /// 변환할 길이 value: f64,
/// 변환 전 단위 from: String,
/// 변환 후 단위 to: String, },}
fn main() { let cli = Cli::parse_from(["convert", "temp", "100", "--from", "c", "--to", "f"]); println!("{cli:?}");
let cli = Cli::parse_from(["convert", "length", "5", "km", "mi", "-p", "1"]); println!("{cli:?}");
// 어떤 하위 명령어가 쓰였는지는 평범한 match로 알아내요. match cli.command { Command::Temp { value, from, to } => println!("temp: {value} {from} -> {to}"), Command::Length { value, from, to } => println!("length: {value} {from} -> {to}"), }}실행 결과:
Cli { precision: 2, command: Temp { value: 100.0, from: "c", to: "f" } }Cli { precision: 1, command: Length { value: 5.0, from: "km", to: "mi" } }length: 5 km -> mi몇 가지 자세한 내용이에요.
command필드의#[command(subcommand)]는 clap에게 “이 필드에 선택된 하위 명령어가 들어간다”고 알려 줘요.- 배리언트 이름
Temp는 하위 명령어temp가 돼요(clap이 소문자로 바꿔 줘요). temp는 이름 있는 옵션(--from c --to f)을 쓰고,length는 위치 인자(5 km mi)를 써요. 두 방식 모두 흔히 쓰이니까 둘 다 살펴볼 수 있게 했어요.global = true를 붙이면--precision을 하위 명령어 앞에 써도, 뒤에 써도 돼요. 이게 없으면-p 1은length앞에서만 받아들여져요.
이제 도움말 페이지에 하위 명령어가 나오고, 하위 명령어마다 자기만의 도움말도 생겨요.
$ cargo run -q -- --help온도와 길이를 바꿔 주는 작은 단위 변환기
Usage: convert [OPTIONS] <COMMAND>
Commands: temp 온도 변환 length 길이 변환 help Print this message or the help of the given subcommand(s)
Options: -p, --precision <PRECISION> 소수점 아래로 표시할 자릿수 [default: 2] -h, --help Print help -V, --version Print version$ cargo run -q -- temp --help온도 변환
Usage: convert temp [OPTIONS] --from <FROM> --to <TO> <VALUE>
Arguments: <VALUE> 변환할 온도
Options: -f, --from <FROM> 변환 전 단위 -p, --precision <PRECISION> 소수점 아래로 표시할 자릿수 [default: 2] -t, --to <TO> 변환 후 단위 -h, --help Print help그런데 문제가 하나 있어요. 단위가 평범한 String이라서 clap은 아무 값이나 받아들여요.
$ cargo run -q -- temp 20 --from rankine --to cCli { precision: 2, command: Temp { value: 20.0, from: "rankine", to: "c" } }이대로라면 나중에 단위를 하나하나 직접 검사해야 해요. 이 일을 clap에게 맡겨 볼게요.
5단계: ValueEnum으로 열거형과 검증
섹션 제목: “5단계: ValueEnum으로 열거형과 검증”인자가 정해진 몇 개의 낱말 중 하나여야 한다면, 그 낱말들을 열거형으로 적고 ValueEnum을 derive하세요. 그러면 clap은 그 낱말들만 받아들이고, 도움말에 목록을 보여 주고, 나머지는 모두 거절해요. 이 열거형들이 4단계의 String 필드를 대신해요.
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]enum TempUnit { #[value(alias = "c")] Celsius, #[value(alias = "f")] Fahrenheit, #[value(alias = "k")] Kelvin,}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]enum LengthUnit { Mm, Cm, M, Km, In, Ft, Mi,}- 각 배리언트는 소문자 낱말이 돼요.
Celsius→celsius,Km→km처럼요. #[value(alias = "c")]를 붙이면 짧은 형태인c도 받아들여요. 도움말에는 나오지 않아요.- clap에는
Clone이 필요해요.Copy도 함께 붙였는데, 이런 작은 열거형은 복사 비용이 싸고match를 쓰기도 편해지기 때문이에요.
Command 열거형에서 from: String을 from: TempUnit으로 바꾸세요(길이는 LengthUnit). 규칙도 두 개 더 추가해요.
/// 소수점 아래로 표시할 자릿수#[arg(short, long, default_value_t = 2, global = true, value_parser = clap::value_parser!(u8).range(0..=6))]precision: u8,/// 변환할 온도#[arg(allow_negative_numbers = true)]value: f64,value_parser!(u8).range(0..=6)은 0부터 6까지의 자릿수만 받아들여요. 그보다 많으면 의미 없는 숫자만 늘어날 뿐이에요.allow_negative_numbers = true를 붙이면-40을 입력할 수 있어요. 이게 없으면 clap은-40을-4라는 짧은 옵션으로 생각해요(자주 하는 실수 참고). 온도는 음수일 수 있으니까 허용하고, 길이는 음수일 수 없으니까 허용하지 않아요.
이제 clap이 여러분의 코드가 실행되기도 전에 잘못된 입력을 잡아내요.
$ cargo run -q -- temp 20 --from rankine --to cerror: invalid value 'rankine' for '--from <FROM>' [possible values: celsius, fahrenheit, kelvin]
For more information, try '--help'.$ cargo run -q -- length 3 yd merror: invalid value 'yd' for '<FROM>' [possible values: mm, cm, m, km, in, ft, mi]
For more information, try '--help'.$ cargo run -q -- -p 9 length 1 m cmerror: invalid value '9' for '--precision <PRECISION>': 9 is not in 0..=6
For more information, try '--help'.그리고 도움말 페이지에 가능한 값(possible values)이 표시돼요.
$ cargo run -q -- temp --help온도 변환
Usage: convert temp [OPTIONS] --from <FROM> --to <TO> <VALUE>
Arguments: <VALUE> 변환할 온도
Options: -f, --from <FROM> 변환 전 단위 [possible values: celsius, fahrenheit, kelvin] -p, --precision <PRECISION> 소수점 아래로 표시할 자릿수 [default: 2] -t, --to <TO> 변환 후 단위 [possible values: celsius, fahrenheit, kelvin] -h, --help Print help이게 clap의 핵심 아이디어예요. 파싱에 성공했다면 데이터는 이미 올바른 상태예요. from은 무슨 값이든 들어 있을 수 있는 문자열이 아니라 진짜 TempUnit이에요. 프로그램의 나머지 부분은 이 값을 믿고 쓰면 돼요.
6단계: 에러와 종료 코드
섹션 제목: “6단계: 에러와 종료 코드”clap이 잡아낼 수 없는 실수도 있어요. 값의 의미에 따라 달라지는 실수예요. -500 °C는 숫자로서는 아무 문제가 없지만, 절대 영도(-273.15 °C)보다 낮으니까 현실에 존재할 수 없는 온도예요. 이런 검사는 우리 코드에서 해야 해요.
깔끔한 방법은 실제 작업을 Result를 돌려주는 run 함수에 넣고, 무엇을 출력할지와 어떤 종료 코드(exit code)를 돌려줄지는 main이 정하게 하는 거예요. 프로그램은 성공했는지 실패했는지를 숫자로 셸에 알려요. 0은 성공이고, 그 밖의 숫자는 실패예요. Rust의 std::process::ExitCode를 쓰면 main이 종료 코드를 돌려줄 수 있어요.
fn main() -> ExitCode { let cli = Cli::parse(); match run(&cli) { Ok(line) => { println!("{line}"); ExitCode::SUCCESS // 종료 코드 0 } Err(message) => { eprintln!("에러: {message}"); // 에러는 stderr로 보내요 ExitCode::FAILURE // 종료 코드 1 } }}clap도 같은 규칙을 따라요. 파싱에 실패하면 Cli::parse()는 에러를 stderr에 출력하고 종료 코드 2로 끝나요. “명령어를 잘못 사용했다”는 뜻으로 흔히 쓰는 코드예요. --help와 --version은 0으로 끝나요. 터미널에서 echo $?를 입력하면 마지막 종료 코드를 볼 수 있어요.
$ cargo run -q -- temp -500 -f c -t k에러: -500 °C는 절대 영도보다 낮은 온도예요$ echo $?1$ cargo run -q -- length 3 yd merror: invalid value 'yd' for '<FROM>' [possible values: mm, cm, m, km, in, ft, mi]
For more information, try '--help'.$ echo $?2이게 왜 중요할까요? 스크립트나 다른 프로그램은 종료 코드를 보고 여러분의 도구가 제대로 동작했는지 판단해요. 종료 코드와 stderr는 환경 변수와 프로세스 페이지에서 더 자세히 다뤄요. 더 큰 도구라면 run의 에러 타입으로 String 대신 anyhow를 쓰는 게 좋을 거예요. 그러면 문맥(context)과 에러 체인이 공짜로 따라와요.
7단계: 완성된 프로그램
섹션 제목: “7단계: 완성된 프로그램”이제 모두 합쳐 볼게요. src/main.rs를 다음 코드로 바꾸고, 페이지 맨 위에 있던 명령어들을 실행해 보세요.
use clap::{Parser, Subcommand, ValueEnum};use std::process::ExitCode;
/// 온도와 길이를 바꿔 주는 작은 단위 변환기#[derive(Parser, Debug)]#[command(name = "convert", version = "1.0")]struct Cli { /// 소수점 아래로 표시할 자릿수 #[arg(short, long, default_value_t = 2, global = true, value_parser = clap::value_parser!(u8).range(0..=6))] precision: u8,
#[command(subcommand)] command: Command,}
#[derive(Subcommand, Debug)]enum Command { /// 온도 변환 Temp { /// 변환할 온도 #[arg(allow_negative_numbers = true)] value: f64,
/// 변환 전 단위 #[arg(short, long)] from: TempUnit,
/// 변환 후 단위 #[arg(short, long)] to: TempUnit, }, /// 길이 변환 Length { /// 변환할 길이 value: f64,
/// 변환 전 단위 from: LengthUnit,
/// 변환 후 단위 to: LengthUnit, },}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]enum TempUnit { #[value(alias = "c")] Celsius, #[value(alias = "f")] Fahrenheit, #[value(alias = "k")] Kelvin,}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]enum LengthUnit { Mm, Cm, M, Km, In, Ft, Mi,}
impl TempUnit { fn symbol(self) -> &'static str { match self { TempUnit::Celsius => "°C", TempUnit::Fahrenheit => "°F", TempUnit::Kelvin => "K", } }}
impl LengthUnit { // 이 단위 1이 몇 미터인지 알려 줘요. fn meters(self) -> f64 { match self { LengthUnit::Mm => 0.001, LengthUnit::Cm => 0.01, LengthUnit::M => 1.0, LengthUnit::Km => 1000.0, LengthUnit::In => 0.0254, LengthUnit::Ft => 0.3048, LengthUnit::Mi => 1609.344, } }
fn name(self) -> &'static str { match self { LengthUnit::Mm => "mm", LengthUnit::Cm => "cm", LengthUnit::M => "m", LengthUnit::Km => "km", LengthUnit::In => "in", LengthUnit::Ft => "ft", LengthUnit::Mi => "mi", } }}
fn convert_temp(value: f64, from: TempUnit, to: TempUnit) -> Result<f64, String> { // 1단계: 어떤 단위로 받았든 먼저 섭씨로 바꿔요. let celsius = match from { TempUnit::Celsius => value, TempUnit::Fahrenheit => (value - 32.0) * 5.0 / 9.0, TempUnit::Kelvin => value - 273.15, }; if celsius < -273.15 { return Err(format!("{value} {}는 절대 영도보다 낮은 온도예요", from.symbol())); } // 2단계: 섭씨를 원하는 단위로 바꿔요. Ok(match to { TempUnit::Celsius => celsius, TempUnit::Fahrenheit => celsius * 9.0 / 5.0 + 32.0, TempUnit::Kelvin => celsius + 273.15, })}
fn convert_length(value: f64, from: LengthUnit, to: LengthUnit) -> f64 { value * from.meters() / to.meters()}
fn run(cli: &Cli) -> Result<String, String> { let p = cli.precision as usize; match cli.command { Command::Temp { value, from, to } => { let result = convert_temp(value, from, to)?; Ok(format!("{value} {} = {result:.p$} {}", from.symbol(), to.symbol())) } Command::Length { value, from, to } => { let result = convert_length(value, from, to); Ok(format!("{value} {} = {result:.p$} {}", from.name(), to.name())) } }}
fn main() -> ExitCode { let cli = Cli::parse(); match run(&cli) { Ok(line) => { println!("{line}"); ExitCode::SUCCESS } Err(message) => { eprintln!("에러: {message}"); ExitCode::FAILURE } }}run 안에는 작은 요령이 두 가지 있어요. {result:.p$}는 “result를 소수점 아래 p자리까지 출력한다”는 뜻이고, convert_temp(...) 뒤의 ?는 불가능한 온도일 때 에러를 들고 바로 돌아가요.
8단계: try_parse_from으로 파서 테스트하기
섹션 제목: “8단계: try_parse_from으로 파서 테스트하기”Cli::parse()는 뭔가 잘못되면 프로그램을 끝내 버려요. 사용자에게는 그게 맞지만, 테스트에서는 곤란해요. Cli::try_parse_from(...)은 직접 고른 인자 목록으로 똑같이 파싱하되, 프로그램을 끝내는 대신 Result를 돌려줘요. 그래서 파서를 확인하기에 딱 좋고, Playground에서도 실행돼요.
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
/// 온도와 길이를 바꿔 주는 작은 단위 변환기#[derive(Parser, Debug)]#[command(name = "convert", version = "1.0")]struct Cli { /// 소수점 아래로 표시할 자릿수 #[arg(short, long, default_value_t = 2, global = true, value_parser = clap::value_parser!(u8).range(0..=6))] precision: u8,
#[command(subcommand)] command: Command,}
#[derive(Subcommand, Debug)]enum Command { /// 온도 변환 Temp { /// 변환할 온도 #[arg(allow_negative_numbers = true)] value: f64,
/// 변환 전 단위 #[arg(short, long)] from: TempUnit,
/// 변환 후 단위 #[arg(short, long)] to: TempUnit, }, /// 길이 변환 Length { /// 변환할 길이 value: f64,
/// 변환 전 단위 from: LengthUnit,
/// 변환 후 단위 to: LengthUnit, },}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]enum TempUnit { #[value(alias = "c")] Celsius, #[value(alias = "f")] Fahrenheit, #[value(alias = "k")] Kelvin,}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]enum LengthUnit { Mm, Cm, M, Km, In, Ft, Mi,}
fn check(line: &str) { println!("$ {line}"); // split_whitespace로 인자를 얻어요. 맨 앞은 프로그램 이름이에요. match Cli::try_parse_from(line.split_whitespace()) { Ok(cli) => println!(" ok: {cli:?}"), Err(err) => { // clap 메시지의 첫 줄만 보여 줘요. let first_line = err.to_string().lines().next().unwrap_or("").to_string(); println!(" {:?}: {first_line}", err.kind()); } }}
fn main() { // 파서 설정 자체가 잘못되면(예를 들어 짧은 이름이 같은 옵션이 // 두 개라면) 패닉을 일으켜요. 비용 없이 드는 보험이에요. Cli::command().debug_assert();
check("convert temp -40 -f c -t fahrenheit"); check("convert length 5 km mi -p 1"); check("convert temp 20 --from rankine --to c"); check("convert -p 9 length 1 m cm"); check("convert temp 10 --to c"); check("convert --version");}실행 결과:
$ convert temp -40 -f c -t fahrenheit ok: Cli { precision: 2, command: Temp { value: -40.0, from: Celsius, to: Fahrenheit } }$ convert length 5 km mi -p 1 ok: Cli { precision: 1, command: Length { value: 5.0, from: Km, to: Mi } }$ convert temp 20 --from rankine --to c InvalidValue: error: invalid value 'rankine' for '--from <FROM>'$ convert -p 9 length 1 m cm ValueValidation: error: invalid value '9' for '--precision <PRECISION>': 9 is not in 0..=6$ convert temp 10 --to c MissingRequiredArgument: error: the following required arguments were not provided:$ convert --version DisplayVersion: convert 1.0--version도 Err로 돌아온다는 점을 눈여겨보세요. clap 입장에서 “버전을 보여 주고 멈추기”는 파싱에 성공한 게 아니라서 DisplayVersion이라는 종류(kind)로 알려 줘요(--help는 DisplayHelp예요). Cli::parse()는 이런 경우를 알아서 처리하고 종료 코드 0으로 끝나요.
실제 프로젝트에서는 같은 검사를 테스트에 넣어요. src/main.rs 맨 아래에 다음 코드를 추가하세요.
#[cfg(test)]mod tests { use super::*; use clap::CommandFactory;
#[test] fn cli_is_set_up_correctly() { Cli::command().debug_assert(); }
#[test] fn parses_a_temperature() { let cli = Cli::try_parse_from(["convert", "temp", "-40", "-f", "c", "-t", "f"]).unwrap(); assert_eq!(cli.precision, 2); match cli.command { Command::Temp { value, from, to } => { assert_eq!(value, -40.0); assert_eq!(from, TempUnit::Celsius); assert_eq!(to, TempUnit::Fahrenheit); } other => panic!("temp를 기대했는데 {other:?}가 나왔어요"), } }
#[test] fn rejects_an_unknown_unit() { let result = Cli::try_parse_from(["convert", "length", "3", "yd", "m"]); let err = result.unwrap_err(); assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue); }
#[test] fn minus_forty_is_the_same_in_both() { let f = convert_temp(-40.0, TempUnit::Celsius, TempUnit::Fahrenheit).unwrap(); assert_eq!(f, -40.0); }}$ cargo test -q
running 4 tests....test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s자주 하는 실수
섹션 제목: “자주 하는 실수”derive 기능을 빠뜨리기. --features derive 없이 cargo add clap만 실행하면 derive 매크로가 존재하지 않아요.
error: cannot find derive macro `Parser` in this scope --> src/main.rs:4:10 |4 | #[derive(Parser, Debug)] | ^^^^^^ |note: `Parser` is imported here, but it is only a trait, without a derive macro --> src/main.rs:1:5 |1 | use clap::Parser; | ^^^^^^^^^^^^
error: cannot find attribute `command` in this scope컴파일러는 “Parser라는 derive 매크로를 찾을 수 없고, 가져온 Parser는 derive 매크로가 없는 트레이트일 뿐”이라고 말하고 있어요. 해결 방법은 cargo add clap --features derive를 실행하거나, Cargo.toml에 features = ["derive"]를 직접 추가하는 거예요.
음수가 옵션처럼 보여요. 2단계 프로그램으로 추운 날의 기온을 넣어 보세요.
$ cargo run -q -- -40error: unexpected argument '-4' found
tip: to pass '-4' as a value, use '-- -4'
Usage: convert [OPTIONS] <CELSIUS>
For more information, try '--help'.clap은 -를 보고 -4를 짧은 옵션이라고 생각해요. 해결 방법은 5단계에서 한 것처럼 필드에 #[arg(allow_negative_numbers = true)]를 붙이는 거예요. (사용자가 -- -40이라고 입력해도 돼요. 홀로 쓰인 -- 뒤의 내용은 모두 평범한 값으로 취급돼요.)
Playground에서 Cli::parse() 부르기. Playground는 인자 없이 프로그램을 실행하기 때문에, parse()는 여러분의 코드가 무언가 하기도 전에 에러(하위 명령어가 있는 도구라면 도움말 페이지)를 출력하고 종료 코드 2로 끝나요. Playground와 테스트에서는 Cli::parse_from([...])이나 Cli::try_parse_from([...])을 쓰세요.
에러를 println!으로 출력하기. 에러 메시지는 stdout이 아니라 stderr(eprintln!)로 보내야 해요. 누군가 convert ... > result.txt처럼 실행해도 에러는 파일 속에 숨지 않고 화면에 나타나요.
확장 아이디어
섹션 제목: “확장 아이디어”- 단위 늘리기.
LengthUnit에 야드(yd)와 해리를 추가해 보세요. 고쳐야 할match는 컴파일러가 전부 짚어 줘요. weight하위 명령어를 만들어서 킬로그램, 파운드, 온스를 변환해 보세요.--json플래그를 만들어서 serde_json으로 결과를 JSON으로 출력해 보세요.- 값이 없으면 stdin에서 읽기. 그러면
echo 100 | convert temp -f c -t f처럼 숫자를 파이프로 넘길 수 있어요.Option<f64>필드를 쓰면 값이 입력됐는지 알 수 있어요. - anyhow로 더 나은 에러 만들기.
run이anyhow::Result<String>을 돌려주게 바꾸고anyhow::bail!을 써 보세요. anyhow와 thiserror를 참고하세요. - 셸 자동 완성. clap_complete(영어) 크레이트는
Cli구조체에서 bash, zsh, fish, PowerShell용 탭 자동 완성 스크립트를 만들어 줘요.
cargo add clap --features derive로 clap을 추가하고, 구조체에#[derive(Parser)]를 붙여요. 필드 하나가 인자 하나예요.- 평범한 필드는 위치 인자,
#[arg(short, long)]은 옵션,bool은 플래그가 되고,default_value_t로 기본값을 정해요.///문서 주석은--help텍스트가 돼요. - 열거형에
#[derive(Subcommand)]를 붙이면 하위 명령어가 생기고,#[derive(ValueEnum)]은 인자를 정해진 낱말 중 하나로 제한해요.value_parser!(...).range(...)는 숫자 범위를 검사해요. - 파싱에 성공하면 데이터는 이미 올바른 상태예요. 남은 검사는
Result를 돌려주는run함수에 넣고,main에서는ExitCode를 돌려주세요. - clap은 사용법 에러일 때 종료 코드 2로,
--help/--version일 때 0으로 끝나요. 여러분이 처리하는 실패도 0이 아닌 코드를 쓰고 stderr에 출력해야 해요. Cli::try_parse_from은 직접 고른 목록을 파싱하고Result를 돌려줘요. 테스트와 Playground에 딱 맞아요.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. #[derive(Parser)]를 쓸 수 있게 해 주는 cargo add 명령은 무엇일까요?
2. clap은 --help에 나오는 각 옵션의 설명을 어디에서 가져올까요?
3. Cli::try_parse_from(["convert", "--version"])은 무엇을 돌려줄까요?
4. 사용자가 잘못된 단위를 입력해서 Cli::parse()가 실패했어요. clap은 어떤 종료 코드를 쓸까요?
5. 2단계 프로그램에서 convert -40은 왜 실패할까요?
점수: 0 / 5
더 알아보기
섹션 제목: “더 알아보기”- docs.rs의 clap(영어) — 모든 속성에 대한 예제가 들어 있는 전체 API 문서예요.
- clap derive 튜토리얼(영어) — 인자, 옵션, 하위 명령어, 검증을 단계별로 다루는 공식 튜토리얼이에요.
- clap derive 레퍼런스(영어) —
#[command(...)],#[arg(...)],#[value(...)]의 모든 옵션을 설명해요. - Command Line Applications in Rust(영어) — 출력, 에러, 테스트, 패키징까지 진짜 CLI 도구를 만드는 방법을 다루는 무료 책이에요.
- Exit codes — Command Line Applications in Rust(영어) — 종료 코드가 무슨 뜻이고 어떻게 잘 쓰는지 설명해요.