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    /// Append what this run did to the audit log.
220    ///
221    /// On by default. Opt-in would defeat the purpose — the first
222    /// unattended run is exactly the one that would have no record. The
223    /// file is local, append-only, and never contains typed text; see
224    /// [`crate::audit`].
225    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/// A parsed flow file.
245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
246#[serde(deny_unknown_fields)]
247pub struct Flow {
248    /// Path to the pixelcoords session directory (or its session.json).
249    pub session: String,
250    #[serde(default)]
251    pub settings: Settings,
252    // Defaulted so a step-less flow reaches the Empty check and gets a
253    // sentence a human can act on, rather than serde's "missing field".
254    #[serde(rename = "step", default)]
255    pub steps: Vec<Step>,
256}
257
258/// Parse errors, actionable by construction — the message names what to
259/// fix, in the tradition of the sister tool's strict config parsing.
260#[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    /// Parse a flow from TOML source.
270    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    /// Every distinct label the flow references, in first-use order.
279    pub fn targets(&self) -> Vec<&str> {
280        self.labels(|_| true)
281    }
282
283    /// The labels a run will actually *act on*, in first-use order.
284    ///
285    /// These are the ones that must be found before anything is
286    /// injected. The rest — a `wait_for` waiting for a dialog, a
287    /// `wait_gone` waiting for a spinner to clear — are by definition
288    /// allowed to be absent, and demanding them up front would make
289    /// those verbs impossible to use.
290    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        // Observation only — these read the screen and never move it.
330        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        // The regression: a flow that clicks one region and then waits
339        // for another used to demand *both* before starting, which made
340        // wait_for — the whole point of the verb — impossible to use.
341        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        // The least predictable value in the tool is the one place a
372        // silent default would hurt most.
373        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}