Skip to main content

pixelactions_core/
flow.rs

1//! The flow file: a list of steps referencing a pixelcoords session by
2//! **label**, never by raw coordinate.
3//!
4//! That indirection is the point. A label survives the UI moving; a
5//! coordinate does not. It also keeps a flow reviewable in git — a diff
6//! shows intent ("click submit") rather than arithmetic.
7//!
8//! Parsing is strict: unknown keys are errors, not silent no-ops, so a
9//! typo fails loudly at parse time rather than skipping a step at run
10//! time. (Session parsing, by contrast, is deliberately tolerant — see
11//! AGENTS.md on the compatibility contract.)
12
13use serde::{Deserialize, Serialize};
14
15use crate::convert::Space;
16
17/// Whether a run re-confirms a region before acting on it.
18///
19/// This is a **precondition**, not a report card. "Is the region I am
20/// about to touch present and unambiguous?" has a stable answer; "did it
21/// survive being touched?" does not, because acting on something changes
22/// it. To assert an outcome, name what should have changed — `wait_for`,
23/// `wait_gone`, or a `verify` step on another region.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
25#[serde(rename_all = "lowercase")]
26pub enum Verify {
27    /// Re-confirm each region immediately before the step that touches it,
28    /// and act on where it is found. Keeps coordinates correct as the UI
29    /// reflows mid-run, at the cost of one capture per acting step.
30    #[default]
31    Each,
32    /// Act on the coordinates already known. Faster, and appropriate when
33    /// the run asserts its own outcomes with `wait_for` / `verify`.
34    None,
35}
36
37/// Which way a scroll goes.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
39#[serde(rename_all = "lowercase")]
40pub enum Axis {
41    /// Up and down. Positive amounts scroll **down**, matching every
42    /// platform's own wheel convention.
43    #[default]
44    Vertical,
45    /// Left and right, for side-scrolling panes. Positive scrolls right.
46    Horizontal,
47}
48
49/// What a step does.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
52pub enum Step {
53    /// Click the resolved point of a labeled region.
54    Click { target: String },
55    /// Double-click the resolved point of a labeled region.
56    DoubleClick { target: String },
57    /// Type literal text through the platform's Unicode path — layout
58    /// independent, and unable to express shortcuts (use `key`).
59    Type { text: String },
60    /// Press a chord of physical keys, e.g. `cmd+s`.
61    Key { chord: String },
62    /// Press at one region's point, move, release at another's.
63    Drag { from: String, to: String },
64    /// Hover a region and turn the wheel over it.
65    ///
66    /// `target` picks *what* to scroll — a wheel event goes to whatever
67    /// is under the cursor — and resolves exactly like a click's does.
68    /// `amount` is the one quantity in this tool that is **not**
69    /// exact: it counts 15° wheel clicks, and how far that moves depends
70    /// on the reader's own OS scroll-speed setting. Scroll until
71    /// something is visible (`wait_for`), never a fixed distance.
72    Scroll {
73        target: String,
74        amount: i32,
75        #[serde(default)]
76        axis: Axis,
77    },
78    /// Confirm a labeled region still matches its saved crop.
79    Verify { target: String },
80    /// Poll until a labeled region matches its saved crop again, or the
81    /// timeout expires. The honest alternative to guessing with a sleep:
82    /// the OS accepts an event long before an app finishes reacting.
83    WaitFor { target: String },
84    /// Poll until a labeled region STOPS matching — "wait until this
85    /// spinner goes away", "wait until the button changes state".
86    WaitGone { target: String },
87    /// Wait a fixed duration. Present because some waits genuinely have
88    /// no observable, and pretending otherwise would push people to
89    /// sleep-and-hope outside the tool.
90    Pause { ms: u64 },
91}
92
93impl Step {
94    /// Every session label this step needs. Resolution fails before any
95    /// action runs when one is missing.
96    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    /// Whether this step posts input, as opposed to only looking at the
110    /// screen.
111    ///
112    /// The distinction decides which regions must be *present* before a
113    /// run starts. Acting on a region whose position cannot be trusted
114    /// clicks an unknown thing; looking for one that is absent is the
115    /// entire job of `wait_for`.
116    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    /// A short human label for reports and dry-run output.
132    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/// Run-wide settings. Every field has a defensible default so a minimal
161/// flow file is three lines.
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163#[serde(deny_unknown_fields, default)]
164pub struct Settings {
165    /// Re-locate regions against a fresh capture before acting, and act
166    /// on the corrected coordinates.
167    pub relocate: bool,
168    /// How thoroughly to verify.
169    pub verify: Verify,
170    /// Coordinate space to resolve into. `Auto` is what the platform's
171    /// input API wants and is almost always right.
172    pub space: Space,
173    /// Milliseconds to settle between steps. Not a substitute for
174    /// verification — the OS accepts an event long before an app has
175    /// finished reacting to it.
176    pub settle_ms: u64,
177    /// How long a `wait_for` / `wait_gone` step may poll before it fails.
178    pub timeout_ms: u64,
179    /// Milliseconds between polls while waiting. Each poll is a screen
180    /// capture, so this is a real cost, not a formality.
181    pub poll_ms: u64,
182    /// Abort the run if the cursor is found in a screen corner before a
183    /// step. The kill switch: grabbing the mouse is what a person does
184    /// when automation goes wrong, and a corner needs no aim. On by
185    /// default — turning it off means nothing but the watchdog can stop
186    /// a run from the outside.
187    pub failsafe: bool,
188    /// How close to a corner counts, in the input space's own units.
189    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/// A parsed flow file.
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct Flow {
211    /// Path to the pixelcoords session directory (or its session.json).
212    pub session: String,
213    #[serde(default)]
214    pub settings: Settings,
215    // Defaulted so a step-less flow reaches the Empty check and gets a
216    // sentence a human can act on, rather than serde's "missing field".
217    #[serde(rename = "step", default)]
218    pub steps: Vec<Step>,
219}
220
221/// Parse errors, actionable by construction — the message names what to
222/// fix, in the tradition of the sister tool's strict config parsing.
223#[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    /// Parse a flow from TOML source.
233    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    /// Every distinct label the flow references, in first-use order.
242    pub fn targets(&self) -> Vec<&str> {
243        self.labels(|_| true)
244    }
245
246    /// The labels a run will actually *act on*, in first-use order.
247    ///
248    /// These are the ones that must be found before anything is
249    /// injected. The rest — a `wait_for` waiting for a dialog, a
250    /// `wait_gone` waiting for a spinner to clear — are by definition
251    /// allowed to be absent, and demanding them up front would make
252    /// those verbs impossible to use.
253    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        // Observation only — these read the screen and never move it.
293        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        // The regression: a flow that clicks one region and then waits
302        // for another used to demand *both* before starting, which made
303        // wait_for — the whole point of the verb — impossible to use.
304        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        // The least predictable value in the tool is the one place a
335        // silent default would hurt most.
336        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}