iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Rust

Rust 後端入門系列 第 7

Day 7 用 Clap 框架完成第一個 Rust 專案:待辦事項管理器

  • 分享至 

  • xImage
  •  

我們每天都會在終端機或命令提示字元上使用git、npm或cargo之類的CLI工具。作為一個工程師我們經常和這些工具打交道。那麽,你有沒有想過可以自己打造一個CLI工具?

接下來,我們將使用Clap框架,從零開始完成一個實用的待辦事項管理器。

專案初始化

現在,我們從建立新的Rust專案開始:

cargo new todo-cli
cd todo-cli

接下來,添加一些依賴的套件。

在沒有要求特定版本的情況下,可以使用以下的方式添加:

cargo add clap --features derive
cargo add serde --features derive
cargo add serde_json
cargo add chrono --features serde
cargo add colored
cargo add dirs

當然也可以選擇手動修改Cargo.toml,它可以指定特定的版本的套件。

我們來簡單說明這些套件的作用

  • clap:CLI框架
  • serde、serde_json:把待辦事項序列化,並使用JSON格式儲存起來
  • chrono:用來處理日期跟時間相關內容
  • colored:豐富CLI的色彩,不再只有冷冰冰的白色文字
  • dirs:找尋使用者的目錄

規劃待辦事項的架構

我們在src/main.rs中,添加關於待辦事項的資料格式的程式碼。

use chrono::{DateTime, Local};  // 處理日期時間和時區
use clap::{Parser, Subcommand};  // 解讀傳入參數和子命令
use colored::*;  // 在畫面輸出有顏色的文字
use serde::{Deserialize, Serialize};  // 序列化和反序列化資料結構
use std::fs;  // 管理檔案讀取儲存
use std::path::PathBuf;  // 調整各個系統(MacOS,Linux,Windows)的路徑格式,如\跟/的差異
use dirs::home_dir; // 取得家目錄路徑

// 待辦事項的結構
// derive可以自動產生對應的Debug, Serialize, Deserialize, Clone的程式碼,不必自己實做
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Todo {
    id: usize,
    title: String,
    description: Option<String>,
    completed: bool,
    created_at: DateTime<Local>,
    completed_at: Option<DateTime<Local>>,
    priority: Priority,
}

// 待辦事項的優先度
#[derive(Debug, Serialize, Deserialize, Clone)]
enum Priority {
    Low,
    Medium,
    High,
}

// 實做待辦事項的方法
impl Todo {
		// 新增待辦事項
    fn new(id: usize, title: String, description: Option<String>, priority: Priority) -> Self {
        Self {
            id,
            title,
            description,
            completed: false,
            created_at: Local::now(),
            completed_at: None,
            priority,
        }
    }
    
    // 將待辦事項設定為完成的狀態
    fn complete(&mut self) {
        self.completed = true;
        self.completed_at = Some(Local::now());
    }
}

我相信大家可能有些疑惑,為什麼要這樣設計,我來解釋一下想法:

  • 簡單易懂的欄位名稱,看到就能大概理解欄位的作用
  • 使用Option表示這個欄位可以不去設定內容
  • 利用DateTime格式,輕鬆記錄待辦事項建立以及完成的時間
  • 使用enum定義事項的優先度,分清楚輕重緩急

使用Clap完成CLI界面

接下來就是最重要的部分,使用Clap框架讓我們的程式可以透過命令提示字元與使用者互動,同樣在src/main.rs中,添加底下的程式碼:

#[derive(Parser)]
#[command(name = "todo-list")]
#[command(author = "96gen")]
#[command(version = "1.0")]
#[command(about = "待辦事項管理器", long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// 新增待辦事項,與傳入參數
    Add {
        /// 標題
        title: String,
        
        /// 描述(可留空)
        #[arg(short, long)]
        description: Option<String>,
        
        /// 優先度,預設是中
        #[arg(short, long, default_value = "medium")]
        priority: String,
    },
    
    /// 列出所有待辦事項
    List {
        /// 只顯示未完成的待辦事項
        #[arg(short, long)]
        pending: bool,
        
        /// 只顯示已完成的待辦事項
        #[arg(short, long)]
        completed: bool,
    },
    
    /// 標記待辦事項為完成
    Complete {
        /// 待辦事項id
        id: usize,
    },
    
    /// 刪除待辦事項
    Delete {
        /// 待辦事項id
        id: usize,
    },
    
    /// 清除已完成的待辦事項
    Clean,
	  
}

需要特別注意是三條的///,不是一般的註解//,這些內容將顯示在Clap框架自動產生的help中。

實作待辦事項管理器

我們剛才只是定義的Clap的界面,並沒有完成對應的實作,在src/main.rs繼續完成它們:

struct TodoController {
    todos: Vec<Todo>,
    storage_path: PathBuf,
}

impl TodoController {
		// 初始化待辦事項管理器
    fn new() -> Self {
		    // 設定儲存的路徑,待辦事項儲存在家目錄下的.todo-cli/todos.json
        let storage_path = dirs::home_dir()
            .unwrap()
            .join(".todo-cli")
            .join("todos.json");
        
        // 檢查目錄是否存在,不存在就建立
        if let Some(parent) = storage_path.parent() {
            fs::create_dir_all(parent).ok();
        }
        
        // 從todos.json檔案載入之前儲存的待辦事項
        let todos = Self::load_todos(&storage_path);
        
        // 回傳初始化完成的TodoController 
        Self {
            todos,
            storage_path,
        }
    }
    
    fn load_todos(path: &PathBuf) -> Vec<Todo> {
		    // 如果檔案存在,就解析後回傳
        if path.exists() {
            let data = fs::read_to_string(path).unwrap_or_default();
            serde_json::from_str(&data).unwrap_or_default()
        } else {
            Vec::new() // 不存在todos.json,回傳空Vec
        }
    }
    
    // 將待辦事項儲存到檔案中
    fn save_todos(&self) -> Result<(), Box<dyn std::error::Error>> {
        let data = serde_json::to_string_pretty(&self.todos)?; // 轉換成易讀性高的JSON
        fs::write(&self.storage_path, data)?;// 寫入檔案中
        Ok(())
    }
    
    // 新增待辦事項
    fn add_todo(&mut self, title: String, description: Option<String>, priority: Priority) {
        let id = self.todos.len() + 1; // 用自增id
        let todo = Todo::new(id, title, description, priority);
        
        println!("{} 新增待辦事項: {}", "V".green().bold(), todo.title.cyan());
        
        self.todos.push(todo); // 將剛新增的內容,放入待辦事項管理器中
        self.save_todos().ok(); // 儲存新增的內容
    }
    
    // 顯示待辦事項
    fn list_todos(&self, pending_only: bool, completed_only: bool) {
		    // 根據傳入的參數,選擇只顯示 未完成/已完成,未指定時顯示全部的待辦事項
        let filtered_todos: Vec<&Todo> = self.todos
            .iter()
            .filter(|t| {
                if pending_only { !t.completed }
                else if completed_only { t.completed }
                else { true }
            })
            .collect();
        
        if filtered_todos.is_empty() {
            println!("{}", "沒有找到待辦事項".yellow());
            return;
        }
        
        println!("\n{}", "=== 待辦事項 ===".bold().blue());
        
        for todo in filtered_todos {
		        // 完成的顯示V,未完成則是X
            let status = if todo.completed { 
                "V".green() 
            } else { 
                "X".red() 
            };
            
            // 將優先度轉換成清楚的標識
            let priority_str = match todo.priority {
                Priority::High => "高".red().bold(),
                Priority::Medium => "中".yellow(),
                Priority::Low => "低".white(),
            };
            
            // 顯示待辦事項的狀態、id、標題、優先度、建立日期
            println!(
                "{} [{}] {} [{}] {}",
                status,
                todo.id.to_string().dimmed(),
                todo.title.bold(),
                priority_str,
                todo.created_at.format("%Y-%m-%d").to_string().dimmed()
            );
            
            // 如果有待辦事項描述就顯示
            if let Some(desc) = &todo.description {
                println!("    {}", desc.italic());
            }
        }
    }
    
	  // 將指定id的待辦事項設定為完成
    fn complete_todo(&mut self, id: usize) -> Result<(), String> {
        match self.todos.iter_mut().find(|t| t.id == id) {
            Some(todo) => {
                if todo.completed {
                    return Err(format!("待辦事項 {} 已經完成", id));
                }
                todo.complete();
                println!("{} 完成待辦事項: {}", "V".green().bold(), todo.title.cyan());
                self.save_todos().ok();
                Ok(())
            }
            None => Err(format!("找不到id為 {} 的待辦事項", id))
        }
    }
    
     // 刪除指定id的待辦事項
    fn delete_todo(&mut self, id: usize) -> Result<(), String> {
        if let Some(pos) = self.todos.iter().position(|t| t.id == id) {
            let todo = self.todos.remove(pos);
            println!("{} 刪除待辦事項: {}", "X".red().bold(), todo.title);
            
            // 重新編號後面的待辦事項,保持id編號連續
            for (index, todo) in self.todos.iter_mut().enumerate() {
                todo.id = index + 1;
            }
            
            self.save_todos().ok();
            Ok(())
        } else {
            Err(format!("找不到id為 {} 的待辦事項", id))
        }
    }
    
    // 清理已完成的待辦事項
    fn clean_completed(&mut self) {
        let completed_count = self.todos.iter().filter(|t| t.completed).count();
        self.todos.retain(|t| !t.completed);
        
        // 重新編號
        for (idx, todo) in self.todos.iter_mut().enumerate() {
            todo.id = idx + 1;
        }
        
        println!("{} 清除了 {} 個已完成的待辦事項", "V".green().bold(), completed_count);
        self.save_todos().ok();
    }
    
}

編寫main

我們的程式快要完成了,只要把之前寫的程式連接起來,並加入CLI處理邏輯的程式碼即可。

fn main() {
	// 設定windows系統顯示顏色,而不是控制碼
	#[cfg(windows)]
	let _ = colored::control::set_virtual_terminal(true);
	// MacOS/Linux用戶不需要添加以上兩行程式碼
	
    let cli = Cli::parse();
    let mut manager = TodoController::new();
    
    match cli.command {
        Commands::Add { title, description, priority } => {
            let priority = match priority.to_lowercase().as_str() {
                "high" | "h" => Priority::High,
                "low" | "l" => Priority::Low,
                _ => Priority::Medium,
            };
            manager.add_todo(title, description, priority);
        }
        
        Commands::List { pending, completed } => {
            manager.list_todos(pending, completed);
        }
        
        Commands::Complete { id } => {
            if let Err(e) = manager.complete_todo(id) {
                eprintln!("{} {}", "錯誤:".red().bold(), e);
            }
        }
        
        Commands::Delete { id } => {
            if let Err(e) = manager.delete_todo(id) {
                eprintln!("{} {}", "錯誤:".red().bold(), e);
            }
        }
        
        Commands::Clean => {
            manager.clean_completed();
        }
    }
}

編譯與使用

在編寫結束後,我們來編譯程式,讓我們實際的使用它。

# 在專案目錄下執行,來編譯專案
cargo build --release

# 可跳過,執行後可以在任何地方執行todo-cli,否則我們要在target\release下使用todo-cli
cargo install --path .

# 顯示說明,這是我們在使用Clap完成CLI界面章節設定的內容
todo-cli help
# 查詢副指令add的使用方式與參數
todo-cli help add

# 新增待辦事項,-d添加描述,-p設定優先度,high高,medium中,low是低
todo-cli add "學習Rust" -d "完成待辦事項管理器" -p high
todo-cli add "寫文章" -p medium
todo-cli add "看Youtube" -p low

# 無參數,顯示全部的待辦事項
todo-cli list
# 標示待辦事項完成
todo-cli complete 1
# 帶上pending參數,顯示未完成的待辦事項
todo-cli list --pending
# 清除已完成的待辦事項
todo-cli clean
# 刪除id是1的待辦事項
todo-cli delete 1

我們完成了待辦事項管理器,希望這個教學能大家能夠更加熟悉如何使用Rust。


上一篇
Day 6 Vec、HashMap 與疊代器
下一篇
Day8 Axum 入門:打造第一個 Rust Web 應用
系列文
Rust 後端入門9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言