タスクトラッカー CLI を構築する

RustBeginner
オンラインで実践に進む

はじめに

実用的なコマンドラインアプリケーションは、型付き引数、ドメインデータ、永続ストレージ、分かりやすい出力、復旧可能なエラー、自動チェックといった複数の境界をつなぎます。これらを空のファイルからすべて構築すると、大量の入力作業に設計が埋もれてしまいます。

この実験では、セットアップ済みのタスクトラッカーの骨組みを使用します。まずモジュール構成を確認し、その後、ストレージの読み込みと保存、タスクの追加、タスク一覧の表示、タスクの完了、テスト済みリリースビルドという各境界を、一度に一つずつ完成させます。ソースファイル全体を貼り付ける必要はありません。

プロジェクト構成とコマンドインターフェースを確認する

このステップでは、用意されたプロジェクトの構成を確認し、コードを編集する前に各ファイルとデータの流れを結び付けます。

プロジェクトに移動し、ソースファイルを一覧表示します。

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

各ファイルには、主に次の役割があります。

  • main.rs はプロセスとの境界を担当します。引数の解析、成功メッセージの表示、失敗の報告を行います。
  • lib.rs は、CLI とテストが呼び出すタスク操作を担当します。
  • model.rsTask を定義し、ストレージ用の行との相互変換を行います。
  • store.rs はタスクのコレクションを読み書きします。

この分離により、引数解析、ドメイン操作、ファイルの詳細が一つの大きな関数に集中するのを防げます。セットアップでは、モジュール宣言と比較的長いモデルパーサーがすでに記述されています。ここでは、マークされた操作部分だけを完成させます。

CLI のエントリポイントを確認します。

nano src/main.rs

#[command(subcommand)] は、次のコマンド語によって Commands enum のバリアントを選択することを clap に伝えます。--file オプションには global = true が指定されているため、サブコマンドの前後どちらにも配置できます。PathBuf 型の値のデフォルトは tasks.db です。ファイルを変更せずに Ctrl+X を押して終了します。

生成されたトップレベルのヘルプを表示します。

cargo run --quiet -- --help

ヘルプには addlistdone が表示されます。add に絞ったヘルプを表示します。

cargo run --quiet -- add --help

必須の <TITLE> は、Add バリアントの title: String フィールドから指定されます。インターフェースはすでに用意されています。以降のステップで、各コマンドのライブラリ操作を動作させます。

ストレージとの境界を完成させる

このステップでは、タスクの値とローカルのテキストファイルをつなぐ、二つの小さな変換処理を完成させます。

用意されているストレージモジュールを開きます。

nano src/store.rs

ファイル形式では、1 行に 1 つのタスクを格納し、3 つのフィールドをタブで区切ります。

id<TAB>status<TAB>title

model.rs には、すでに Task::encodeTask::decode が用意されています。ストレージモジュールでは、これらのヘルパーをタスクのコレクション全体に適用します。

load の TODO と、その下の 2 行を次のコードに置き換えます。

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

このイテレーターは、空でない各行を Result<Task, String> に変換します。Result<Vec<_>, _> へ収集すると、無効な行が最初に現れた時点で処理を停止するか、デコード済みのすべてのタスクを返します。疑問符演算子は、そのエラーを load の呼び出し元へ伝播させます。

save では、その TODO と最後の 3 行を次のコードに置き換えます。

    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

テストが 1 件成功すれば、タスクのコレクションがファイルとの境界を通過し、同じ Rust の値として戻ってくることを確認できます。

新しいタスクを追加して保存する

このステップでは、add サブコマンドの内部で使用されるライブラリ操作を実装します。

ライブラリのエントリポイントを開きます。

nano src/lib.rs

add_taskTODO とプレースホルダー本体を、次のコードに置き換えます。

    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 大きい値が生成されます。タスクは一度クローンします。所有権を持つ一方のコピーをベクターに追加し、戻り値のもう一方のコピーを使って、CLI が追加した内容を表示できるようにします。

nano で保存して終了します。専用のデモ用ファイルにタスクを 2 件追加します。

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

2 件目の ID が 2 になったことで、コマンドが最初のレコードを読み込んでから次の ID を決め、保存したことを確認できます。

タスク一覧を整形する

このステップでは、保存されたタスクを安定した読みやすいコマンド出力に変換します。

もう一度 src/lib.rs を開きます。

nano src/lib.rs

list_tasksTODO とプレースホルダー本体を、次のコードに置き換えます。

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

マーカーは状態を簡潔に表します。[ ] は未完了、[x] は完了を意味します。この関数は行を直接表示せず、表示用の行を返します。そのため、テストや他の呼び出し元は、ターミナル出力を取得しなくても結果を確認できます。

nano で保存して終了します。前のステップで作成したファイルをそのまま使用します。

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

タスクの行の整形はライブラリが担当し、main.rs は返された行を表示するだけの役割を保ちます。

1 件のタスクを完了にする

このステップでは、保存されている他のタスクを維持したまま、1 件のタスクを更新します。

ライブラリのソースを開きます。

nano src/lib.rs

complete_taskTODO とプレースホルダー本体を、次のコードに置き換えます。

    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 は、その状態を説明付きのエラーに変換します。完了したタスクは、保存対象のベクターが可変借用中であるため、保存する前にクローンします。

nano で保存して終了します。デモ用ファイルのタスク 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

このコマンドは失敗する想定です。メッセージは stderr に出力され、main.rs がすでにライブラリの Err をプロセス境界で処理しているため、プロセスは 0 以外の終了ステータスで終了します。

テストを実行してリリースビルドを作成する

このステップでは、引き継ぎ前の品質確認ループを実行し、完成したプロジェクトからリリース用の実行ファイルを作成します。

まず、ストレージモジュールとライブラリモジュールで行った編集を整形します。

cargo fmt

フォーマッターによる未反映の変更が残っていないことを確認します。

cargo fmt -- --check

厳格な Clippy を実行します。

cargo clippy -- -D warnings

テストスイート全体を実行します。

cargo test

2 件のテストが成功するはずです。内訳は、ストレージのラウンドトリップを確認する対象テストと、ライブラリでの add-list-done の一連のワークフローを確認するテストです。これらのテストは一時ファイルを使用するため、デモ用データベースに依存せず、実際の永続化を検証できます。

最適化されたリリース用ターゲットをビルドします。

cargo build --release --locked

--release は、コンパイルが速い開発用プロファイルではなく、Cargo の最適化されたリリースプロファイルを選択します。--locked は、Cargo.lock に記録された依存関係グラフと完全に一致することを要求します。生成された実行ファイルを直接実行します。

./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 にコンパイルと起動を任せるのではなく、ビルド済みの成果物を実行していることを確認できます。

まとめ

アーキテクチャを最初から入力し直すことなく、複数コマンドの Rust CLI を完成させました。完成したプロジェクトでは、clap による型付きサブコマンドの解析、専用ストレージモジュールによるタスクモデルの永続化、コンテキスト付きエラーの伝播、main へのプロセス出力の集約、対象テストとエンドツーエンドのライブラリテストの成功、品質確認ループの完了、ロックされたリリース用実行ファイルの生成を実現しています。