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