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