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, 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}
37
38pub fn parse(argument: &str) -> Result<Step, VerbError> {
45 let Some((verb, rest)) = argument.split_once(':') else {
46 return Err(VerbError::Malformed(argument.to_string()));
47 };
48 let verb = verb.trim();
49
50 let step = match verb {
51 "click" => Step::Click {
52 target: label(verb, rest)?,
53 },
54 "double" => Step::DoubleClick {
55 target: label(verb, rest)?,
56 },
57 "verify" => Step::Verify {
58 target: label(verb, rest)?,
59 },
60 "wait" => Step::WaitFor {
61 target: label(verb, rest)?,
62 },
63 "gone" => Step::WaitGone {
64 target: label(verb, rest)?,
65 },
66 "type" => Step::Type {
67 text: rest.to_string(),
68 },
69 "key" => Step::Key {
70 chord: label(verb, rest)?,
71 },
72 "drag" => {
73 let Some((from, to)) = rest.split_once('>') else {
74 return Err(VerbError::DragShape(rest.to_string()));
75 };
76 if from.trim().is_empty() || to.trim().is_empty() {
77 return Err(VerbError::DragShape(rest.to_string()));
78 }
79 Step::Drag {
80 from: from.trim().to_string(),
81 to: to.trim().to_string(),
82 }
83 }
84 "scroll" => scroll(verb, rest, Axis::Vertical)?,
85 "hscroll" => scroll(verb, rest, Axis::Horizontal)?,
86 "pause" => {
87 let ms = rest
88 .trim()
89 .parse::<u64>()
90 .map_err(|_| VerbError::PauseValue(rest.to_string()))?;
91 Step::Pause { ms }
92 }
93 other => return Err(VerbError::Unknown(other.to_string())),
94 };
95 Ok(step)
96}
97
98fn scroll(verb: &str, rest: &str, axis: Axis) -> Result<Step, VerbError> {
104 let shape = || VerbError::ScrollShape(verb.to_string(), rest.to_string());
105 let Some((target, amount)) = rest.split_once('>') else {
106 return Err(shape());
107 };
108 let target = target.trim();
109 if target.is_empty() {
110 return Err(shape());
111 }
112 let amount: i32 = amount.trim().parse().map_err(|_| shape())?;
113 if amount == 0 {
114 return Err(shape());
115 }
116 Ok(Step::Scroll {
117 target: target.to_string(),
118 amount,
119 axis,
120 })
121}
122
123pub fn parse_all<I, S>(arguments: I) -> Result<Vec<Step>, VerbError>
128where
129 I: IntoIterator<Item = S>,
130 S: AsRef<str>,
131{
132 arguments.into_iter().map(|a| parse(a.as_ref())).collect()
133}
134
135fn label(verb: &str, rest: &str) -> Result<String, VerbError> {
136 let trimmed = rest.trim();
137 if trimmed.is_empty() {
138 return Err(VerbError::EmptyLabel(verb.to_string()));
139 }
140 Ok(trimmed.to_string())
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn scroll_chains_as_label_then_amount() {
149 assert_eq!(
150 parse("scroll:results>-3").expect("valid"),
151 Step::Scroll {
152 target: "results".into(),
153 amount: -3,
154 axis: crate::flow::Axis::Vertical,
155 }
156 );
157 assert_eq!(
158 parse("hscroll:timeline>5").expect("valid"),
159 Step::Scroll {
160 target: "timeline".into(),
161 amount: 5,
162 axis: crate::flow::Axis::Horizontal,
163 }
164 );
165 }
166
167 #[test]
168 fn a_scroll_without_an_amount_is_refused() {
169 for bad in [
170 "scroll:results",
171 "scroll:>3",
172 "scroll:results>",
173 "scroll:results>lots",
174 ] {
175 assert!(parse(bad).is_err(), "should refuse {bad:?}");
176 }
177 }
178
179 #[test]
180 fn a_scroll_of_zero_is_refused_rather_than_silently_doing_nothing() {
181 assert!(parse("scroll:results>0").is_err());
182 }
183
184 #[test]
185 fn every_verb_maps_to_its_flow_action() {
186 assert_eq!(
187 parse("click:submit"),
188 Ok(Step::Click {
189 target: "submit".into()
190 })
191 );
192 assert_eq!(
193 parse("double:icon"),
194 Ok(Step::DoubleClick {
195 target: "icon".into()
196 })
197 );
198 assert_eq!(
199 parse("verify:done"),
200 Ok(Step::Verify {
201 target: "done".into()
202 })
203 );
204 assert_eq!(
205 parse("wait:dialog"),
206 Ok(Step::WaitFor {
207 target: "dialog".into()
208 })
209 );
210 assert_eq!(
211 parse("gone:spinner"),
212 Ok(Step::WaitGone {
213 target: "spinner".into()
214 })
215 );
216 assert_eq!(
217 parse("key:cmd+s"),
218 Ok(Step::Key {
219 chord: "cmd+s".into()
220 })
221 );
222 assert_eq!(parse("pause:250"), Ok(Step::Pause { ms: 250 }));
223 assert_eq!(
224 parse("drag:handle>zone"),
225 Ok(Step::Drag {
226 from: "handle".into(),
227 to: "zone".into()
228 })
229 );
230 }
231
232 #[test]
233 fn type_keeps_everything_after_the_first_colon() {
234 assert_eq!(
237 parse("type:https://example.com"),
238 Ok(Step::Type {
239 text: "https://example.com".into()
240 })
241 );
242 assert_eq!(
243 parse("type:a:b:c"),
244 Ok(Step::Type {
245 text: "a:b:c".into()
246 })
247 );
248 }
249
250 #[test]
251 fn type_may_be_deliberately_empty() {
252 assert_eq!(
255 parse("type:"),
256 Ok(Step::Type {
257 text: String::new()
258 })
259 );
260 }
261
262 #[test]
263 fn an_argument_without_a_colon_is_malformed() {
264 assert!(matches!(parse("click"), Err(VerbError::Malformed(_))));
265 }
266
267 #[test]
268 fn an_unknown_verb_names_the_real_ones() {
269 let error = parse("teleport:home").expect_err("unknown");
270 let message = error.to_string();
271 assert!(message.contains("click"), "lists the options: {message}");
272 }
273
274 #[test]
275 fn a_label_verb_without_a_label_is_refused() {
276 assert!(matches!(parse("click:"), Err(VerbError::EmptyLabel(_))));
277 assert!(matches!(parse("wait: "), Err(VerbError::EmptyLabel(_))));
278 }
279
280 #[test]
281 fn drag_requires_both_ends() {
282 assert!(matches!(parse("drag:handle"), Err(VerbError::DragShape(_))));
283 assert!(matches!(parse("drag:>zone"), Err(VerbError::DragShape(_))));
284 assert!(matches!(
285 parse("drag:handle>"),
286 Err(VerbError::DragShape(_))
287 ));
288 }
289
290 #[test]
291 fn pause_needs_a_number() {
292 assert!(matches!(parse("pause:soon"), Err(VerbError::PauseValue(_))));
293 assert!(matches!(parse("pause:-5"), Err(VerbError::PauseValue(_))));
294 }
295
296 #[test]
297 fn whitespace_around_labels_is_tolerated() {
298 assert_eq!(
299 parse("click: submit "),
300 Ok(Step::Click {
301 target: "submit".into()
302 })
303 );
304 }
305
306 #[test]
307 fn a_chain_fails_whole_rather_than_running_the_good_part() {
308 let result = parse_all(["click:a", "type:hi", "key:cmd+s", "clik:b"]);
311 assert!(matches!(result, Err(VerbError::Unknown(_))));
312 }
313
314 #[test]
315 fn a_whole_chain_parses_in_order() {
316 let steps = parse_all(["click:submit", "type:hello", "wait:done"]).expect("valid");
317 assert_eq!(steps.len(), 3);
318 assert_eq!(
319 steps[2],
320 Step::WaitFor {
321 target: "done".into()
322 }
323 );
324 }
325}