1pub mod task;
2
3use std::path::PathBuf;
4use std::sync::{Arc, Mutex};
5use std::time::Duration;
6
7use task::{next_id, LiveTask, Task, TaskStatus};
8
9use crate::plugin::{
10 BoxFuture, CommandContext, CommandInfo, ParamSchema, ParamType, Plugin, PluginResult,
11};
12
13const DEFAULT_LEASE_TTL_SECS: u64 = 600;
15
16pub struct TaskboardPlugin {
23 board: Arc<Mutex<Vec<LiveTask>>>,
25 storage_path: PathBuf,
27 lease_ttl: Duration,
29}
30
31impl TaskboardPlugin {
32 pub fn new(storage_path: PathBuf, lease_ttl_secs: Option<u64>) -> Self {
34 let ttl = lease_ttl_secs.unwrap_or(DEFAULT_LEASE_TTL_SECS);
35 let tasks = task::load_tasks(&storage_path);
36 let live_tasks: Vec<LiveTask> = tasks.into_iter().map(LiveTask::new).collect();
37 Self {
38 board: Arc::new(Mutex::new(live_tasks)),
39 storage_path,
40 lease_ttl: Duration::from_secs(ttl),
41 }
42 }
43
44 pub fn taskboard_path_from_chat(chat_path: &std::path::Path) -> PathBuf {
46 chat_path.with_extension("taskboard")
47 }
48
49 pub fn default_commands() -> Vec<CommandInfo> {
52 vec![CommandInfo {
53 name: "taskboard".to_owned(),
54 description:
55 "Manage task lifecycle — post, list, show, claim, plan, approve, update, release, finish"
56 .to_owned(),
57 usage: "/taskboard <action> [args...]".to_owned(),
58 params: vec![
59 ParamSchema {
60 name: "action".to_owned(),
61 param_type: ParamType::Choice(vec![
62 "post".to_owned(),
63 "list".to_owned(),
64 "show".to_owned(),
65 "claim".to_owned(),
66 "plan".to_owned(),
67 "approve".to_owned(),
68 "update".to_owned(),
69 "release".to_owned(),
70 "finish".to_owned(),
71 ]),
72 required: true,
73 description: "Subcommand".to_owned(),
74 },
75 ParamSchema {
76 name: "args".to_owned(),
77 param_type: ParamType::Text,
78 required: false,
79 description: "Task ID or description".to_owned(),
80 },
81 ],
82 }]
83 }
84
85 fn sweep_expired(&self) -> Vec<String> {
87 let mut board = self.board.lock().unwrap();
88 let ttl = self.lease_ttl.as_secs();
89 let mut expired_ids = Vec::new();
90 for lt in board.iter_mut() {
91 if lt.is_expired(ttl)
92 && matches!(
93 lt.task.status,
94 TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
95 )
96 {
97 let prev_assignee = lt.task.assigned_to.clone().unwrap_or_default();
98 expired_ids.push(format!("{} (was {})", lt.task.id, prev_assignee));
99 lt.expire();
100 }
101 }
102 if !expired_ids.is_empty() {
103 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
104 let _ = task::save_tasks(&self.storage_path, &tasks);
105 }
106 expired_ids
107 }
108
109 fn handle_post(&self, ctx: &CommandContext) -> (String, bool) {
110 let description = ctx.params[1..].join(" ");
111 if description.is_empty() {
112 return ("usage: /taskboard post <description>".to_owned(), false);
113 }
114 let mut board = self.board.lock().unwrap();
115 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
116 let id = next_id(&tasks);
117 let task = Task {
118 id: id.clone(),
119 description: description.clone(),
120 status: TaskStatus::Open,
121 posted_by: ctx.sender.clone(),
122 assigned_to: None,
123 posted_at: chrono::Utc::now(),
124 claimed_at: None,
125 plan: None,
126 approved_by: None,
127 approved_at: None,
128 updated_at: None,
129 notes: None,
130 };
131 board.push(LiveTask::new(task.clone()));
132 let all_tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
133 let _ = task::save_tasks(&self.storage_path, &all_tasks);
134 (format!("task {id} posted: {description}"), true)
135 }
136
137 fn handle_list(&self) -> String {
138 let expired = self.sweep_expired();
139 let board = self.board.lock().unwrap();
140 if board.is_empty() {
141 return "taskboard is empty".to_owned();
142 }
143 let mut lines = Vec::new();
144 if !expired.is_empty() {
145 lines.push(format!("expired: {}", expired.join(", ")));
146 }
147 lines.push(format!(
148 "{:<8} {:<10} {:<12} {:<12} {}",
149 "ID", "STATUS", "ASSIGNEE", "ELAPSED", "DESCRIPTION"
150 ));
151 for lt in board.iter() {
152 let elapsed = match lt.lease_start {
153 Some(start) => {
154 let secs = start.elapsed().as_secs();
155 if secs < 60 {
156 format!("{secs}s")
157 } else {
158 format!("{}m", secs / 60)
159 }
160 }
161 None => "-".to_owned(),
162 };
163 let assignee = lt.task.assigned_to.as_deref().unwrap_or("-").to_owned();
164 let desc = if lt.task.description.len() > 40 {
165 format!("{}...", <.task.description[..37])
166 } else {
167 lt.task.description.clone()
168 };
169 lines.push(format!(
170 "{:<8} {:<10} {:<12} {:<12} {}",
171 lt.task.id, lt.task.status, assignee, elapsed, desc
172 ));
173 }
174 lines.join("\n")
175 }
176
177 fn handle_claim(&self, ctx: &CommandContext) -> (String, bool) {
178 let task_id = match ctx.params.get(1) {
179 Some(id) => id,
180 None => return ("usage: /taskboard claim <task-id>".to_owned(), false),
181 };
182 self.sweep_expired();
183 let mut board = self.board.lock().unwrap();
184 let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
185 Some(lt) => lt,
186 None => return (format!("task {task_id} not found"), false),
187 };
188 if lt.task.status != TaskStatus::Open {
189 return (
190 format!(
191 "task {task_id} is {} (must be open to claim)",
192 lt.task.status
193 ),
194 false,
195 );
196 }
197 lt.task.status = TaskStatus::Claimed;
198 lt.task.assigned_to = Some(ctx.sender.clone());
199 lt.task.claimed_at = Some(chrono::Utc::now());
200 lt.renew_lease();
201 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
202 let _ = task::save_tasks(&self.storage_path, &tasks);
203 (
204 format!(
205 "task {task_id} claimed by {} — submit plan with /taskboard plan {task_id} <plan>",
206 ctx.sender
207 ),
208 true,
209 )
210 }
211
212 fn handle_plan(&self, ctx: &CommandContext) -> (String, bool) {
213 let task_id = match ctx.params.get(1) {
214 Some(id) => id,
215 None => {
216 return (
217 "usage: /taskboard plan <task-id> <plan text>".to_owned(),
218 false,
219 )
220 }
221 };
222 let plan_text = ctx.params[2..].join(" ");
223 if plan_text.is_empty() {
224 return (
225 "usage: /taskboard plan <task-id> <plan text>".to_owned(),
226 false,
227 );
228 }
229 self.sweep_expired();
230 let mut board = self.board.lock().unwrap();
231 let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
232 Some(lt) => lt,
233 None => return (format!("task {task_id} not found"), false),
234 };
235 if !matches!(lt.task.status, TaskStatus::Claimed | TaskStatus::Planned) {
236 return (
237 format!(
238 "task {task_id} is {} (must be claimed to submit plan)",
239 lt.task.status
240 ),
241 false,
242 );
243 }
244 if lt.task.assigned_to.as_deref() != Some(&ctx.sender) {
245 return (format!("task {task_id} is assigned to someone else"), false);
246 }
247 lt.task.status = TaskStatus::Planned;
248 lt.task.plan = Some(plan_text.clone());
249 lt.renew_lease();
250 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
251 let _ = task::save_tasks(&self.storage_path, &tasks);
252 (
253 format!("task {task_id} plan submitted — awaiting approval\nplan: {plan_text}"),
254 true,
255 )
256 }
257
258 fn handle_approve(&self, ctx: &CommandContext) -> (String, bool) {
259 let task_id = match ctx.params.get(1) {
260 Some(id) => id,
261 None => return ("usage: /taskboard approve <task-id>".to_owned(), false),
262 };
263 self.sweep_expired();
264 let mut board = self.board.lock().unwrap();
265 let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
266 Some(lt) => lt,
267 None => return (format!("task {task_id} not found"), false),
268 };
269 if lt.task.status != TaskStatus::Planned {
270 return (
271 format!(
272 "task {task_id} is {} (must be planned to approve)",
273 lt.task.status
274 ),
275 false,
276 );
277 }
278 let is_poster = lt.task.posted_by == ctx.sender;
280 let is_host = ctx.metadata.host.as_deref() == Some(&ctx.sender);
281 if !is_poster && !is_host {
282 return ("only the task poster or host can approve".to_owned(), false);
283 }
284 lt.task.status = TaskStatus::Approved;
285 lt.task.approved_by = Some(ctx.sender.clone());
286 lt.task.approved_at = Some(chrono::Utc::now());
287 lt.renew_lease();
288 let assignee = lt
289 .task
290 .assigned_to
291 .as_deref()
292 .unwrap_or("unknown")
293 .to_owned();
294 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
295 let _ = task::save_tasks(&self.storage_path, &tasks);
296 (
297 format!(
298 "task {task_id} approved by {} — @{assignee} proceed with implementation",
299 ctx.sender
300 ),
301 true,
302 )
303 }
304
305 fn handle_update(&self, ctx: &CommandContext) -> (String, bool) {
306 let task_id = match ctx.params.get(1) {
307 Some(id) => id,
308 None => {
309 return (
310 "usage: /taskboard update <task-id> [notes]".to_owned(),
311 false,
312 )
313 }
314 };
315 let notes = if ctx.params.len() > 2 {
316 Some(ctx.params[2..].join(" "))
317 } else {
318 None
319 };
320 self.sweep_expired();
321 let mut board = self.board.lock().unwrap();
322 let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
323 Some(lt) => lt,
324 None => return (format!("task {task_id} not found"), false),
325 };
326 if !matches!(
327 lt.task.status,
328 TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
329 ) {
330 return (
331 format!(
332 "task {task_id} is {} (must be claimed/planned/approved to update)",
333 lt.task.status
334 ),
335 false,
336 );
337 }
338 if lt.task.assigned_to.as_deref() != Some(&ctx.sender) {
339 return (format!("task {task_id} is assigned to someone else"), false);
340 }
341 let mut warning = String::new();
342 if lt.task.status != TaskStatus::Approved {
343 warning = format!(" [warning: task is {} — not yet approved]", lt.task.status);
344 }
345 if let Some(n) = notes {
346 lt.task.notes = Some(n);
347 }
348 lt.renew_lease();
349 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
350 let _ = task::save_tasks(&self.storage_path, &tasks);
351 (
352 format!("task {task_id} updated, lease renewed{warning}"),
353 true,
354 )
355 }
356
357 fn handle_release(&self, ctx: &CommandContext) -> (String, bool) {
358 let task_id = match ctx.params.get(1) {
359 Some(id) => id,
360 None => return ("usage: /taskboard release <task-id>".to_owned(), false),
361 };
362 self.sweep_expired();
363 let mut board = self.board.lock().unwrap();
364 let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
365 Some(lt) => lt,
366 None => return (format!("task {task_id} not found"), false),
367 };
368 if !matches!(
369 lt.task.status,
370 TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
371 ) {
372 return (
373 format!(
374 "task {task_id} is {} (must be claimed/planned/approved to release)",
375 lt.task.status
376 ),
377 false,
378 );
379 }
380 if lt.task.assigned_to.as_deref() != Some(&ctx.sender)
382 && ctx.metadata.host.as_deref() != Some(&ctx.sender)
383 {
384 return (
385 format!("task {task_id} can only be released by the assignee or host"),
386 false,
387 );
388 }
389 let prev = lt.task.assigned_to.clone().unwrap_or_default();
390 lt.task.status = TaskStatus::Open;
391 lt.task.assigned_to = None;
392 lt.task.claimed_at = None;
393 lt.task.plan = None;
394 lt.task.approved_by = None;
395 lt.task.approved_at = None;
396 lt.lease_start = None;
397 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
398 let _ = task::save_tasks(&self.storage_path, &tasks);
399 (
400 format!("task {task_id} released by {prev} — back to open"),
401 true,
402 )
403 }
404
405 fn handle_show(&self, ctx: &CommandContext) -> String {
406 let task_id = match ctx.params.get(1) {
407 Some(id) => id,
408 None => return "usage: /taskboard show <task-id>".to_owned(),
409 };
410 self.sweep_expired();
411 let board = self.board.lock().unwrap();
412 let lt = match board.iter().find(|lt| lt.task.id == *task_id) {
413 Some(lt) => lt,
414 None => return format!("task {task_id} not found"),
415 };
416 let t = <.task;
417 let assignee = t.assigned_to.as_deref().unwrap_or("-");
418 let plan = t.plan.as_deref().unwrap_or("-");
419 let approved_by = t.approved_by.as_deref().unwrap_or("-");
420 let notes = t.notes.as_deref().unwrap_or("-");
421 let elapsed = match lt.lease_start {
422 Some(start) => {
423 let secs = start.elapsed().as_secs();
424 if secs < 60 {
425 format!("{secs}s")
426 } else {
427 format!("{}m", secs / 60)
428 }
429 }
430 None => "-".to_owned(),
431 };
432 format!(
433 "task {}\n status: {}\n description: {}\n posted by: {}\n assigned to: {}\n plan: {}\n approved by: {}\n notes: {}\n lease: {}",
434 t.id, t.status, t.description, t.posted_by, assignee, plan, approved_by, notes, elapsed
435 )
436 }
437
438 fn handle_finish(&self, ctx: &CommandContext) -> (String, bool) {
439 let task_id = match ctx.params.get(1) {
440 Some(id) => id,
441 None => return ("usage: /taskboard finish <task-id>".to_owned(), false),
442 };
443 self.sweep_expired();
444 let mut board = self.board.lock().unwrap();
445 let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
446 Some(lt) => lt,
447 None => return (format!("task {task_id} not found"), false),
448 };
449 if !matches!(
450 lt.task.status,
451 TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
452 ) {
453 return (
454 format!(
455 "task {task_id} is {} (must be claimed/planned/approved to finish)",
456 lt.task.status
457 ),
458 false,
459 );
460 }
461 if lt.task.assigned_to.as_deref() != Some(&ctx.sender) {
462 return (
463 format!("task {task_id} can only be finished by the assignee"),
464 false,
465 );
466 }
467 lt.task.status = TaskStatus::Finished;
468 lt.lease_start = None;
469 lt.task.updated_at = Some(chrono::Utc::now());
470 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
471 let _ = task::save_tasks(&self.storage_path, &tasks);
472 (format!("task {task_id} finished by {}", ctx.sender), true)
473 }
474}
475
476impl Plugin for TaskboardPlugin {
477 fn name(&self) -> &str {
478 "taskboard"
479 }
480
481 fn commands(&self) -> Vec<CommandInfo> {
482 Self::default_commands()
483 }
484
485 fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
486 Box::pin(async move {
487 let action = ctx.params.first().map(String::as_str).unwrap_or("");
488 let (result, broadcast) = match action {
489 "post" => self.handle_post(&ctx),
490 "list" => (self.handle_list(), false),
491 "claim" => self.handle_claim(&ctx),
492 "plan" => self.handle_plan(&ctx),
493 "approve" => self.handle_approve(&ctx),
494 "show" => (self.handle_show(&ctx), false),
495 "update" => self.handle_update(&ctx),
496 "release" => self.handle_release(&ctx),
497 "finish" => self.handle_finish(&ctx),
498 "" => ("usage: /taskboard <post|list|show|claim|plan|approve|update|release|finish> [args...]".to_owned(), false),
499 other => (format!("unknown action: {other}. use: post, list, show, claim, plan, approve, update, release, finish"), false),
500 };
501 if broadcast {
502 Ok(PluginResult::Broadcast(result))
503 } else {
504 Ok(PluginResult::Reply(result))
505 }
506 })
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 fn make_plugin() -> (TaskboardPlugin, tempfile::NamedTempFile) {
515 let tmp = tempfile::NamedTempFile::new().unwrap();
516 let plugin = TaskboardPlugin::new(tmp.path().to_path_buf(), Some(600));
517 (plugin, tmp)
518 }
519
520 #[test]
521 fn plugin_name() {
522 let (plugin, _tmp) = make_plugin();
523 assert_eq!(plugin.name(), "taskboard");
524 }
525
526 #[test]
527 fn plugin_commands() {
528 let (plugin, _tmp) = make_plugin();
529 let cmds = plugin.commands();
530 assert_eq!(cmds.len(), 1);
531 assert_eq!(cmds[0].name, "taskboard");
532 assert_eq!(cmds[0].params.len(), 2);
533 if let ParamType::Choice(ref choices) = cmds[0].params[0].param_type {
534 assert!(choices.contains(&"post".to_owned()));
535 assert!(choices.contains(&"approve".to_owned()));
536 assert_eq!(choices.len(), 9);
537 } else {
538 panic!("expected Choice param type");
539 }
540 }
541
542 #[test]
543 fn handle_post_creates_task() {
544 let (plugin, _tmp) = make_plugin();
545 let ctx = test_ctx("alice", &["post", "fix the bug"]);
546 let (result, broadcast) = plugin.handle_post(&ctx);
547 assert!(result.contains("tb-001"));
548 assert!(result.contains("fix the bug"));
549 assert!(broadcast);
550 let board = plugin.board.lock().unwrap();
551 assert_eq!(board.len(), 1);
552 assert_eq!(board[0].task.status, TaskStatus::Open);
553 }
554
555 #[test]
556 fn handle_post_empty_description() {
557 let (plugin, _tmp) = make_plugin();
558 let ctx = test_ctx("alice", &["post"]);
559 let (result, broadcast) = plugin.handle_post(&ctx);
560 assert!(result.contains("usage"));
561 assert!(!broadcast);
562 }
563
564 #[test]
565 fn handle_claim_and_plan_flow() {
566 let (plugin, _tmp) = make_plugin();
567 plugin.handle_post(&test_ctx("ba", &["post", "implement feature"]));
569 let (result, broadcast) = plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
571 assert!(result.contains("claimed by agent"));
572 assert!(broadcast);
573 let (result, broadcast) = plugin.handle_plan(&test_ctx(
575 "agent",
576 &["plan", "tb-001", "add struct, write tests"],
577 ));
578 assert!(result.contains("plan submitted"));
579 assert!(result.contains("plan: add struct, write tests"));
580 assert!(broadcast);
581 let board = plugin.board.lock().unwrap();
582 assert_eq!(board[0].task.status, TaskStatus::Planned);
583 assert_eq!(
584 board[0].task.plan.as_deref(),
585 Some("add struct, write tests")
586 );
587 }
588
589 #[test]
590 fn handle_approve_by_poster() {
591 let (plugin, _tmp) = make_plugin();
592 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
593 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
594 plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "my plan"]));
595 let (result, broadcast) =
597 plugin.handle_approve(&test_ctx_with_host("ba", &["approve", "tb-001"], None));
598 assert!(result.contains("approved"));
599 assert!(broadcast);
600 let board = plugin.board.lock().unwrap();
601 assert_eq!(board[0].task.status, TaskStatus::Approved);
602 }
603
604 #[test]
605 fn handle_approve_by_host() {
606 let (plugin, _tmp) = make_plugin();
607 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
608 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
609 plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "my plan"]));
610 let (result, broadcast) = plugin.handle_approve(&test_ctx_with_host(
612 "joao",
613 &["approve", "tb-001"],
614 Some("joao"),
615 ));
616 assert!(result.contains("approved"));
617 assert!(broadcast);
618 }
619
620 #[test]
621 fn handle_approve_rejected_for_non_poster_non_host() {
622 let (plugin, _tmp) = make_plugin();
623 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
624 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
625 plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "my plan"]));
626 let (result, broadcast) = plugin.handle_approve(&test_ctx_with_host(
628 "random",
629 &["approve", "tb-001"],
630 Some("joao"),
631 ));
632 assert!(result.contains("only the task poster or host"));
633 assert!(!broadcast);
634 }
635
636 #[test]
637 fn handle_update_renews_lease() {
638 let (plugin, _tmp) = make_plugin();
639 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
640 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
641 let (result, broadcast) =
642 plugin.handle_update(&test_ctx("agent", &["update", "tb-001", "progress note"]));
643 assert!(result.contains("lease renewed"));
644 assert!(result.contains("warning")); assert!(broadcast);
646 let board = plugin.board.lock().unwrap();
647 assert_eq!(board[0].task.notes.as_deref(), Some("progress note"));
648 }
649
650 #[test]
651 fn handle_update_no_warning_when_approved() {
652 let (plugin, _tmp) = make_plugin();
653 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
654 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
655 plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "plan"]));
656 plugin.handle_approve(&test_ctx_with_host(
657 "ba",
658 &["approve", "tb-001"],
659 Some("ba"),
660 ));
661 let (result, broadcast) = plugin.handle_update(&test_ctx("agent", &["update", "tb-001"]));
662 assert!(result.contains("lease renewed"));
663 assert!(!result.contains("warning"));
664 assert!(broadcast);
665 }
666
667 #[test]
668 fn handle_release_back_to_open() {
669 let (plugin, _tmp) = make_plugin();
670 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
671 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
672 let (result, broadcast) = plugin.handle_release(&test_ctx("agent", &["release", "tb-001"]));
673 assert!(result.contains("released"));
674 assert!(broadcast);
675 let board = plugin.board.lock().unwrap();
676 assert_eq!(board[0].task.status, TaskStatus::Open);
677 assert!(board[0].task.assigned_to.is_none());
678 }
679
680 #[test]
681 fn handle_finish() {
682 let (plugin, _tmp) = make_plugin();
683 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
684 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
685 let (result, broadcast) = plugin.handle_finish(&test_ctx("agent", &["finish", "tb-001"]));
686 assert!(result.contains("finished"));
687 assert!(broadcast);
688 let board = plugin.board.lock().unwrap();
689 assert_eq!(board[0].task.status, TaskStatus::Finished);
690 }
691
692 #[test]
693 fn handle_claim_wrong_status() {
694 let (plugin, _tmp) = make_plugin();
695 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
696 plugin.handle_claim(&test_ctx("a", &["claim", "tb-001"]));
697 let (result, broadcast) = plugin.handle_claim(&test_ctx("b", &["claim", "tb-001"]));
698 assert!(result.contains("must be open"));
699 assert!(!broadcast);
700 }
701
702 #[test]
703 fn handle_plan_wrong_user() {
704 let (plugin, _tmp) = make_plugin();
705 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
706 plugin.handle_claim(&test_ctx("agent-a", &["claim", "tb-001"]));
707 let (result, broadcast) =
708 plugin.handle_plan(&test_ctx("agent-b", &["plan", "tb-001", "my plan"]));
709 assert!(result.contains("assigned to someone else"));
710 assert!(!broadcast);
711 }
712
713 #[test]
714 fn handle_list_shows_tasks() {
715 let (plugin, _tmp) = make_plugin();
716 plugin.handle_post(&test_ctx("ba", &["post", "first task"]));
717 plugin.handle_post(&test_ctx("ba", &["post", "second task"]));
718 let result = plugin.handle_list();
719 assert!(result.contains("tb-001"));
720 assert!(result.contains("tb-002"));
721 assert!(result.contains("first task"));
722 }
723
724 #[test]
725 fn handle_list_empty() {
726 let (plugin, _tmp) = make_plugin();
727 let result = plugin.handle_list();
728 assert_eq!(result, "taskboard is empty");
729 }
730
731 #[test]
732 fn handle_show_displays_full_detail() {
733 let (plugin, _tmp) = make_plugin();
734 plugin.handle_post(&test_ctx("ba", &["post", "build the feature"]));
735 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
736 plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "add struct, tests"]));
737 let result = plugin.handle_show(&test_ctx("anyone", &["show", "tb-001"]));
738 assert!(result.contains("tb-001"));
739 assert!(result.contains("planned"));
740 assert!(result.contains("build the feature"));
741 assert!(result.contains("agent"));
742 assert!(result.contains("add struct, tests"));
743 assert!(result.contains("ba")); }
745
746 #[test]
747 fn handle_show_not_found() {
748 let (plugin, _tmp) = make_plugin();
749 let result = plugin.handle_show(&test_ctx("a", &["show", "tb-999"]));
750 assert!(result.contains("not found"));
751 }
752
753 #[test]
754 fn handle_show_no_args() {
755 let (plugin, _tmp) = make_plugin();
756 let result = plugin.handle_show(&test_ctx("a", &["show"]));
757 assert!(result.contains("usage"));
758 }
759
760 #[test]
761 fn handle_not_found() {
762 let (plugin, _tmp) = make_plugin();
763 let (result, broadcast) = plugin.handle_claim(&test_ctx("a", &["claim", "tb-999"]));
764 assert!(result.contains("not found"));
765 assert!(!broadcast);
766 }
767
768 #[test]
769 fn persistence_survives_reload() {
770 let tmp = tempfile::NamedTempFile::new().unwrap();
771 let path = tmp.path().to_path_buf();
772 {
773 let plugin = TaskboardPlugin::new(path.clone(), Some(600));
774 plugin.handle_post(&test_ctx("ba", &["post", "persistent task"]));
775 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
776 }
777 let plugin2 = TaskboardPlugin::new(path, Some(600));
779 let board = plugin2.board.lock().unwrap();
780 assert_eq!(board.len(), 1);
781 assert_eq!(board[0].task.id, "tb-001");
782 assert_eq!(board[0].task.status, TaskStatus::Claimed);
783 }
784
785 #[test]
786 fn lease_expiry_on_list() {
787 let (plugin, _tmp) = make_plugin();
788 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
789 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
790 {
792 let mut board = plugin.board.lock().unwrap();
793 board[0].lease_start =
794 Some(std::time::Instant::now() - std::time::Duration::from_secs(700));
795 }
796 let result = plugin.handle_list();
797 assert!(result.contains("expired"));
798 let board = plugin.board.lock().unwrap();
799 assert_eq!(board[0].task.status, TaskStatus::Open);
800 }
801
802 #[test]
803 fn full_lifecycle() {
804 let (plugin, _tmp) = make_plugin();
805 plugin.handle_post(&test_ctx("ba", &["post", "implement #42"]));
807 plugin.handle_claim(&test_ctx("saphire", &["claim", "tb-001"]));
808 plugin.handle_plan(&test_ctx(
809 "saphire",
810 &["plan", "tb-001", "add Foo, write tests"],
811 ));
812 plugin.handle_approve(&test_ctx_with_host(
813 "ba",
814 &["approve", "tb-001"],
815 Some("ba"),
816 ));
817 plugin.handle_update(&test_ctx("saphire", &["update", "tb-001", "tests passing"]));
818 plugin.handle_finish(&test_ctx("saphire", &["finish", "tb-001"]));
819 let board = plugin.board.lock().unwrap();
820 assert_eq!(board[0].task.status, TaskStatus::Finished);
821 }
822
823 #[test]
824 fn taskboard_path_from_chat_replaces_extension() {
825 let chat = PathBuf::from("/data/room-dev.chat");
826 let tb = TaskboardPlugin::taskboard_path_from_chat(&chat);
827 assert_eq!(tb, PathBuf::from("/data/room-dev.taskboard"));
828 }
829
830 #[test]
831 fn default_commands_matches_commands() {
832 let (plugin, _tmp) = make_plugin();
833 let default = TaskboardPlugin::default_commands();
834 let instance = plugin.commands();
835 assert_eq!(default.len(), instance.len());
836 assert_eq!(default[0].name, instance[0].name);
837 assert_eq!(default[0].params.len(), instance[0].params.len());
838 }
839
840 fn test_ctx(sender: &str, params: &[&str]) -> CommandContext {
843 test_ctx_with_host(sender, params, None)
844 }
845
846 fn test_ctx_with_host(sender: &str, params: &[&str], host: Option<&str>) -> CommandContext {
847 use std::collections::HashMap;
848 use std::sync::atomic::AtomicU64;
849
850 use crate::plugin::{ChatWriter, RoomMetadata, UserInfo};
851
852 let clients = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
853 let chat_path = Arc::new(PathBuf::from("/dev/null"));
854 let room_id = Arc::new("test-room".to_owned());
855 let seq_counter = Arc::new(AtomicU64::new(0));
856 let writer = ChatWriter::new(&clients, &chat_path, &room_id, &seq_counter, "taskboard");
857
858 CommandContext {
859 command: "taskboard".to_owned(),
860 params: params.iter().map(|s| s.to_string()).collect(),
861 sender: sender.to_owned(),
862 room_id: "test-room".to_owned(),
863 message_id: "msg-001".to_owned(),
864 timestamp: chrono::Utc::now(),
865 history: crate::plugin::HistoryReader::new(std::path::Path::new("/dev/null"), sender),
866 writer,
867 metadata: RoomMetadata {
868 online_users: vec![UserInfo {
869 username: sender.to_owned(),
870 status: String::new(),
871 }],
872 host: host.map(|h| h.to_owned()),
873 message_count: 0,
874 },
875 available_commands: vec![],
876 }
877 }
878}