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, Serialize, Deserialize)]
55#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
56pub enum Step {
57 Click { target: String },
59 DoubleClick { target: String },
61 Type { text: String },
64 Key { chord: String },
66 Drag { from: String, to: String },
68 Scroll {
77 target: String,
78 amount: i32,
79 #[serde(default)]
80 axis: Axis,
81 },
82 Verify { target: String },
84 WaitFor { target: String },
88 WaitGone { target: String },
91 Changed {
106 target: String,
107 #[serde(default)]
108 tolerance: f64,
109 },
110 Pause { ms: u64 },
114}
115
116impl Step {
117 pub fn targets(&self) -> Vec<&str> {
120 match self {
121 Self::Click { target }
122 | Self::DoubleClick { target }
123 | Self::Scroll { target, .. }
124 | Self::Verify { target }
125 | Self::WaitFor { target }
126 | Self::WaitGone { target }
127 | Self::Changed { target, .. } => vec![target.as_str()],
128 Self::Drag { from, to } => vec![from.as_str(), to.as_str()],
129 Self::Type { .. } | Self::Key { .. } | Self::Pause { .. } => Vec::new(),
130 }
131 }
132
133 pub fn injects(&self) -> bool {
141 match self {
142 Self::Click { .. }
143 | Self::DoubleClick { .. }
144 | Self::Drag { .. }
145 | Self::Scroll { .. }
146 | Self::Type { .. }
147 | Self::Key { .. } => true,
148 Self::Verify { .. }
149 | Self::WaitFor { .. }
150 | Self::WaitGone { .. }
151 | Self::Changed { .. }
152 | Self::Pause { .. } => false,
153 }
154 }
155
156 pub fn summary(&self) -> String {
158 match self {
159 Self::Click { target } => format!("click {target}"),
160 Self::DoubleClick { target } => format!("double-click {target}"),
161 Self::Type { text } => format!("type {} chars", text.chars().count()),
162 Self::Key { chord } => format!("key {chord}"),
163 Self::Drag { from, to } => format!("drag {from} -> {to}"),
164 Self::Scroll {
165 target,
166 amount,
167 axis,
168 } => {
169 let way = match (axis, amount.is_negative()) {
170 (Axis::Vertical, false) => "down",
171 (Axis::Vertical, true) => "up",
172 (Axis::Horizontal, false) => "right",
173 (Axis::Horizontal, true) => "left",
174 };
175 format!("scroll {target} {way} {}", amount.abs())
176 }
177 Self::Verify { target } => format!("verify {target}"),
178 Self::WaitFor { target } => format!("wait for {target}"),
179 Self::WaitGone { target } => format!("wait until {target} is gone"),
180 Self::Changed { target, tolerance } if *tolerance > 0.0 => {
181 format!("changed {target} by over {tolerance}%")
182 }
183 Self::Changed { target, .. } => format!("changed {target}"),
184 Self::Pause { ms } => format!("pause {ms}ms"),
185 }
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields, default)]
193pub struct Settings {
194 pub relocate: bool,
197 pub verify: Verify,
199 pub space: Space,
202 pub settle_ms: u64,
206 pub timeout_ms: u64,
208 pub poll_ms: u64,
211 pub failsafe: bool,
217 pub failsafe_margin: f64,
219 pub audit: bool,
226}
227
228impl Default for Settings {
229 fn default() -> Self {
230 Self {
231 relocate: true,
232 verify: Verify::Each,
233 space: Space::Auto,
234 settle_ms: 120,
235 timeout_ms: 10_000,
236 poll_ms: 400,
237 failsafe: true,
238 failsafe_margin: 10.0,
239 audit: true,
240 }
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
246#[serde(deny_unknown_fields)]
247pub struct Flow {
248 pub session: String,
250 #[serde(default)]
251 pub settings: Settings,
252 #[serde(rename = "step", default)]
255 pub steps: Vec<Step>,
256}
257
258#[derive(Debug, thiserror::Error)]
261pub enum FlowError {
262 #[error("flow file is not valid TOML: {0}")]
263 Toml(#[from] toml::de::Error),
264 #[error("flow has no steps — nothing to run")]
265 Empty,
266}
267
268impl Flow {
269 pub fn parse(source: &str) -> Result<Self, FlowError> {
271 let flow: Self = toml::from_str(source)?;
272 if flow.steps.is_empty() {
273 return Err(FlowError::Empty);
274 }
275 Ok(flow)
276 }
277
278 pub fn targets(&self) -> Vec<&str> {
280 self.labels(|_| true)
281 }
282
283 pub fn acting_targets(&self) -> Vec<&str> {
291 self.labels(Step::injects)
292 }
293
294 fn labels(&self, keep: impl Fn(&Step) -> bool) -> Vec<&str> {
295 let mut seen = Vec::new();
296 for step in self.steps.iter().filter(|step| keep(step)) {
297 for target in step.targets() {
298 if !seen.contains(&target) {
299 seen.push(target);
300 }
301 }
302 }
303 seen
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn only_injecting_steps_must_be_present_before_a_run() {
313 assert!(Step::Click { target: "a".into() }.injects());
314 assert!(Step::Type { text: "hi".into() }.injects());
315 assert!(
316 Step::Key {
317 chord: "cmd+s".into()
318 }
319 .injects()
320 );
321 assert!(
322 Step::Scroll {
323 target: "a".into(),
324 amount: 1,
325 axis: Axis::Vertical
326 }
327 .injects()
328 );
329 assert!(!Step::Verify { target: "a".into() }.injects());
331 assert!(!Step::WaitFor { target: "a".into() }.injects());
332 assert!(!Step::WaitGone { target: "a".into() }.injects());
333 assert!(!Step::Pause { ms: 10 }.injects());
334 }
335
336 #[test]
337 fn a_wait_for_target_is_not_required_to_exist_up_front() {
338 let flow = Flow::parse(
342 "session = \"s\"\n\n\
343 [[step]]\naction = \"click\"\ntarget = \"submit\"\n\n\
344 [[step]]\naction = \"wait_for\"\ntarget = \"confirmation\"\n\n\
345 [[step]]\naction = \"wait_gone\"\ntarget = \"spinner\"\n",
346 )
347 .expect("valid");
348
349 assert_eq!(flow.targets(), vec!["submit", "confirmation", "spinner"]);
350 assert_eq!(flow.acting_targets(), vec!["submit"]);
351 }
352
353 #[test]
354 fn a_scroll_step_reads_its_amount_and_defaults_to_vertical() {
355 let flow = Flow::parse(
356 "session = \"s\"\n\n[[step]]\naction = \"scroll\"\ntarget = \"results\"\namount = -3\n",
357 )
358 .expect("valid");
359 assert_eq!(
360 flow.steps[0],
361 Step::Scroll {
362 target: "results".into(),
363 amount: -3,
364 axis: Axis::Vertical,
365 }
366 );
367 }
368
369 #[test]
370 fn a_scroll_needs_an_amount_rather_than_guessing_one() {
371 let flow =
374 Flow::parse("session = \"s\"\n\n[[step]]\naction = \"scroll\"\ntarget = \"x\"\n");
375 assert!(flow.is_err(), "amount is required");
376 }
377
378 #[test]
379 fn a_scroll_names_the_direction_a_human_would_say() {
380 let down = Step::Scroll {
381 target: "list".into(),
382 amount: 3,
383 axis: Axis::Vertical,
384 };
385 let left = Step::Scroll {
386 target: "list".into(),
387 amount: -2,
388 axis: Axis::Horizontal,
389 };
390 assert_eq!(down.summary(), "scroll list down 3");
391 assert_eq!(left.summary(), "scroll list left 2");
392 }
393
394 #[test]
395 fn a_scroll_targets_the_region_it_hovers() {
396 let step = Step::Scroll {
397 target: "pane".into(),
398 amount: 1,
399 axis: Axis::Vertical,
400 };
401 assert_eq!(step.targets(), vec!["pane"]);
402 }
403
404 const MINIMAL: &str = r#"
405session = "~/captures/20260728"
406
407[[step]]
408action = "click"
409target = "submit"
410"#;
411
412 #[test]
413 fn a_minimal_flow_parses_with_defensible_defaults() {
414 let flow = Flow::parse(MINIMAL).expect("valid");
415 assert_eq!(flow.steps.len(), 1);
416 assert!(flow.settings.relocate, "relocation defaults on");
417 assert_eq!(flow.settings.verify, Verify::Each);
418 assert_eq!(flow.settings.space, Space::Auto);
419 }
420
421 #[test]
422 fn every_action_kind_round_trips() {
423 let source = r#"
424session = "s"
425
426[[step]]
427action = "click"
428target = "a"
429
430[[step]]
431action = "double_click"
432target = "b"
433
434[[step]]
435action = "type"
436text = "hello"
437
438[[step]]
439action = "key"
440chord = "cmd+s"
441
442[[step]]
443action = "drag"
444from = "handle"
445to = "zone"
446
447[[step]]
448action = "verify"
449target = "done"
450"#;
451 let flow = Flow::parse(source).expect("valid");
452 assert_eq!(flow.steps.len(), 6);
453 assert_eq!(flow.targets(), vec!["a", "b", "handle", "zone", "done"]);
454 }
455
456 #[test]
457 fn an_unknown_key_is_an_error_not_a_silent_skip() {
458 let source = r#"
459session = "s"
460
461[[step]]
462action = "click"
463targt = "typo"
464"#;
465 assert!(matches!(Flow::parse(source), Err(FlowError::Toml(_))));
466 }
467
468 #[test]
469 fn an_unknown_action_is_an_error() {
470 let source = r#"
471session = "s"
472
473[[step]]
474action = "teleport"
475target = "a"
476"#;
477 assert!(matches!(Flow::parse(source), Err(FlowError::Toml(_))));
478 }
479
480 #[test]
481 fn an_empty_flow_is_refused() {
482 assert!(matches!(
483 Flow::parse(r#"session = "s""#),
484 Err(FlowError::Empty)
485 ));
486 }
487
488 #[test]
489 fn targets_are_deduplicated_in_first_use_order() {
490 let source = r#"
491session = "s"
492
493[[step]]
494action = "click"
495target = "b"
496
497[[step]]
498action = "click"
499target = "a"
500
501[[step]]
502action = "verify"
503target = "b"
504"#;
505 assert_eq!(
506 Flow::parse(source).expect("valid").targets(),
507 vec!["b", "a"]
508 );
509 }
510
511 #[test]
512 fn waiting_and_pausing_parse() {
513 let source = r#"
514session = "s"
515
516[settings]
517timeout_ms = 3000
518poll_ms = 250
519
520[[step]]
521action = "wait_for"
522target = "dialog"
523
524[[step]]
525action = "wait_gone"
526target = "spinner"
527
528[[step]]
529action = "pause"
530ms = 500
531"#;
532 let flow = Flow::parse(source).expect("valid");
533 assert_eq!(flow.settings.timeout_ms, 3000);
534 assert_eq!(flow.settings.poll_ms, 250);
535 assert_eq!(flow.targets(), vec!["dialog", "spinner"]);
536 assert_eq!(flow.steps[2].summary(), "pause 500ms");
537 }
538
539 #[test]
540 fn keyboard_steps_need_no_targets() {
541 let step = Step::Type { text: "hi".into() };
542 assert!(step.targets().is_empty());
543 assert_eq!(step.summary(), "type 2 chars");
544 }
545
546 #[test]
547 fn settings_reject_unknown_keys_too() {
548 let source = r#"
549session = "s"
550
551[settings]
552reloacte = true
553
554[[step]]
555action = "click"
556target = "a"
557"#;
558 assert!(Flow::parse(source).is_err());
559 }
560}