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    #[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    /// Refuse a combination that cannot do what it says.
276    ///
277    /// On `Settings` rather than in `Flow::parse` because a flow file is
278    /// not the only way settings arrive — the line protocol's `hello`
279    /// carries them too, and a rule enforced on one path is a rule with a
280    /// hole in it.
281    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    /// Parse a flow from TOML source.
294    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        // In the flow file's own vocabulary, rather than surfacing from
300        // the tool this shells out to. `pixelcoords wait` refuses the same
301        // pair — correctly, on its own terms — but its message names
302        // `--interval` and `--timeout`, which are not fields a flow file
303        // has.
304        flow.settings.validate()?;
305        Ok(flow)
306    }
307
308    /// Every distinct label the flow references, in first-use order.
309    pub fn targets(&self) -> Vec<&str> {
310        self.labels(|_| true)
311    }
312
313    /// The labels a run will actually *act on*, in first-use order.
314    ///
315    /// These are the ones that must be found before anything is
316    /// injected. The rest — a `wait_for` waiting for a dialog, a
317    /// `wait_gone` waiting for a spinner to clear — are by definition
318    /// allowed to be absent, and demanding them up front would make
319    /// those verbs impossible to use.
320    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        // Observation only — these read the screen and never move it.
360        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        // The regression: a flow that clicks one region and then waits
369        // for another used to demand *both* before starting, which made
370        // wait_for — the whole point of the verb — impossible to use.
371        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        // The least predictable value in the tool is the one place a
402        // silent default would hurt most.
403        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    /// The message must name what the reader wrote — `poll_ms` and
592    /// `timeout_ms` — not the flags of the tool this shells out to. A
593    /// flow file has no `--interval`.
594    #[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    /// Equal is fine: exactly one look is a choice someone can make.
610    #[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}