lechuck.dev

Rust 로 CLI 애플리케이션 만들기 #1

마지막 업데이트:

CLI (Command-Line Interface) 는 GUI를 사용하지 않고 원하는 작업을 빠르게 수행할 수 있기 때문에 다양한 환경에서 많이 사용되고 있습니다. 특히 유닉스 계열에서는 유닉스 파이프 기능을 사용하면 다양한 CLI 들의 조합으로 다양한 작업들을 효과적으로 수행할 수 있습니다.

CLI 를 작성할 때는 본인에게 익숙한 언어를 사용하는 것도 좋지만, 개인적으로는 CLI 작성시 필요한 다음과 같은 요구사항을 만족하는 언어를 선호합니다.

위의 조건을 만족하는 모던 프로그래밍 언어라면 Go, Rust, Zig 등이 있는데, 여기서는 Rust 를 사용해 구현해보도록 하겠습니다.

다음과 같은 순서로 CLI 애플리케이션을 작성해 보도록 하겠습니다.

  1. Rust 프로젝트 생성
  2. 통합 테스트 (Integration Test)
  3. 커맨드라인 옵션 파싱
  4. 유닉스 파이프 지원
  5. 멀티플랫폼 빌드
  6. CI
  7. 배포

0. 프로젝트 소개

핸드폰이나 카메라로 찍은 사진이나 동영상 파일의 메타데이터를 분석해 파일 이름을 변경하는 CLI 애플리케이션을 만들어 보도록 하겠습니다.

파일의 메타데이터 분석은 exiftool CLI 애플리케이션을 사용합니다.

작성할 애플리케이션이 하는 일은

  1. exiftool 을 사용해 파일의 메타데이터를 출력합니다.
  2. 1.에서 생성한 메타 데이터 정보를 파일 혹은 파이프를 통해 CLI 애플리케이션의 입력으로 받습니다.
  3. 커맨드라인 옵션으로 지정한 패턴 형식으로 원본 파일의 파일명을 변경합니다. 파일 이름 패턴에는 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 디렉토리에 작성합니다.

  1. assert_cmd 를 dev-dependencies 에 추가합니다.

    $ cargo add --dev assert_cmd

    assert_cmd 크레이트는 다음과 같은 기능을 제공합니다.

    • 테스트할 실행 파일 찾기 (여기서는 target/debug/exif-rename 가 됩니다.)
    • 프로그램 실행 결과에 대한 Assert 지원
  2. 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
  3. 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"] }

애플리케이션에서 사용할 커맨드라인 옵션은 다음과 같이 정의합니다.

개인적으로 Exif 정보 중에서 촬영 일시, 이미지 번호, 카메라 모델 정보를 자주 사용하기 때문에, 이러한 정보를 사용하기 쉽도록 하기 위해 별도의 패턴을 추가하도록 하겠습니다.

여기서 사용한 패턴은 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);
        }
    }
}

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"])
}

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개의 인자를 받습니다:

라이브러리 추가

코드를 구현하기에 앞서 구현에 필요한 디펜던시들을 추가합니다.

[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();
}

실행 순서

  1. read_exif_file() 함수를 호출해 Exif 정보를 파일에서 읽어온 후, Vars 타입의 HashMap 을 반환합니다.
  2. format_filename() 함수에 Vars 타입의 Exif 정보를 전달하면, extend_vars() 함수를 사용해 커스텀 변수 정보를 추가합니다.
  3. 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 애플리케이션을 만들고, 테스트 코드까지 작성해 봤습니다.

나머지 추가 기능들은 다음 포스트에서 추가로 구현해보도록 하겠습니다.