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///
51/// `PartialEq` but not `Eq`: `changed`'s tolerance is a percentage, and a
52/// float has no total equality. Nothing keys a map on a step, so the
53/// weaker bound costs nothing.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
56pub enum Step {
57    /// Click the resolved point of a labeled region.
58    Click { target: String },
59    /// Double-click the resolved point of a labeled region.
60    DoubleClick { target: String },
61    /// Type literal text through the platform's Unicode path — layout
62    /// independent, and unable to express shortcuts (use `key`).
63    Type { text: String },
64    /// Press a chord of physical keys, e.g. `cmd+s`.
65    Key { chord: String },
66    /// Press at one region's point, move, release at another's.
67    Drag { from: String, to: String },
68    /// Hover a region and turn the wheel over it.
69    ///
70    /// `target` picks *what* to scroll — a wheel event goes to whatever
71    /// is under the cursor — and resolves exactly like a click's does.
72    /// `amount` is the one quantity in this tool that is **not**
73    /// exact: it counts 15° wheel clicks, and how far that moves depends
74    /// on the reader's own OS scroll-speed setting. Scroll until
75    /// something is visible (`wait_for`), never a fixed distance.
76    Scroll {
77        target: String,
78        amount: i32,
79        #[serde(default)]
80        axis: Axis,
81    },
82    /// Confirm a labeled region still matches its saved crop.
83    Verify { target: String },
84    /// Poll until a labeled region matches its saved crop again, or the
85    /// timeout expires. The honest alternative to guessing with a sleep:
86    /// the OS accepts an event long before an app finishes reacting.
87    WaitFor { target: String },
88    /// Poll until a labeled region STOPS matching — "wait until this
89    /// spinner goes away", "wait until the button changes state".
90    WaitGone { target: String },
91    /// Confirm a labeled region **changed** — the assertion that proves
92    /// an action did something, rather than that it was accepted.
93    ///
94    /// `verify` is the opposite question and answers it with normalized
95    /// correlation, which is brightness- and contrast-blind: a region that
96    /// dims uniformly behind a modal still scores ~1.0. This compares RGB
97    /// directly, so it sees what correlation cannot.
98    ///
99    /// `tolerance` is the percentage of the region's masked pixels that
100    /// must differ before it counts as changed. Zero — any pixel — is the
101    /// default and the right one for "did anything happen at all": if the
102    /// action did nothing, nothing differs. Raise it when something
103    /// unrelated lives in the region; a text caret blinking inside one is
104    /// enough to register on its own.
105    Changed {
106        target: String,
107        #[serde(default)]
108        tolerance: f64,
109    },
110    /// Wait a fixed duration. Present because some waits genuinely have
111    /// no observable, and pretending otherwise would push people to
112    /// sleep-and-hope outside the tool.
113    Pause { ms: u64 },
114}
115
116impl Step {
117    /// Every session label this step needs. Resolution fails before any
118    /// action runs when one is missing.
119    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    /// Whether this step posts input, as opposed to only looking at the
134    /// screen.
135    ///
136    /// The distinction decides which regions must be *present* before a
137    /// run starts. Acting on a region whose position cannot be trusted
138    /// clicks an unknown thing; looking for one that is absent is the
139    /// entire job of `wait_for`.
140    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    /// A short human label for reports and dry-run output.
157    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/// Run-wide settings. Every field has a defensible default so a minimal
190/// flow file is three lines.
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields, default)]
193pub struct Settings {
194    /// Re-locate regions against a fresh capture before acting, and act
195    /// on the corrected coordinates.
196    pub relocate: bool,
197    /// How thoroughly to verify.
198    pub verify: Verify,
199    /// Coordinate space to resolve into. `Auto` is what the platform's
200    /// input API wants and is almost always right.
201    pub space: Space,
202    /// Milliseconds to settle between steps. Not a substitute for
203    /// verification — the OS accepts an event long before an app has
204    /// finished reacting to it.
205    pub settle_ms: u64,
206    /// How long a `wait_for` / `wait_gone` step may poll before it fails.
207    pub timeout_ms: u64,
208    /// Milliseconds between polls while waiting. Each poll is a screen
209    /// capture, so this is a real cost, not a formality.
210    pub poll_ms: u64,
211    /// Abort the run if the cursor is found in a screen corner before a
212    /// step. The kill switch: grabbing the mouse is what a person does
213    /// when automation goes wrong, and a corner needs no aim. On by
214    /// default — turning it off means nothing but the watchdog can stop
215    /// a run from the outside.
216    pub failsafe: bool,
217    /// How close to a corner counts, in the input space's own units.
218    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/// A parsed flow file.
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[serde(deny_unknown_fields)]
239pub struct Flow {
240    /// Path to the pixelcoords session directory (or its session.json).
241    pub session: String,
242    #[serde(default)]
243    pub settings: Settings,
244    // Defaulted so a step-less flow reaches the Empty check and gets a
245    // sentence a human can act on, rather than serde's "missing field".
246    #[serde(rename = "step", default)]
247    pub steps: Vec<Step>,
248}
249
250/// Parse errors, actionable by construction — the message names what to
251/// fix, in the tradition of the sister tool's strict config parsing.
252#[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    /// Parse a flow from TOML source.
262    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    /// Every distinct label the flow references, in first-use order.
271    pub fn targets(&self) -> Vec<&str> {
272        self.labels(|_| true)
273    }
274
275    /// The labels a run will actually *act on*, in first-use order.
276    ///
277    /// These are the ones that must be found before anything is
278    /// injected. The rest — a `wait_for` waiting for a dialog, a
279    /// `wait_gone` waiting for a spinner to clear — are by definition
280    /// allowed to be absent, and demanding them up front would make
281    /// those verbs impossible to use.
282    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        // Observation only — these read the screen and never move it.
322        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        // The regression: a flow that clicks one region and then waits
331        // for another used to demand *both* before starting, which made
332        // wait_for — the whole point of the verb — impossible to use.
333        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        // The least predictable value in the tool is the one place a
364        // silent default would hurt most.
365        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}