1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum TaskStatus {
12 Open,
14 InProgress,
16 Closed,
18 Failed,
20}
21
22impl TaskStatus {
23 pub fn is_terminal(&self) -> bool {
27 matches!(self, TaskStatus::Closed | TaskStatus::Failed)
28 }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Task {
34 pub id: String,
36
37 pub title: String,
39
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub description: Option<String>,
43
44 pub status: TaskStatus,
46
47 pub priority: u8,
49
50 #[serde(default)]
52 pub blocked_by: Vec<String>,
53
54 #[serde(skip_serializing_if = "Option::is_none")]
57 pub loop_id: Option<String>,
58
59 pub created: String,
61
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub closed: Option<String>,
65}
66
67impl Task {
68 pub fn new(title: String, priority: u8) -> Self {
70 Self {
71 id: Self::generate_id(),
72 title,
73 description: None,
74 status: TaskStatus::Open,
75 priority: priority.clamp(1, 5),
76 blocked_by: Vec::new(),
77 loop_id: None,
78 created: chrono::Utc::now().to_rfc3339(),
79 closed: None,
80 }
81 }
82
83 pub fn with_loop_id(mut self, loop_id: Option<String>) -> Self {
85 self.loop_id = loop_id;
86 self
87 }
88
89 pub fn generate_id() -> String {
91 use std::time::{SystemTime, UNIX_EPOCH};
92 let duration = SystemTime::now()
93 .duration_since(UNIX_EPOCH)
94 .expect("Time went backwards");
95 let timestamp = duration.as_secs();
96 let hex_suffix = format!("{:04x}", duration.subsec_micros() % 0x10000);
97 format!("task-{}-{}", timestamp, hex_suffix)
98 }
99
100 pub fn is_ready(&self, all_tasks: &[Task]) -> bool {
102 if self.status != TaskStatus::Open {
103 return false;
104 }
105 self.blocked_by.iter().all(|blocker_id| {
106 all_tasks
107 .iter()
108 .find(|t| &t.id == blocker_id)
109 .is_some_and(|t| t.status == TaskStatus::Closed)
110 })
111 }
112
113 pub fn with_description(mut self, description: Option<String>) -> Self {
115 self.description = description;
116 self
117 }
118
119 pub fn with_blocker(mut self, task_id: String) -> Self {
121 self.blocked_by.push(task_id);
122 self
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn test_task_creation() {
132 let task = Task::new("Test task".to_string(), 2);
133 assert_eq!(task.title, "Test task");
134 assert_eq!(task.priority, 2);
135 assert_eq!(task.status, TaskStatus::Open);
136 assert!(task.blocked_by.is_empty());
137 }
138
139 #[test]
140 fn test_priority_clamping() {
141 let task_low = Task::new("Low".to_string(), 0);
142 assert_eq!(task_low.priority, 1);
143
144 let task_high = Task::new("High".to_string(), 10);
145 assert_eq!(task_high.priority, 5);
146 }
147
148 #[test]
149 fn test_task_id_format() {
150 let task = Task::new("Test".to_string(), 1);
151 assert!(task.id.starts_with("task-"));
152 let parts: Vec<&str> = task.id.split('-').collect();
153 assert_eq!(parts.len(), 3);
154 }
155
156 #[test]
157 fn test_is_ready_open_no_blockers() {
158 let task = Task::new("Test".to_string(), 1);
159 assert!(task.is_ready(&[]));
160 }
161
162 #[test]
163 fn test_is_ready_with_open_blocker() {
164 let blocker = Task::new("Blocker".to_string(), 1);
165 let mut task = Task::new("Test".to_string(), 1);
166 task.blocked_by.push(blocker.id.clone());
167
168 assert!(!task.is_ready(std::slice::from_ref(&blocker)));
169 }
170
171 #[test]
172 fn test_is_ready_with_closed_blocker() {
173 let mut blocker = Task::new("Blocker".to_string(), 1);
174 blocker.status = TaskStatus::Closed;
175
176 let mut task = Task::new("Test".to_string(), 1);
177 task.blocked_by.push(blocker.id.clone());
178
179 assert!(task.is_ready(std::slice::from_ref(&blocker)));
180 }
181
182 #[test]
183 fn test_is_not_ready_when_not_open() {
184 let mut task = Task::new("Test".to_string(), 1);
185 task.status = TaskStatus::Closed;
186 assert!(!task.is_ready(&[]));
187
188 task.status = TaskStatus::InProgress;
189 assert!(!task.is_ready(&[]));
190
191 task.status = TaskStatus::Failed;
192 assert!(!task.is_ready(&[]));
193 }
194
195 #[test]
196 fn test_is_terminal() {
197 assert!(!TaskStatus::Open.is_terminal());
198 assert!(!TaskStatus::InProgress.is_terminal());
199 assert!(TaskStatus::Closed.is_terminal());
200 assert!(TaskStatus::Failed.is_terminal());
201 }
202}