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    #[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/// A task on the board, persisted as NDJSON.
39#[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    /// Optional team restriction — only members of this team can claim or be
54    /// assigned to the task. `None` means unrestricted.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub team: Option<String>,
57    /// The user who claimed the review (ReviewClaimed state).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub reviewer: Option<String>,
60}
61
62/// In-memory task with a lease timestamp for TTL tracking.
63///
64/// The `lease_start` field is `Instant`-based (monotonic) and is NOT
65/// serialized — on load from disk, it is set to `Instant::now()` for
66/// claimed/planned/approved tasks.
67pub 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            // AwaitingReview has no lease (indefinite — paused during review).
80            _ => None,
81        };
82        Self { task, lease_start }
83    }
84
85    /// Renew the lease timer (called on claim/plan/update).
86    pub fn renew_lease(&mut self) {
87        self.lease_start = Some(Instant::now());
88        self.task.updated_at = Some(Utc::now());
89    }
90
91    /// Check if the lease has expired.
92    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    /// Auto-release an expired task back to open status.
100    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
112/// Load tasks from an NDJSON file. Returns empty vec if the file does not exist.
113pub 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
131/// Write all tasks to an NDJSON file (full rewrite).
132pub 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
143/// Generate the next task ID from the current list.
144pub 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        // Force lease to the past.
232        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    /// Regression: `is_expired` uses `>=`, so a task whose elapsed time equals
333    /// exactly the TTL must be considered expired — not "still alive by 1 tick".
334    #[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        // Set lease_start so elapsed is exactly 600 seconds.
339        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        // One second less: still alive.
345        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    /// Finished tasks must never be treated as expired even if they happen to
353    /// have a `lease_start` set (e.g. from a prior Claimed state before finish).
354    #[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        // Finished tasks get `lease_start = None` from `LiveTask::new`, but
359        // manually set one to simulate a code path that sets it before finishing.
360        live.lease_start = Some(Instant::now() - Duration::from_secs(9999));
361        // is_expired only checks elapsed vs TTL — it returns true.
362        // But sweep_expired guards on status, so the task won't be touched.
363        // Verify is_expired reports true (it's status-unaware)...
364        assert!(live.is_expired(600));
365        // ...but expire() would reset to Open. The real protection is in
366        // sweep_expired's status filter. Verify that Finished status is
367        // preserved if we DON'T call expire():
368        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    /// Backwards compat: "approved" in NDJSON deserializes as InProgress.
386    #[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    /// Tasks without the reviewer field deserialize correctly (backwards compat).
394    #[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}