Task Tracker CLI 빌드하기

RustBeginner
지금 연습하기

소개

유용한 명령줄 애플리케이션은 타입이 지정된 인수, 도메인 데이터, 영속 저장소, 명확한 출력, 복구 가능한 오류, 자동화된 검사를 서로 연결합니다. 빈 파일에서 이 모든 기능을 만들면 많은 코드 입력에 설계가 묻히기 쉽습니다.

이 실습에서는 설정 단계에서 완성된 task tracker 골격을 제공합니다. 먼저 모듈 구성을 확인한 다음, 저장소 디코딩 및 저장, task 추가, task 목록 표시, task 완료 처리, 테스트된 릴리스 빌드 생성까지 각 경계를 하나씩 완성합니다. 전체 소스 파일을 붙여넣을 필요는 없습니다.

프로젝트 구성과 명령어 인터페이스 확인하기

이 단계에서는 준비된 프로젝트의 구조를 파악하고, 코드를 수정하기 전에 파일과 데이터 흐름의 관계를 확인합니다.

프로젝트 디렉터리로 이동한 후 소스 파일을 나열합니다.

cd /home/labex/project/tasker
ls src
lib.rs  main.rs  model.rs  store.rs

각 파일은 다음과 같은 주요 책임을 가집니다.

  • main.rs는 프로세스 경계를 담당합니다. 인수를 구문 분석하고, 성공을 출력하며, 실패를 보고합니다.
  • lib.rs는 CLI와 테스트에서 호출하는 task 작업을 담당합니다.
  • model.rs는 하나의 Task를 정의하고, 이를 저장소의 한 줄로 변환하거나 한 줄에서 변환합니다.
  • store.rs는 task 컬렉션을 읽고 씁니다.

이렇게 분리하면 인수 구문 분석, 도메인 작업, 파일 세부 사항이 하나의 큰 함수에 섞이지 않습니다. 설정 단계에서 모듈 선언과 더 긴 model parser는 이미 작성되어 있습니다. 여러분은 표시된 작업 경계만 완성하면 됩니다.

CLI 진입점을 확인합니다.

nano src/main.rs

#[command(subcommand)]는 다음 명령어 단어가 Commands enum variant를 선택한다고 clap에 알려줍니다. --file 옵션에는 global = true가 지정되어 있으므로 사용자는 이 옵션을 subcommand 앞이나 뒤에 둘 수 있습니다. 이 옵션의 PathBuf 값은 기본적으로 tasks.db입니다. 파일을 수정하지 말고 Ctrl+X를 누릅니다.

생성된 최상위 도움말을 표시합니다.

cargo run --quiet -- --help

도움말에는 add, list, done이 표시됩니다. add에 대한 자세한 도움말을 확인합니다.

cargo run --quiet -- add --help

필수 인수 <TITLE>Add variant의 title: String 필드에서 가져옵니다. 인터페이스는 이미 준비되어 있으며, 이후 단계에서 각 명령어가 라이브러리 작업을 수행하도록 완성합니다.

저장소 경계 완성하기

이 단계에서는 task 값을 로컬 텍스트 파일과 연결하는 두 가지 작은 변환을 완성합니다.

준비된 저장소 모듈을 엽니다.

nano src/store.rs

파일 형식은 한 줄에 하나의 task를 저장하며, 세 필드는 탭으로 구분됩니다.

id<TAB>status<TAB>title

model.rs에는 이미 Task::encodeTask::decode가 제공됩니다. 저장소 모듈에서는 이 헬퍼를 전체 컬렉션에 적용하기만 하면 됩니다.

load의 TODO와 그 아래 두 줄을 다음 코드로 바꿉니다.

    let tasks = contents
        .lines()
        .filter(|line| !line.is_empty())
        .map(Task::decode)
        .collect::<Result<Vec<_>, _>>()?;
    Ok(tasks)

이 iterator는 비어 있지 않은 각 줄을 Result<Task, String>으로 변환합니다. Result<Vec<_>, _>로 수집하면 잘못된 줄을 처음 만나는 즉시 중단하거나, 디코딩된 모든 task를 반환합니다. 물음표 연산자는 해당 오류를 load에서 전파합니다.

save 함수에서 TODO와 마지막 세 줄을 다음 코드로 바꿉니다.

    fs::write(path, contents)
        .map_err(|error| format!("could not write {}: {error}", path.display()))

fs::write는 저장소 파일을 새로 만들거나 기존 파일을 교체합니다. map_err는 복구 가능한 Result를 유지하면서 실패한 경로를 오류 메시지에 추가합니다.

nano에서 저장하고 종료합니다. 저장소 테스트만 실행합니다.

cargo test store::tests::saves_and_loads_tasks

테스트 하나가 통과하면 task 컬렉션이 파일 경계를 넘어갔다가 동일한 Rust 값으로 다시 반환되는지 확인할 수 있습니다.

새 task 추가하고 저장하기

이 단계에서는 add subcommand가 사용하는 라이브러리 작업을 구현합니다.

라이브러리 진입점을 엽니다.

nano src/lib.rs

add_taskTODO와 placeholder 본문을 다음 코드로 바꿉니다.

    let mut tasks = store::load(path)?;
    let next_id = tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1;
    let task = Task::new(next_id, title);
    tasks.push(task.clone());
    store::save(path, &tasks)?;
    Ok(task)

이 작업은 먼저 현재 상태를 불러옵니다. max().unwrap_or(0) + 1은 빈 파일에서는 ID 1을 만들고, 기존 파일에서는 가장 큰 ID보다 1 큰 값을 만듭니다. task를 한 번 복제하는 이유는 소유된 한 복사본은 vector에 넣고, 반환하는 다른 복사본은 CLI가 추가된 내용을 설명하는 데 사용하기 때문입니다.

nano에서 저장하고 종료합니다. 전용 데모 파일에 task 두 개를 추가합니다.

cargo run --quiet -- --file add-demo.db add "Write release notes"
Added 1: Write release notes
cargo run --quiet -- --file add-demo.db add "Tag version"
Added 2: Tag version

두 번째 ID가 2라는 것은 명령어가 첫 번째 레코드를 불러온 후 다음 ID를 선택하고 저장했다는 뜻입니다.

task 목록 형식 지정하기

이 단계에서는 저장된 task를 안정적이고 사람이 읽기 쉬운 명령줄 출력으로 변환합니다.

src/lib.rs를 다시 엽니다.

nano src/lib.rs

list_tasksTODO와 placeholder 본문을 다음 코드로 바꿉니다.

    let tasks = store::load(path)?;
    Ok(tasks
        .iter()
        .map(|task| {
            let marker = if task.done { "x" } else { " " };
            format!("[{marker}] {}: {}", task.id, task.title)
        })
        .collect())

marker는 간결한 상태 표시입니다. [ ]는 아직 완료되지 않은 상태를, [x]는 완료된 상태를 뜻합니다. 이 함수는 결과를 직접 출력하지 않고 표시용 행을 반환합니다. 따라서 테스트와 다른 호출자는 터미널 출력을 가로채지 않고 결과를 확인할 수 있습니다.

nano에서 저장하고 종료합니다. 이전 단계에서 만든 파일을 다시 사용합니다.

cargo run --quiet -- --file add-demo.db list
[ ] 1: Write release notes
[ ] 2: Tag version

task 행의 형식 지정은 라이브러리가 담당하고, main.rs는 반환된 행을 출력하는 일만 담당합니다.

task 하나를 완료 상태로 표시하기

이 단계에서는 저장된 나머지 task를 유지하면서 task 하나를 업데이트합니다.

라이브러리 소스 파일을 엽니다.

nano src/lib.rs

complete_taskTODO와 placeholder 본문을 다음 코드로 바꿉니다.

    let mut tasks = store::load(path)?;
    let task = tasks
        .iter_mut()
        .find(|task| task.id == id)
        .ok_or_else(|| format!("task {id} was not found"))?;
    task.done = true;
    let completed = task.clone();
    store::save(path, &tasks)?;
    Ok(completed)

iter_mut()은 변경 가능한 참조를 제공하므로 일치하는 레코드를 제자리에서 수정할 수 있습니다. ID가 없으면 findNone을 반환하고, ok_or_else는 이를 설명이 포함된 함수 오류로 변환합니다. 저장할 vector가 변경 가능한 borrow를 소유하고 있으므로, 저장하기 전에 완료된 task를 복제합니다.

nano에서 저장하고 종료합니다. 데모 파일의 task 1을 완료 처리합니다.

cargo run --quiet -- --file add-demo.db done 1
Completed 1: Write release notes

저장된 상태를 다시 확인합니다.

cargo run --quiet -- --file add-demo.db list
[x] 1: Write release notes
[ ] 2: Tag version

존재하지 않는 ID를 시도합니다.

cargo run --quiet -- --file add-demo.db done 99

이 명령어는 실패해야 합니다. main.rs가 이미 라이브러리의 Err를 프로세스 경계에서 처리하므로, 메시지는 stderr로 출력되고 프로세스는 0이 아닌 종료 코드로 끝납니다.

테스트하고 릴리스 빌드 만들기

이 단계에서는 인수인계 전 품질 확인 절차를 적용하고, 완성된 프로젝트에서 릴리스 실행 파일을 생성합니다.

먼저 저장소 모듈과 라이브러리 모듈에서 수정한 내용을 포맷합니다.

cargo fmt

formatter에 남은 변경 사항이 없는지 확인합니다.

cargo fmt -- --check

엄격한 Clippy 검사를 실행합니다.

cargo clippy -- -D warnings

전체 테스트 모음을 실행합니다.

cargo test

두 테스트가 통과해야 합니다. 하나는 저장소 왕복 테스트이고, 다른 하나는 add-list-done 라이브러리 전체 workflow 테스트입니다. 이 테스트는 임시 파일을 사용하므로 데모 데이터베이스에 의존하지 않고 실제 영속 저장을 검증합니다.

최적화된 릴리스 대상을 빌드합니다.

cargo build --release --locked

--release는 더 빠르게 컴파일되는 development profile 대신 Cargo의 최적화된 release profile을 선택합니다. --lockedCargo.lock에 이미 기록된 정확한 dependency graph를 사용하도록 요구합니다. 생성된 실행 파일을 직접 실행합니다.

./target/release/tasker --file release-demo.db add "Publish tasker"
Added 1: Publish tasker
./target/release/tasker --file release-demo.db list
[ ] 1: Publish tasker

직접 경로로 실행하면 Cargo에 컴파일과 실행을 맡긴 것이 아니라 빌드된 artifact를 실행하고 있음을 확인할 수 있습니다.

요약

아키텍처를 다시 입력하지 않고 여러 명령어로 구성된 Rust CLI를 완성했습니다. 완성된 프로젝트는 clap으로 타입이 지정된 subcommand를 구문 분석하고, 전용 저장소 모듈을 통해 task model을 영속화하며, 맥락이 포함된 오류를 전파합니다. 또한 프로세스 출력은 main에 유지하고, 집중 테스트와 end-to-end 라이브러리 테스트를 통과하며, 품질 확인 절차를 충족하고, 잠긴 dependency로 릴리스 실행 파일을 생성합니다.