RustでCLI Todoアプリを作りながら学ぶ設計パターン

に公開

どうも最近Rustにハマっている初学者です🙇

いきなりですが初学者の方で、Rustの文法はある程度学習をしているけど
実際にアプリを作るとなると何から始めればいいか迷いませんか?

私も最初はそうでした。公式ドキュメントやチュートリアルで基本はある程度は理解できても
実際のコードを書く時には「この場合はどう書くのがRustらしいのか?」と悩むことが多かったです。

そこで今回は、誰もが使ったことのあるTodoアプリを題材に、Rustの基本的な書き方を学びましたので備忘録として書きました。
単純な機能追加だけでなく、Rustエコシステムの活用方法や、開発で役立つパターンも紹介していきます!

なぜTodoアプリを題材にしたのか?

Todoアプリは一見シンプルですが、実は多くの重要な概念が詰まっていると思っています!

  • データ構造の設計
  • ファイルI/O(データの永続化)
  • エラーハンドリング
  • ユーザーインターフェース(CLI)

これらを通じて、Rustの所有権システムや型安全性の恩恵を実感できます。

プロジェクトの作成とクレート選び

段階的に紹介していきますが、最後に全体のコードもありますのでご安心ください🙇

では、まずは新しいプロジェクトを作成します。

cargo new rust-todo
cd rust-todo

今回はデータの永続化にJSONを使用します。RustでJSON操作といえばserdeが定番です。

Cargo.tomlに以下の記述をします。

[package]
name = "rust-todo"
version = "0.1.0"
edition = "2024"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

serdeは「Serialize(シリアライズ)」と「Deserialize(デシリアライズ)」を組み合わせた造語です。
features = ["derive"]を指定することで、構造体にアトリビュートを付けるだけで自動的にシリアライゼーション機能が追加されます。

データモデルの設計

Todoアプリといえば「タスク」です!
まずはどんな情報が必要か考えてみましょう。

use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, Write};

#[derive(Serialize, Deserialize, Debug, Clone)]
struct Task {
    id: usize,
    description: String,
    completed: bool,
}

impl Task {
    fn new(id: usize, description: String) -> Self {
        Self {
            id,
            description,
            completed: false,
        }
    }
}

ここでのポイントはimplブロックでコンストラクタを定義していることです。
これにより、タスク作成時のデフォルト値を確実に設定できます。

#[derive(...)]について少し詳しく説明すると

  • Serialize, Deserialize: JSON変換用
  • Debug: デバッグ出力用(開発時に助かります)
  • Clone: タスクのコピーが必要な場面で使用

アプリケーション構造体の導入

単純にベクターでタスクを管理するのではなく、アプリケーション自体を構造体として表現してみましょう。

struct TodoApp {
    tasks: Vec<Task>,
    next_id: usize,
}

impl TodoApp {
    fn new() -> Self {
        Self {
            tasks: Vec::new(),
            next_id: 1,
        }
    }

    fn load() -> Self {
        match fs::read_to_string("tasks.json") {
            Ok(content) => {
                match serde_json::from_str::<Vec<Task>>(&content) {
                    Ok(tasks) => {
                        let next_id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
                        Self { tasks, next_id }
                    }
                    Err(_) => Self::new(),
                }
            }
            Err(_) => Self::new(),
        }
    }

    fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
        let json = serde_json::to_string_pretty(&self.tasks)?;
        fs::write("tasks.json", json)?;
        Ok(())
    }
}

この設計で良いと思っている点は

  1. ID管理が確実: next_idでユニークなIDを保証
  2. 状態管理が明確: アプリの状態がすべて構造体に含まれる
  3. メソッドが直感的: app.load(), app.save()のように自然に書ける

ユーザーインターフェースの実装

CLIアプリケーションでは、ユーザビリティが重要だと思います。
わかりやすいメニューと適切なフィードバックを心がけました!

impl TodoApp {
    fn run(&mut self) {
        loop {
            self.show_menu();
            
            match self.get_user_choice() {
                1 => self.add_task(),
                2 => self.list_tasks(),
                3 => self.complete_task(),
                4 => self.delete_task(),
                5 => {
                    if let Err(e) = self.save() {
                        eprintln!("保存エラー: {}", e);
                    } else {
                        println!("タスクを保存しました。お疲れさまでした!");
                    }
                    break;
                }
                _ => println!("1-5の番号を入力してください。"),
            }
            
            println!(); // 見やすさのための空行
        }
    }

    fn show_menu(&self) {
        println!("=== Todo アプリ ===");
        println!("1. タスクを追加");
        println!("2. タスク一覧表示");
        println!("3. タスクを完了にする");
        println!("4. タスクを削除");
        println!("5. 終了");
        print!("選択 (1-5): ");
        io::stdout().flush().unwrap();
    }

    fn get_user_choice(&self) -> u8 {
        let input = self.get_input();
        input.trim().parse().unwrap_or(0)
    }

    fn get_input(&self) -> String {
        let mut input = String::new();
        io::stdin().read_line(&mut input).unwrap_or(0);
        input
    }
}

各機能の実装

タスクの追加

impl TodoApp {
    fn add_task(&mut self) {
        print!("タスクの内容を入力してください: ");
        io::stdout().flush().unwrap();
        
        let description = self.get_input().trim().to_string();
        
        if description.is_empty() {
            println!("タスクの内容が空です。");
            return;
        }

        let task = Task::new(self.next_id, description);
        self.tasks.push(task);
        self.next_id += 1;
        
        println!("タスクを追加しました!");
    }
}

タスクの表示

impl TodoApp {
    fn list_tasks(&self) {
        if self.tasks.is_empty() {
            println!("タスクはありません。");
            return;
        }

        println!("\n--- タスク一覧 ---");
        for task in &self.tasks {
            let status_icon = if task.completed { "✅" } else { "⏳" };
            println!("{}: {} {}", task.id, status_icon, task.description);
        }
        
        let completed_count = self.tasks.iter().filter(|t| t.completed).count();
        println!("\n完了: {}/{}", completed_count, self.tasks.len());
    }
}

進捗表示を追加しています。

タスクの完了・削除

impl TodoApp {
    fn complete_task(&mut self) {
        if self.tasks.is_empty() {
            println!("完了にするタスクがありません。");
            return;
        }

        self.list_tasks();
        print!("\n完了にするタスクのIDを入力: ");
        io::stdout().flush().unwrap();

        let input = self.get_input();
        match input.trim().parse::<usize>() {
            Ok(id) => {
                if let Some(task) = self.tasks.iter_mut().find(|t| t.id == id) {
                    if task.completed {
                        println!("そのタスクは既に完了しています。");
                    } else {
                        task.completed = true;
                        println!("タスクを完了にしました! 🎉");
                    }
                } else {
                    println!("指定されたIDのタスクが見つかりません。");
                }
            }
            Err(_) => println!("正しい数字を入力してください。"),
        }
    }

    fn delete_task(&mut self) {
        if self.tasks.is_empty() {
            println!("削除するタスクがありません。");
            return;
        }

        self.list_tasks();
        print!("\n削除するタスクのIDを入力: ");
        io::stdout().flush().unwrap();

        let input = self.get_input();
        match input.trim().parse::<usize>() {
            Ok(id) => {
                if let Some(index) = self.tasks.iter().position(|t| t.id == id) {
                    let task = self.tasks.remove(index);
                    println!("「{}」を削除しました。", task.description);
                } else {
                    println!("指定されたIDのタスクが見つかりません。");
                }
            }
            Err(_) => println!("正しい数字を入力してください。"),
        }
    }
}

メイン関数

fn main() {
    let mut app = TodoApp::load();
    app.run();
}

結構シンプルになったかと思います!
構造体とメソッドを適切に分離したつもりです。

実行してみる

cargo run

アプリを起動すると、以下のようなメニューが表示されます

=== Todo アプリ ===
1. タスクを追加
2. タスク一覧表示
3. タスクを完了にする
4. タスクを削除
5. 終了
選択 (1-5): 

より良いコードに向けて

現在のコードでも十分動作しますが、実際のプロダクション環境では以下の点を改善できます

エラーハンドリングの改善

use std::error::Error;
use std::fmt;

#[derive(Debug)]
enum TodoError {
    InvalidInput(String),
    IoError(std::io::Error),
    SerializationError(serde_json::Error),
}

impl fmt::Display for TodoError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            TodoError::InvalidInput(msg) => write!(f, "入力エラー: {}", msg),
            TodoError::IoError(e) => write!(f, "IO エラー: {}", e),
            TodoError::SerializationError(e) => write!(f, "シリアライゼーションエラー: {}", e),
        }
    }
}

impl Error for TodoError {}

設定の外部化もできれば…

# config.toml
data_file = "tasks.json"
auto_save = true

テストの追加

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_task_creation() {
        let task = Task::new(1, "テストタスク".to_string());
        assert_eq!(task.id, 1);
        assert_eq!(task.description, "テストタスク");
        assert_eq!(task.completed, false);
    }

    #[test]
    fn test_app_initialization() {
        let app = TodoApp::new();
        assert_eq!(app.tasks.len(), 0);
        assert_eq!(app.next_id, 1);
    }
}

完全なコード

これまで断片的に紹介してきたコードを、src/main.rsに書く完全な形で示します:

use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, Write};

#[derive(Serialize, Deserialize, Debug, Clone)]
struct Task {
    id: usize,
    description: String,
    completed: bool,
}

impl Task {
    fn new(id: usize, description: String) -> Self {
        Self {
            id,
            description,
            completed: false,
        }
    }
}

struct TodoApp {
    tasks: Vec<Task>,
    next_id: usize,
}

impl TodoApp {
    fn new() -> Self {
        Self {
            tasks: Vec::new(),
            next_id: 1,
        }
    }

    fn load() -> Self {
        match fs::read_to_string("tasks.json") {
            Ok(content) => {
                match serde_json::from_str::<Vec<Task>>(&content) {
                    Ok(tasks) => {
                        let next_id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
                        Self { tasks, next_id }
                    }
                    Err(_) => Self::new(),
                }
            }
            Err(_) => Self::new(),
        }
    }

    fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
        let json = serde_json::to_string_pretty(&self.tasks)?;
        fs::write("tasks.json", json)?;
        Ok(())
    }

    fn run(&mut self) {
        loop {
            self.show_menu();
            
            match self.get_user_choice() {
                1 => self.add_task(),
                2 => self.list_tasks(),
                3 => self.complete_task(),
                4 => self.delete_task(),
                5 => {
                    if let Err(e) = self.save() {
                        eprintln!("保存エラー: {}", e);
                    } else {
                        println!("タスクを保存しました。お疲れさまでした!");
                    }
                    break;
                }
                _ => println!("1-5の番号を入力してください。"),
            }
            
            println!(); // 見やすさのための空行
        }
    }

    fn show_menu(&self) {
        println!("=== Todo アプリ ===");
        println!("1. タスクを追加");
        println!("2. タスク一覧表示");
        println!("3. タスクを完了にする");
        println!("4. タスクを削除");
        println!("5. 終了");
        print!("選択 (1-5): ");
        io::stdout().flush().unwrap();
    }

    fn get_user_choice(&self) -> u8 {
        let input = self.get_input();
        input.trim().parse().unwrap_or(0)
    }

    fn get_input(&self) -> String {
        let mut input = String::new();
        io::stdin().read_line(&mut input).unwrap_or(0);
        input
    }

    fn add_task(&mut self) {
        print!("タスクの内容を入力してください: ");
        io::stdout().flush().unwrap();
        
        let description = self.get_input().trim().to_string();
        
        if description.is_empty() {
            println!("タスクの内容が空です。");
            return;
        }

        let task = Task::new(self.next_id, description);
        self.tasks.push(task);
        self.next_id += 1;
        
        println!("タスクを追加しました!");
    }

    fn list_tasks(&self) {
        if self.tasks.is_empty() {
            println!("タスクはありません。");
            return;
        }

        println!("\n--- タスク一覧 ---");
        for task in &self.tasks {
            let status_icon = if task.completed { "✅" } else { "⏳" };
            println!("{}: {} {}", task.id, status_icon, task.description);
        }
        
        let completed_count = self.tasks.iter().filter(|t| t.completed).count();
        println!("\n完了: {}/{}", completed_count, self.tasks.len());
    }

    fn complete_task(&mut self) {
        if self.tasks.is_empty() {
            println!("完了にするタスクがありません。");
            return;
        }

        self.list_tasks();
        print!("\n完了にするタスクのIDを入力: ");
        io::stdout().flush().unwrap();

        let input = self.get_input();
        match input.trim().parse::<usize>() {
            Ok(id) => {
                if let Some(task) = self.tasks.iter_mut().find(|t| t.id == id) {
                    if task.completed {
                        println!("そのタスクは既に完了しています。");
                    } else {
                        task.completed = true;
                        println!("タスクを完了にしました! 🎉");
                    }
                } else {
                    println!("指定されたIDのタスクが見つかりません。");
                }
            }
            Err(_) => println!("正しい数字を入力してください。"),
        }
    }

    fn delete_task(&mut self) {
        if self.tasks.is_empty() {
            println!("削除するタスクがありません。");
            return;
        }

        self.list_tasks();
        print!("\n削除するタスクのIDを入力: ");
        io::stdout().flush().unwrap();

        let input = self.get_input();
        match input.trim().parse::<usize>() {
            Ok(id) => {
                if let Some(index) = self.tasks.iter().position(|t| t.id == id) {
                    let task = self.tasks.remove(index);
                    println!("「{}」を削除しました。", task.description);
                } else {
                    println!("指定されたIDのタスクが見つかりません。");
                }
            }
            Err(_) => println!("正しい数字を入力してください。"),
        }
    }
}

fn main() {
    let mut app = TodoApp::load();
    app.run();
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_task_creation() {
        let task = Task::new(1, "テストタスク".to_string());
        assert_eq!(task.id, 1);
        assert_eq!(task.description, "テストタスク");
        assert_eq!(task.completed, false);
    }

    #[test]
    fn test_app_initialization() {
        let app = TodoApp::new();
        assert_eq!(app.tasks.len(), 0);
        assert_eq!(app.next_id, 1);
    }
}

実行手順

  1. プロジェクトを作成

    cargo new rust-todo
    cd rust-todo
    
  2. Cargo.tomlを編集

    [package]
    name = "rust-todo"
    version = "0.1.0"
    edition = "2024"
    
    [dependencies]
    serde = { version = "1.0", features = ["derive"] }
    serde_json = "1.0"
    
  3. 依存関係をダウンロード・ビルド

    cargo build
    

    このコマンドでserdeserde_jsonがダウンロードされ、プロジェクトがビルドされます。

  4. 上記のコードをsrc/main.rsに貼り付け

  5. 実行

    cargo run
    
  6. テスト実行

    cargo test
    

これで完全に動作するTodoアプリが完成します!

まとめ

この記事では、単純なTodoアプリを通じて以下のRustの概念を学びになれば幸いです!

  • 構造体を使った適切なデータモデリング
  • serdeによるJSONシリアライゼーション
  • implブロックによるメソッド定義
  • パターンマッチングを使った堅牢なエラーハンドリング
  • CLIアプリケーションのユーザビリティ向上

重要なのは、Rustの型システムと所有権モデルを活用して、コンパイル時に多くのエラーを発見できることです。これにより、実行時エラーが大幅に減り、より安全なソフトウェアを作ることができます。

次のステップとして、以下のような機能追加にもチャレンジしてみてください!

  • タスクの優先度機能
  • 期限設定機能
  • カテゴリ分類機能
  • 検索・フィルタリング機能

Rustは開発体験が良くて、楽しい!
初学者の皆さんもぜひやってみてください!

Discussion