ui_automata/step.rs
1use std::time::Duration;
2
3use schemars::JsonSchema;
4use serde::Deserialize;
5
6use crate::Action;
7use crate::Condition;
8
9/// A single automation intent: execute an action, then wait for an expected UI state.
10#[derive(Deserialize, JsonSchema)]
11pub struct Step {
12 /// Human-readable label shown in logs, e.g. `"click the Save button"`.
13 pub intent: String,
14 /// Optional guard evaluated before the action. If false, the step is skipped (not an error).
15 /// Useful for conditional steps such as "dismiss dialog if it appeared".
16 #[serde(default)]
17 pub precondition: Option<Condition>,
18 /// The UI action to perform: click, type text, press a key, close a window, etc.
19 pub action: Action,
20 /// Optional fallback action run when `expect` times out on the primary action.
21 /// After the fallback runs, `expect` is re-polled once with a fresh timeout.
22 /// If it succeeds, the step succeeds; otherwise `on_failure` decides what happens.
23 #[serde(default)]
24 pub fallback: Option<Action>,
25 /// Condition that must become true after the action for the step to succeed.
26 /// Polled every 100 ms until satisfied or the timeout elapses.
27 pub expect: Condition,
28 /// Maximum time to wait for `expect` to become true. Overrides the workflow default.
29 /// Accepts duration strings such as `"5s"`, `"300ms"`, `"2m"`.
30 #[serde(default, with = "crate::duration::serde::option")]
31 #[schemars(schema_with = "crate::schema::duration_schema")]
32 pub timeout: Option<Duration>,
33 /// Retry policy on timeout. Overrides the workflow default.
34 /// Default: `none` — falls back to the workflow-level default.
35 #[serde(default)]
36 pub retry: RetryPolicy,
37 /// What to do when this step fails (expect condition times out, or fallback also fails).
38 /// Default: `abort` — propagate the error and stop the phase.
39 #[serde(default)]
40 pub on_failure: OnFailure,
41 /// What to do immediately after this step succeeds.
42 /// Default: `continue` — proceed to the next step.
43 #[serde(default)]
44 pub on_success: OnSuccess,
45}
46
47/// Controls executor behaviour when a step's `expect` condition times out (and any
48/// `fallback` action also fails to satisfy it).
49#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
50#[serde(rename_all = "snake_case")]
51pub enum OnFailure {
52 /// Propagate the error; abort the current phase. This is the default.
53 #[default]
54 Abort,
55 /// Log the failure, then continue to the next step as if the step had succeeded.
56 Continue,
57}
58
59/// Controls executor behaviour immediately after a step succeeds.
60#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
61#[serde(rename_all = "snake_case")]
62pub enum OnSuccess {
63 /// Proceed to the next step. This is the default.
64 #[default]
65 Continue,
66 /// Stop executing steps in the current phase immediately (not an error).
67 ReturnPhase,
68}
69
70/// What the executor does when a step's `expect` condition times out.
71///
72/// Custom `Deserialize` via `TryFrom<serde_yaml::Value>` so YAML
73/// `fixed: { count: 1, delay: 300ms }` maps cleanly without serde_yaml's
74/// externally-tagged enum quirks.
75#[derive(Debug, Clone, Default, Deserialize)]
76#[serde(try_from = "serde_yaml::Value")]
77pub enum RetryPolicy {
78 /// No retries — fall back to the workflow default, or fail immediately if there is none.
79 #[default]
80 None,
81 /// Re-execute the action up to `count` additional times.
82 Fixed { count: u32, delay: Duration },
83 /// Run recovery handlers on timeout, then retry the step.
84 WithRecovery,
85}
86
87impl TryFrom<serde_yaml::Value> for RetryPolicy {
88 type Error = String;
89
90 fn try_from(v: serde_yaml::Value) -> Result<Self, String> {
91 match &v {
92 serde_yaml::Value::String(s) => match s.as_str() {
93 "none" => Ok(RetryPolicy::None),
94 "with_recovery" => Ok(RetryPolicy::WithRecovery),
95 other => Err(format!("unknown RetryPolicy '{other}'")),
96 },
97 serde_yaml::Value::Mapping(map) => {
98 if let Some(fixed) = map.get("fixed") {
99 let count = fixed
100 .get("count")
101 .and_then(|v| v.as_u64())
102 .ok_or("RetryPolicy.fixed missing 'count'")?
103 as u32;
104 let delay = fixed
105 .get("delay")
106 .and_then(|v| v.as_str())
107 .ok_or("RetryPolicy.fixed missing 'delay'")
108 .and_then(|s| crate::duration::from_str(s))?;
109 Ok(RetryPolicy::Fixed { count, delay })
110 } else {
111 Err(format!("unknown RetryPolicy mapping: {v:?}"))
112 }
113 }
114 _ => Err(format!("invalid RetryPolicy value: {v:?}")),
115 }
116 }
117}