返回 CodeWhale
automation.rs
根目录 / crates / tui / src / commands / groups / utility / automation.rs
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
249 lines RUST