use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Open,
InProgress,
Closed,
Failed,
}
impl TaskStatus {
pub fn is_terminal(&self) -> bool {
matches!(self, TaskStatus::Closed | TaskStatus::Failed)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
pub status: TaskStatus,
pub priority: u8,
#[serde(default)]
pub blocked_by: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub loop_id: Option<String>,
pub created: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub started: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub closed: Option<String>,
}
impl Task {
pub fn new(title: String, priority: u8) -> Self {
Self {
id: Self::generate_id(),
title,
description: None,
key: None,
status: TaskStatus::Open,
priority: priority.clamp(1, 5),
blocked_by: Vec::new(),
loop_id: None,
created: chrono::Utc::now().to_rfc3339(),
started: None,
closed: None,
}
}
pub fn with_loop_id(mut self, loop_id: Option<String>) -> Self {
self.loop_id = loop_id;
self
}
pub fn generate_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards");
let timestamp = duration.as_secs();
let hex_suffix = format!("{:04x}", duration.subsec_micros() % 0x10000);
format!("task-{}-{}", timestamp, hex_suffix)
}
pub fn is_ready(&self, all_tasks: &[Task]) -> bool {
if self.status != TaskStatus::Open {
return false;
}
self.blocked_by.iter().all(|blocker_id| {
all_tasks
.iter()
.find(|t| &t.id == blocker_id)
.is_some_and(|t| t.status == TaskStatus::Closed)
})
}
pub fn with_description(mut self, description: Option<String>) -> Self {
self.description = description;
self
}
pub fn with_key(mut self, key: Option<String>) -> Self {
self.key = key;
self
}
pub fn with_blocker(mut self, task_id: String) -> Self {
self.blocked_by.push(task_id);
self
}
pub fn start(&mut self) {
self.status = TaskStatus::InProgress;
if self.started.is_none() {
self.started = Some(chrono::Utc::now().to_rfc3339());
}
self.closed = None;
}
pub fn reopen(&mut self) {
self.status = TaskStatus::Open;
self.closed = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_task_creation() {
let task = Task::new("Test task".to_string(), 2);
assert_eq!(task.title, "Test task");
assert_eq!(task.priority, 2);
assert_eq!(task.status, TaskStatus::Open);
assert!(task.blocked_by.is_empty());
assert!(task.key.is_none());
assert!(task.started.is_none());
}
#[test]
fn test_priority_clamping() {
let task_low = Task::new("Low".to_string(), 0);
assert_eq!(task_low.priority, 1);
let task_high = Task::new("High".to_string(), 10);
assert_eq!(task_high.priority, 5);
}
#[test]
fn test_task_id_format() {
let task = Task::new("Test".to_string(), 1);
assert!(task.id.starts_with("task-"));
let parts: Vec<&str> = task.id.split('-').collect();
assert_eq!(parts.len(), 3);
}
#[test]
fn test_is_ready_open_no_blockers() {
let task = Task::new("Test".to_string(), 1);
assert!(task.is_ready(&[]));
}
#[test]
fn test_is_ready_with_open_blocker() {
let blocker = Task::new("Blocker".to_string(), 1);
let mut task = Task::new("Test".to_string(), 1);
task.blocked_by.push(blocker.id.clone());
assert!(!task.is_ready(std::slice::from_ref(&blocker)));
}
#[test]
fn test_is_ready_with_closed_blocker() {
let mut blocker = Task::new("Blocker".to_string(), 1);
blocker.status = TaskStatus::Closed;
let mut task = Task::new("Test".to_string(), 1);
task.blocked_by.push(blocker.id.clone());
assert!(task.is_ready(std::slice::from_ref(&blocker)));
}
#[test]
fn test_is_not_ready_when_not_open() {
let mut task = Task::new("Test".to_string(), 1);
task.status = TaskStatus::Closed;
assert!(!task.is_ready(&[]));
task.status = TaskStatus::InProgress;
assert!(!task.is_ready(&[]));
task.status = TaskStatus::Failed;
assert!(!task.is_ready(&[]));
}
#[test]
fn test_is_terminal() {
assert!(!TaskStatus::Open.is_terminal());
assert!(!TaskStatus::InProgress.is_terminal());
assert!(TaskStatus::Closed.is_terminal());
assert!(TaskStatus::Failed.is_terminal());
}
#[test]
fn test_with_key_sets_stable_key() {
let task = Task::new("Test".to_string(), 1).with_key(Some("spec:build".to_string()));
assert_eq!(task.key.as_deref(), Some("spec:build"));
}
#[test]
fn test_start_marks_task_in_progress() {
let mut task = Task::new("Test".to_string(), 1);
task.start();
assert_eq!(task.status, TaskStatus::InProgress);
assert!(task.started.is_some());
assert!(task.closed.is_none());
}
#[test]
fn test_reopen_resets_terminal_state() {
let mut task = Task::new("Test".to_string(), 1);
task.status = TaskStatus::Closed;
task.closed = Some(chrono::Utc::now().to_rfc3339());
task.reopen();
assert_eq!(task.status, TaskStatus::Open);
assert!(task.closed.is_none());
}
}