1mod handlers;
2pub mod task;
3
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use task::{next_id, LiveTask, Task, TaskStatus};
9
10use room_protocol::plugin::{
11 BoxFuture, CommandContext, CommandInfo, ParamSchema, ParamType, Plugin, PluginResult,
12};
13use room_protocol::EventType;
14
15#[derive(serde::Deserialize)]
26struct TaskboardConfig {
27 storage_path: PathBuf,
28 #[serde(default)]
29 lease_ttl_secs: Option<u64>,
30}
31
32fn create_taskboard_from_config(config: &str) -> TaskboardPlugin {
36 if config.is_empty() {
37 TaskboardPlugin::new(PathBuf::from("/tmp/room-default.taskboard"), None)
39 } else {
40 let cfg: TaskboardConfig =
41 serde_json::from_str(config).expect("invalid taskboard plugin config JSON");
42 TaskboardPlugin::new(cfg.storage_path, cfg.lease_ttl_secs)
43 }
44}
45
46room_protocol::declare_plugin!("taskboard", create_taskboard_from_config);
47
48const DEFAULT_LEASE_TTL_SECS: u64 = 0;
51
52pub struct TaskboardPlugin {
60 board: Arc<Mutex<Vec<LiveTask>>>,
62 storage_path: PathBuf,
64 lease_ttl: Duration,
66}
67
68impl TaskboardPlugin {
69 pub fn new(storage_path: PathBuf, lease_ttl_secs: Option<u64>) -> Self {
71 let ttl = lease_ttl_secs.unwrap_or(DEFAULT_LEASE_TTL_SECS);
72 let tasks = task::load_tasks(&storage_path);
73 let live_tasks: Vec<LiveTask> = tasks.into_iter().map(LiveTask::new).collect();
74 Self {
75 board: Arc::new(Mutex::new(live_tasks)),
76 storage_path,
77 lease_ttl: Duration::from_secs(ttl),
78 }
79 }
80
81 pub fn taskboard_path_from_chat(chat_path: &std::path::Path) -> PathBuf {
83 chat_path.with_extension("taskboard")
84 }
85
86 pub fn default_commands() -> Vec<CommandInfo> {
89 vec![CommandInfo {
90 name: "taskboard".to_owned(),
91 description:
92 "Manage task lifecycle — post, list, history, mine, qa-queue, show, claim, assign, plan, approve, update, request_review, review_claim, reject, release, finish, cancel"
93 .to_owned(),
94 usage: "/taskboard <action> [args...]".to_owned(),
95 params: vec![
96 ParamSchema {
97 name: "action".to_owned(),
98 param_type: ParamType::Choice(vec![
99 "post".to_owned(),
100 "list".to_owned(),
101 "history".to_owned(),
102 "mine".to_owned(),
103 "qa-queue".to_owned(),
104 "show".to_owned(),
105 "claim".to_owned(),
106 "assign".to_owned(),
107 "plan".to_owned(),
108 "approve".to_owned(),
109 "update".to_owned(),
110 "request_review".to_owned(),
111 "review_claim".to_owned(),
112 "reject".to_owned(),
113 "release".to_owned(),
114 "finish".to_owned(),
115 "cancel".to_owned(),
116 ]),
117 required: true,
118 description: "Subcommand".to_owned(),
119 },
120 ParamSchema {
121 name: "args".to_owned(),
122 param_type: ParamType::Text,
123 required: false,
124 description: "Task ID or description".to_owned(),
125 },
126 ],
127 subcommands: taskboard_subcommands(),
128 }]
129 }
130
131 fn sweep_expired(&self) -> Vec<String> {
135 let ttl = self.lease_ttl.as_secs();
136 if ttl == 0 {
137 return Vec::new();
138 }
139 let mut board = self.board.lock().unwrap();
140 let mut expired_ids = Vec::new();
141 for lt in board.iter_mut() {
142 if lt.is_expired(ttl)
143 && matches!(
144 lt.task.status,
145 TaskStatus::Claimed
146 | TaskStatus::Planned
147 | TaskStatus::InProgress
148 | TaskStatus::ReviewClaimed
149 )
150 {
151 let prev_assignee = lt.task.assigned_to.clone().unwrap_or_default();
152 expired_ids.push(format!("{} (was {})", lt.task.id, prev_assignee));
153 lt.expire();
154 }
155 }
156 if !expired_ids.is_empty() {
157 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
158 let _ = task::save_tasks(&self.storage_path, &tasks);
159 }
160 expired_ids
161 }
162}
163
164fn taskboard_subcommands() -> Vec<CommandInfo> {
166 vec![
167 CommandInfo {
168 name: "post".to_owned(),
169 description: "Create a new task on the board. Anyone can post.".to_owned(),
170 usage: "/taskboard post <description>".to_owned(),
171 params: vec![ParamSchema {
172 name: "description".to_owned(),
173 param_type: ParamType::Text,
174 required: true,
175 description: "Task description".to_owned(),
176 }],
177 subcommands: vec![],
178 },
179 CommandInfo {
180 name: "list".to_owned(),
181 description: "Show all active tasks. Use 'list all' to include finished/cancelled.".to_owned(),
182 usage: "/taskboard list [all]".to_owned(),
183 params: vec![ParamSchema {
184 name: "all".to_owned(),
185 param_type: ParamType::Choice(vec!["all".to_owned()]),
186 required: false,
187 description: "Include finished and cancelled tasks".to_owned(),
188 }],
189 subcommands: vec![],
190 },
191 CommandInfo {
192 name: "show".to_owned(),
193 description: "Show full detail for a single task.".to_owned(),
194 usage: "/taskboard show <task-id>".to_owned(),
195 params: vec![ParamSchema {
196 name: "task-id".to_owned(),
197 param_type: ParamType::Text,
198 required: true,
199 description: "Task ID (e.g. tb-001)".to_owned(),
200 }],
201 subcommands: vec![],
202 },
203 CommandInfo {
204 name: "claim".to_owned(),
205 description: "Claim an open task. Starts the lease timer. Task must be in Open status.".to_owned(),
206 usage: "/taskboard claim <task-id>".to_owned(),
207 params: vec![ParamSchema {
208 name: "task-id".to_owned(),
209 param_type: ParamType::Text,
210 required: true,
211 description: "Task ID (e.g. tb-001)".to_owned(),
212 }],
213 subcommands: vec![],
214 },
215 CommandInfo {
216 name: "assign".to_owned(),
217 description: "Assign an open task to a specific user. Only the poster or host can assign.".to_owned(),
218 usage: "/taskboard assign <task-id> <username>".to_owned(),
219 params: vec![
220 ParamSchema {
221 name: "task-id".to_owned(),
222 param_type: ParamType::Text,
223 required: true,
224 description: "Task ID (e.g. tb-001)".to_owned(),
225 },
226 ParamSchema {
227 name: "username".to_owned(),
228 param_type: ParamType::Username,
229 required: true,
230 description: "User to assign the task to".to_owned(),
231 },
232 ],
233 subcommands: vec![],
234 },
235 CommandInfo {
236 name: "plan".to_owned(),
237 description: "Submit an implementation plan. Task must be in Claimed status and you must be the assignee. Wait for approval before implementing.".to_owned(),
238 usage: "/taskboard plan <task-id> <plan-text>".to_owned(),
239 params: vec![
240 ParamSchema {
241 name: "task-id".to_owned(),
242 param_type: ParamType::Text,
243 required: true,
244 description: "Task ID (e.g. tb-001)".to_owned(),
245 },
246 ParamSchema {
247 name: "plan-text".to_owned(),
248 param_type: ParamType::Text,
249 required: true,
250 description: "Your implementation plan".to_owned(),
251 },
252 ],
253 subcommands: vec![],
254 },
255 CommandInfo {
256 name: "approve".to_owned(),
257 description: "Approve a planned task so the assignee can proceed. Only the poster or host can approve.".to_owned(),
258 usage: "/taskboard approve <task-id>".to_owned(),
259 params: vec![ParamSchema {
260 name: "task-id".to_owned(),
261 param_type: ParamType::Text,
262 required: true,
263 description: "Task ID (e.g. tb-001)".to_owned(),
264 }],
265 subcommands: vec![],
266 },
267 CommandInfo {
268 name: "update".to_owned(),
269 description: "Renew the lease timer and optionally add progress notes. Only the assignee can update.".to_owned(),
270 usage: "/taskboard update <task-id> [notes]".to_owned(),
271 params: vec![
272 ParamSchema {
273 name: "task-id".to_owned(),
274 param_type: ParamType::Text,
275 required: true,
276 description: "Task ID (e.g. tb-001)".to_owned(),
277 },
278 ParamSchema {
279 name: "notes".to_owned(),
280 param_type: ParamType::Text,
281 required: false,
282 description: "Progress notes".to_owned(),
283 },
284 ],
285 subcommands: vec![],
286 },
287 CommandInfo {
288 name: "review".to_owned(),
289 description: "Mark a task as ready for review. Only the assignee can request review.".to_owned(),
290 usage: "/taskboard review <task-id>".to_owned(),
291 params: vec![ParamSchema {
292 name: "task-id".to_owned(),
293 param_type: ParamType::Text,
294 required: true,
295 description: "Task ID (e.g. tb-001)".to_owned(),
296 }],
297 subcommands: vec![],
298 },
299 CommandInfo {
300 name: "release".to_owned(),
301 description: "Release a task back to Open status. The assignee or host can release.".to_owned(),
302 usage: "/taskboard release <task-id>".to_owned(),
303 params: vec![ParamSchema {
304 name: "task-id".to_owned(),
305 param_type: ParamType::Text,
306 required: true,
307 description: "Task ID (e.g. tb-001)".to_owned(),
308 }],
309 subcommands: vec![],
310 },
311 CommandInfo {
312 name: "finish".to_owned(),
313 description: "Mark a task as finished. Only the assignee can finish.".to_owned(),
314 usage: "/taskboard finish <task-id>".to_owned(),
315 params: vec![ParamSchema {
316 name: "task-id".to_owned(),
317 param_type: ParamType::Text,
318 required: true,
319 description: "Task ID (e.g. tb-001)".to_owned(),
320 }],
321 subcommands: vec![],
322 },
323 CommandInfo {
324 name: "cancel".to_owned(),
325 description: "Cancel a task with an optional reason. The poster, assignee, or host can cancel.".to_owned(),
326 usage: "/taskboard cancel <task-id> [reason]".to_owned(),
327 params: vec![
328 ParamSchema {
329 name: "task-id".to_owned(),
330 param_type: ParamType::Text,
331 required: true,
332 description: "Task ID (e.g. tb-001)".to_owned(),
333 },
334 ParamSchema {
335 name: "reason".to_owned(),
336 param_type: ParamType::Text,
337 required: false,
338 description: "Cancellation reason".to_owned(),
339 },
340 ],
341 subcommands: vec![],
342 },
343 CommandInfo {
344 name: "mine".to_owned(),
345 description: "Show only tasks assigned to you.".to_owned(),
346 usage: "/taskboard mine".to_owned(),
347 params: vec![],
348 subcommands: vec![],
349 },
350 ]
351}
352
353impl Plugin for TaskboardPlugin {
354 fn name(&self) -> &str {
355 "taskboard"
356 }
357
358 fn version(&self) -> &str {
359 env!("CARGO_PKG_VERSION")
360 }
361
362 fn commands(&self) -> Vec<CommandInfo> {
363 Self::default_commands()
364 }
365
366 fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
367 Box::pin(async move {
368 let action = ctx.params.first().map(String::as_str).unwrap_or("");
369 let (result, broadcast) = match action {
370 "post" => self.handle_post(&ctx),
371 "list" => {
372 let show_all = ctx.params.get(1).map(|s| s.as_str()) == Some("all");
373 (self.handle_list(show_all), false)
374 }
375 "history" => (self.handle_history(), false),
376 "mine" => (self.handle_mine(&ctx.sender), false),
377 "qa-queue" => (self.handle_qa_queue(), false),
378 "claim" => self.handle_claim(&ctx),
379 "assign" => self.handle_assign(&ctx),
380 "plan" => self.handle_plan(&ctx),
381 "approve" => self.handle_approve(&ctx),
382 "show" => (self.handle_show(&ctx), false),
383 "update" => self.handle_update(&ctx),
384 "release" => self.handle_release(&ctx),
385 "request_review" => self.handle_request_review(&ctx),
386 "review_claim" => self.handle_review_claim(&ctx),
387 "reject" => self.handle_reject(&ctx),
388 "finish" => self.handle_finish(&ctx),
389 "cancel" => self.handle_cancel(&ctx),
390 "" => ("usage: /taskboard <post|list|history|mine|qa-queue|show|claim|assign|plan|approve|update|request_review|review_claim|reject|release|finish|cancel> [args...]".to_owned(), false),
391 other => (format!("unknown action: {other}. use: post, list, history, mine, qa-queue, show, claim, assign, plan, approve, update, request_review, review_claim, reject, release, finish, cancel"), false),
392 };
393 if broadcast {
394 let event_type = match action {
396 "post" => Some(EventType::TaskPosted),
397 "claim" => Some(EventType::TaskClaimed),
398 "assign" => Some(EventType::TaskAssigned),
399 "plan" => Some(EventType::TaskPlanned),
400 "approve" => Some(EventType::TaskApproved),
401 "update" => Some(EventType::TaskUpdated),
402 "request_review" => Some(EventType::ReviewRequested),
403 "review_claim" => Some(EventType::ReviewClaimed),
404 "reject" => Some(EventType::TaskRejected),
405 "release" => Some(EventType::TaskReleased),
406 "finish" => Some(EventType::TaskFinished),
407 "cancel" => Some(EventType::TaskCancelled),
408 _ => None,
409 };
410 if let Some(et) = event_type {
411 let _ = ctx.writer.emit_event(et, &result, None).await;
412 }
413 Ok(PluginResult::Broadcast(result, None))
414 } else {
415 Ok(PluginResult::Reply(result, None))
416 }
417 })
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 fn make_plugin() -> (TaskboardPlugin, tempfile::NamedTempFile) {
426 let tmp = tempfile::NamedTempFile::new().unwrap();
427 let plugin = TaskboardPlugin::new(tmp.path().to_path_buf(), Some(600));
428 (plugin, tmp)
429 }
430
431 #[test]
432 fn plugin_name() {
433 let (plugin, _tmp) = make_plugin();
434 assert_eq!(plugin.name(), "taskboard");
435 }
436
437 #[test]
438 fn plugin_version_matches_crate() {
439 let (plugin, _tmp) = make_plugin();
440 assert_eq!(plugin.version(), env!("CARGO_PKG_VERSION"));
441 }
442
443 #[test]
444 fn plugin_api_version_is_current() {
445 let (plugin, _tmp) = make_plugin();
446 assert_eq!(
447 plugin.api_version(),
448 room_protocol::plugin::PLUGIN_API_VERSION
449 );
450 }
451
452 #[test]
453 fn plugin_min_protocol_is_compatible() {
454 let (plugin, _tmp) = make_plugin();
455 assert_eq!(plugin.min_protocol(), "0.0.0");
457 }
458
459 #[test]
460 fn plugin_commands() {
461 let (plugin, _tmp) = make_plugin();
462 let cmds = plugin.commands();
463 assert_eq!(cmds.len(), 1);
464 assert_eq!(cmds[0].name, "taskboard");
465 assert_eq!(cmds[0].params.len(), 2);
466 if let ParamType::Choice(ref choices) = cmds[0].params[0].param_type {
467 assert!(choices.contains(&"post".to_owned()));
468 assert!(choices.contains(&"approve".to_owned()));
469 assert!(choices.contains(&"assign".to_owned()));
470 assert_eq!(choices.len(), 17);
471 } else {
472 panic!("expected Choice param type");
473 }
474 }
475
476 #[test]
477 fn plugin_commands_has_subcommands() {
478 let (plugin, _tmp) = make_plugin();
479 let cmds = plugin.commands();
480 assert_eq!(cmds[0].subcommands.len(), 13, "should have 13 subcommands");
481 let names: Vec<&str> = cmds[0]
482 .subcommands
483 .iter()
484 .map(|s| s.name.as_str())
485 .collect();
486 assert!(names.contains(&"post"));
487 assert!(names.contains(&"plan"));
488 assert!(names.contains(&"claim"));
489 assert!(names.contains(&"cancel"));
490 }
491
492 #[test]
493 fn taskboard_path_from_chat_replaces_extension() {
494 let chat = PathBuf::from("/data/room-dev.chat");
495 let tb = TaskboardPlugin::taskboard_path_from_chat(&chat);
496 assert_eq!(tb, PathBuf::from("/data/room-dev.taskboard"));
497 }
498
499 #[test]
500 fn default_commands_matches_commands() {
501 let (plugin, _tmp) = make_plugin();
502 let default = TaskboardPlugin::default_commands();
503 let instance = plugin.commands();
504 assert_eq!(default.len(), instance.len());
505 assert_eq!(default[0].name, instance[0].name);
506 assert_eq!(default[0].params.len(), instance[0].params.len());
507 }
508
509 fn seed_task(plugin: &TaskboardPlugin, id: &str, status: TaskStatus) {
510 let mut board = plugin.board.lock().unwrap();
511 let t = task::Task {
512 id: id.to_owned(),
513 description: format!("task {id}"),
514 status,
515 posted_by: "alice".to_owned(),
516 assigned_to: if status != TaskStatus::Open {
517 Some("bob".to_owned())
518 } else {
519 None
520 },
521 posted_at: chrono::Utc::now(),
522 claimed_at: None,
523 plan: None,
524 approved_by: None,
525 approved_at: None,
526 updated_at: None,
527 notes: None,
528 team: None,
529 reviewer: None,
530 };
531 board.push(LiveTask::new(t));
532 }
533
534 #[test]
535 fn handle_list_filters_terminal_tasks() {
536 let (plugin, _tmp) = make_plugin();
537 seed_task(&plugin, "tb-001", TaskStatus::Open);
538 seed_task(&plugin, "tb-002", TaskStatus::Claimed);
539 seed_task(&plugin, "tb-003", TaskStatus::Finished);
540 seed_task(&plugin, "tb-004", TaskStatus::Cancelled);
541
542 let output = plugin.handle_list(false);
543 assert!(output.contains("tb-001"), "open task should appear");
544 assert!(output.contains("tb-002"), "claimed task should appear");
545 assert!(!output.contains("tb-003"), "finished task should be hidden");
546 assert!(
547 !output.contains("tb-004"),
548 "cancelled task should be hidden"
549 );
550 }
551
552 #[test]
553 fn handle_list_all_shows_everything() {
554 let (plugin, _tmp) = make_plugin();
555 seed_task(&plugin, "tb-001", TaskStatus::Open);
556 seed_task(&plugin, "tb-002", TaskStatus::Finished);
557 seed_task(&plugin, "tb-003", TaskStatus::Cancelled);
558
559 let output = plugin.handle_list(true);
560 assert!(output.contains("tb-001"), "open task should appear");
561 assert!(
562 output.contains("tb-002"),
563 "finished task should appear with all"
564 );
565 assert!(
566 output.contains("tb-003"),
567 "cancelled task should appear with all"
568 );
569 }
570
571 #[test]
572 fn handle_list_empty_after_filter() {
573 let (plugin, _tmp) = make_plugin();
574 seed_task(&plugin, "tb-001", TaskStatus::Finished);
575 seed_task(&plugin, "tb-002", TaskStatus::Cancelled);
576
577 let output = plugin.handle_list(false);
578 assert!(
579 output.contains("no active tasks"),
580 "should show helpful empty message, got: {output}"
581 );
582 assert!(
583 output.contains("/taskboard list all"),
584 "should hint at 'list all' command"
585 );
586 }
587
588 #[test]
591 fn handle_history_shows_finished_and_cancelled() {
592 let (plugin, _tmp) = make_plugin();
593 seed_task(&plugin, "tb-001", TaskStatus::Open);
594 seed_task(&plugin, "tb-002", TaskStatus::Claimed);
595 seed_task(&plugin, "tb-003", TaskStatus::Finished);
596 seed_task(&plugin, "tb-004", TaskStatus::Cancelled);
597
598 let output = plugin.handle_history();
599 assert!(!output.contains("tb-001"), "open task should not appear");
600 assert!(!output.contains("tb-002"), "claimed task should not appear");
601 assert!(output.contains("tb-003"), "finished task should appear");
602 assert!(output.contains("tb-004"), "cancelled task should appear");
603 }
604
605 #[test]
606 fn handle_history_empty_when_no_completed() {
607 let (plugin, _tmp) = make_plugin();
608 seed_task(&plugin, "tb-001", TaskStatus::Open);
609 seed_task(&plugin, "tb-002", TaskStatus::Claimed);
610
611 let output = plugin.handle_history();
612 assert!(
613 output.contains("no completed tasks"),
614 "should show empty message, got: {output}"
615 );
616 }
617
618 #[test]
619 fn handle_history_shows_assignee() {
620 let (plugin, _tmp) = make_plugin();
621 seed_task(&plugin, "tb-001", TaskStatus::Finished);
622
623 let output = plugin.handle_history();
624 assert!(output.contains("bob"), "should show assignee");
625 assert!(output.contains("ASSIGNEE"), "should have header");
626 }
627
628 #[test]
631 fn abi_declaration_matches_plugin() {
632 let decl = &ROOM_PLUGIN_DECLARATION;
633 assert_eq!(decl.api_version, room_protocol::plugin::PLUGIN_API_VERSION);
634 unsafe {
635 assert_eq!(decl.name().unwrap(), "taskboard");
636 assert_eq!(decl.version().unwrap(), env!("CARGO_PKG_VERSION"));
637 assert_eq!(decl.min_protocol().unwrap(), "0.0.0");
638 }
639 }
640
641 #[test]
642 fn abi_create_with_empty_config() {
643 let plugin_ptr = unsafe { room_plugin_create(std::ptr::null(), 0) };
644 assert!(!plugin_ptr.is_null());
645 let plugin = unsafe { Box::from_raw(plugin_ptr) };
646 assert_eq!(plugin.name(), "taskboard");
647 assert_eq!(plugin.version(), env!("CARGO_PKG_VERSION"));
648 }
649
650 #[test]
651 fn abi_create_with_json_config() {
652 let tmp = tempfile::NamedTempFile::new().unwrap();
653 let config = format!(
654 r#"{{"storage_path":"{}","lease_ttl_secs":300}}"#,
655 tmp.path().display()
656 );
657 let plugin_ptr = unsafe { room_plugin_create(config.as_ptr(), config.len()) };
658 assert!(!plugin_ptr.is_null());
659 let plugin = unsafe { Box::from_raw(plugin_ptr) };
660 assert_eq!(plugin.name(), "taskboard");
661 }
662
663 #[test]
664 fn abi_destroy_frees_plugin() {
665 let plugin_ptr = unsafe { room_plugin_create(std::ptr::null(), 0) };
666 assert!(!plugin_ptr.is_null());
667 unsafe { room_plugin_destroy(plugin_ptr) };
669 }
670
671 #[test]
672 fn abi_destroy_null_is_safe() {
673 unsafe { room_plugin_destroy(std::ptr::null_mut()) };
675 }
676
677 #[test]
680 fn sweep_expired_multiple_simultaneous_and_skips_finished() {
681 let (plugin, _tmp) = make_plugin();
682
683 {
685 let mut board = plugin.board.lock().unwrap();
686 let stale = std::time::Instant::now() - std::time::Duration::from_secs(700);
687 for i in 1..=3 {
688 let t = task::Task {
689 id: format!("tb-{i:03}"),
690 description: format!("expiry test {i}"),
691 status: TaskStatus::Claimed,
692 posted_by: "alice".to_owned(),
693 assigned_to: Some(format!("agent-{i}")),
694 posted_at: chrono::Utc::now(),
695 claimed_at: Some(chrono::Utc::now()),
696 plan: None,
697 approved_by: None,
698 approved_at: None,
699 updated_at: None,
700 notes: None,
701 team: None,
702 reviewer: None,
703 };
704 let mut lt = LiveTask::new(t);
705 lt.lease_start = Some(stale);
706 board.push(lt);
707 }
708 let finished = task::Task {
710 id: "tb-004".to_owned(),
711 description: "finished task".to_owned(),
712 status: TaskStatus::Finished,
713 posted_by: "alice".to_owned(),
714 assigned_to: Some("bob".to_owned()),
715 posted_at: chrono::Utc::now(),
716 claimed_at: Some(chrono::Utc::now()),
717 plan: Some("done".to_owned()),
718 approved_by: None,
719 approved_at: None,
720 updated_at: None,
721 notes: None,
722 team: None,
723 reviewer: None,
724 };
725 let mut lt_finished = LiveTask::new(finished);
726 lt_finished.lease_start = Some(stale);
728 board.push(lt_finished);
729 }
730
731 let expired = plugin.sweep_expired();
732
733 assert_eq!(
735 expired.len(),
736 3,
737 "expected 3 expired tasks, got {expired:?}"
738 );
739 for id in &expired {
740 assert!(!id.contains("tb-004"), "Finished task must not be swept");
741 }
742
743 let board = plugin.board.lock().unwrap();
745 for lt in board.iter() {
746 if lt.task.id == "tb-004" {
747 assert_eq!(lt.task.status, TaskStatus::Finished);
748 assert_eq!(lt.task.assigned_to.as_deref(), Some("bob"));
749 assert_eq!(lt.task.plan.as_deref(), Some("done"));
750 } else {
751 assert_eq!(lt.task.status, TaskStatus::Open);
752 assert!(lt.task.assigned_to.is_none());
753 assert!(lt.lease_start.is_none());
754 }
755 }
756 }
757
758 #[test]
761 fn sweep_expired_disabled_when_ttl_zero() {
762 let tmp = tempfile::NamedTempFile::new().unwrap();
763 let plugin = TaskboardPlugin::new(tmp.path().to_path_buf(), None); {
766 let mut board = plugin.board.lock().unwrap();
767 let stale = std::time::Instant::now() - std::time::Duration::from_secs(9999);
768 let t = task::Task {
769 id: "tb-001".to_owned(),
770 description: "should not expire".to_owned(),
771 status: TaskStatus::Claimed,
772 posted_by: "alice".to_owned(),
773 assigned_to: Some("bob".to_owned()),
774 posted_at: chrono::Utc::now(),
775 claimed_at: Some(chrono::Utc::now()),
776 plan: None,
777 approved_by: None,
778 approved_at: None,
779 updated_at: None,
780 notes: None,
781 team: None,
782 reviewer: None,
783 };
784 let mut lt = LiveTask::new(t);
785 lt.lease_start = Some(stale);
786 board.push(lt);
787 }
788
789 let expired = plugin.sweep_expired();
790 assert!(expired.is_empty(), "TTL=0 must disable expiry");
791
792 let board = plugin.board.lock().unwrap();
793 assert_eq!(board[0].task.status, TaskStatus::Claimed);
794 assert_eq!(board[0].task.assigned_to.as_deref(), Some("bob"));
795 }
796
797 #[test]
800 fn handle_mine_returns_only_assigned_tasks() {
801 let (plugin, _tmp) = make_plugin();
802 seed_task(&plugin, "tb-001", TaskStatus::Open);
803 seed_task(&plugin, "tb-002", TaskStatus::Claimed); seed_task(&plugin, "tb-003", TaskStatus::InProgress); {
808 let mut board = plugin.board.lock().unwrap();
809 let t = task::Task {
810 id: "tb-004".to_owned(),
811 description: "alice task".to_owned(),
812 status: TaskStatus::Claimed,
813 posted_by: "manager".to_owned(),
814 assigned_to: Some("alice".to_owned()),
815 posted_at: chrono::Utc::now(),
816 claimed_at: None,
817 plan: None,
818 approved_by: None,
819 approved_at: None,
820 updated_at: None,
821 notes: None,
822 team: None,
823 reviewer: None,
824 };
825 board.push(LiveTask::new(t));
826 }
827
828 let output = plugin.handle_mine("bob");
829 assert!(
830 output.contains("tb-002"),
831 "bob's claimed task should appear"
832 );
833 assert!(
834 output.contains("tb-003"),
835 "bob's approved task should appear"
836 );
837 assert!(
838 !output.contains("tb-001"),
839 "unassigned task should not appear"
840 );
841 assert!(
842 !output.contains("tb-004"),
843 "alice's task should not appear for bob"
844 );
845 }
846
847 #[test]
848 fn handle_mine_empty_when_no_tasks_assigned() {
849 let (plugin, _tmp) = make_plugin();
850 seed_task(&plugin, "tb-001", TaskStatus::Open);
851 seed_task(&plugin, "tb-002", TaskStatus::Claimed); let output = plugin.handle_mine("alice");
854 assert!(
855 output.contains("no tasks assigned to alice"),
856 "should show empty message, got: {output}"
857 );
858 }
859
860 #[test]
861 fn handle_mine_includes_finished_tasks() {
862 let (plugin, _tmp) = make_plugin();
863 seed_task(&plugin, "tb-001", TaskStatus::Finished); let output = plugin.handle_mine("bob");
866 assert!(
867 output.contains("tb-001"),
868 "finished tasks should appear in mine"
869 );
870 }
871
872 #[test]
875 fn handle_qa_queue_returns_only_awaiting_review() {
876 let (plugin, _tmp) = make_plugin();
877 seed_task(&plugin, "tb-001", TaskStatus::Open);
878 seed_task(&plugin, "tb-002", TaskStatus::Claimed);
879 seed_task(&plugin, "tb-003", TaskStatus::AwaitingReview);
880 seed_task(&plugin, "tb-004", TaskStatus::Finished);
881
882 let output = plugin.handle_qa_queue();
883 assert!(
884 output.contains("tb-003"),
885 "AwaitingReview task should appear"
886 );
887 assert!(!output.contains("tb-001"), "open task should not appear");
888 assert!(!output.contains("tb-002"), "claimed task should not appear");
889 assert!(
890 !output.contains("tb-004"),
891 "finished task should not appear"
892 );
893 }
894
895 #[test]
896 fn handle_qa_queue_empty_when_none_awaiting() {
897 let (plugin, _tmp) = make_plugin();
898 seed_task(&plugin, "tb-001", TaskStatus::Open);
899 seed_task(&plugin, "tb-002", TaskStatus::Claimed);
900 seed_task(&plugin, "tb-003", TaskStatus::Finished);
901
902 let output = plugin.handle_qa_queue();
903 assert!(
904 output.contains("no tasks awaiting review"),
905 "should show empty message, got: {output}"
906 );
907 }
908
909 #[test]
910 fn handle_qa_queue_shows_assignee_and_header() {
911 let (plugin, _tmp) = make_plugin();
912 seed_task(&plugin, "tb-001", TaskStatus::AwaitingReview);
913
914 let output = plugin.handle_qa_queue();
915 assert!(output.contains("bob"), "should show assignee");
916 assert!(output.contains("ASSIGNEE"), "should have header");
917 assert!(output.contains("ID"), "should have ID header");
918 }
919}