| 1 | //! Task commands: add/list/show/cancel |
| 2 | |
| 3 | use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; |
| 4 | use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; |
| 5 | |
| 6 | use crate::commands::CommandResult; |
| 7 | use crate::tui::app::AppAction; |
| 8 | |
| 9 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 10 | name: "task", |
| 11 | aliases: &["tasks"], |
| 12 | usage: "/task [add <prompt>|list|digest|show <id>|cancel <id>]", |
| 13 | description_key: "cmd_task_description", |
| 14 | }; |
| 15 | |
| 16 | pub(in crate::commands) struct TaskCmd; |
| 17 | |
| 18 | impl RegisterCommand<CommandResult> for TaskCmd { |
| 19 | fn info() -> &'static CommandInfo { |
| 20 | &COMMAND_INFO |
| 21 | } |
| 22 | |
| 23 | fn handler() -> CommandHandler<CommandResult> { |
| 24 | CommandHandler::Contextual { |
| 25 | capabilities: CommandCapabilities::WORKSPACE, |
| 26 | handler: task_contextual, |
| 27 | } |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | fn task_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult { |
| 32 | let mut parts = contexts.into_parts(); |
| 33 | let Some(workspace) = parts.workspace.as_deref_mut() else { |
| 34 | return CommandResult::error("Command capability unavailable: workspace"); |
| 35 | }; |
| 36 | task(workspace, arg) |
| 37 | } |
| 38 | |
| 39 | fn task( |
| 40 | workspace: &mut dyn codewhale_command_contract::facets::CommandWorkspaceContext, |
| 41 | args: Option<&str>, |
| 42 | ) -> CommandResult { |
| 43 | let raw = args.unwrap_or("").trim(); |
| 44 | if raw.is_empty() || raw.eq_ignore_ascii_case("list") { |
| 45 | return CommandResult::action(AppAction::TaskList); |
| 46 | } |
| 47 | |
| 48 | let mut parts = raw.splitn(2, char::is_whitespace); |
| 49 | let action = parts.next().unwrap_or("").to_ascii_lowercase(); |
| 50 | let remainder = parts.next().map(str::trim).filter(|s| !s.is_empty()); |
| 51 | |
| 52 | match action.as_str() { |
| 53 | "add" => { |
| 54 | let Some(prompt) = remainder else { |
| 55 | return CommandResult::error("Usage: /task add <prompt>"); |
| 56 | }; |
| 57 | CommandResult::action(AppAction::TaskAdd { |
| 58 | prompt: prompt.to_string(), |
| 59 | }) |
| 60 | } |
| 61 | "list" => CommandResult::action(AppAction::TaskList), |
| 62 | "digest" => match workspace.operation_digest() { |
| 63 | Ok(text) => CommandResult::message(text), |
| 64 | Err(error) => CommandResult::error(error), |
| 65 | }, |
| 66 | "show" => { |
| 67 | let Some(id) = remainder else { |
| 68 | return CommandResult::error("Usage: /task show <id>"); |
| 69 | }; |
| 70 | CommandResult::action(AppAction::TaskShow { id: id.to_string() }) |
| 71 | } |
| 72 | "cancel" | "stop" => { |
| 73 | let Some(id) = remainder else { |
| 74 | return CommandResult::error("Usage: /task cancel <id>"); |
| 75 | }; |
| 76 | CommandResult::action(AppAction::TaskCancel { id: id.to_string() }) |
| 77 | } |
| 78 | _ => CommandResult::error("Usage: /task [add <prompt>|list|digest|show <id>|cancel <id>]"), |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | #[cfg(test)] |
| 83 | mod tests { |
| 84 | use super::*; |
| 85 | use std::path::PathBuf; |
| 86 | |
| 87 | struct FakeWorkspace; |
| 88 | impl codewhale_command_contract::facets::CommandWorkspaceContext for FakeWorkspace { |
| 89 | fn workspace(&self) -> PathBuf { |
| 90 | PathBuf::from(".") |
| 91 | } |
| 92 | fn work_state_snapshot(&self) -> Result<Option<String>, String> { |
| 93 | Ok(None) |
| 94 | } |
| 95 | fn operation_digest(&mut self) -> Result<String, String> { |
| 96 | Ok("No active operations or to-do items.".to_string()) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | struct FailingWorkspace; |
| 101 | impl codewhale_command_contract::facets::CommandWorkspaceContext for FailingWorkspace { |
| 102 | fn workspace(&self) -> PathBuf { |
| 103 | PathBuf::from(".") |
| 104 | } |
| 105 | fn work_state_snapshot(&self) -> Result<Option<String>, String> { |
| 106 | Ok(None) |
| 107 | } |
| 108 | fn operation_digest(&mut self) -> Result<String, String> { |
| 109 | Err("Operation digest is temporarily unavailable: boom".to_string()) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | #[test] |
| 114 | fn parses_add_and_cancel() { |
| 115 | let add = task(&mut FakeWorkspace, Some("add write tests")); |
| 116 | assert!(matches!( |
| 117 | add.action, |
| 118 | Some(AppAction::TaskAdd { prompt }) if prompt == "write tests" |
| 119 | )); |
| 120 | |
| 121 | let cancel = task(&mut FakeWorkspace, Some("cancel task_1234")); |
| 122 | assert!(matches!( |
| 123 | cancel.action, |
| 124 | Some(AppAction::TaskCancel { id }) if id == "task_1234" |
| 125 | )); |
| 126 | } |
| 127 | |
| 128 | #[test] |
| 129 | fn validates_usage() { |
| 130 | let result = task(&mut FakeWorkspace, Some("add")); |
| 131 | assert!(result.message.is_some()); |
| 132 | assert!(result.action.is_none()); |
| 133 | } |
| 134 | |
| 135 | #[test] |
| 136 | fn digest_uses_canonical_work_runtime_without_another_state_store() { |
| 137 | let result = task(&mut FakeWorkspace, Some("digest")); |
| 138 | assert_eq!( |
| 139 | result.message.as_deref(), |
| 140 | Some("No active operations or to-do items.") |
| 141 | ); |
| 142 | assert!(result.action.is_none()); |
| 143 | |
| 144 | let failing = task(&mut FailingWorkspace, Some("digest")); |
| 145 | assert!(failing.is_error); |
| 146 | assert!( |
| 147 | failing |
| 148 | .message |
| 149 | .as_deref() |
| 150 | .unwrap_or_default() |
| 151 | .contains("Operation digest is temporarily unavailable: boom") |
| 152 | ); |
| 153 | } |
| 154 | |
| 155 | #[test] |
| 156 | fn handler_is_contextual() { |
| 157 | let CommandHandler::Contextual { |
| 158 | capabilities, |
| 159 | handler, |
| 160 | } = TaskCmd::handler() |
| 161 | else { |
| 162 | panic!("task must be contextual"); |
| 163 | }; |
| 164 | assert_eq!(capabilities, CommandCapabilities::WORKSPACE); |
| 165 | let missing = handler(CommandContexts::empty(), Some("list")); |
| 166 | assert!(missing.is_error); |
| 167 | assert_eq!( |
| 168 | missing.message.as_deref(), |
| 169 | Some("Error: Command capability unavailable: workspace") |
| 170 | ); |
| 171 | assert_eq!(TaskCmd::info().description_key, "cmd_task_description"); |
| 172 | assert_eq!(TaskCmd::info().aliases, &["tasks"]); |
| 173 | } |
| 174 | } |
| 175 |