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