1use std::path::Path;
2use std::time::Instant;
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum TaskStatus {
11 Open,
12 Claimed,
13 Planned,
14 Approved,
15 AwaitingReview,
16 Finished,
17 Cancelled,
18}
19
20impl std::fmt::Display for TaskStatus {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 match self {
23 TaskStatus::Open => write!(f, "open"),
24 TaskStatus::Claimed => write!(f, "claimed"),
25 TaskStatus::Planned => write!(f, "planned"),
26 TaskStatus::Approved => write!(f, "approved"),
27 TaskStatus::AwaitingReview => write!(f, "in_review"),
28 TaskStatus::Finished => write!(f, "finished"),
29 TaskStatus::Cancelled => write!(f, "cancelled"),
30 }
31 }
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Task {
37 pub id: String,
38 pub description: String,
39 pub status: TaskStatus,
40 pub posted_by: String,
41 pub assigned_to: Option<String>,
42 pub posted_at: DateTime<Utc>,
43 pub claimed_at: Option<DateTime<Utc>>,
44 pub plan: Option<String>,
45 pub approved_by: Option<String>,
46 pub approved_at: Option<DateTime<Utc>>,
47 pub updated_at: Option<DateTime<Utc>>,
48 pub notes: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub team: Option<String>,
53}
54
55pub struct LiveTask {
61 pub task: Task,
62 pub lease_start: Option<Instant>,
63}
64
65impl LiveTask {
66 pub fn new(task: Task) -> Self {
67 let lease_start = match task.status {
68 TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved => {
69 Some(Instant::now())
70 }
71 _ => None,
73 };
74 Self { task, lease_start }
75 }
76
77 pub fn renew_lease(&mut self) {
79 self.lease_start = Some(Instant::now());
80 self.task.updated_at = Some(Utc::now());
81 }
82
83 pub fn is_expired(&self, ttl_secs: u64) -> bool {
85 match self.lease_start {
86 Some(start) => start.elapsed().as_secs() >= ttl_secs,
87 None => false,
88 }
89 }
90
91 pub fn expire(&mut self) {
93 self.task.status = TaskStatus::Open;
94 self.task.assigned_to = None;
95 self.task.claimed_at = None;
96 self.task.plan = None;
97 self.task.approved_by = None;
98 self.task.approved_at = None;
99 self.task.notes = Some("lease expired — auto-released".to_owned());
100 self.lease_start = None;
101 }
102}
103
104pub fn load_tasks(path: &Path) -> Vec<Task> {
106 let contents = match std::fs::read_to_string(path) {
107 Ok(c) => c,
108 Err(_) => return Vec::new(),
109 };
110 contents
111 .lines()
112 .filter(|l| !l.trim().is_empty())
113 .filter_map(|l| match serde_json::from_str::<Task>(l) {
114 Ok(t) => Some(t),
115 Err(e) => {
116 eprintln!("[taskboard] corrupt line in {}: {e}", path.display());
117 None
118 }
119 })
120 .collect()
121}
122
123pub fn save_tasks(path: &Path, tasks: &[Task]) -> Result<(), String> {
125 let mut buf = String::new();
126 for task in tasks {
127 let line =
128 serde_json::to_string(task).map_err(|e| format!("serialize task {}: {e}", task.id))?;
129 buf.push_str(&line);
130 buf.push('\n');
131 }
132 std::fs::write(path, buf).map_err(|e| format!("write {}: {e}", path.display()))
133}
134
135pub fn next_id(tasks: &[Task]) -> String {
137 let max_num = tasks
138 .iter()
139 .filter_map(|t| t.id.strip_prefix("tb-"))
140 .filter_map(|s| s.parse::<u32>().ok())
141 .max()
142 .unwrap_or(0);
143 format!("tb-{:03}", max_num + 1)
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use std::time::Duration;
150
151 fn make_task(id: &str, status: TaskStatus) -> Task {
152 Task {
153 id: id.to_owned(),
154 description: "test task".to_owned(),
155 status,
156 posted_by: "alice".to_owned(),
157 assigned_to: None,
158 posted_at: Utc::now(),
159 claimed_at: None,
160 plan: None,
161 approved_by: None,
162 approved_at: None,
163 updated_at: None,
164 notes: None,
165 team: None,
166 }
167 }
168
169 #[test]
170 fn task_status_display() {
171 assert_eq!(TaskStatus::Open.to_string(), "open");
172 assert_eq!(TaskStatus::Claimed.to_string(), "claimed");
173 assert_eq!(TaskStatus::Planned.to_string(), "planned");
174 assert_eq!(TaskStatus::Approved.to_string(), "approved");
175 assert_eq!(TaskStatus::AwaitingReview.to_string(), "in_review");
176 assert_eq!(TaskStatus::Finished.to_string(), "finished");
177 assert_eq!(TaskStatus::Cancelled.to_string(), "cancelled");
178 }
179
180 #[test]
181 fn task_status_serde_round_trip() {
182 let task = make_task("tb-001", TaskStatus::Approved);
183 let json = serde_json::to_string(&task).unwrap();
184 let parsed: Task = serde_json::from_str(&json).unwrap();
185 assert_eq!(parsed.status, TaskStatus::Approved);
186 assert_eq!(parsed.id, "tb-001");
187 }
188
189 #[test]
190 fn live_task_lease_starts_for_claimed() {
191 let task = make_task("tb-001", TaskStatus::Claimed);
192 let live = LiveTask::new(task);
193 assert!(live.lease_start.is_some());
194 }
195
196 #[test]
197 fn live_task_no_lease_for_open() {
198 let task = make_task("tb-001", TaskStatus::Open);
199 let live = LiveTask::new(task);
200 assert!(live.lease_start.is_none());
201 }
202
203 #[test]
204 fn live_task_no_lease_for_finished() {
205 let task = make_task("tb-001", TaskStatus::Finished);
206 let live = LiveTask::new(task);
207 assert!(live.lease_start.is_none());
208 }
209
210 #[test]
211 fn live_task_no_lease_for_awaiting_review() {
212 let task = make_task("tb-001", TaskStatus::AwaitingReview);
213 let live = LiveTask::new(task);
214 assert!(live.lease_start.is_none());
215 }
216
217 #[test]
218 fn live_task_is_expired() {
219 let task = make_task("tb-001", TaskStatus::Claimed);
220 let mut live = LiveTask::new(task);
221 live.lease_start = Some(Instant::now() - Duration::from_secs(700));
223 assert!(live.is_expired(600));
224 assert!(!live.is_expired(900));
225 }
226
227 #[test]
228 fn live_task_renew_lease() {
229 let task = make_task("tb-001", TaskStatus::Claimed);
230 let mut live = LiveTask::new(task);
231 live.lease_start = Some(Instant::now() - Duration::from_secs(500));
232 live.renew_lease();
233 assert!(!live.is_expired(600));
234 assert!(live.task.updated_at.is_some());
235 }
236
237 #[test]
238 fn live_task_expire_resets() {
239 let mut task = make_task("tb-001", TaskStatus::Approved);
240 task.assigned_to = Some("bob".to_owned());
241 task.plan = Some("do the thing".to_owned());
242 let mut live = LiveTask::new(task);
243 live.expire();
244 assert_eq!(live.task.status, TaskStatus::Open);
245 assert!(live.task.assigned_to.is_none());
246 assert!(live.task.plan.is_none());
247 assert!(live.lease_start.is_none());
248 }
249
250 #[test]
251 fn next_id_empty() {
252 assert_eq!(next_id(&[]), "tb-001");
253 }
254
255 #[test]
256 fn next_id_increments() {
257 let tasks = vec![
258 make_task("tb-001", TaskStatus::Open),
259 make_task("tb-005", TaskStatus::Finished),
260 make_task("tb-003", TaskStatus::Claimed),
261 ];
262 assert_eq!(next_id(&tasks), "tb-006");
263 }
264
265 #[test]
266 fn ndjson_round_trip() {
267 let tmp = tempfile::NamedTempFile::new().unwrap();
268 let path = tmp.path();
269 let tasks = vec![
270 make_task("tb-001", TaskStatus::Open),
271 make_task("tb-002", TaskStatus::Claimed),
272 ];
273 save_tasks(path, &tasks).unwrap();
274 let loaded = load_tasks(path);
275 assert_eq!(loaded.len(), 2);
276 assert_eq!(loaded[0].id, "tb-001");
277 assert_eq!(loaded[1].id, "tb-002");
278 assert_eq!(loaded[1].status, TaskStatus::Claimed);
279 }
280
281 #[test]
282 fn load_tasks_missing_file() {
283 let tasks = load_tasks(Path::new("/nonexistent/path.ndjson"));
284 assert!(tasks.is_empty());
285 }
286
287 #[test]
288 fn load_tasks_skips_corrupt_lines() {
289 let tmp = tempfile::NamedTempFile::new().unwrap();
290 let path = tmp.path();
291 let task = make_task("tb-001", TaskStatus::Open);
292 let mut content = serde_json::to_string(&task).unwrap();
293 content.push('\n');
294 content.push_str("this is not json\n");
295 let task2 = make_task("tb-002", TaskStatus::Finished);
296 content.push_str(&serde_json::to_string(&task2).unwrap());
297 content.push('\n');
298 std::fs::write(path, content).unwrap();
299 let loaded = load_tasks(path);
300 assert_eq!(loaded.len(), 2);
301 }
302
303 #[test]
304 fn task_status_all_variants_serialize() {
305 for status in [
306 TaskStatus::Open,
307 TaskStatus::Claimed,
308 TaskStatus::Planned,
309 TaskStatus::Approved,
310 TaskStatus::AwaitingReview,
311 TaskStatus::Finished,
312 TaskStatus::Cancelled,
313 ] {
314 let task = make_task("tb-001", status);
315 let json = serde_json::to_string(&task).unwrap();
316 let parsed: Task = serde_json::from_str(&json).unwrap();
317 assert_eq!(parsed.status, status);
318 }
319 }
320
321 #[test]
324 fn is_expired_at_exact_ttl_boundary() {
325 let task = make_task("tb-001", TaskStatus::Claimed);
326 let mut live = LiveTask::new(task);
327 live.lease_start = Some(Instant::now() - Duration::from_secs(600));
329 assert!(
330 live.is_expired(600),
331 "task must expire when elapsed == ttl (>= semantics)"
332 );
333 live.lease_start = Some(Instant::now() - Duration::from_secs(599));
335 assert!(
336 !live.is_expired(600),
337 "task must NOT expire when elapsed < ttl"
338 );
339 }
340
341 #[test]
344 fn finished_task_with_stale_lease_not_expired() {
345 let task = make_task("tb-001", TaskStatus::Finished);
346 let mut live = LiveTask::new(task);
347 live.lease_start = Some(Instant::now() - Duration::from_secs(9999));
350 assert!(live.is_expired(600));
354 assert_eq!(live.task.status, TaskStatus::Finished);
358 }
359}