1210 lines
40 KiB
Rust
1210 lines
40 KiB
Rust
//! Task Manager - A CLI application for managing tasks with priorities and subtasks.
|
|
//!
|
|
//! This module provides a complete task management system demonstrating
|
|
//! idiomatic Rust patterns including error handling, traits, and collections.
|
|
|
|
use std::collections::HashMap;
|
|
use std::fmt;
|
|
use std::io::{self, Write};
|
|
|
|
// ============================================================================
|
|
// Constants
|
|
// ============================================================================
|
|
|
|
/// Maximum tasks before warning about performance.
|
|
/// Chosen because HashMap operations remain O(1) up to this scale,
|
|
/// and terminal output remains readable.
|
|
const MAX_RECOMMENDED_TASKS: usize = 1000;
|
|
|
|
// ============================================================================
|
|
// Enums
|
|
// ============================================================================
|
|
|
|
/// Task priority levels, ordered from lowest to highest urgency.
|
|
///
|
|
/// Priority affects the urgency score calculation and sort order
|
|
/// when listing tasks. Critical tasks are flagged in statistics.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Priority {
|
|
Low,
|
|
Medium,
|
|
High,
|
|
Critical,
|
|
}
|
|
|
|
impl Priority {
|
|
/// Parses a priority from user input string.
|
|
///
|
|
/// Accepts both full names and single-letter abbreviations,
|
|
/// case-insensitive. Returns `None` for unrecognized input.
|
|
///
|
|
/// # Examples
|
|
/// ```
|
|
/// assert_eq!(Priority::from_str("high"), Some(Priority::High));
|
|
/// assert_eq!(Priority::from_str("H"), Some(Priority::High));
|
|
/// assert_eq!(Priority::from_str("invalid"), None);
|
|
/// ```
|
|
pub fn from_str(s: &str) -> Option<Priority> {
|
|
match s.to_lowercase().as_str() {
|
|
"low" | "l" => Some(Priority::Low),
|
|
"medium" | "m" => Some(Priority::Medium),
|
|
"high" | "h" => Some(Priority::High),
|
|
"critical" | "c" => Some(Priority::Critical),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Returns a numeric score for sorting and urgency calculation.
|
|
///
|
|
/// Higher scores indicate higher priority. Scale is 1-4 to allow
|
|
/// multiplication in urgency formulas without overflow concerns.
|
|
fn score(&self) -> u8 {
|
|
match self {
|
|
Priority::Low => 1,
|
|
Priority::Medium => 2,
|
|
Priority::High => 3,
|
|
Priority::Critical => 4,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Priority {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let symbol = match self {
|
|
Priority::Low => "[ ]",
|
|
Priority::Medium => "[=]",
|
|
Priority::High => "[!]",
|
|
Priority::Critical => "[*]",
|
|
};
|
|
write!(f, "{:?} {}", self, symbol)
|
|
}
|
|
}
|
|
|
|
/// Task workflow status.
|
|
///
|
|
/// Status transitions follow a typical kanban flow:
|
|
/// Todo -> InProgress -> Done, with Blocked as a side state.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Status {
|
|
Todo,
|
|
InProgress,
|
|
Blocked,
|
|
Done,
|
|
}
|
|
|
|
impl fmt::Display for Status {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let (name, symbol) = match self {
|
|
Status::Todo => ("Todo", "[ ]"),
|
|
Status::InProgress => ("In Progress", "[~]"),
|
|
Status::Blocked => ("Blocked", "[x]"),
|
|
Status::Done => ("Done", "[v]"),
|
|
};
|
|
write!(f, "{} {}", name, symbol)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Error Types
|
|
// ============================================================================
|
|
|
|
/// Errors that can occur during task operations.
|
|
///
|
|
/// Each variant provides context for user-friendly error messages.
|
|
/// Implements std::error::Error for compatibility with error handling crates.
|
|
#[derive(Debug)]
|
|
pub enum TaskError {
|
|
/// Task with the specified ID does not exist.
|
|
NotFound(u32),
|
|
/// User provided invalid input (with explanation).
|
|
InvalidInput(String),
|
|
/// Attempted to add a duplicate tag to a task.
|
|
DuplicateTag(String),
|
|
}
|
|
|
|
impl fmt::Display for TaskError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
TaskError::NotFound(id) => write!(f, "Task with ID {} not found", id),
|
|
TaskError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
|
|
TaskError::DuplicateTag(tag) => write!(f, "Tag '{}' already exists", tag),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for TaskError {}
|
|
|
|
/// Convenience type alias for Results with TaskError.
|
|
pub type Result<T> = std::result::Result<T, TaskError>;
|
|
|
|
// ============================================================================
|
|
// Traits
|
|
// ============================================================================
|
|
|
|
/// Provides a one-line summary of an item for list views.
|
|
pub trait Summarize {
|
|
fn summary(&self) -> String;
|
|
}
|
|
|
|
/// Calculates an urgency/priority score for sorting.
|
|
pub trait Scoreable {
|
|
fn calculate_score(&self) -> u32;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Core Data Structures
|
|
// ============================================================================
|
|
|
|
/// A task with metadata, subtasks, and tags.
|
|
///
|
|
/// Tasks are the core entity in the system. Each task has a unique ID
|
|
/// assigned by the TaskManager, along with user-defined attributes.
|
|
///
|
|
/// # Invariants
|
|
/// - `id` is unique within a TaskManager instance
|
|
/// - `tags` contains no duplicates (enforced by `add_tag`)
|
|
#[derive(Debug, Clone)]
|
|
pub struct Task {
|
|
id: u32,
|
|
title: String,
|
|
description: Option<String>,
|
|
priority: Priority,
|
|
status: Status,
|
|
tags: Vec<String>,
|
|
subtasks: Vec<Subtask>,
|
|
}
|
|
|
|
/// A subtask within a parent task.
|
|
///
|
|
/// Subtasks are simpler than tasks - they only have a title and
|
|
/// completion status. They cannot be nested further.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Subtask {
|
|
title: String,
|
|
completed: bool,
|
|
}
|
|
|
|
impl Task {
|
|
/// Creates a new task with the given title and priority.
|
|
///
|
|
/// The task starts in `Todo` status with no description, tags, or subtasks.
|
|
///
|
|
/// # Arguments
|
|
/// * `id` - Unique identifier (assigned by TaskManager)
|
|
/// * `title` - Brief description of the task
|
|
/// * `priority` - Initial priority level
|
|
fn new(id: u32, title: impl Into<String>, priority: Priority) -> Self {
|
|
Task {
|
|
id,
|
|
title: title.into(),
|
|
description: None,
|
|
priority,
|
|
status: Status::Todo,
|
|
tags: Vec::new(),
|
|
subtasks: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Adds a tag to this task.
|
|
///
|
|
/// Tags are case-sensitive and must be unique within a task.
|
|
/// Returns an error if the tag already exists.
|
|
pub fn add_tag(&mut self, tag: impl Into<String>) -> Result<()> {
|
|
let tag = tag.into();
|
|
if self.tags.contains(&tag) {
|
|
return Err(TaskError::DuplicateTag(tag));
|
|
}
|
|
self.tags.push(tag);
|
|
Ok(())
|
|
}
|
|
|
|
/// Adds a new subtask to this task.
|
|
///
|
|
/// Subtasks start as incomplete. The order of subtasks is preserved.
|
|
pub fn add_subtask(&mut self, title: impl Into<String>) {
|
|
self.subtasks.push(Subtask {
|
|
title: title.into(),
|
|
completed: false,
|
|
});
|
|
}
|
|
|
|
/// Marks a subtask as completed by its index.
|
|
///
|
|
/// # Arguments
|
|
/// * `index` - Zero-based index of the subtask
|
|
///
|
|
/// # Errors
|
|
/// Returns `InvalidInput` if the index is out of bounds.
|
|
pub fn complete_subtask(&mut self, index: usize) -> Result<()> {
|
|
self.subtasks
|
|
.get_mut(index)
|
|
.ok_or_else(|| {
|
|
TaskError::InvalidInput(format!("Subtask index {} out of bounds", index))
|
|
})?
|
|
.completed = true;
|
|
Ok(())
|
|
}
|
|
|
|
/// Calculates the percentage of completed subtasks.
|
|
///
|
|
/// If there are no subtasks, returns 100% for Done tasks, 0% otherwise.
|
|
/// This provides meaningful progress even for tasks without subtasks.
|
|
pub fn progress_percentage(&self) -> f64 {
|
|
if self.subtasks.is_empty() {
|
|
return if self.status == Status::Done {
|
|
100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
}
|
|
let completed = self.subtasks.iter().filter(|s| s.completed).count();
|
|
(completed as f64 / self.subtasks.len() as f64) * 100.0
|
|
}
|
|
|
|
/// Returns true if this task is critical and not yet done.
|
|
///
|
|
/// Used to flag high-priority incomplete work in statistics.
|
|
fn is_critical_incomplete(&self) -> bool {
|
|
self.priority == Priority::Critical && self.status != Status::Done
|
|
}
|
|
}
|
|
|
|
impl Summarize for Task {
|
|
fn summary(&self) -> String {
|
|
let tags_str = if self.tags.is_empty() {
|
|
String::from("none")
|
|
} else {
|
|
self.tags.join(", ")
|
|
};
|
|
format!(
|
|
"[#{}] {} | Priority: {} | Status: {} | Tags: {} | Progress: {:.0}%",
|
|
self.id,
|
|
self.title,
|
|
self.priority,
|
|
self.status,
|
|
tags_str,
|
|
self.progress_percentage()
|
|
)
|
|
}
|
|
}
|
|
|
|
impl Scoreable for Task {
|
|
/// Calculates urgency score for task prioritization.
|
|
///
|
|
/// Formula: (priority * 10) + status_penalty + incomplete_subtasks
|
|
///
|
|
/// This weights priority highest, then penalizes blocked/todo status,
|
|
/// then adds urgency for incomplete subtasks. Higher score = more urgent.
|
|
fn calculate_score(&self) -> u32 {
|
|
let priority_score = self.priority.score() as u32 * 10;
|
|
let status_penalty = match self.status {
|
|
Status::Done => 0,
|
|
Status::InProgress => 5,
|
|
Status::Todo => 10,
|
|
Status::Blocked => 15, // Blocked items need attention
|
|
};
|
|
let subtask_bonus = self.subtasks.iter().filter(|s| !s.completed).count() as u32;
|
|
priority_score + status_penalty + subtask_bonus
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Task {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
writeln!(f, "+----------------------------------------------------------------+")?;
|
|
writeln!(f, "| Task #{}: {}", self.id, self.title)?;
|
|
writeln!(f, "+----------------------------------------------------------------+")?;
|
|
writeln!(f, "| Priority: {}", self.priority)?;
|
|
writeln!(f, "| Status: {}", self.status)?;
|
|
|
|
if let Some(ref desc) = self.description {
|
|
writeln!(f, "| Description: {}", desc)?;
|
|
}
|
|
|
|
if !self.tags.is_empty() {
|
|
writeln!(f, "| Tags: [{}]", self.tags.join(", "))?;
|
|
}
|
|
|
|
if !self.subtasks.is_empty() {
|
|
writeln!(f, "| Subtasks:")?;
|
|
for (i, subtask) in self.subtasks.iter().enumerate() {
|
|
let check = if subtask.completed { "v" } else { " " };
|
|
writeln!(f, "| {}. [{}] {}", i + 1, check, subtask.title)?;
|
|
}
|
|
}
|
|
|
|
writeln!(f, "| Progress: {:.1}%", self.progress_percentage())?;
|
|
writeln!(f, "| Urgency Score: {}", self.calculate_score())?;
|
|
write!(f, "+----------------------------------------------------------------+")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Task Manager
|
|
// ============================================================================
|
|
|
|
/// Manages a collection of tasks with CRUD operations and queries.
|
|
///
|
|
/// TaskManager owns all tasks and assigns unique IDs. It provides
|
|
/// filtering, searching, and statistical aggregation.
|
|
///
|
|
/// # Example
|
|
/// ```
|
|
/// let mut manager = TaskManager::new();
|
|
/// let id = manager.create_task("My task", Priority::High);
|
|
/// manager.update_status(id, Status::InProgress)?;
|
|
/// ```
|
|
pub struct TaskManager {
|
|
tasks: HashMap<u32, Task>,
|
|
next_id: u32,
|
|
}
|
|
|
|
impl TaskManager {
|
|
/// Creates an empty TaskManager.
|
|
pub fn new() -> Self {
|
|
TaskManager {
|
|
tasks: HashMap::new(),
|
|
next_id: 1,
|
|
}
|
|
}
|
|
|
|
/// Creates a new task and returns its assigned ID.
|
|
///
|
|
/// IDs are assigned sequentially starting from 1.
|
|
/// Warns if approaching the recommended task limit.
|
|
pub fn create_task(&mut self, title: impl Into<String>, priority: Priority) -> u32 {
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
|
|
if self.tasks.len() >= MAX_RECOMMENDED_TASKS {
|
|
eprintln!(
|
|
"Warning: {} tasks exceeds recommended limit of {}",
|
|
self.tasks.len(),
|
|
MAX_RECOMMENDED_TASKS
|
|
);
|
|
}
|
|
|
|
let task = Task::new(id, title, priority);
|
|
self.tasks.insert(id, task);
|
|
id
|
|
}
|
|
|
|
/// Returns an immutable reference to a task by ID.
|
|
pub fn get_task(&self, id: u32) -> Result<&Task> {
|
|
self.tasks.get(&id).ok_or(TaskError::NotFound(id))
|
|
}
|
|
|
|
/// Returns a mutable reference to a task by ID.
|
|
pub fn get_task_mut(&mut self, id: u32) -> Result<&mut Task> {
|
|
self.tasks.get_mut(&id).ok_or(TaskError::NotFound(id))
|
|
}
|
|
|
|
/// Deletes a task and returns it.
|
|
pub fn delete_task(&mut self, id: u32) -> Result<Task> {
|
|
self.tasks.remove(&id).ok_or(TaskError::NotFound(id))
|
|
}
|
|
|
|
/// Updates a task's status.
|
|
pub fn update_status(&mut self, id: u32, status: Status) -> Result<()> {
|
|
self.get_task_mut(id)?.status = status;
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns all tasks sorted by urgency score (highest first).
|
|
pub fn list_all(&self) -> Vec<&Task> {
|
|
let mut tasks: Vec<&Task> = self.tasks.values().collect();
|
|
tasks.sort_by(|a, b| b.calculate_score().cmp(&a.calculate_score()));
|
|
tasks
|
|
}
|
|
|
|
/// Returns tasks matching the given status.
|
|
pub fn filter_by_status(&self, status: Status) -> Vec<&Task> {
|
|
self.tasks
|
|
.values()
|
|
.filter(|t| t.status == status)
|
|
.collect()
|
|
}
|
|
|
|
/// Returns tasks containing the given tag (case-insensitive).
|
|
pub fn filter_by_tag(&self, tag: &str) -> Vec<&Task> {
|
|
self.tasks
|
|
.values()
|
|
.filter(|t| t.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)))
|
|
.collect()
|
|
}
|
|
|
|
/// Searches tasks by title and description (case-insensitive substring).
|
|
pub fn search(&self, query: &str) -> Vec<&Task> {
|
|
let query_lower = query.to_lowercase();
|
|
self.tasks
|
|
.values()
|
|
.filter(|t| {
|
|
t.title.to_lowercase().contains(&query_lower)
|
|
|| t.description
|
|
.as_ref()
|
|
.map(|d| d.to_lowercase().contains(&query_lower))
|
|
.unwrap_or(false)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Computes aggregate statistics across all tasks.
|
|
pub fn statistics(&self) -> Statistics {
|
|
let total = self.tasks.len();
|
|
let by_status = |s: Status| self.tasks.values().filter(|t| t.status == s).count();
|
|
let by_priority = |p: Priority| self.tasks.values().filter(|t| t.priority == p).count();
|
|
|
|
let avg_progress = if total > 0 {
|
|
self.tasks
|
|
.values()
|
|
.map(|t| t.progress_percentage())
|
|
.sum::<f64>()
|
|
/ total as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let critical_incomplete = self
|
|
.tasks
|
|
.values()
|
|
.filter(|t| t.is_critical_incomplete())
|
|
.count();
|
|
|
|
Statistics {
|
|
total,
|
|
todo: by_status(Status::Todo),
|
|
in_progress: by_status(Status::InProgress),
|
|
blocked: by_status(Status::Blocked),
|
|
done: by_status(Status::Done),
|
|
low_priority: by_priority(Priority::Low),
|
|
medium_priority: by_priority(Priority::Medium),
|
|
high_priority: by_priority(Priority::High),
|
|
critical_priority: by_priority(Priority::Critical),
|
|
average_progress: avg_progress,
|
|
critical_incomplete,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for TaskManager {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Statistics
|
|
// ============================================================================
|
|
|
|
/// Aggregated statistics across all tasks.
|
|
pub struct Statistics {
|
|
pub total: usize,
|
|
pub todo: usize,
|
|
pub in_progress: usize,
|
|
pub blocked: usize,
|
|
pub done: usize,
|
|
pub low_priority: usize,
|
|
pub medium_priority: usize,
|
|
pub high_priority: usize,
|
|
pub critical_priority: usize,
|
|
pub average_progress: f64,
|
|
pub critical_incomplete: usize,
|
|
}
|
|
|
|
impl fmt::Display for Statistics {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
writeln!(f, "\n## Task Statistics")?;
|
|
writeln!(f, "========================================")?;
|
|
writeln!(f, "Total Tasks: {}", self.total)?;
|
|
writeln!(f)?;
|
|
writeln!(f, "By Status:")?;
|
|
writeln!(f, " [ ] Todo: {}", self.todo)?;
|
|
writeln!(f, " [~] In Progress: {}", self.in_progress)?;
|
|
writeln!(f, " [x] Blocked: {}", self.blocked)?;
|
|
writeln!(f, " [v] Done: {}", self.done)?;
|
|
writeln!(f)?;
|
|
writeln!(f, "By Priority:")?;
|
|
writeln!(f, " Low: {}", self.low_priority)?;
|
|
writeln!(f, " Medium: {}", self.medium_priority)?;
|
|
writeln!(f, " High: {}", self.high_priority)?;
|
|
writeln!(f, " Critical: {}", self.critical_priority)?;
|
|
writeln!(f)?;
|
|
writeln!(f, "Average Progress: {:.1}%", self.average_progress)?;
|
|
if self.critical_incomplete > 0 {
|
|
writeln!(
|
|
f,
|
|
"!! Critical tasks incomplete: {}",
|
|
self.critical_incomplete
|
|
)?;
|
|
}
|
|
write!(f, "========================================")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLI Command Handlers
|
|
// ============================================================================
|
|
// Each handler is a small function (<50 lines) following the single
|
|
// responsibility principle. This makes testing individual commands easy.
|
|
|
|
/// Result of executing a command.
|
|
enum CommandResult {
|
|
Continue,
|
|
Exit,
|
|
}
|
|
|
|
/// Parses a task ID from a string slice.
|
|
fn parse_id(s: Option<&&str>, usage: &str) -> Result<u32> {
|
|
s.and_then(|s| s.parse().ok())
|
|
.ok_or_else(|| TaskError::InvalidInput(usage.into()))
|
|
}
|
|
|
|
/// Handles the 'add' command.
|
|
fn cmd_add(manager: &mut TaskManager, parts: &[&str]) -> Result<CommandResult> {
|
|
if parts.len() < 2 {
|
|
return Err(TaskError::InvalidInput("Usage: add <title> [priority]".into()));
|
|
}
|
|
|
|
let priority = parts
|
|
.get(2)
|
|
.and_then(|p| Priority::from_str(p))
|
|
.unwrap_or(Priority::Medium);
|
|
|
|
let id = manager.create_task(parts[1], priority);
|
|
println!("[OK] Created task #{}", id);
|
|
Ok(CommandResult::Continue)
|
|
}
|
|
|
|
/// Handles the 'list' command.
|
|
fn cmd_list(manager: &TaskManager) -> Result<CommandResult> {
|
|
let tasks = manager.list_all();
|
|
if tasks.is_empty() {
|
|
println!("No tasks found. Use 'demo' to load sample data.");
|
|
} else {
|
|
println!("\n## All Tasks (sorted by urgency):\n");
|
|
for task in tasks {
|
|
println!(" {}", task.summary());
|
|
}
|
|
}
|
|
Ok(CommandResult::Continue)
|
|
}
|
|
|
|
/// Handles the 'show' command.
|
|
fn cmd_show(manager: &TaskManager, parts: &[&str]) -> Result<CommandResult> {
|
|
let id = parse_id(parts.get(1), "Usage: show <id>")?;
|
|
let task = manager.get_task(id)?;
|
|
println!("{}", task);
|
|
Ok(CommandResult::Continue)
|
|
}
|
|
|
|
/// Handles status update commands (done, progress, block).
|
|
fn cmd_update_status(
|
|
manager: &mut TaskManager,
|
|
parts: &[&str],
|
|
status: Status,
|
|
label: &str,
|
|
) -> Result<CommandResult> {
|
|
let usage = format!("Usage: {} <id>", label);
|
|
let id = parse_id(parts.get(1), &usage)?;
|
|
manager.update_status(id, status)?;
|
|
println!("[OK] Task #{} marked as {}", id, label);
|
|
Ok(CommandResult::Continue)
|
|
}
|
|
|
|
/// Handles the 'delete' command.
|
|
fn cmd_delete(manager: &mut TaskManager, parts: &[&str]) -> Result<CommandResult> {
|
|
let id = parse_id(parts.get(1), "Usage: delete <id>")?;
|
|
let task = manager.delete_task(id)?;
|
|
println!("[OK] Deleted task: {}", task.title);
|
|
Ok(CommandResult::Continue)
|
|
}
|
|
|
|
/// Handles the 'tag' command.
|
|
fn cmd_tag(manager: &mut TaskManager, parts: &[&str]) -> Result<CommandResult> {
|
|
if parts.len() < 3 {
|
|
return Err(TaskError::InvalidInput("Usage: tag <id> <tag>".into()));
|
|
}
|
|
|
|
let id: u32 = parts[1]
|
|
.parse()
|
|
.map_err(|_| TaskError::InvalidInput("Invalid ID".into()))?;
|
|
|
|
manager.get_task_mut(id)?.add_tag(parts[2])?;
|
|
println!("[OK] Added tag '{}' to task #{}", parts[2], id);
|
|
Ok(CommandResult::Continue)
|
|
}
|
|
|
|
/// Handles the 'subtask' command.
|
|
fn cmd_subtask(manager: &mut TaskManager, parts: &[&str]) -> Result<CommandResult> {
|
|
if parts.len() < 3 {
|
|
return Err(TaskError::InvalidInput("Usage: subtask <id> <title>".into()));
|
|
}
|
|
|
|
let id: u32 = parts[1]
|
|
.parse()
|
|
.map_err(|_| TaskError::InvalidInput("Invalid ID".into()))?;
|
|
|
|
manager.get_task_mut(id)?.add_subtask(parts[2]);
|
|
println!("[OK] Added subtask to task #{}", id);
|
|
Ok(CommandResult::Continue)
|
|
}
|
|
|
|
/// Handles the 'check' command.
|
|
fn cmd_check(manager: &mut TaskManager, parts: &[&str]) -> Result<CommandResult> {
|
|
if parts.len() < 3 {
|
|
return Err(TaskError::InvalidInput("Usage: check <id> <subtask#>".into()));
|
|
}
|
|
|
|
let id: u32 = parts[1]
|
|
.parse()
|
|
.map_err(|_| TaskError::InvalidInput("Invalid ID".into()))?;
|
|
|
|
let subtask_idx: usize = parts[2]
|
|
.parse::<usize>()
|
|
.map_err(|_| TaskError::InvalidInput("Invalid subtask number".into()))?
|
|
.saturating_sub(1); // Convert 1-based to 0-based index
|
|
|
|
manager.get_task_mut(id)?.complete_subtask(subtask_idx)?;
|
|
println!("[OK] Completed subtask");
|
|
Ok(CommandResult::Continue)
|
|
}
|
|
|
|
/// Handles the 'desc' command.
|
|
fn cmd_desc(manager: &mut TaskManager, parts: &[&str]) -> Result<CommandResult> {
|
|
if parts.len() < 3 {
|
|
return Err(TaskError::InvalidInput(
|
|
"Usage: desc <id> <description>".into(),
|
|
));
|
|
}
|
|
|
|
let id: u32 = parts[1]
|
|
.parse()
|
|
.map_err(|_| TaskError::InvalidInput("Invalid ID".into()))?;
|
|
|
|
manager.get_task_mut(id)?.description = Some(parts[2].into());
|
|
println!("[OK] Updated description for task #{}", id);
|
|
Ok(CommandResult::Continue)
|
|
}
|
|
|
|
/// Handles the 'filter' command.
|
|
fn cmd_filter(manager: &TaskManager, parts: &[&str]) -> Result<CommandResult> {
|
|
let status_str = parts.get(1).unwrap_or(&"");
|
|
let tasks = match status_str.to_lowercase().as_str() {
|
|
"todo" => manager.filter_by_status(Status::Todo),
|
|
"progress" | "inprogress" => manager.filter_by_status(Status::InProgress),
|
|
"blocked" => manager.filter_by_status(Status::Blocked),
|
|
"done" => manager.filter_by_status(Status::Done),
|
|
_ => {
|
|
return Err(TaskError::InvalidInput(
|
|
"Usage: filter <todo|progress|blocked|done>".into(),
|
|
));
|
|
}
|
|
};
|
|
|
|
println!("\nFiltered tasks:");
|
|
if tasks.is_empty() {
|
|
println!(" No tasks match the filter.");
|
|
} else {
|
|
for task in tasks {
|
|
println!(" {}", task.summary());
|
|
}
|
|
}
|
|
Ok(CommandResult::Continue)
|
|
}
|
|
|
|
/// Handles the 'search' command.
|
|
fn cmd_search(manager: &TaskManager, parts: &[&str]) -> Result<CommandResult> {
|
|
if parts.len() < 2 {
|
|
return Err(TaskError::InvalidInput("Usage: search <query>".into()));
|
|
}
|
|
|
|
let query = parts[1..].join(" ");
|
|
let tasks = manager.search(&query);
|
|
|
|
println!("\nSearch results for '{}':", query);
|
|
if tasks.is_empty() {
|
|
println!(" No tasks found.");
|
|
} else {
|
|
for task in tasks {
|
|
println!(" {}", task.summary());
|
|
}
|
|
}
|
|
Ok(CommandResult::Continue)
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLI Infrastructure
|
|
// ============================================================================
|
|
|
|
fn print_help() {
|
|
println!("\n## Task Manager Commands:");
|
|
println!("==========================================================");
|
|
println!(" add <title> [priority] - Add a new task (priority: l/m/h/c)");
|
|
println!(" list - List all tasks");
|
|
println!(" show <id> - Show task details");
|
|
println!(" done <id> - Mark task as done");
|
|
println!(" progress <id> - Mark task as in progress");
|
|
println!(" block <id> - Mark task as blocked");
|
|
println!(" delete <id> - Delete a task");
|
|
println!(" tag <id> <tag> - Add tag to task");
|
|
println!(" subtask <id> <title> - Add subtask");
|
|
println!(" check <id> <subtask#> - Complete subtask");
|
|
println!(" desc <id> <description> - Add description");
|
|
println!(" filter <status> - Filter by status");
|
|
println!(" search <query> - Search tasks");
|
|
println!(" stats - Show statistics");
|
|
println!(" demo - Load demo data");
|
|
println!(" help - Show this help");
|
|
println!(" quit - Exit");
|
|
println!("==========================================================\n");
|
|
}
|
|
|
|
/// Loads sample tasks for demonstration and testing.
|
|
fn load_demo_data(manager: &mut TaskManager) {
|
|
let id1 = manager.create_task("Deploy production server", Priority::Critical);
|
|
if let Ok(task) = manager.get_task_mut(id1) {
|
|
task.description =
|
|
Some("Deploy the new version to production with zero downtime".into());
|
|
let _ = task.add_tag("devops");
|
|
let _ = task.add_tag("urgent");
|
|
task.add_subtask("Backup database");
|
|
task.add_subtask("Run migrations");
|
|
task.add_subtask("Deploy new containers");
|
|
task.add_subtask("Verify health checks");
|
|
let _ = task.complete_subtask(0);
|
|
task.status = Status::InProgress;
|
|
}
|
|
|
|
let id2 = manager.create_task("Fix authentication bug", Priority::High);
|
|
if let Ok(task) = manager.get_task_mut(id2) {
|
|
task.description = Some("Users getting logged out unexpectedly".into());
|
|
let _ = task.add_tag("bug");
|
|
let _ = task.add_tag("security");
|
|
task.status = Status::Blocked;
|
|
}
|
|
|
|
let id3 = manager.create_task("Implement user dashboard", Priority::Medium);
|
|
if let Ok(task) = manager.get_task_mut(id3) {
|
|
let _ = task.add_tag("feature");
|
|
let _ = task.add_tag("frontend");
|
|
task.add_subtask("Design mockups");
|
|
task.add_subtask("Create React components");
|
|
task.add_subtask("Add API integration");
|
|
task.add_subtask("Write tests");
|
|
let _ = task.complete_subtask(0);
|
|
let _ = task.complete_subtask(1);
|
|
}
|
|
|
|
let id4 = manager.create_task("Update documentation", Priority::Low);
|
|
if let Ok(task) = manager.get_task_mut(id4) {
|
|
let _ = task.add_tag("docs");
|
|
}
|
|
|
|
let id5 = manager.create_task("Setup CI/CD pipeline", Priority::High);
|
|
if let Ok(task) = manager.get_task_mut(id5) {
|
|
let _ = task.add_tag("devops");
|
|
task.add_subtask("Configure GitHub Actions");
|
|
task.add_subtask("Add test stage");
|
|
task.add_subtask("Add deploy stage");
|
|
let _ = task.complete_subtask(0);
|
|
let _ = task.complete_subtask(1);
|
|
let _ = task.complete_subtask(2);
|
|
task.status = Status::Done;
|
|
}
|
|
|
|
println!("[OK] Loaded 5 demo tasks!");
|
|
}
|
|
|
|
/// Routes a command to the appropriate handler.
|
|
///
|
|
/// This dispatcher keeps each command handler small and testable.
|
|
/// Adding new commands requires only adding a new match arm and handler.
|
|
fn execute_command(manager: &mut TaskManager, parts: &[&str]) -> Result<CommandResult> {
|
|
let command = parts[0].to_lowercase();
|
|
|
|
match command.as_str() {
|
|
"quit" | "q" | "exit" => {
|
|
println!("Goodbye!");
|
|
Ok(CommandResult::Exit)
|
|
}
|
|
"help" | "h" | "?" => {
|
|
print_help();
|
|
Ok(CommandResult::Continue)
|
|
}
|
|
"demo" => {
|
|
load_demo_data(manager);
|
|
Ok(CommandResult::Continue)
|
|
}
|
|
"add" => cmd_add(manager, parts),
|
|
"list" | "ls" => cmd_list(manager),
|
|
"show" => cmd_show(manager, parts),
|
|
"done" => cmd_update_status(manager, parts, Status::Done, "done"),
|
|
"progress" => cmd_update_status(manager, parts, Status::InProgress, "in progress"),
|
|
"block" => cmd_update_status(manager, parts, Status::Blocked, "blocked"),
|
|
"delete" | "rm" => cmd_delete(manager, parts),
|
|
"tag" => cmd_tag(manager, parts),
|
|
"subtask" => cmd_subtask(manager, parts),
|
|
"check" => cmd_check(manager, parts),
|
|
"desc" => cmd_desc(manager, parts),
|
|
"filter" => cmd_filter(manager, parts),
|
|
"search" => cmd_search(manager, parts),
|
|
"stats" => {
|
|
println!("{}", manager.statistics());
|
|
Ok(CommandResult::Continue)
|
|
}
|
|
_ => Err(TaskError::InvalidInput(format!(
|
|
"Unknown command: '{}'. Type 'help' for commands.",
|
|
command
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// Main REPL loop for the CLI.
|
|
fn run_interactive(manager: &mut TaskManager) {
|
|
println!("\n## Welcome to Rust Task Manager!\n");
|
|
print_help();
|
|
|
|
loop {
|
|
print!("> ");
|
|
io::stdout().flush().unwrap();
|
|
|
|
let mut input = String::new();
|
|
if io::stdin().read_line(&mut input).is_err() {
|
|
println!("Error reading input");
|
|
continue;
|
|
}
|
|
|
|
let parts: Vec<&str> = input.trim().splitn(3, ' ').collect();
|
|
if parts.is_empty() || parts[0].is_empty() {
|
|
continue;
|
|
}
|
|
|
|
match execute_command(manager, &parts) {
|
|
Ok(CommandResult::Exit) => break,
|
|
Ok(CommandResult::Continue) => {}
|
|
Err(e) => println!("[ERROR] {}", e),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Entry Point
|
|
// ============================================================================
|
|
|
|
fn main() {
|
|
let mut manager = TaskManager::new();
|
|
run_interactive(&mut manager);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Unit Tests
|
|
// ============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Priority Tests
|
|
// -------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_priority_from_str_full_names() {
|
|
assert_eq!(Priority::from_str("low"), Some(Priority::Low));
|
|
assert_eq!(Priority::from_str("medium"), Some(Priority::Medium));
|
|
assert_eq!(Priority::from_str("high"), Some(Priority::High));
|
|
assert_eq!(Priority::from_str("critical"), Some(Priority::Critical));
|
|
}
|
|
|
|
#[test]
|
|
fn test_priority_from_str_abbreviations() {
|
|
assert_eq!(Priority::from_str("l"), Some(Priority::Low));
|
|
assert_eq!(Priority::from_str("m"), Some(Priority::Medium));
|
|
assert_eq!(Priority::from_str("h"), Some(Priority::High));
|
|
assert_eq!(Priority::from_str("c"), Some(Priority::Critical));
|
|
}
|
|
|
|
#[test]
|
|
fn test_priority_from_str_case_insensitive() {
|
|
assert_eq!(Priority::from_str("HIGH"), Some(Priority::High));
|
|
assert_eq!(Priority::from_str("High"), Some(Priority::High));
|
|
assert_eq!(Priority::from_str("H"), Some(Priority::High));
|
|
}
|
|
|
|
#[test]
|
|
fn test_priority_from_str_invalid() {
|
|
assert_eq!(Priority::from_str("invalid"), None);
|
|
assert_eq!(Priority::from_str(""), None);
|
|
assert_eq!(Priority::from_str("x"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_priority_score_ordering() {
|
|
assert!(Priority::Low.score() < Priority::Medium.score());
|
|
assert!(Priority::Medium.score() < Priority::High.score());
|
|
assert!(Priority::High.score() < Priority::Critical.score());
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Task Tests
|
|
// -------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_task_new_defaults() {
|
|
let task = Task::new(1, "Test task", Priority::Medium);
|
|
|
|
assert_eq!(task.id, 1);
|
|
assert_eq!(task.title, "Test task");
|
|
assert_eq!(task.priority, Priority::Medium);
|
|
assert_eq!(task.status, Status::Todo);
|
|
assert!(task.description.is_none());
|
|
assert!(task.tags.is_empty());
|
|
assert!(task.subtasks.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_task_add_tag_success() {
|
|
let mut task = Task::new(1, "Test", Priority::Low);
|
|
|
|
assert!(task.add_tag("rust").is_ok());
|
|
assert_eq!(task.tags, vec!["rust"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_task_add_tag_duplicate_error() {
|
|
let mut task = Task::new(1, "Test", Priority::Low);
|
|
|
|
task.add_tag("rust").unwrap();
|
|
let result = task.add_tag("rust");
|
|
|
|
assert!(result.is_err());
|
|
match result {
|
|
Err(TaskError::DuplicateTag(tag)) => assert_eq!(tag, "rust"),
|
|
_ => panic!("Expected DuplicateTag error"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_task_add_subtask() {
|
|
let mut task = Task::new(1, "Test", Priority::Low);
|
|
|
|
task.add_subtask("Subtask 1");
|
|
task.add_subtask("Subtask 2");
|
|
|
|
assert_eq!(task.subtasks.len(), 2);
|
|
assert_eq!(task.subtasks[0].title, "Subtask 1");
|
|
assert!(!task.subtasks[0].completed);
|
|
}
|
|
|
|
#[test]
|
|
fn test_task_complete_subtask_success() {
|
|
let mut task = Task::new(1, "Test", Priority::Low);
|
|
task.add_subtask("Subtask 1");
|
|
|
|
assert!(task.complete_subtask(0).is_ok());
|
|
assert!(task.subtasks[0].completed);
|
|
}
|
|
|
|
#[test]
|
|
fn test_task_complete_subtask_out_of_bounds() {
|
|
let mut task = Task::new(1, "Test", Priority::Low);
|
|
task.add_subtask("Subtask 1");
|
|
|
|
let result = task.complete_subtask(5);
|
|
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_task_progress_no_subtasks_todo() {
|
|
let task = Task::new(1, "Test", Priority::Low);
|
|
assert_eq!(task.progress_percentage(), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_task_progress_no_subtasks_done() {
|
|
let mut task = Task::new(1, "Test", Priority::Low);
|
|
task.status = Status::Done;
|
|
assert_eq!(task.progress_percentage(), 100.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_task_progress_with_subtasks() {
|
|
let mut task = Task::new(1, "Test", Priority::Low);
|
|
task.add_subtask("Sub 1");
|
|
task.add_subtask("Sub 2");
|
|
task.add_subtask("Sub 3");
|
|
task.add_subtask("Sub 4");
|
|
|
|
task.complete_subtask(0).unwrap();
|
|
task.complete_subtask(1).unwrap();
|
|
|
|
assert_eq!(task.progress_percentage(), 50.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_task_urgency_score() {
|
|
let mut task = Task::new(1, "Test", Priority::High);
|
|
task.status = Status::Todo;
|
|
task.add_subtask("Incomplete subtask");
|
|
|
|
// priority(3) * 10 + status_todo(10) + 1 incomplete subtask = 41
|
|
assert_eq!(task.calculate_score(), 41);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// TaskManager Tests
|
|
// -------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_manager_create_task_assigns_sequential_ids() {
|
|
let mut manager = TaskManager::new();
|
|
|
|
let id1 = manager.create_task("Task 1", Priority::Low);
|
|
let id2 = manager.create_task("Task 2", Priority::Low);
|
|
let id3 = manager.create_task("Task 3", Priority::Low);
|
|
|
|
assert_eq!(id1, 1);
|
|
assert_eq!(id2, 2);
|
|
assert_eq!(id3, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_manager_get_task_success() {
|
|
let mut manager = TaskManager::new();
|
|
let id = manager.create_task("Test", Priority::High);
|
|
|
|
let task = manager.get_task(id).unwrap();
|
|
|
|
assert_eq!(task.title, "Test");
|
|
assert_eq!(task.priority, Priority::High);
|
|
}
|
|
|
|
#[test]
|
|
fn test_manager_get_task_not_found() {
|
|
let manager = TaskManager::new();
|
|
let result = manager.get_task(999);
|
|
|
|
assert!(result.is_err());
|
|
match result {
|
|
Err(TaskError::NotFound(id)) => assert_eq!(id, 999),
|
|
_ => panic!("Expected NotFound error"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_manager_delete_task() {
|
|
let mut manager = TaskManager::new();
|
|
let id = manager.create_task("To delete", Priority::Low);
|
|
|
|
let deleted = manager.delete_task(id).unwrap();
|
|
|
|
assert_eq!(deleted.title, "To delete");
|
|
assert!(manager.get_task(id).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_manager_update_status() {
|
|
let mut manager = TaskManager::new();
|
|
let id = manager.create_task("Test", Priority::Low);
|
|
|
|
manager.update_status(id, Status::InProgress).unwrap();
|
|
|
|
assert_eq!(manager.get_task(id).unwrap().status, Status::InProgress);
|
|
}
|
|
|
|
#[test]
|
|
fn test_manager_list_all_sorted_by_urgency() {
|
|
let mut manager = TaskManager::new();
|
|
manager.create_task("Low priority", Priority::Low);
|
|
manager.create_task("Critical priority", Priority::Critical);
|
|
manager.create_task("Medium priority", Priority::Medium);
|
|
|
|
let tasks = manager.list_all();
|
|
|
|
assert_eq!(tasks[0].priority, Priority::Critical);
|
|
assert_eq!(tasks[2].priority, Priority::Low);
|
|
}
|
|
|
|
#[test]
|
|
fn test_manager_filter_by_status() {
|
|
let mut manager = TaskManager::new();
|
|
let id1 = manager.create_task("Task 1", Priority::Low);
|
|
let id2 = manager.create_task("Task 2", Priority::Low);
|
|
manager.create_task("Task 3", Priority::Low);
|
|
|
|
manager.update_status(id1, Status::Done).unwrap();
|
|
manager.update_status(id2, Status::Done).unwrap();
|
|
|
|
let done_tasks = manager.filter_by_status(Status::Done);
|
|
|
|
assert_eq!(done_tasks.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_manager_search_by_title() {
|
|
let mut manager = TaskManager::new();
|
|
manager.create_task("Fix authentication bug", Priority::High);
|
|
manager.create_task("Add new feature", Priority::Medium);
|
|
manager.create_task("Fix database bug", Priority::High);
|
|
|
|
let results = manager.search("fix");
|
|
|
|
assert_eq!(results.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_manager_search_case_insensitive() {
|
|
let mut manager = TaskManager::new();
|
|
manager.create_task("Authentication Module", Priority::High);
|
|
|
|
let results = manager.search("authentication");
|
|
|
|
assert_eq!(results.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_manager_statistics() {
|
|
let mut manager = TaskManager::new();
|
|
let id1 = manager.create_task("Task 1", Priority::Critical);
|
|
let id2 = manager.create_task("Task 2", Priority::High);
|
|
manager.create_task("Task 3", Priority::Low);
|
|
|
|
manager.update_status(id1, Status::Done).unwrap();
|
|
manager.update_status(id2, Status::InProgress).unwrap();
|
|
|
|
let stats = manager.statistics();
|
|
|
|
assert_eq!(stats.total, 3);
|
|
assert_eq!(stats.done, 1);
|
|
assert_eq!(stats.in_progress, 1);
|
|
assert_eq!(stats.todo, 1);
|
|
assert_eq!(stats.critical_priority, 1);
|
|
assert_eq!(stats.critical_incomplete, 0); // Critical task is done
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Command Handler Tests
|
|
// -------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_parse_id_success() {
|
|
let parts = vec!["show", "42"];
|
|
let id = parse_id(parts.get(1), "Usage: show <id>").unwrap();
|
|
assert_eq!(id, 42);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_id_missing() {
|
|
let parts: Vec<&str> = vec!["show"];
|
|
let result = parse_id(parts.get(1), "Usage: show <id>");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_id_invalid() {
|
|
let parts = vec!["show", "abc"];
|
|
let result = parse_id(parts.get(1), "Usage: show <id>");
|
|
assert!(result.is_err());
|
|
}
|
|
}
|