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 #[serde(skip_serializing_if = "Option::is_none")]
46 pub key: Option<String>,
47
48 pub status: TaskStatus,
50
51 pub priority: u8,
53
54 #[serde(default)]
56 pub blocked_by: Vec<String>,
57
58 #[serde(skip_serializing_if = "Option::is_none")]
61 pub loop_id: Option<String>,
62
63 pub created: String,
65
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub started: Option<String>,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub closed: Option<String>,
73}
74
75impl Task {
76 pub fn new(title: String, priority: u8) -> Self {
78 Self {
79 id: Self::generate_id(),
80 title,
81 description: None,
82 key: None,
83 status: TaskStatus::Open,
84 priority: priority.clamp(1, 5),
85 blocked_by: Vec::new(),
86 loop_id: None,
87 created: chrono::Utc::now().to_rfc3339(),
88 started: None,
89 closed: None,
90 }
91 }
92
93 pub fn with_loop_id(mut self, loop_id: Option<String>) -> Self {
95 self.loop_id = loop_id;
96 self
97 }
98
99 pub fn generate_id() -> String {
101 use std::time::{SystemTime, UNIX_EPOCH};
102 let duration = SystemTime::now()
103 .duration_since(UNIX_EPOCH)
104 .expect("Time went backwards");
105 let timestamp = duration.as_secs();
106 let hex_suffix = format!("{:04x}", duration.subsec_micros() % 0x10000);
107 format!("task-{}-{}", timestamp, hex_suffix)
108 }
109
110 pub fn is_ready(&self, all_tasks: &[Task]) -> bool {
112 if self.status != TaskStatus::Open {
113 return false;
114 }
115 self.blocked_by.iter().all(|blocker_id| {
116 all_tasks
117 .iter()
118 .find(|t| &t.id == blocker_id)
119 .is_some_and(|t| t.status == TaskStatus::Closed)
120 })
121 }
122
123 pub fn with_description(mut self, description: Option<String>) -> Self {
125 self.description = description;
126 self
127 }
128
129 pub fn with_key(mut self, key: Option<String>) -> Self {
131 self.key = key;
132 self
133 }
134
135 pub fn with_blocker(mut self, task_id: String) -> Self {
137 self.blocked_by.push(task_id);
138 self
139 }
140
141 pub fn start(&mut self) {
143 self.status = TaskStatus::InProgress;
144 if self.started.is_none() {
145 self.started = Some(chrono::Utc::now().to_rfc3339());
146 }
147 self.closed = None;
148 }
149
150 pub fn reopen(&mut self) {
152 self.status = TaskStatus::Open;
153 self.closed = None;
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn test_task_creation() {
163 let task = Task::new("Test task".to_string(), 2);
164 assert_eq!(task.title, "Test task");
165 assert_eq!(task.priority, 2);
166 assert_eq!(task.status, TaskStatus::Open);
167 assert!(task.blocked_by.is_empty());
168 assert!(task.key.is_none());
169 assert!(task.started.is_none());
170 }
171
172 #[test]
173 fn test_priority_clamping() {
174 let task_low = Task::new("Low".to_string(), 0);
175 assert_eq!(task_low.priority, 1);
176
177 let task_high = Task::new("High".to_string(), 10);
178 assert_eq!(task_high.priority, 5);
179 }
180
181 #[test]
182 fn test_task_id_format() {
183 let task = Task::new("Test".to_string(), 1);
184 assert!(task.id.starts_with("task-"));
185 let parts: Vec<&str> = task.id.split('-').collect();
186 assert_eq!(parts.len(), 3);
187 }
188
189 #[test]
190 fn test_is_ready_open_no_blockers() {
191 let task = Task::new("Test".to_string(), 1);
192 assert!(task.is_ready(&[]));
193 }
194
195 #[test]
196 fn test_is_ready_with_open_blocker() {
197 let blocker = Task::new("Blocker".to_string(), 1);
198 let mut task = Task::new("Test".to_string(), 1);
199 task.blocked_by.push(blocker.id.clone());
200
201 assert!(!task.is_ready(std::slice::from_ref(&blocker)));
202 }
203
204 #[test]
205 fn test_is_ready_with_closed_blocker() {
206 let mut blocker = Task::new("Blocker".to_string(), 1);
207 blocker.status = TaskStatus::Closed;
208
209 let mut task = Task::new("Test".to_string(), 1);
210 task.blocked_by.push(blocker.id.clone());
211
212 assert!(task.is_ready(std::slice::from_ref(&blocker)));
213 }
214
215 #[test]
216 fn test_is_not_ready_when_not_open() {
217 let mut task = Task::new("Test".to_string(), 1);
218 task.status = TaskStatus::Closed;
219 assert!(!task.is_ready(&[]));
220
221 task.status = TaskStatus::InProgress;
222 assert!(!task.is_ready(&[]));
223
224 task.status = TaskStatus::Failed;
225 assert!(!task.is_ready(&[]));
226 }
227
228 #[test]
229 fn test_is_terminal() {
230 assert!(!TaskStatus::Open.is_terminal());
231 assert!(!TaskStatus::InProgress.is_terminal());
232 assert!(TaskStatus::Closed.is_terminal());
233 assert!(TaskStatus::Failed.is_terminal());
234 }
235
236 #[test]
237 fn test_with_key_sets_stable_key() {
238 let task = Task::new("Test".to_string(), 1).with_key(Some("spec:build".to_string()));
239 assert_eq!(task.key.as_deref(), Some("spec:build"));
240 }
241
242 #[test]
243 fn test_start_marks_task_in_progress() {
244 let mut task = Task::new("Test".to_string(), 1);
245 task.start();
246 assert_eq!(task.status, TaskStatus::InProgress);
247 assert!(task.started.is_some());
248 assert!(task.closed.is_none());
249 }
250
251 #[test]
252 fn test_reopen_resets_terminal_state() {
253 let mut task = Task::new("Test".to_string(), 1);
254 task.status = TaskStatus::Closed;
255 task.closed = Some(chrono::Utc::now().to_rfc3339());
256 task.reopen();
257 assert_eq!(task.status, TaskStatus::Open);
258 assert!(task.closed.is_none());
259 }
260}