Skip to main content

room_cli/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    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/// A task on the board, persisted as NDJSON.
33#[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
49/// In-memory task with a lease timestamp for TTL tracking.
50///
51/// The `lease_start` field is `Instant`-based (monotonic) and is NOT
52/// serialized — on load from disk, it is set to `Instant::now()` for
53/// claimed/planned/approved tasks.
54pub 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    /// Renew the lease timer (called on claim/plan/update).
71    pub fn renew_lease(&mut self) {
72        self.lease_start = Some(Instant::now());
73        self.task.updated_at = Some(Utc::now());
74    }
75
76    /// Check if the lease has expired.
77    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    /// Auto-release an expired task back to open status.
85    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
97/// Load tasks from an NDJSON file. Returns empty vec if the file does not exist.
98pub 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
116/// Write all tasks to an NDJSON file (full rewrite).
117pub 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
128/// Generate the next task ID from the current list.
129pub 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        // Force lease to the past.
206        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}