Rust 로 CLI 애플리케이션 만들기 #1
CLI (Command-Line Interface) 는 GUI를 사용하지 않고 원하는 작업을 빠르게 수행할 수 있기 때문에 다양한 환경에서 많이 사용되고 있습니다. 특히 유닉스 계열에서는 유닉스 파이프 기능을 사용하면 다양한 CLI 들의 조합으로 다양한 작업들을 효과적으로 수행할 수 있습니다.
CLI 를 작성할 때는 본인에게 익숙한 언어를 사용하는 것도 좋지만, 개인적으로는 CLI 작성시 필요한 다음과 같은 요구사항을 만족하는 언어를 선호합니다.
- 간편한 멀티 플랫폼 지원 (Windows, Linux, Mac, …)
- 실행시 별도의 런타임 불필요 (Java, Python, node.js 등은 제외)
- 단일 실행파일 배포 (별도의 공유 라이브러리 설치가 필요없어야 함)
위의 조건을 만족하는 모던 프로그래밍 언어라면 Go, Rust, Zig 등이 있는데, 여기서는 Rust 를 사용해 구현해보도록 하겠습니다.
다음과 같은 순서로 CLI 애플리케이션을 작성해 보도록 하겠습니다.
- Rust 프로젝트 생성
- 통합 테스트 (Integration Test)
- 커맨드라인 옵션 파싱
- 유닉스 파이프 지원
- 멀티플랫폼 빌드
- CI
- 배포
0. 프로젝트 소개
핸드폰이나 카메라로 찍은 사진이나 동영상 파일의 메타데이터를 분석해 파일 이름을 변경하는 CLI 애플리케이션을 만들어 보도록 하겠습니다.
파일의 메타데이터 분석은 exiftool CLI 애플리케이션을 사용합니다.
작성할 애플리케이션이 하는 일은
exiftool
을 사용해 파일의 메타데이터를 출력합니다.1.
에서 생성한 메타 데이터 정보를 파일 혹은 파이프를 통해 CLI 애플리케이션의 입력으로 받습니다.- 커맨드라인 옵션으로 지정한 패턴 형식으로 원본 파일의 파일명을 변경합니다. 파일 이름 패턴에는 Exif 메타 데이터의 키와 커스텀 패턴을 지원합니다.
1. Rust 프로젝트 생성
먼저 cargo
를 사용해 프로젝트를 생성해 보겠습니다.
프로젝트 이름은 exif-rename
라는 이름을 사용하도록 하겠습니다.
$ cargo new exif-rename --bin
실행 결과 exif-rename
라는 디렉토리에 다음과 같은 구조의 프로젝트가 생성됩니다:
.
├── Cargo.toml
└── src
└── main.rs
프로젝트를 빌드하면 target/debug
또는 target/release
디렉토리에 실행파일이 생성되며 다음과 같이 실행합니다.
# debug 빌드
$ cargo build
# debug 용 실행파일 실행
$ ./target/debug/exif-rename
하지만 개발시에는 매번 실행파일을 빌드하고 실행하는 것이 번거롭기 때문에, cargo run
명령을 사용해 한 번에 컴파일 및 실행을 합니다.
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/exif-rename`
Hello, world!
매번 출력되는 빌드 로그 메시지를 보고 싶지 않다면 -q
또는 --quiet
옵션을 사용합니다.
$ cargo run -q
Hello, world!
exif-rename
이라는 실행파일명은 Cargo.toml
파일에 지정되어 있습니다. name
필드를 변경한 후 빌드하면 실행파일이 변경된 것을 볼 수 있습니다.
[package]
name = "exif-rename"
version = "0.1.0"
edition = "2021"
[dependencies]
여기까지의 소스 코드는 이 곳 에서 확인할 수 있습니다.
2. 통합 테스트 (Integration Test)
Hello, world!
를 출력하는 단순한 프로그램이지만, 여기에도 테스트를 작성할 수 있습니다.
사용자가 실행하는 것처럼 exif-rename
을 실행했을 때, Hello, world!
가 출력되는지 확인하는 통합 테스트 코드를 작성해 보겠습니다.
Rust 프로젝트에서 테스트 코드는 일반적으로 tests
디렉토리에 작성합니다.
-
assert_cmd 를 dev-dependencies 에 추가합니다.
$ cargo add --dev assert_cmd
assert_cmd
크레이트는 다음과 같은 기능을 제공합니다.- 테스트할 실행 파일 찾기 (여기서는
target/debug/exif-rename
가 됩니다.) - 프로그램 실행 결과에 대한 Assert 지원
- 테스트할 실행 파일 찾기 (여기서는
-
tests
디렉토리를 생성한 후,tests/cli.rs
파일을 다음과 같이 작성합니다.use assert_cmd::Command; #[test] fn runs() { // 현재 크레이트의 exif-rename 을 실행하는 Command를 생성합니다. // 실행 결과는 Result 를 반환하며, 실행 파일을 찾을 수 있기 때문에, `Result::unwrap`을 실행합니다. // 실행 파일을 찾지 못한 경우 패닉이 발생하면서 테스트가 실패합니다. let mut cmd = Command::cargo_bin("exif-rename").unwrap(); // Assert::success 는 실행 종료 코드가 `0`인지 확인합니다. // `stdout` 함수를 사용해 STDOUT 에 출력된 문자열이 일치하는지 확인합니다. cmd.assert().success().stdout("Hello, world!\n"); }
여기까지 진행했으면 디렉토리 구조는 다음과 같습니다:
. ├── Cargo.toml ├── src │ └── main.rs └── tests └── cli.rs
-
cargo test
명령으로 테스트를 실행합니다.$ cargo test running 1 test test works ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s
여기까지의 소스 코드는 이 곳 에서 확인할 수 있습니다.
3. 커맨드라인 옵션 파싱
CLI 에서 다양한 옵션을 지원하기 위해서는 커맨드 라인 옵션 파싱을 위한 파서 라이브러리를 추가해야 합니다. 여기서는 clap 이라는 크레이트를 사용하도록 하겠습니다.
cargo add
명령을 사용해 디펜던시를 추가합니다. 여기서는 기본 피처 외에 추가로 derive
피처도 추가합니다.
$ cargo add clap --features derive
clap 의 각 피처에 대한 설명은 feature flag reference를 참고합니다.
Cargo.toml
파일을 확인해보면 clap
디펜던시가 추가된 것을 확인할 수 있습니다.
[dependencies]
clap = { version = "4.4.2", features = ["derive"] }
애플리케이션에서 사용할 커맨드라인 옵션은 다음과 같이 정의합니다.
-e
,--exif
- 필수항목. 다음 단계에서 유닉스 파이프를 통해 입력받을 수 있지만, 아직 유닉스 파이프를 지원하지 않기 때문에 필수 옵션으로 설정합니다.
- Exif 데이터를 저장한 텍스트 파일을 지정합니다.
-p
,--pattern
- 필수항목
- 파일이름 패턴을 지정합니다.
- 패턴은
{TagName}
형식으로 지정하며, 사용 가능한 태그 이름은 ExifTool Tag Names 페이지를 참고합니다. - 태그 이름은
exiftool -s
로 실행했을 때 표시되는 태그 이름과 동일합니다.
개인적으로 Exif 정보 중에서 촬영 일시, 이미지 번호, 카메라 모델 정보를 자주 사용하기 때문에, 이러한 정보를 사용하기 쉽도록 하기 위해 별도의 패턴을 추가하도록 하겠습니다.
{Y}
: 4자리 연도 (예:2023
){y}
: 2자리 연도 (예:23
){m}
: 월 (01
-12
){D}
: 일 (01
-31
){t}
: 시각HHMMSS
{H}
: 시 (00
-23
){h}
: 시 (01
-12
){M}
: 분 (00
-59
){S}
: 초 (00
-59
){f}
: 이미지 이름 (예:IMG_1234.JPG
인 경우IMG_
){r}
: 이미지 번호 (예:IMG_1234.JPG
인 경우1234
){e}
: 확장자 (예:JPG
)
여기서 사용한 패턴은 BreezeSys Downloader Pro 에서 사용하는 패턴을 사용했습니다.
src/main.rs
main.rs
파일에서 clap
을 사용해 커맨드라인 옵션을 파싱합니다.
자세한 API 사용방법은 clap 문서를 참조하세요.
use clap::Parser;
use exif_rename::{format_filename, read_exif_file};
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Exif filename
#[arg(short, long)]
exif: String,
/// filename pattern
#[arg(short, long)]
pattern: String,
}
fn main() {
let args = Args::parse();
let exif_filename: &str = args.exif.as_str();
let pattern: &str = args.pattern.as_str();
match read_exif_file(exif_filename) {
Ok(exif_vars) => {
let filename = format_filename(pattern, exif_vars);
println!("{}", filename)
}
Err(err) => {
eprintln!("Error: {}", err);
}
}
}
Args
struct 에exif
,pattern
옵션을 정의합니다. 자세한 사용방법은 clap::arg 문서 참조main()
함수에서Args::parse()
를 호출해 실행시 설정된 커맨드라인 옵션 정보들을 읽어올 수 있습니다.read_exif_file()
함수를 호출해 exiftool에서 출력한 정보를 저장한 텍스트 파일을 읽어옵니다. 나중에는 유닉스 파이프를 통해 읽어오도록 기능을 추가할 예정이지만, 그 전까지는 파일에서 읽어오는 것으로 구현을 합니다.- 읽어온 Exif 정보와
--pattern
옵션을 통해 전달받은 패턴을 사용해format_filename()
함수를 호출합니다. format_filename
함수에서 반환한 최종 파일명을 stdout 에 출력한 후, 프로그램을 종료합니다.format_filename
,read_exif_file
함수는src/lib.rs
파일에 정의합니다.
src/lib.rs
main.rs
에서 사용한 format_filename
, read_exif_file
함수를 public 으로 정의합니다.
use std::collections::HashMap;
pub type Vars = HashMap<String, String>;
type MyResult<T> = Result<T, Box<dyn Error>>;
pub fn read_exif_file(filepath: &str) -> MyResult<Vars> {
let mut vars: Vars = HashMap::new();
// ...
Ok(vars)
}
pub fn format_filename(pattern: &str, exif_vars: Vars) -> String {
// ...
return "".to_string();
}
상세한 구현은 나중에 살펴보도록 하고 일단은 위와 같이 함수 스켈레톤만 정의합니다.
tests/inputs/exif-jpg.txt
테스트에서 사용할 Exif 정보를 저장한 텍스트 파일을 생성합니다.
실제 Exif 정보 중에서 테스트에 사용할 메타 정보만 포함하도록 다음과 같이 생성합니다.
FileName : IMG_2533.JPG
CreateDate : 2023:09:08 18:56:54
Model : iPhone 14
tests/cli.rs
앞에서 구현했던 Integration 테스트 대신, 실제 커맨드라인 옵션을 전달해서 원하는 결과가 stdout 에 출력되는지 확인하는 테스트를 작성합니다.
use assert_cmd::Command;
type TestResult = Result<(), Box<dyn std::error::Error>>;
const EXECUTABLE: &str = "exif-rename";
fn run(args: &[&str], expected_lines: &[&str]) -> TestResult {
let expected = expected_lines.join("\n") + "\n";
Command::cargo_bin(EXECUTABLE)?
.args(args)
.assert()
.success()
.stdout(expected);
Ok(())
}
#[test]
fn jpg_with_exif() -> TestResult {
run(&["--exif", "tests/inputs/exif-jpg.txt", "--pattern", "{y}{m}{D}_{t}_{T2}_{r}.{e}"],
&["230908_185654_iPhone 14_2533.JPG"])
}
run
함수는 실행 인자, stdout 에 출력될 라인들을 인자로 받습니다.- 주어진 실행 인자들을 사용해 실행 파일을 실행한 후, stdout 에 출력된 문자열이 일치하는지 확인합니다. 일치하지 않는 경우 에러가 발생하면서 테스트가 실패하게 됩니다.
jpg_with_exif
테스트 함수에서는tests/inputs/exif-jpg.txt
파일을 사용해 주어진 패턴대로 파일명이 출력되는지 확인하는 테스트를 실행합니다.
tests/lib_test.rs
src/lib.rs
파일에 정의된 함수들의 테스트를 작성합니다.
use std::collections::HashMap;
use exif_rename::{extend_vars, format_filename, Vars};
#[test]
fn test_format_filename() {
let mut exif_vars = HashMap::new();
exif_vars.insert("CreateDate".to_string(), "2023:09:08 18:56:54".to_string());
exif_vars.insert("FileName".to_string(), "IMG_9876.JPG".to_string());
exif_vars.insert("Model".to_string(), "iPhone 14".to_string());
let pattern = "{y}{m}{D}_{t}_{T2}_{r}.{e}";
let expected = "230908_185654_iPhone 14_9876.JPG";
let mut vars: Vars = extend_vars(&exif_vars);
vars.extend(exif_vars);
let actual = format_filename(pattern, vars);
assert_eq!(expected, actual)
}
format_filename()
함수는 2개의 인자를 받습니다:
pattern
: 사용할 파일명 패턴vars
: Exif 변수들을 저장하고 있는Vars
타입의 HashMap. Exif 변수 외에도 커스텀으로 추가한 변수들이 필요한데, 이 변수들은extend_vars
라는 함수를 사용해 생성합니다. 이 함수는 앞에서lib.rs
에서 정의하지 않았기 때문에 추가로 정의하도록 합니다.
라이브러리 추가
코드를 구현하기에 앞서 구현에 필요한 디펜던시들을 추가합니다.
[package]
name = "exif-rename"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = "0.4.30"
clap = { version = "4.4.2", features = ["derive"] }
regex = "1.9.5"
strfmt = "0.2.4"
[dev-dependencies]
assert_cmd = "2.0.12"
코드 구현
이제 이 테스트를 통과할 수 있도록 src/lib.rs
파일을 아래와 같이 구현합니다.
use std::collections::HashMap;
use std::fs::read_to_string;
use chrono::{Datelike, DateTime, NaiveDateTime, Timelike};
use regex::Regex;
use strfmt::strfmt;
pub type Vars = HashMap<String, String>;
type MyResult<T> = Result<T, Box<dyn Error>>;
fn read_lines(filename: &str) -> Vec<String> {
return read_to_string(filename).unwrap().lines().map(|s| s.to_string()).collect();
}
fn split_exif_line(s: &str) -> Option<(String, String)> {
let tokens: Vec<&str> = s.splitn(2, ":").collect();
if tokens.len() == 2 {
Some((tokens[0].trim().to_string(), tokens[1].trim().to_string()))
} else {
None
}
}
pub fn read_exif_file(filepath: &str) -> MyResult<Vars> {
let mut vars: Vars = HashMap::new();
let lines = read_lines(filepath);
for line in lines {
if let Some((key, value)) = split_exif_line(line.as_str()) {
vars.insert(key, value);
}
}
Ok(vars)
}
pub fn create_vars_from_create_date(create_date: &str) -> Vars {
let mut vars: Vars = HashMap::new();
if let Some(datetime) = parse_datetime_from_string(create_date) {
let date = datetime.date();
let time = datetime.time();
// Y - 4-digit year
vars.insert("Y".to_string(), date.year().to_string());
// y - 2-digit year
vars.insert("y".to_string(), (date.year() % 100).to_string());
// m - month (01-12)
vars.insert("m".to_string(), format!("{:02}", date.month()));
// D - Day of the month (01-31)
vars.insert("D".to_string(), format!("{:02}", date.day()));
// t - time HHMMSS
vars.insert("t".to_string(), format!("{:02}{:02}{:02}", time.hour(), time.minute(), time.second()));
// H - hour (00-23)
vars.insert("H".to_string(), format!("{:02}", time.hour()));
// h - hour (01-12)
vars.insert("h".to_string(), format!("{:02}", time.hour12().1));
// M - minutes (00-59)
vars.insert("M".to_string(), format!("{:02}", time.minute()));
// S - seconds (00-59)
vars.insert("S".to_string(), format!("{:02}", time.second()));
}
vars
}
fn parse_datetime_from_string(s: &str) -> Option<NaiveDateTime> {
return match NaiveDateTime::parse_from_str(s, "%Y:%m:%d %H:%M:%S") {
Ok(dt) => Some(dt),
Err(_) => {
match DateTime::parse_from_str(s, "%Y:%m:%d %H:%M:%S%z") {
Ok(dt) => Some(dt.naive_local()),
Err(e) => {
eprintln!("parse error: {}", e.to_string());
None
}
}
}
};
}
pub fn create_vars_from_filename(filename: &str) -> Vars {
let mut vars: Vars = HashMap::new();
let regex = Regex::new(r"(.*\D)(\d*)\.([a-zA-Z0-9]+)").unwrap();
if let Some(groups) = regex.captures(filename) {
let image_name = groups.get(1).map_or("", |m| m.as_str());
let image_number = groups.get(2).map_or("", |m| m.as_str());
let extension = groups.get(3).map_or("", |m| m.as_str());
vars.insert("f".to_string(), image_name.to_string());
vars.insert("r".to_string(), image_number.to_string());
vars.insert("e".to_string(), extension.to_string());
}
return vars;
}
pub fn extend_vars(exif_vars: &Vars) -> Vars {
let mut vars: Vars = HashMap::new();
if let Some(create_date) = exif_vars.get("CreateDate") {
vars.extend(create_vars_from_create_date(create_date));
}
if let Some(filename) = exif_vars.get("FileName") {
vars.extend(create_vars_from_filename(filename));
}
if let Some(model) = exif_vars.get("Model") {
vars.insert("T2".to_string(), model.to_string());
}
return vars;
}
pub fn format_filename(pattern: &str, exif_vars: Vars) -> String {
let mut vars: Vars = extend_vars(&exif_vars);
vars.extend(exif_vars);
return strfmt(pattern, &vars).unwrap();
}
실행 순서
read_exif_file()
함수를 호출해 Exif 정보를 파일에서 읽어온 후,Vars
타입의 HashMap 을 반환합니다.format_filename()
함수에Vars
타입의 Exif 정보를 전달하면,extend_vars()
함수를 사용해 커스텀 변수 정보를 추가합니다.strfmt()
함수를 사용해 파일명 패턴에 해당하는 Exif 정보를 인터폴레이션해서 최종 파일명을 반환합니다.
테스트 실행
테스트를 실행해 모든 테스트 케이스가 통과하는지 확인합니다.
$ cargo test
터미널에서 실행 파일을 빌드한 후, 실행해서 원하는 결과가 출력되는지 확인합니다.
$ cargo build
$ target/debug/exif-rename --exif tests/inputs/exif-jpg.txt --pattern "{y}{m}{D}_{t}_{T2}_{r}.{e}"
230908_185654_iPhone 14_2533.JPG
여기까지의 소스 코드는 이 곳 에서 확인할 수 있습니다.
4. 추가 기능
지금까지 Rust 를 사용해 커맨드라인 옵션을 지원하는 CLI 애플리케이션을 만들고, 테스트 코드까지 작성해 봤습니다.
나머지 추가 기능들은 다음 포스트에서 추가로 구현해보도록 하겠습니다.
- 유닉스 파이프 지원
- 멀티플랫폼 빌드
- CI
- 배포