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)
}
}
#[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,
#[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>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recur_id: Option<Uuid>,
#[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,
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(),
}
}
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()
}
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());
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());
}
}