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