1use serde::{Deserialize, Serialize};
14
15use crate::convert::Space;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
25#[serde(rename_all = "lowercase")]
26pub enum Verify {
27 #[default]
31 Each,
32 None,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
39#[serde(rename_all = "lowercase")]
40pub enum Axis {
41 #[default]
44 Vertical,
45 Horizontal,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
52pub enum Step {
53 Click { target: String },
55 DoubleClick { target: String },
57 Type { text: String },
60 Key { chord: String },
62 Drag { from: String, to: String },
64 Scroll {
73 target: String,
74 amount: i32,
75 #[serde(default)]
76 axis: Axis,
77 },
78 Verify { target: String },
80 WaitFor { target: String },
84 WaitGone { target: String },
87 Pause { ms: u64 },
91}
92
93impl Step {
94 pub fn targets(&self) -> Vec<&str> {
97 match self {
98 Self::Click { target }
99 | Self::DoubleClick { target }
100 | Self::Scroll { target, .. }
101 | Self::Verify { target }
102 | Self::WaitFor { target }
103 | Self::WaitGone { target } => vec![target.as_str()],
104 Self::Drag { from, to } => vec![from.as_str(), to.as_str()],
105 Self::Type { .. } | Self::Key { .. } | Self::Pause { .. } => Vec::new(),
106 }
107 }
108
109 pub fn injects(&self) -> bool {
117 match self {
118 Self::Click { .. }
119 | Self::DoubleClick { .. }
120 | Self::Drag { .. }
121 | Self::Scroll { .. }
122 | Self::Type { .. }
123 | Self::Key { .. } => true,
124 Self::Verify { .. }
125 | Self::WaitFor { .. }
126 | Self::WaitGone { .. }
127 | Self::Pause { .. } => false,
128 }
129 }
130
131 pub fn summary(&self) -> String {
133 match self {
134 Self::Click { target } => format!("click {target}"),
135 Self::DoubleClick { target } => format!("double-click {target}"),
136 Self::Type { text } => format!("type {} chars", text.chars().count()),
137 Self::Key { chord } => format!("key {chord}"),
138 Self::Drag { from, to } => format!("drag {from} -> {to}"),
139 Self::Scroll {
140 target,
141 amount,
142 axis,
143 } => {
144 let way = match (axis, amount.is_negative()) {
145 (Axis::Vertical, false) => "down",
146 (Axis::Vertical, true) => "up",
147 (Axis::Horizontal, false) => "right",
148 (Axis::Horizontal, true) => "left",
149 };
150 format!("scroll {target} {way} {}", amount.abs())
151 }
152 Self::Verify { target } => format!("verify {target}"),
153 Self::WaitFor { target } => format!("wait for {target}"),
154 Self::WaitGone { target } => format!("wait until {target} is gone"),
155 Self::Pause { ms } => format!("pause {ms}ms"),
156 }
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163#[serde(deny_unknown_fields, default)]
164pub struct Settings {
165 pub relocate: bool,
168 pub verify: Verify,
170 pub space: Space,
173 pub settle_ms: u64,
177 pub timeout_ms: u64,
179 pub poll_ms: u64,
182 pub failsafe: bool,
188 pub failsafe_margin: f64,
190}
191
192impl Default for Settings {
193 fn default() -> Self {
194 Self {
195 relocate: true,
196 verify: Verify::Each,
197 space: Space::Auto,
198 settle_ms: 120,
199 timeout_ms: 10_000,
200 poll_ms: 400,
201 failsafe: true,
202 failsafe_margin: 10.0,
203 }
204 }
205}
206
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct Flow {
211 pub session: String,
213 #[serde(default)]
214 pub settings: Settings,
215 #[serde(rename = "step", default)]
218 pub steps: Vec<Step>,
219}
220
221#[derive(Debug, thiserror::Error)]
224pub enum FlowError {
225 #[error("flow file is not valid TOML: {0}")]
226 Toml(#[from] toml::de::Error),
227 #[error("flow has no steps — nothing to run")]
228 Empty,
229}
230
231impl Flow {
232 pub fn parse(source: &str) -> Result<Self, FlowError> {
234 let flow: Self = toml::from_str(source)?;
235 if flow.steps.is_empty() {
236 return Err(FlowError::Empty);
237 }
238 Ok(flow)
239 }
240
241 pub fn targets(&self) -> Vec<&str> {
243 self.labels(|_| true)
244 }
245
246 pub fn acting_targets(&self) -> Vec<&str> {
254 self.labels(Step::injects)
255 }
256
257 fn labels(&self, keep: impl Fn(&Step) -> bool) -> Vec<&str> {
258 let mut seen = Vec::new();
259 for step in self.steps.iter().filter(|step| keep(step)) {
260 for target in step.targets() {
261 if !seen.contains(&target) {
262 seen.push(target);
263 }
264 }
265 }
266 seen
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn only_injecting_steps_must_be_present_before_a_run() {
276 assert!(Step::Click { target: "a".into() }.injects());
277 assert!(Step::Type { text: "hi".into() }.injects());
278 assert!(
279 Step::Key {
280 chord: "cmd+s".into()
281 }
282 .injects()
283 );
284 assert!(
285 Step::Scroll {
286 target: "a".into(),
287 amount: 1,
288 axis: Axis::Vertical
289 }
290 .injects()
291 );
292 assert!(!Step::Verify { target: "a".into() }.injects());
294 assert!(!Step::WaitFor { target: "a".into() }.injects());
295 assert!(!Step::WaitGone { target: "a".into() }.injects());
296 assert!(!Step::Pause { ms: 10 }.injects());
297 }
298
299 #[test]
300 fn a_wait_for_target_is_not_required_to_exist_up_front() {
301 let flow = Flow::parse(
305 "session = \"s\"\n\n\
306 [[step]]\naction = \"click\"\ntarget = \"submit\"\n\n\
307 [[step]]\naction = \"wait_for\"\ntarget = \"confirmation\"\n\n\
308 [[step]]\naction = \"wait_gone\"\ntarget = \"spinner\"\n",
309 )
310 .expect("valid");
311
312 assert_eq!(flow.targets(), vec!["submit", "confirmation", "spinner"]);
313 assert_eq!(flow.acting_targets(), vec!["submit"]);
314 }
315
316 #[test]
317 fn a_scroll_step_reads_its_amount_and_defaults_to_vertical() {
318 let flow = Flow::parse(
319 "session = \"s\"\n\n[[step]]\naction = \"scroll\"\ntarget = \"results\"\namount = -3\n",
320 )
321 .expect("valid");
322 assert_eq!(
323 flow.steps[0],
324 Step::Scroll {
325 target: "results".into(),
326 amount: -3,
327 axis: Axis::Vertical,
328 }
329 );
330 }
331
332 #[test]
333 fn a_scroll_needs_an_amount_rather_than_guessing_one() {
334 let flow =
337 Flow::parse("session = \"s\"\n\n[[step]]\naction = \"scroll\"\ntarget = \"x\"\n");
338 assert!(flow.is_err(), "amount is required");
339 }
340
341 #[test]
342 fn a_scroll_names_the_direction_a_human_would_say() {
343 let down = Step::Scroll {
344 target: "list".into(),
345 amount: 3,
346 axis: Axis::Vertical,
347 };
348 let left = Step::Scroll {
349 target: "list".into(),
350 amount: -2,
351 axis: Axis::Horizontal,
352 };
353 assert_eq!(down.summary(), "scroll list down 3");
354 assert_eq!(left.summary(), "scroll list left 2");
355 }
356
357 #[test]
358 fn a_scroll_targets_the_region_it_hovers() {
359 let step = Step::Scroll {
360 target: "pane".into(),
361 amount: 1,
362 axis: Axis::Vertical,
363 };
364 assert_eq!(step.targets(), vec!["pane"]);
365 }
366
367 const MINIMAL: &str = r#"
368session = "~/captures/20260728"
369
370[[step]]
371action = "click"
372target = "submit"
373"#;
374
375 #[test]
376 fn a_minimal_flow_parses_with_defensible_defaults() {
377 let flow = Flow::parse(MINIMAL).expect("valid");
378 assert_eq!(flow.steps.len(), 1);
379 assert!(flow.settings.relocate, "relocation defaults on");
380 assert_eq!(flow.settings.verify, Verify::Each);
381 assert_eq!(flow.settings.space, Space::Auto);
382 }
383
384 #[test]
385 fn every_action_kind_round_trips() {
386 let source = r#"
387session = "s"
388
389[[step]]
390action = "click"
391target = "a"
392
393[[step]]
394action = "double_click"
395target = "b"
396
397[[step]]
398action = "type"
399text = "hello"
400
401[[step]]
402action = "key"
403chord = "cmd+s"
404
405[[step]]
406action = "drag"
407from = "handle"
408to = "zone"
409
410[[step]]
411action = "verify"
412target = "done"
413"#;
414 let flow = Flow::parse(source).expect("valid");
415 assert_eq!(flow.steps.len(), 6);
416 assert_eq!(flow.targets(), vec!["a", "b", "handle", "zone", "done"]);
417 }
418
419 #[test]
420 fn an_unknown_key_is_an_error_not_a_silent_skip() {
421 let source = r#"
422session = "s"
423
424[[step]]
425action = "click"
426targt = "typo"
427"#;
428 assert!(matches!(Flow::parse(source), Err(FlowError::Toml(_))));
429 }
430
431 #[test]
432 fn an_unknown_action_is_an_error() {
433 let source = r#"
434session = "s"
435
436[[step]]
437action = "teleport"
438target = "a"
439"#;
440 assert!(matches!(Flow::parse(source), Err(FlowError::Toml(_))));
441 }
442
443 #[test]
444 fn an_empty_flow_is_refused() {
445 assert!(matches!(
446 Flow::parse(r#"session = "s""#),
447 Err(FlowError::Empty)
448 ));
449 }
450
451 #[test]
452 fn targets_are_deduplicated_in_first_use_order() {
453 let source = r#"
454session = "s"
455
456[[step]]
457action = "click"
458target = "b"
459
460[[step]]
461action = "click"
462target = "a"
463
464[[step]]
465action = "verify"
466target = "b"
467"#;
468 assert_eq!(
469 Flow::parse(source).expect("valid").targets(),
470 vec!["b", "a"]
471 );
472 }
473
474 #[test]
475 fn waiting_and_pausing_parse() {
476 let source = r#"
477session = "s"
478
479[settings]
480timeout_ms = 3000
481poll_ms = 250
482
483[[step]]
484action = "wait_for"
485target = "dialog"
486
487[[step]]
488action = "wait_gone"
489target = "spinner"
490
491[[step]]
492action = "pause"
493ms = 500
494"#;
495 let flow = Flow::parse(source).expect("valid");
496 assert_eq!(flow.settings.timeout_ms, 3000);
497 assert_eq!(flow.settings.poll_ms, 250);
498 assert_eq!(flow.targets(), vec!["dialog", "spinner"]);
499 assert_eq!(flow.steps[2].summary(), "pause 500ms");
500 }
501
502 #[test]
503 fn keyboard_steps_need_no_targets() {
504 let step = Step::Type { text: "hi".into() };
505 assert!(step.targets().is_empty());
506 assert_eq!(step.summary(), "type 2 chars");
507 }
508
509 #[test]
510 fn settings_reject_unknown_keys_too() {
511 let source = r#"
512session = "s"
513
514[settings]
515reloacte = true
516
517[[step]]
518action = "click"
519target = "a"
520"#;
521 assert!(Flow::parse(source).is_err());
522 }
523}