| 1 | //! Operator controls for durable scheduled automations. |
| 2 | |
| 3 | use codewhale_command_contract::facets::CommandPresentationContext; |
| 4 | use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; |
| 5 | use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; |
| 6 | |
| 7 | use crate::commands::CommandResult; |
| 8 | use crate::tui::app::{AppAction, AutomationAction}; |
| 9 | |
| 10 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 11 | name: "automation", |
| 12 | aliases: &["automations", "scheduled"], |
| 13 | usage: "/automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]", |
| 14 | description_key: "cmd_automation_description", |
| 15 | }; |
| 16 | |
| 17 | pub(in crate::commands) struct AutomationCmd; |
| 18 | |
| 19 | impl RegisterCommand<CommandResult> for AutomationCmd { |
| 20 | fn info() -> &'static CommandInfo { |
| 21 | &COMMAND_INFO |
| 22 | } |
| 23 | |
| 24 | fn handler() -> CommandHandler<CommandResult> { |
| 25 | CommandHandler::Contextual { |
| 26 | capabilities: CommandCapabilities::PRESENTATION, |
| 27 | handler: automation_contextual, |
| 28 | } |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | fn automation_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult { |
| 33 | let mut parts = contexts.into_parts(); |
| 34 | let Some(presentation) = parts.presentation.as_deref_mut() else { |
| 35 | return CommandResult::error("Command capability unavailable: presentation"); |
| 36 | }; |
| 37 | automation(presentation, arg) |
| 38 | } |
| 39 | |
| 40 | fn automation( |
| 41 | presentation: &mut dyn CommandPresentationContext, |
| 42 | args: Option<&str>, |
| 43 | ) -> CommandResult { |
| 44 | let raw = args.unwrap_or("").trim(); |
| 45 | // Bare `/automation` opens the room; `list` keeps the text receipt for |
| 46 | // scripts and transcripts. |
| 47 | if raw.is_empty() { |
| 48 | return action(AutomationAction::Open { focus: None }); |
| 49 | } |
| 50 | if raw.eq_ignore_ascii_case("list") { |
| 51 | return action(AutomationAction::List); |
| 52 | } |
| 53 | |
| 54 | let mut parts = raw.split_whitespace(); |
| 55 | let verb = parts.next().unwrap_or("").to_ascii_lowercase(); |
| 56 | |
| 57 | match verb.as_str() { |
| 58 | "show" | "status" => single_id(presentation, &mut parts, |id| AutomationAction::Open { |
| 59 | focus: Some(id), |
| 60 | }), |
| 61 | "print" => single_id(presentation, &mut parts, AutomationAction::Show), |
| 62 | "pause" => single_id(presentation, &mut parts, AutomationAction::Pause), |
| 63 | "resume" => single_id(presentation, &mut parts, AutomationAction::Resume), |
| 64 | "delete" | "remove" | "rm" => delete(presentation, &mut parts), |
| 65 | "run" | "trigger" => single_id(presentation, &mut parts, AutomationAction::Run), |
| 66 | _ => usage_error(presentation), |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | fn single_id<'a>( |
| 71 | presentation: &mut dyn CommandPresentationContext, |
| 72 | parts: &mut impl Iterator<Item = &'a str>, |
| 73 | make_action: fn(String) -> AutomationAction, |
| 74 | ) -> CommandResult { |
| 75 | let Some(id) = parts.next() else { |
| 76 | return usage_error(presentation); |
| 77 | }; |
| 78 | if parts.next().is_some() { |
| 79 | return usage_error(presentation); |
| 80 | } |
| 81 | action(make_action(id.to_string())) |
| 82 | } |
| 83 | |
| 84 | fn delete<'a>( |
| 85 | presentation: &mut dyn CommandPresentationContext, |
| 86 | parts: &mut impl Iterator<Item = &'a str>, |
| 87 | ) -> CommandResult { |
| 88 | let Some(id) = parts.next() else { |
| 89 | return usage_error(presentation); |
| 90 | }; |
| 91 | let confirmation = match (parts.next(), parts.next(), parts.next()) { |
| 92 | (None, None, None) => None, |
| 93 | (Some(flag), Some(token), None) if flag.eq_ignore_ascii_case("--confirm") => { |
| 94 | Some(token.to_string()) |
| 95 | } |
| 96 | _ => return usage_error(presentation), |
| 97 | }; |
| 98 | action(AutomationAction::Delete { |
| 99 | id: id.to_string(), |
| 100 | confirmation, |
| 101 | }) |
| 102 | } |
| 103 | |
| 104 | fn usage_error(presentation: &mut dyn CommandPresentationContext) -> CommandResult { |
| 105 | match presentation.translate("automation_usage", &[]) { |
| 106 | Ok(text) => CommandResult::error(text), |
| 107 | // The key is catalog-known; a translation failure must still fail |
| 108 | // safely without exposing a raw lookup key (D3). |
| 109 | Err(_) => CommandResult::error( |
| 110 | "Usage: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]", |
| 111 | ), |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | fn action(action: AutomationAction) -> CommandResult { |
| 116 | CommandResult::action(AppAction::Automation(action)) |
| 117 | } |
| 118 | |
| 119 | #[cfg(test)] |
| 120 | mod tests { |
| 121 | use super::*; |
| 122 | |
| 123 | struct FakePresentation; |
| 124 | impl CommandPresentationContext for FakePresentation { |
| 125 | fn translate(&self, key: &str, _r: &[(&str, &str)]) -> Result<String, String> { |
| 126 | if key == "automation_usage" { |
| 127 | Ok("Usage: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]".to_string()) |
| 128 | } else { |
| 129 | Err("unknown translation key".to_string()) |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | fn parsed(args: Option<&str>) -> Option<AutomationAction> { |
| 135 | match automation(&mut FakePresentation, args).action { |
| 136 | Some(AppAction::Automation(action)) => Some(action), |
| 137 | _ => None, |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | #[test] |
| 142 | fn parses_list_show_and_mutations() { |
| 143 | assert_eq!(parsed(None), Some(AutomationAction::Open { focus: None })); |
| 144 | assert_eq!(parsed(Some("list")), Some(AutomationAction::List)); |
| 145 | assert_eq!( |
| 146 | parsed(Some("show auto_1")), |
| 147 | Some(AutomationAction::Open { |
| 148 | focus: Some("auto_1".to_string()) |
| 149 | }) |
| 150 | ); |
| 151 | assert_eq!( |
| 152 | parsed(Some("print auto_1")), |
| 153 | Some(AutomationAction::Show("auto_1".to_string())) |
| 154 | ); |
| 155 | assert_eq!( |
| 156 | parsed(Some("pause auto_1")), |
| 157 | Some(AutomationAction::Pause("auto_1".to_string())) |
| 158 | ); |
| 159 | assert_eq!( |
| 160 | parsed(Some("resume auto_1")), |
| 161 | Some(AutomationAction::Resume("auto_1".to_string())) |
| 162 | ); |
| 163 | assert_eq!( |
| 164 | parsed(Some("delete auto_1")), |
| 165 | Some(AutomationAction::Delete { |
| 166 | id: "auto_1".to_string(), |
| 167 | confirmation: None, |
| 168 | }) |
| 169 | ); |
| 170 | assert_eq!( |
| 171 | parsed(Some("run auto_1")), |
| 172 | Some(AutomationAction::Run("auto_1".to_string())) |
| 173 | ); |
| 174 | } |
| 175 | |
| 176 | #[test] |
| 177 | fn accepts_operator_aliases() { |
| 178 | assert_eq!( |
| 179 | parsed(Some("status auto_1")), |
| 180 | Some(AutomationAction::Open { |
| 181 | focus: Some("auto_1".to_string()) |
| 182 | }) |
| 183 | ); |
| 184 | assert_eq!( |
| 185 | parsed(Some("rm auto_1")), |
| 186 | Some(AutomationAction::Delete { |
| 187 | id: "auto_1".to_string(), |
| 188 | confirmation: None, |
| 189 | }) |
| 190 | ); |
| 191 | assert_eq!( |
| 192 | parsed(Some("trigger auto_1")), |
| 193 | Some(AutomationAction::Run("auto_1".to_string())) |
| 194 | ); |
| 195 | } |
| 196 | |
| 197 | #[test] |
| 198 | fn validates_missing_ids_and_unknown_actions() { |
| 199 | for verb in ["show", "pause", "resume", "delete", "run", "unknown"] { |
| 200 | let result = automation(&mut FakePresentation, Some(verb)); |
| 201 | assert!(result.message.is_some(), "{verb} should show usage"); |
| 202 | assert!(result.action.is_none()); |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | #[test] |
| 207 | fn delete_confirmation_is_explicit_and_exact() { |
| 208 | assert_eq!( |
| 209 | parsed(Some("delete auto_1 --confirm receipt")), |
| 210 | Some(AutomationAction::Delete { |
| 211 | id: "auto_1".to_string(), |
| 212 | confirmation: Some("receipt".to_string()), |
| 213 | }) |
| 214 | ); |
| 215 | for invalid in [ |
| 216 | "delete auto_1 --confirm", |
| 217 | "delete auto_1 receipt", |
| 218 | "delete auto_1 --confirm receipt extra", |
| 219 | ] { |
| 220 | let result = automation(&mut FakePresentation, Some(invalid)); |
| 221 | assert!(result.is_error, "{invalid} should be rejected"); |
| 222 | assert!(result.action.is_none()); |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | #[test] |
| 227 | fn handler_is_contextual_and_requests_presentation_facet() { |
| 228 | let CommandHandler::Contextual { |
| 229 | capabilities, |
| 230 | handler, |
| 231 | } = AutomationCmd::handler() |
| 232 | else { |
| 233 | panic!("automation must be contextual"); |
| 234 | }; |
| 235 | assert_eq!(capabilities, CommandCapabilities::PRESENTATION); |
| 236 | let missing = handler(CommandContexts::empty(), Some("list")); |
| 237 | assert!(missing.is_error); |
| 238 | assert_eq!( |
| 239 | missing.message.as_deref(), |
| 240 | Some("Error: Command capability unavailable: presentation") |
| 241 | ); |
| 242 | assert_eq!( |
| 243 | AutomationCmd::info().description_key, |
| 244 | "cmd_automation_description" |
| 245 | ); |
| 246 | assert_eq!(AutomationCmd::info().aliases, &["automations", "scheduled"]); |
| 247 | } |
| 248 | } |
| 249 |