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}
220
221impl Default for Settings {
222 fn default() -> Self {
223 Self {
224 relocate: true,
225 verify: Verify::Each,
226 space: Space::Auto,
227 settle_ms: 120,
228 timeout_ms: 10_000,
229 poll_ms: 400,
230 failsafe: true,
231 failsafe_margin: 10.0,
232 }
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[serde(deny_unknown_fields)]
239pub struct Flow {
240 pub session: String,
242 #[serde(default)]
243 pub settings: Settings,
244 #[serde(rename = "step", default)]
247 pub steps: Vec<Step>,
248}
249
250#[derive(Debug, thiserror::Error)]
253pub enum FlowError {
254 #[error("flow file is not valid TOML: {0}")]
255 Toml(#[from] toml::de::Error),
256 #[error("flow has no steps — nothing to run")]
257 Empty,
258}
259
260impl Flow {
261 pub fn parse(source: &str) -> Result<Self, FlowError> {
263 let flow: Self = toml::from_str(source)?;
264 if flow.steps.is_empty() {
265 return Err(FlowError::Empty);
266 }
267 Ok(flow)
268 }
269
270 pub fn targets(&self) -> Vec<&str> {
272 self.labels(|_| true)
273 }
274
275 pub fn acting_targets(&self) -> Vec<&str> {
283 self.labels(Step::injects)
284 }
285
286 fn labels(&self, keep: impl Fn(&Step) -> bool) -> Vec<&str> {
287 let mut seen = Vec::new();
288 for step in self.steps.iter().filter(|step| keep(step)) {
289 for target in step.targets() {
290 if !seen.contains(&target) {
291 seen.push(target);
292 }
293 }
294 }
295 seen
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn only_injecting_steps_must_be_present_before_a_run() {
305 assert!(Step::Click { target: "a".into() }.injects());
306 assert!(Step::Type { text: "hi".into() }.injects());
307 assert!(
308 Step::Key {
309 chord: "cmd+s".into()
310 }
311 .injects()
312 );
313 assert!(
314 Step::Scroll {
315 target: "a".into(),
316 amount: 1,
317 axis: Axis::Vertical
318 }
319 .injects()
320 );
321 assert!(!Step::Verify { target: "a".into() }.injects());
323 assert!(!Step::WaitFor { target: "a".into() }.injects());
324 assert!(!Step::WaitGone { target: "a".into() }.injects());
325 assert!(!Step::Pause { ms: 10 }.injects());
326 }
327
328 #[test]
329 fn a_wait_for_target_is_not_required_to_exist_up_front() {
330 let flow = Flow::parse(
334 "session = \"s\"\n\n\
335 [[step]]\naction = \"click\"\ntarget = \"submit\"\n\n\
336 [[step]]\naction = \"wait_for\"\ntarget = \"confirmation\"\n\n\
337 [[step]]\naction = \"wait_gone\"\ntarget = \"spinner\"\n",
338 )
339 .expect("valid");
340
341 assert_eq!(flow.targets(), vec!["submit", "confirmation", "spinner"]);
342 assert_eq!(flow.acting_targets(), vec!["submit"]);
343 }
344
345 #[test]
346 fn a_scroll_step_reads_its_amount_and_defaults_to_vertical() {
347 let flow = Flow::parse(
348 "session = \"s\"\n\n[[step]]\naction = \"scroll\"\ntarget = \"results\"\namount = -3\n",
349 )
350 .expect("valid");
351 assert_eq!(
352 flow.steps[0],
353 Step::Scroll {
354 target: "results".into(),
355 amount: -3,
356 axis: Axis::Vertical,
357 }
358 );
359 }
360
361 #[test]
362 fn a_scroll_needs_an_amount_rather_than_guessing_one() {
363 let flow =
366 Flow::parse("session = \"s\"\n\n[[step]]\naction = \"scroll\"\ntarget = \"x\"\n");
367 assert!(flow.is_err(), "amount is required");
368 }
369
370 #[test]
371 fn a_scroll_names_the_direction_a_human_would_say() {
372 let down = Step::Scroll {
373 target: "list".into(),
374 amount: 3,
375 axis: Axis::Vertical,
376 };
377 let left = Step::Scroll {
378 target: "list".into(),
379 amount: -2,
380 axis: Axis::Horizontal,
381 };
382 assert_eq!(down.summary(), "scroll list down 3");
383 assert_eq!(left.summary(), "scroll list left 2");
384 }
385
386 #[test]
387 fn a_scroll_targets_the_region_it_hovers() {
388 let step = Step::Scroll {
389 target: "pane".into(),
390 amount: 1,
391 axis: Axis::Vertical,
392 };
393 assert_eq!(step.targets(), vec!["pane"]);
394 }
395
396 const MINIMAL: &str = r#"
397session = "~/captures/20260728"
398
399[[step]]
400action = "click"
401target = "submit"
402"#;
403
404 #[test]
405 fn a_minimal_flow_parses_with_defensible_defaults() {
406 let flow = Flow::parse(MINIMAL).expect("valid");
407 assert_eq!(flow.steps.len(), 1);
408 assert!(flow.settings.relocate, "relocation defaults on");
409 assert_eq!(flow.settings.verify, Verify::Each);
410 assert_eq!(flow.settings.space, Space::Auto);
411 }
412
413 #[test]
414 fn every_action_kind_round_trips() {
415 let source = r#"
416session = "s"
417
418[[step]]
419action = "click"
420target = "a"
421
422[[step]]
423action = "double_click"
424target = "b"
425
426[[step]]
427action = "type"
428text = "hello"
429
430[[step]]
431action = "key"
432chord = "cmd+s"
433
434[[step]]
435action = "drag"
436from = "handle"
437to = "zone"
438
439[[step]]
440action = "verify"
441target = "done"
442"#;
443 let flow = Flow::parse(source).expect("valid");
444 assert_eq!(flow.steps.len(), 6);
445 assert_eq!(flow.targets(), vec!["a", "b", "handle", "zone", "done"]);
446 }
447
448 #[test]
449 fn an_unknown_key_is_an_error_not_a_silent_skip() {
450 let source = r#"
451session = "s"
452
453[[step]]
454action = "click"
455targt = "typo"
456"#;
457 assert!(matches!(Flow::parse(source), Err(FlowError::Toml(_))));
458 }
459
460 #[test]
461 fn an_unknown_action_is_an_error() {
462 let source = r#"
463session = "s"
464
465[[step]]
466action = "teleport"
467target = "a"
468"#;
469 assert!(matches!(Flow::parse(source), Err(FlowError::Toml(_))));
470 }
471
472 #[test]
473 fn an_empty_flow_is_refused() {
474 assert!(matches!(
475 Flow::parse(r#"session = "s""#),
476 Err(FlowError::Empty)
477 ));
478 }
479
480 #[test]
481 fn targets_are_deduplicated_in_first_use_order() {
482 let source = r#"
483session = "s"
484
485[[step]]
486action = "click"
487target = "b"
488
489[[step]]
490action = "click"
491target = "a"
492
493[[step]]
494action = "verify"
495target = "b"
496"#;
497 assert_eq!(
498 Flow::parse(source).expect("valid").targets(),
499 vec!["b", "a"]
500 );
501 }
502
503 #[test]
504 fn waiting_and_pausing_parse() {
505 let source = r#"
506session = "s"
507
508[settings]
509timeout_ms = 3000
510poll_ms = 250
511
512[[step]]
513action = "wait_for"
514target = "dialog"
515
516[[step]]
517action = "wait_gone"
518target = "spinner"
519
520[[step]]
521action = "pause"
522ms = 500
523"#;
524 let flow = Flow::parse(source).expect("valid");
525 assert_eq!(flow.settings.timeout_ms, 3000);
526 assert_eq!(flow.settings.poll_ms, 250);
527 assert_eq!(flow.targets(), vec!["dialog", "spinner"]);
528 assert_eq!(flow.steps[2].summary(), "pause 500ms");
529 }
530
531 #[test]
532 fn keyboard_steps_need_no_targets() {
533 let step = Step::Type { text: "hi".into() };
534 assert!(step.targets().is_empty());
535 assert_eq!(step.summary(), "type 2 chars");
536 }
537
538 #[test]
539 fn settings_reject_unknown_keys_too() {
540 let source = r#"
541session = "s"
542
543[settings]
544reloacte = true
545
546[[step]]
547action = "click"
548target = "a"
549"#;
550 assert!(Flow::parse(source).is_err());
551 }
552}