use crate::flow::{Axis, Step};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum VerbError {
#[error("{0:?} is not verb:argument — try click:submit, type:\"hello\", or wait:done")]
Malformed(String),
#[error(
"unknown verb {0:?} — expected click, double, drag, scroll, hscroll, type, key, \
verify, changed, wait, gone, or pause"
)]
Unknown(String),
#[error("drag needs from>to, e.g. drag:handle>dropzone (got {0:?})")]
DragShape(String),
#[error("pause needs milliseconds, e.g. pause:250 (got {0:?})")]
PauseValue(String),
#[error(
"{0} needs label>amount, e.g. {0}:results>3 to go one way and {0}:results>-3 the \
other (got {1:?})"
)]
ScrollShape(String, String),
#[error("{0} needs a label, e.g. {0}:submit")]
EmptyLabel(String),
#[error(
"changed takes a label, optionally with a percentage: changed:panel, or \
changed:panel>2.5 to require more than 2.5% of its pixels to differ (got {0:?})"
)]
ChangedShape(String),
}
pub fn parse(argument: &str) -> Result<Step, VerbError> {
let Some((verb, rest)) = argument.split_once(':') else {
return Err(VerbError::Malformed(argument.to_string()));
};
let verb = verb.trim();
let step = match verb {
"click" => Step::Click {
target: label(verb, rest)?,
},
"double" => Step::DoubleClick {
target: label(verb, rest)?,
},
"verify" => Step::Verify {
target: label(verb, rest)?,
},
"wait" => Step::WaitFor {
target: label(verb, rest)?,
},
"gone" => Step::WaitGone {
target: label(verb, rest)?,
},
"type" => Step::Type {
text: rest.to_string(),
},
"key" => Step::Key {
chord: label(verb, rest)?,
},
"drag" => {
let Some((from, to)) = rest.split_once('>') else {
return Err(VerbError::DragShape(rest.to_string()));
};
if from.trim().is_empty() || to.trim().is_empty() {
return Err(VerbError::DragShape(rest.to_string()));
}
Step::Drag {
from: from.trim().to_string(),
to: to.trim().to_string(),
}
}
"changed" => {
let (target, tolerance) = match rest.split_once('>') {
None => (rest, 0.0),
Some((target, pct)) => {
let parsed: f64 = pct
.trim()
.parse()
.map_err(|_| VerbError::ChangedShape(rest.to_string()))?;
if !parsed.is_finite() || !(0.0..=100.0).contains(&parsed) {
return Err(VerbError::ChangedShape(rest.to_string()));
}
(target, parsed)
}
};
Step::Changed {
target: label(verb, target)?,
tolerance,
}
}
"scroll" => scroll(verb, rest, Axis::Vertical)?,
"hscroll" => scroll(verb, rest, Axis::Horizontal)?,
"pause" => {
let ms = rest
.trim()
.parse::<u64>()
.map_err(|_| VerbError::PauseValue(rest.to_string()))?;
Step::Pause { ms }
}
other => return Err(VerbError::Unknown(other.to_string())),
};
Ok(step)
}
fn scroll(verb: &str, rest: &str, axis: Axis) -> Result<Step, VerbError> {
let shape = || VerbError::ScrollShape(verb.to_string(), rest.to_string());
let Some((target, amount)) = rest.split_once('>') else {
return Err(shape());
};
let target = target.trim();
if target.is_empty() {
return Err(shape());
}
let amount: i32 = amount.trim().parse().map_err(|_| shape())?;
if amount == 0 {
return Err(shape());
}
Ok(Step::Scroll {
target: target.to_string(),
amount,
axis,
})
}
pub fn parse_all<I, S>(arguments: I) -> Result<Vec<Step>, VerbError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
arguments.into_iter().map(|a| parse(a.as_ref())).collect()
}
fn label(verb: &str, rest: &str) -> Result<String, VerbError> {
let trimmed = rest.trim();
if trimmed.is_empty() {
return Err(VerbError::EmptyLabel(verb.to_string()));
}
Ok(trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scroll_chains_as_label_then_amount() {
assert_eq!(
parse("scroll:results>-3").expect("valid"),
Step::Scroll {
target: "results".into(),
amount: -3,
axis: crate::flow::Axis::Vertical,
}
);
assert_eq!(
parse("hscroll:timeline>5").expect("valid"),
Step::Scroll {
target: "timeline".into(),
amount: 5,
axis: crate::flow::Axis::Horizontal,
}
);
}
#[test]
fn a_scroll_without_an_amount_is_refused() {
for bad in [
"scroll:results",
"scroll:>3",
"scroll:results>",
"scroll:results>lots",
] {
assert!(parse(bad).is_err(), "should refuse {bad:?}");
}
}
#[test]
fn a_scroll_of_zero_is_refused_rather_than_silently_doing_nothing() {
assert!(parse("scroll:results>0").is_err());
}
#[test]
fn every_verb_maps_to_its_flow_action() {
assert_eq!(
parse("click:submit"),
Ok(Step::Click {
target: "submit".into()
})
);
assert_eq!(
parse("double:icon"),
Ok(Step::DoubleClick {
target: "icon".into()
})
);
assert_eq!(
parse("verify:done"),
Ok(Step::Verify {
target: "done".into()
})
);
assert_eq!(
parse("wait:dialog"),
Ok(Step::WaitFor {
target: "dialog".into()
})
);
assert_eq!(
parse("gone:spinner"),
Ok(Step::WaitGone {
target: "spinner".into()
})
);
assert_eq!(
parse("key:cmd+s"),
Ok(Step::Key {
chord: "cmd+s".into()
})
);
assert_eq!(parse("pause:250"), Ok(Step::Pause { ms: 250 }));
assert_eq!(
parse("drag:handle>zone"),
Ok(Step::Drag {
from: "handle".into(),
to: "zone".into()
})
);
}
#[test]
fn type_keeps_everything_after_the_first_colon() {
assert_eq!(
parse("type:https://example.com"),
Ok(Step::Type {
text: "https://example.com".into()
})
);
assert_eq!(
parse("type:a:b:c"),
Ok(Step::Type {
text: "a:b:c".into()
})
);
}
#[test]
fn type_may_be_deliberately_empty() {
assert_eq!(
parse("type:"),
Ok(Step::Type {
text: String::new()
})
);
}
#[test]
fn an_argument_without_a_colon_is_malformed() {
assert!(matches!(parse("click"), Err(VerbError::Malformed(_))));
}
#[test]
fn an_unknown_verb_names_the_real_ones() {
let error = parse("teleport:home").expect_err("unknown");
let message = error.to_string();
assert!(message.contains("click"), "lists the options: {message}");
}
#[test]
fn a_label_verb_without_a_label_is_refused() {
assert!(matches!(parse("click:"), Err(VerbError::EmptyLabel(_))));
assert!(matches!(parse("wait: "), Err(VerbError::EmptyLabel(_))));
}
#[test]
fn drag_requires_both_ends() {
assert!(matches!(parse("drag:handle"), Err(VerbError::DragShape(_))));
assert!(matches!(parse("drag:>zone"), Err(VerbError::DragShape(_))));
assert!(matches!(
parse("drag:handle>"),
Err(VerbError::DragShape(_))
));
}
#[test]
fn pause_needs_a_number() {
assert!(matches!(parse("pause:soon"), Err(VerbError::PauseValue(_))));
assert!(matches!(parse("pause:-5"), Err(VerbError::PauseValue(_))));
}
#[test]
fn whitespace_around_labels_is_tolerated() {
assert_eq!(
parse("click: submit "),
Ok(Step::Click {
target: "submit".into()
})
);
}
#[test]
fn a_chain_fails_whole_rather_than_running_the_good_part() {
let result = parse_all(["click:a", "type:hi", "key:cmd+s", "clik:b"]);
assert!(matches!(result, Err(VerbError::Unknown(_))));
}
#[test]
fn a_whole_chain_parses_in_order() {
let steps = parse_all(["click:submit", "type:hello", "wait:done"]).expect("valid");
assert_eq!(steps.len(), 3);
assert_eq!(
steps[2],
Step::WaitFor {
target: "done".into()
}
);
}
#[test]
fn changed_takes_a_bare_label_and_defaults_to_any_pixel() {
let Step::Changed { target, tolerance } = parse("changed:panel").expect("parses") else {
panic!("wrong step");
};
assert_eq!(target, "panel");
assert!((tolerance - 0.0).abs() < f64::EPSILON);
}
#[test]
fn changed_takes_a_percentage_after_the_label() {
let Step::Changed { target, tolerance } = parse("changed:panel>2.5").expect("parses")
else {
panic!("wrong step");
};
assert_eq!(target, "panel");
assert!((tolerance - 2.5).abs() < f64::EPSILON);
}
#[test]
fn changed_refuses_a_percentage_that_is_not_one() {
for bad in [
"changed:panel>",
"changed:panel>banana",
"changed:panel>-1",
"changed:panel>101",
"changed:panel>NaN",
] {
assert!(
matches!(parse(bad), Err(VerbError::ChangedShape(_))),
"should refuse {bad:?}"
);
}
}
#[test]
fn changed_still_needs_a_label() {
assert!(matches!(parse("changed:"), Err(VerbError::EmptyLabel(_))));
}
}