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 #[error(
267 "poll_ms ({poll_ms}) is longer than timeout_ms ({timeout_ms}), so a wait_for or \
268 wait_gone step would get at most one look at the screen before giving up — \
269 shorten poll_ms or lengthen timeout_ms"
270 )]
271 PollLongerThanTimeout { poll_ms: u64, timeout_ms: u64 },
272}
273
274impl Settings {
275 pub fn validate(&self) -> Result<(), FlowError> {
282 if self.poll_ms > self.timeout_ms {
283 return Err(FlowError::PollLongerThanTimeout {
284 poll_ms: self.poll_ms,
285 timeout_ms: self.timeout_ms,
286 });
287 }
288 Ok(())
289 }
290}
291
292impl Flow {
293 pub fn parse(source: &str) -> Result<Self, FlowError> {
295 let flow: Self = toml::from_str(source)?;
296 if flow.steps.is_empty() {
297 return Err(FlowError::Empty);
298 }
299 flow.settings.validate()?;
305 Ok(flow)
306 }
307
308 pub fn targets(&self) -> Vec<&str> {
310 self.labels(|_| true)
311 }
312
313 pub fn acting_targets(&self) -> Vec<&str> {
321 self.labels(Step::injects)
322 }
323
324 fn labels(&self, keep: impl Fn(&Step) -> bool) -> Vec<&str> {
325 let mut seen = Vec::new();
326 for step in self.steps.iter().filter(|step| keep(step)) {
327 for target in step.targets() {
328 if !seen.contains(&target) {
329 seen.push(target);
330 }
331 }
332 }
333 seen
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn only_injecting_steps_must_be_present_before_a_run() {
343 assert!(Step::Click { target: "a".into() }.injects());
344 assert!(Step::Type { text: "hi".into() }.injects());
345 assert!(
346 Step::Key {
347 chord: "cmd+s".into()
348 }
349 .injects()
350 );
351 assert!(
352 Step::Scroll {
353 target: "a".into(),
354 amount: 1,
355 axis: Axis::Vertical
356 }
357 .injects()
358 );
359 assert!(!Step::Verify { target: "a".into() }.injects());
361 assert!(!Step::WaitFor { target: "a".into() }.injects());
362 assert!(!Step::WaitGone { target: "a".into() }.injects());
363 assert!(!Step::Pause { ms: 10 }.injects());
364 }
365
366 #[test]
367 fn a_wait_for_target_is_not_required_to_exist_up_front() {
368 let flow = Flow::parse(
372 "session = \"s\"\n\n\
373 [[step]]\naction = \"click\"\ntarget = \"submit\"\n\n\
374 [[step]]\naction = \"wait_for\"\ntarget = \"confirmation\"\n\n\
375 [[step]]\naction = \"wait_gone\"\ntarget = \"spinner\"\n",
376 )
377 .expect("valid");
378
379 assert_eq!(flow.targets(), vec!["submit", "confirmation", "spinner"]);
380 assert_eq!(flow.acting_targets(), vec!["submit"]);
381 }
382
383 #[test]
384 fn a_scroll_step_reads_its_amount_and_defaults_to_vertical() {
385 let flow = Flow::parse(
386 "session = \"s\"\n\n[[step]]\naction = \"scroll\"\ntarget = \"results\"\namount = -3\n",
387 )
388 .expect("valid");
389 assert_eq!(
390 flow.steps[0],
391 Step::Scroll {
392 target: "results".into(),
393 amount: -3,
394 axis: Axis::Vertical,
395 }
396 );
397 }
398
399 #[test]
400 fn a_scroll_needs_an_amount_rather_than_guessing_one() {
401 let flow =
404 Flow::parse("session = \"s\"\n\n[[step]]\naction = \"scroll\"\ntarget = \"x\"\n");
405 assert!(flow.is_err(), "amount is required");
406 }
407
408 #[test]
409 fn a_scroll_names_the_direction_a_human_would_say() {
410 let down = Step::Scroll {
411 target: "list".into(),
412 amount: 3,
413 axis: Axis::Vertical,
414 };
415 let left = Step::Scroll {
416 target: "list".into(),
417 amount: -2,
418 axis: Axis::Horizontal,
419 };
420 assert_eq!(down.summary(), "scroll list down 3");
421 assert_eq!(left.summary(), "scroll list left 2");
422 }
423
424 #[test]
425 fn a_scroll_targets_the_region_it_hovers() {
426 let step = Step::Scroll {
427 target: "pane".into(),
428 amount: 1,
429 axis: Axis::Vertical,
430 };
431 assert_eq!(step.targets(), vec!["pane"]);
432 }
433
434 const MINIMAL: &str = r#"
435session = "~/captures/20260728"
436
437[[step]]
438action = "click"
439target = "submit"
440"#;
441
442 #[test]
443 fn a_minimal_flow_parses_with_defensible_defaults() {
444 let flow = Flow::parse(MINIMAL).expect("valid");
445 assert_eq!(flow.steps.len(), 1);
446 assert!(flow.settings.relocate, "relocation defaults on");
447 assert_eq!(flow.settings.verify, Verify::Each);
448 assert_eq!(flow.settings.space, Space::Auto);
449 }
450
451 #[test]
452 fn every_action_kind_round_trips() {
453 let source = r#"
454session = "s"
455
456[[step]]
457action = "click"
458target = "a"
459
460[[step]]
461action = "double_click"
462target = "b"
463
464[[step]]
465action = "type"
466text = "hello"
467
468[[step]]
469action = "key"
470chord = "cmd+s"
471
472[[step]]
473action = "drag"
474from = "handle"
475to = "zone"
476
477[[step]]
478action = "verify"
479target = "done"
480"#;
481 let flow = Flow::parse(source).expect("valid");
482 assert_eq!(flow.steps.len(), 6);
483 assert_eq!(flow.targets(), vec!["a", "b", "handle", "zone", "done"]);
484 }
485
486 #[test]
487 fn an_unknown_key_is_an_error_not_a_silent_skip() {
488 let source = r#"
489session = "s"
490
491[[step]]
492action = "click"
493targt = "typo"
494"#;
495 assert!(matches!(Flow::parse(source), Err(FlowError::Toml(_))));
496 }
497
498 #[test]
499 fn an_unknown_action_is_an_error() {
500 let source = r#"
501session = "s"
502
503[[step]]
504action = "teleport"
505target = "a"
506"#;
507 assert!(matches!(Flow::parse(source), Err(FlowError::Toml(_))));
508 }
509
510 #[test]
511 fn an_empty_flow_is_refused() {
512 assert!(matches!(
513 Flow::parse(r#"session = "s""#),
514 Err(FlowError::Empty)
515 ));
516 }
517
518 #[test]
519 fn targets_are_deduplicated_in_first_use_order() {
520 let source = r#"
521session = "s"
522
523[[step]]
524action = "click"
525target = "b"
526
527[[step]]
528action = "click"
529target = "a"
530
531[[step]]
532action = "verify"
533target = "b"
534"#;
535 assert_eq!(
536 Flow::parse(source).expect("valid").targets(),
537 vec!["b", "a"]
538 );
539 }
540
541 #[test]
542 fn waiting_and_pausing_parse() {
543 let source = r#"
544session = "s"
545
546[settings]
547timeout_ms = 3000
548poll_ms = 250
549
550[[step]]
551action = "wait_for"
552target = "dialog"
553
554[[step]]
555action = "wait_gone"
556target = "spinner"
557
558[[step]]
559action = "pause"
560ms = 500
561"#;
562 let flow = Flow::parse(source).expect("valid");
563 assert_eq!(flow.settings.timeout_ms, 3000);
564 assert_eq!(flow.settings.poll_ms, 250);
565 assert_eq!(flow.targets(), vec!["dialog", "spinner"]);
566 assert_eq!(flow.steps[2].summary(), "pause 500ms");
567 }
568
569 #[test]
570 fn keyboard_steps_need_no_targets() {
571 let step = Step::Type { text: "hi".into() };
572 assert!(step.targets().is_empty());
573 assert_eq!(step.summary(), "type 2 chars");
574 }
575
576 #[test]
577 fn settings_reject_unknown_keys_too() {
578 let source = r#"
579session = "s"
580
581[settings]
582reloacte = true
583
584[[step]]
585action = "click"
586target = "a"
587"#;
588 assert!(Flow::parse(source).is_err());
589 }
590
591 #[test]
595 fn a_poll_longer_than_the_timeout_is_refused_in_the_flows_own_words() {
596 let error = Flow::parse(
597 "session = \"s\"\n[settings]\ntimeout_ms = 500\npoll_ms = 5000\n\n\
598 [[step]]\naction = \"wait_for\"\ntarget = \"x\"\n",
599 )
600 .expect_err("should refuse");
601 let text = error.to_string();
602 assert!(text.contains("poll_ms"), "{text}");
603 assert!(text.contains("timeout_ms"), "{text}");
604 assert!(text.contains("5000") && text.contains("500"), "{text}");
605 assert!(!text.contains("--interval"), "leaks the other tool: {text}");
606 assert!(!text.contains("--timeout"), "leaks the other tool: {text}");
607 }
608
609 #[test]
611 fn a_poll_equal_to_the_timeout_is_allowed() {
612 let settings = Settings {
613 poll_ms: 500,
614 timeout_ms: 500,
615 ..Settings::default()
616 };
617 assert!(settings.validate().is_ok());
618 }
619
620 #[test]
621 fn the_default_settings_are_self_consistent() {
622 assert!(Settings::default().validate().is_ok());
623 }
624}