1use crate::flow::{Axis, Step};
14
15#[derive(Debug, thiserror::Error, PartialEq)]
17pub enum VerbError {
18 #[error("{0:?} is not verb:argument — try click:submit, type:\"hello\", or wait:done")]
19 Malformed(String),
20 #[error(
21 "unknown verb {0:?} — expected click, double, drag, scroll, hscroll, type, key, \
22 verify, changed, wait, gone, or pause"
23 )]
24 Unknown(String),
25 #[error("drag needs from>to, e.g. drag:handle>dropzone (got {0:?})")]
26 DragShape(String),
27 #[error("pause needs milliseconds, e.g. pause:250 (got {0:?})")]
28 PauseValue(String),
29 #[error(
30 "{0} needs label>amount, e.g. {0}:results>3 to go one way and {0}:results>-3 the \
31 other (got {1:?})"
32 )]
33 ScrollShape(String, String),
34 #[error("{0} needs a label, e.g. {0}:submit")]
35 EmptyLabel(String),
36 #[error(
37 "changed takes a label, optionally with a percentage: changed:panel, or \
38 changed:panel>2.5 to require more than 2.5% of its pixels to differ (got {0:?})"
39 )]
40 ChangedShape(String),
41}
42
43pub fn parse(argument: &str) -> Result<Step, VerbError> {
50 let Some((verb, rest)) = argument.split_once(':') else {
51 return Err(VerbError::Malformed(argument.to_string()));
52 };
53 let verb = verb.trim();
54
55 let step = match verb {
56 "click" => Step::Click {
57 target: label(verb, rest)?,
58 },
59 "double" => Step::DoubleClick {
60 target: label(verb, rest)?,
61 },
62 "verify" => Step::Verify {
63 target: label(verb, rest)?,
64 },
65 "wait" => Step::WaitFor {
66 target: label(verb, rest)?,
67 },
68 "gone" => Step::WaitGone {
69 target: label(verb, rest)?,
70 },
71 "type" => Step::Type {
72 text: rest.to_string(),
73 },
74 "key" => Step::Key {
75 chord: label(verb, rest)?,
76 },
77 "drag" => {
78 let Some((from, to)) = rest.split_once('>') else {
79 return Err(VerbError::DragShape(rest.to_string()));
80 };
81 if from.trim().is_empty() || to.trim().is_empty() {
82 return Err(VerbError::DragShape(rest.to_string()));
83 }
84 Step::Drag {
85 from: from.trim().to_string(),
86 to: to.trim().to_string(),
87 }
88 }
89 "changed" => {
90 let (target, tolerance) = match rest.split_once('>') {
94 None => (rest, 0.0),
95 Some((target, pct)) => {
96 let parsed: f64 = pct
97 .trim()
98 .parse()
99 .map_err(|_| VerbError::ChangedShape(rest.to_string()))?;
100 if !parsed.is_finite() || !(0.0..=100.0).contains(&parsed) {
101 return Err(VerbError::ChangedShape(rest.to_string()));
102 }
103 (target, parsed)
104 }
105 };
106 Step::Changed {
107 target: label(verb, target)?,
108 tolerance,
109 }
110 }
111 "scroll" => scroll(verb, rest, Axis::Vertical)?,
112 "hscroll" => scroll(verb, rest, Axis::Horizontal)?,
113 "pause" => {
114 let ms = rest
115 .trim()
116 .parse::<u64>()
117 .map_err(|_| VerbError::PauseValue(rest.to_string()))?;
118 Step::Pause { ms }
119 }
120 other => return Err(VerbError::Unknown(other.to_string())),
121 };
122 Ok(step)
123}
124
125fn scroll(verb: &str, rest: &str, axis: Axis) -> Result<Step, VerbError> {
131 let shape = || VerbError::ScrollShape(verb.to_string(), rest.to_string());
132 let Some((target, amount)) = rest.split_once('>') else {
133 return Err(shape());
134 };
135 let target = target.trim();
136 if target.is_empty() {
137 return Err(shape());
138 }
139 let amount: i32 = amount.trim().parse().map_err(|_| shape())?;
140 if amount == 0 {
141 return Err(shape());
142 }
143 Ok(Step::Scroll {
144 target: target.to_string(),
145 amount,
146 axis,
147 })
148}
149
150pub fn parse_all<I, S>(arguments: I) -> Result<Vec<Step>, VerbError>
155where
156 I: IntoIterator<Item = S>,
157 S: AsRef<str>,
158{
159 arguments.into_iter().map(|a| parse(a.as_ref())).collect()
160}
161
162fn label(verb: &str, rest: &str) -> Result<String, VerbError> {
163 let trimmed = rest.trim();
164 if trimmed.is_empty() {
165 return Err(VerbError::EmptyLabel(verb.to_string()));
166 }
167 Ok(trimmed.to_string())
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn scroll_chains_as_label_then_amount() {
176 assert_eq!(
177 parse("scroll:results>-3").expect("valid"),
178 Step::Scroll {
179 target: "results".into(),
180 amount: -3,
181 axis: crate::flow::Axis::Vertical,
182 }
183 );
184 assert_eq!(
185 parse("hscroll:timeline>5").expect("valid"),
186 Step::Scroll {
187 target: "timeline".into(),
188 amount: 5,
189 axis: crate::flow::Axis::Horizontal,
190 }
191 );
192 }
193
194 #[test]
195 fn a_scroll_without_an_amount_is_refused() {
196 for bad in [
197 "scroll:results",
198 "scroll:>3",
199 "scroll:results>",
200 "scroll:results>lots",
201 ] {
202 assert!(parse(bad).is_err(), "should refuse {bad:?}");
203 }
204 }
205
206 #[test]
207 fn a_scroll_of_zero_is_refused_rather_than_silently_doing_nothing() {
208 assert!(parse("scroll:results>0").is_err());
209 }
210
211 #[test]
212 fn every_verb_maps_to_its_flow_action() {
213 assert_eq!(
214 parse("click:submit"),
215 Ok(Step::Click {
216 target: "submit".into()
217 })
218 );
219 assert_eq!(
220 parse("double:icon"),
221 Ok(Step::DoubleClick {
222 target: "icon".into()
223 })
224 );
225 assert_eq!(
226 parse("verify:done"),
227 Ok(Step::Verify {
228 target: "done".into()
229 })
230 );
231 assert_eq!(
232 parse("wait:dialog"),
233 Ok(Step::WaitFor {
234 target: "dialog".into()
235 })
236 );
237 assert_eq!(
238 parse("gone:spinner"),
239 Ok(Step::WaitGone {
240 target: "spinner".into()
241 })
242 );
243 assert_eq!(
244 parse("key:cmd+s"),
245 Ok(Step::Key {
246 chord: "cmd+s".into()
247 })
248 );
249 assert_eq!(parse("pause:250"), Ok(Step::Pause { ms: 250 }));
250 assert_eq!(
251 parse("drag:handle>zone"),
252 Ok(Step::Drag {
253 from: "handle".into(),
254 to: "zone".into()
255 })
256 );
257 }
258
259 #[test]
260 fn type_keeps_everything_after_the_first_colon() {
261 assert_eq!(
264 parse("type:https://example.com"),
265 Ok(Step::Type {
266 text: "https://example.com".into()
267 })
268 );
269 assert_eq!(
270 parse("type:a:b:c"),
271 Ok(Step::Type {
272 text: "a:b:c".into()
273 })
274 );
275 }
276
277 #[test]
278 fn type_may_be_deliberately_empty() {
279 assert_eq!(
282 parse("type:"),
283 Ok(Step::Type {
284 text: String::new()
285 })
286 );
287 }
288
289 #[test]
290 fn an_argument_without_a_colon_is_malformed() {
291 assert!(matches!(parse("click"), Err(VerbError::Malformed(_))));
292 }
293
294 #[test]
295 fn an_unknown_verb_names_the_real_ones() {
296 let error = parse("teleport:home").expect_err("unknown");
297 let message = error.to_string();
298 assert!(message.contains("click"), "lists the options: {message}");
299 }
300
301 #[test]
302 fn a_label_verb_without_a_label_is_refused() {
303 assert!(matches!(parse("click:"), Err(VerbError::EmptyLabel(_))));
304 assert!(matches!(parse("wait: "), Err(VerbError::EmptyLabel(_))));
305 }
306
307 #[test]
308 fn drag_requires_both_ends() {
309 assert!(matches!(parse("drag:handle"), Err(VerbError::DragShape(_))));
310 assert!(matches!(parse("drag:>zone"), Err(VerbError::DragShape(_))));
311 assert!(matches!(
312 parse("drag:handle>"),
313 Err(VerbError::DragShape(_))
314 ));
315 }
316
317 #[test]
318 fn pause_needs_a_number() {
319 assert!(matches!(parse("pause:soon"), Err(VerbError::PauseValue(_))));
320 assert!(matches!(parse("pause:-5"), Err(VerbError::PauseValue(_))));
321 }
322
323 #[test]
324 fn whitespace_around_labels_is_tolerated() {
325 assert_eq!(
326 parse("click: submit "),
327 Ok(Step::Click {
328 target: "submit".into()
329 })
330 );
331 }
332
333 #[test]
334 fn a_chain_fails_whole_rather_than_running_the_good_part() {
335 let result = parse_all(["click:a", "type:hi", "key:cmd+s", "clik:b"]);
338 assert!(matches!(result, Err(VerbError::Unknown(_))));
339 }
340
341 #[test]
342 fn a_whole_chain_parses_in_order() {
343 let steps = parse_all(["click:submit", "type:hello", "wait:done"]).expect("valid");
344 assert_eq!(steps.len(), 3);
345 assert_eq!(
346 steps[2],
347 Step::WaitFor {
348 target: "done".into()
349 }
350 );
351 }
352
353 #[test]
354 fn changed_takes_a_bare_label_and_defaults_to_any_pixel() {
355 let Step::Changed { target, tolerance } = parse("changed:panel").expect("parses") else {
356 panic!("wrong step");
357 };
358 assert_eq!(target, "panel");
359 assert!((tolerance - 0.0).abs() < f64::EPSILON);
360 }
361
362 #[test]
363 fn changed_takes_a_percentage_after_the_label() {
364 let Step::Changed { target, tolerance } = parse("changed:panel>2.5").expect("parses")
365 else {
366 panic!("wrong step");
367 };
368 assert_eq!(target, "panel");
369 assert!((tolerance - 2.5).abs() < f64::EPSILON);
370 }
371
372 #[test]
373 fn changed_refuses_a_percentage_that_is_not_one() {
374 for bad in [
375 "changed:panel>",
376 "changed:panel>banana",
377 "changed:panel>-1",
378 "changed:panel>101",
379 "changed:panel>NaN",
380 ] {
381 assert!(
382 matches!(parse(bad), Err(VerbError::ChangedShape(_))),
383 "should refuse {bad:?}"
384 );
385 }
386 }
387
388 #[test]
389 fn changed_still_needs_a_label() {
390 assert!(matches!(parse("changed:"), Err(VerbError::EmptyLabel(_))));
391 }
392}