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