Skip to main content

room_plugin_taskboard/
task.rs

1use std::path::Path;
2use std::time::Instant;
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7/// Status of a task on the board.
8#[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/// A task on the board, persisted as NDJSON.
35#[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    /// Optional team restriction — only members of this team can claim or be
50    /// assigned to the task. `None` means unrestricted.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub team: Option<String>,
53}
54
55/// In-memory task with a lease timestamp for TTL tracking.
56///
57/// The `lease_start` field is `Instant`-based (monotonic) and is NOT
58/// serialized — on load from disk, it is set to `Instant::now()` for
59/// claimed/planned/approved tasks.
60pub 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            // AwaitingReview has no lease (indefinite — paused during review).
72            _ => None,
73        };
74        Self { task, lease_start }
75    }
76
77    /// Renew the lease timer (called on claim/plan/update).
78    pub fn renew_lease(&mut self) {
79        self.lease_start = Some(Instant::now());
80        self.task.updated_at = Some(Utc::now());
81    }
82
83    /// Check if the lease has expired.
84    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    /// Auto-release an expired task back to open status.
92    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
104/// Load tasks from an NDJSON file. Returns empty vec if the file does not exist.
105pub 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
123/// Write all tasks to an NDJSON file (full rewrite).
124pub 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
135/// Generate the next task ID from the current list.
136pub 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        // Force lease to the past.
222        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    /// Regression: `is_expired` uses `>=`, so a task whose elapsed time equals
322    /// exactly the TTL must be considered expired — not "still alive by 1 tick".
323    #[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        // Set lease_start so elapsed is exactly 600 seconds.
328        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        // One second less: still alive.
334        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    /// Finished tasks must never be treated as expired even if they happen to
342    /// have a `lease_start` set (e.g. from a prior Claimed state before finish).
343    #[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        // Finished tasks get `lease_start = None` from `LiveTask::new`, but
348        // manually set one to simulate a code path that sets it before finishing.
349        live.lease_start = Some(Instant::now() - Duration::from_secs(9999));
350        // is_expired only checks elapsed vs TTL — it returns true.
351        // But sweep_expired guards on status, so the task won't be touched.
352        // Verify is_expired reports true (it's status-unaware)...
353        assert!(live.is_expired(600));
354        // ...but expire() would reset to Open. The real protection is in
355        // sweep_expired's status filter. Verify that Finished status is
356        // preserved if we DON'T call expire():
357        assert_eq!(live.task.status, TaskStatus::Finished);
358    }
359}