tasks-cli-rs 0.9.0

Markdown-based TODO task management CLI: each task is a Markdown file with YAML front matter
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum Status {
    #[default]
    Todo,
    InProgress,
    Blocked,
    InReview,
    Done,
    Cancelled,
}

impl std::str::FromStr for Status {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "todo" => Ok(Status::Todo),
            "in_progress" => Ok(Status::InProgress),
            "blocked" => Ok(Status::Blocked),
            "in_review" => Ok(Status::InReview),
            "done" => Ok(Status::Done),
            "cancelled" => Ok(Status::Cancelled),
            other => Err(format!("invalid status: {other}")),
        }
    }
}

impl std::fmt::Display for Status {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = match self {
            Status::Todo => "todo",
            Status::InProgress => "in_progress",
            Status::Blocked => "blocked",
            Status::InReview => "in_review",
            Status::Done => "done",
            Status::Cancelled => "cancelled",
        };
        f.write_str(s)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum Priority {
    Low,
    #[default]
    Medium,
    High,
    Urgent,
}

impl std::str::FromStr for Priority {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "low" => Ok(Priority::Low),
            "medium" => Ok(Priority::Medium),
            "high" => Ok(Priority::High),
            "urgent" => Ok(Priority::Urgent),
            other => Err(format!("invalid priority: {other}")),
        }
    }
}

impl std::fmt::Display for Priority {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = match self {
            Priority::Low => "low",
            Priority::Medium => "medium",
            Priority::High => "high",
            Priority::Urgent => "urgent",
        };
        f.write_str(s)
    }
}

/// A step derived from a body checkbox. Not stored in YAML.
#[derive(Debug, Clone, PartialEq)]
pub struct Step {
    pub title: String,
    pub done: bool,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TaskMeta {
    pub id: Uuid,
    pub seq: u64,
    pub title: String,
    /// Longer summary or background; `title` stays short because it becomes
    /// the filename slug.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(default)]
    pub status: Status,
    #[serde(default)]
    pub priority: Priority,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tags: Vec<String>,
    pub created_at: DateTime<Utc>,
    #[serde(default)]
    pub started_at: Option<DateTime<Utc>>,
    #[serde(default)]
    pub due_date: Option<DateTime<Utc>>,
    #[serde(default)]
    pub completed_at: Option<DateTime<Utc>>,
    /// Id of the recurrence rule that generated this task, if any.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub recur_id: Option<Uuid>,
    /// The rule cycle this task stands for, as a local calendar date.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub occurrence: Option<NaiveDate>,
}

impl TaskMeta {
    pub fn validate(&self) -> std::result::Result<(), String> {
        if self.title.trim().is_empty() {
            return Err("field 'title' must not be empty".into());
        }
        if self.seq == 0 {
            return Err("field 'seq' must be greater than 0".into());
        }
        Ok(())
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct Task {
    pub meta: TaskMeta,
    pub body: String,
    /// Derived from body checkboxes on parse; not serialized to YAML.
    pub steps: Vec<Step>,
}

impl Task {
    pub fn new(seq: u64, title: impl Into<String>) -> Self {
        Task {
            meta: TaskMeta {
                id: Uuid::new_v4(),
                seq,
                title: title.into(),
                description: None,
                status: Status::default(),
                priority: Priority::default(),
                tags: Vec::new(),
                created_at: Utc::now(),
                started_at: None,
                due_date: None,
                completed_at: None,
                recur_id: None,
                occurrence: None,
            },
            body: String::new(),
            steps: Vec::new(),
        }
    }

    /// Sets the status and maintains lifecycle timestamps: entering
    /// in_progress stamps started_at (once); entering done/cancelled stamps
    /// completed_at; leaving done/cancelled clears it.
    pub fn set_status(&mut self, new: Status) {
        if new == Status::InProgress && self.meta.started_at.is_none() {
            self.meta.started_at = Some(Utc::now());
        }
        match new {
            Status::Done | Status::Cancelled => {
                if self.meta.completed_at.is_none() {
                    self.meta.completed_at = Some(Utc::now());
                }
            }
            _ => self.meta.completed_at = None,
        }
        self.meta.status = new;
    }

    pub fn short_id(&self) -> String {
        self.meta.id.simple().to_string()[..8].to_string()
    }

    /// Matches full UUID, a hex prefix of at least 4 chars, or the sequence
    /// number (optionally prefixed with '#').
    pub fn matches_identifier(&self, query: &str) -> bool {
        let query = query.trim();
        if let Some(seq) = query.strip_prefix('#') {
            return seq.parse::<u64>().is_ok_and(|n| n == self.meta.seq);
        }
        if query.parse::<u64>().is_ok_and(|n| n == self.meta.seq) {
            return true;
        }
        let hex = query.replace('-', "").to_lowercase();
        if hex.len() >= 4 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
            return self.meta.id.simple().to_string().starts_with(&hex);
        }
        false
    }
}

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

    #[test]
    fn status_round_trip() {
        for s in ["todo", "in_progress", "blocked", "in_review", "done", "cancelled"] {
            let status: Status = s.parse().unwrap();
            assert_eq!(status.to_string(), s);
        }
        assert!("bogus".parse::<Status>().is_err());
    }

    #[test]
    fn short_id_is_8_hex_chars() {
        let task = Task::new(1, "t");
        let sid = task.short_id();
        assert_eq!(sid.len(), 8);
        assert!(sid.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn set_status_manages_timestamps() {
        let mut task = Task::new(1, "t");
        assert!(task.meta.started_at.is_none());

        task.set_status(Status::InProgress);
        let started = task.meta.started_at;
        assert!(started.is_some());

        task.set_status(Status::Done);
        assert!(task.meta.completed_at.is_some());

        // reopening clears completed_at but keeps original started_at
        task.set_status(Status::InProgress);
        assert!(task.meta.completed_at.is_none());
        assert_eq!(task.meta.started_at, started);

        task.set_status(Status::Cancelled);
        assert!(task.meta.completed_at.is_some());
    }

    #[test]
    fn matches_by_seq_and_prefix() {
        let mut task = Task::new(42, "t");
        task.meta.id = "abc12345-89ab-4def-0123-456789abcdef".parse().unwrap();
        assert!(task.matches_identifier("42"));
        assert!(task.matches_identifier("#42"));
        assert!(!task.matches_identifier("41"));
        assert!(task.matches_identifier(&task.short_id()));
        assert!(task.matches_identifier(&task.meta.id.to_string()));
        assert!(!task.matches_identifier("zzzz"));
        assert!(!task.matches_identifier(&task.short_id()[..3]));
    }

    #[test]
    fn validate_rejects_empty_title() {
        let mut task = Task::new(1, "ok");
        task.meta.title = "  ".into();
        assert!(task.meta.validate().is_err());
    }

    #[test]
    fn validate_rejects_zero_seq() {
        let mut task = Task::new(1, "ok");
        task.meta.seq = 0;
        assert!(task.meta.validate().is_err());
    }
}