Skip to main content

ui_automata/
schema.rs

1//! Manual `JsonSchema` implementations for types that use custom serde deserializers.
2use std::borrow::Cow;
3
4use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
5use serde_json::to_value;
6
7use crate::{
8    Condition, RetryPolicy, SelectorPath,
9    condition::{TextMatch, TitleMatch},
10};
11
12// ── Duration helpers ──────────────────────────────────────────────────────────
13
14/// Schema for a `Duration` field serialized as a string like `"5s"` or `"300ms"`.
15/// Use as `#[schemars(schema_with = "crate::schema::duration_schema")]`.
16pub fn duration_schema(_sg: &mut SchemaGenerator) -> Schema {
17    json_schema!({
18        "type": "string",
19        "description": "Duration string, e.g. \"5s\", \"300ms\", \"2m\", \"1h\""
20    })
21}
22
23// ── SelectorPath ──────────────────────────────────────────────────────────────
24
25impl JsonSchema for SelectorPath {
26    fn schema_name() -> Cow<'static, str> {
27        "SelectorPath".into()
28    }
29
30    fn inline_schema() -> bool {
31        true
32    }
33
34    fn json_schema(_sg: &mut SchemaGenerator) -> Schema {
35        json_schema!({
36            "type": "string",
37            "description": "CSS-like path for navigating the UIA element tree.\n\nSyntax: [Combinator] Step [Combinator Step]*\n  Step      = \"*\" | BareRole? [attr Op value]* [:nth(n)]\n  Combinator = \">\" (direct child) | \">>\" (any descendant)\n  attr      = role | name | title | control_type\n  Op        = \"=\" exact | \"~=\" contains | \"^=\" starts-with | \"$=\" ends-with\n  :parent         navigate to the matched element's parent\n  :ancestor(n)    navigate n levels up (1 = parent)\n\nNo leading combinator: first step matches the scope root element itself.\nLeading >> or >: searches inside the scope root without re-matching it (use when scope IS the container).\n\nExamples:\n  \"[name~=Notepad]\"                              root match by title substring\n  \">> [role=button][name=Close]\"                 any descendant button\n  \">> [role='title bar'] > [role=button]\"         child of a descendant\n  \"ToolBar[name=Main] > Group:nth(1)\"             second Group child (0-indexed)\n  \">> [role=button][name^=Don][name$=Save]\"       starts/ends-with for special chars\n  \"*\"                                             any element; combine with process: filter\n\nExamples with ascension:\n  \">> [role=button][name=Performance]:parent\"           container of Performance\n  \">> [role=button][name=Performance]:parent > *:nth(9)\" 9th sibling of Performance"
38        })
39    }
40}
41
42// ── Condition ─────────────────────────────────────────────────────────────────
43
44impl JsonSchema for Condition {
45    fn schema_name() -> Cow<'static, str> {
46        "Condition".into()
47    }
48
49    fn json_schema(sg: &mut SchemaGenerator) -> Schema {
50        use serde_json::json;
51
52        let text_match = to_value(sg.subschema_for::<TextMatch>()).unwrap();
53        let title_match = to_value(sg.subschema_for::<TitleMatch>()).unwrap();
54        let cond_ref = to_value(sg.subschema_for::<Condition>()).unwrap();
55        let cond_arr = to_value(sg.subschema_for::<Vec<Condition>>()).unwrap();
56
57        let mut variants: Vec<serde_json::Value> = Vec::new();
58
59        let scope_s = || json!({ "type": "string", "description": "Anchor name to resolve the element tree from." });
60        let selector_s =
61            || json!({ "type": "string", "description": "Selector path within the scope anchor." });
62        let anchor_s = || json!({ "type": "string", "description": "Name of the anchor whose window is tracked." });
63
64        // scope + selector variants
65        for (type_name, desc) in &[
66            (
67                "ElementFound",
68                "True when the selector matches at least one live element under the scope anchor.",
69            ),
70            (
71                "ElementEnabled",
72                "True when the matched element is not greyed out (UIA IsEnabled).",
73            ),
74            (
75                "ElementVisible",
76                "True when the matched element is visible on screen (UIA IsOffscreen=false).",
77            ),
78            (
79                "ElementHasChildren",
80                "True when the matched element has at least one child element.",
81            ),
82        ] {
83            variants.push(json!({
84                "type": "object",
85                "description": desc,
86                "required": ["type", "scope", "selector"],
87                "properties": {
88                    "type": { "const": type_name },
89                    "scope": scope_s(),
90                    "selector": selector_s()
91                },
92                "additionalProperties": false
93            }));
94        }
95
96        // ElementHasText — adds `pattern: TextMatch`
97        variants.push(json!({
98            "type": "object",
99            "description": "True when the matched element's text value satisfies the pattern.",
100            "required": ["type", "scope", "selector", "pattern"],
101            "properties": {
102                "type": { "const": "ElementHasText" },
103                "scope": scope_s(),
104                "selector": selector_s(),
105                "pattern": text_match
106            },
107            "additionalProperties": false
108        }));
109
110        // WindowWithAttribute — title fields + automation_id + pid + process
111        variants.push(json!({
112            "type": "object",
113            "description": "True when any open application window matches all specified attributes. Requires at least one of: exact, contains, starts_with, automation_id, pid.",
114            "required": ["type"],
115            "properties": {
116                "type": { "const": "WindowWithAttribute" },
117                "exact":          { "type": "string", "description": "Window title must match exactly." },
118                "contains":       { "type": "string", "description": "Window title must contain this substring." },
119                "starts_with":    { "type": "string", "description": "Window title must start with this string." },
120                "automation_id":  { "type": "string", "description": "UIA AutomationId / AXIdentifier must match exactly." },
121                "pid":            { "type": "integer", "minimum": 0, "description": "Process ID to match exactly." },
122                "process":        { "type": "string", "description": "Process name without .exe (case-insensitive)." }
123            },
124            "additionalProperties": false
125        }));
126
127        // ProcessRunning
128        variants.push(json!({
129            "type": "object",
130            "description": "True when any application window belongs to a process whose name (without .exe) matches, case-insensitive.",
131            "required": ["type", "process"],
132            "properties": {
133                "type": { "const": "ProcessRunning" },
134                "process": { "type": "string", "description": "Process name without .exe (e.g. \"notepad\")." }
135            },
136            "additionalProperties": false
137        }));
138
139        // WindowWithState
140        variants.push(json!({
141            "type": "object",
142            "description": "True when the anchor's window is in the given state. Use after ActivateWindow (active) or to confirm a window is not minimized (visible).",
143            "required": ["type", "anchor", "state"],
144            "properties": {
145                "type": { "const": "WindowWithState" },
146                "anchor": anchor_s(),
147                "state": {
148                    "type": "string",
149                    "enum": ["active", "visible"],
150                    "description": "active: window is the OS foreground window. visible: window is visible on screen (not minimized or hidden)."
151                }
152            },
153            "additionalProperties": false
154        }));
155
156        // WindowClosed — uses `anchor` not `scope`
157        variants.push(json!({
158            "type": "object",
159            "description": "True when the anchor's window is no longer present. If the anchor was resolved with a PID, checks at process level; otherwise attempts re-resolution and treats failure as closed.",
160            "required": ["type", "anchor"],
161            "properties": {
162                "type": { "const": "WindowClosed" },
163                "anchor": anchor_s()
164            },
165            "additionalProperties": false
166        }));
167
168        // scope-only variants
169        for (type_name, desc) in &[
170            (
171                "DialogPresent",
172                "True when a direct child of the scope element has control_type=dialog.",
173            ),
174            (
175                "DialogAbsent",
176                "True when no direct child of the scope element has control_type=dialog.",
177            ),
178        ] {
179            variants.push(json!({
180                "type": "object",
181                "description": desc,
182                "required": ["type", "scope"],
183                "properties": {
184                    "type": { "const": type_name },
185                    "scope": scope_s()
186                },
187                "additionalProperties": false
188            }));
189        }
190
191        // ForegroundIsDialog — optional nested `title: TitleMatch`
192        variants.push(json!({
193            "type": "object",
194            "description": "True when the OS foreground window is a dialog belonging to the same process as scope. Optionally also checks the dialog title.",
195            "required": ["type", "scope"],
196            "properties": {
197                "type": { "const": "ForegroundIsDialog" },
198                "scope": scope_s(),
199                "title": title_match
200            },
201            "additionalProperties": false
202        }));
203
204        // ExecSucceeded — no fields required
205        variants.push(json!({
206            "type": "object",
207            "description": "True when the most recent Exec action exited with code 0.",
208            "required": ["type"],
209            "properties": {
210                "type": { "const": "ExecSucceeded" }
211            },
212            "additionalProperties": false
213        }));
214
215        // EvalCondition — evaluates a boolean expression against outputs/locals/params
216        variants.push(json!({
217            "type": "object",
218            "description": "Evaluates a boolean expression against the current outputs, locals, and params. The expression must return a Bool (use a comparison operator), e.g. \"output.count != '0'\".",
219            "required": ["type", "expr"],
220            "properties": {
221                "type": { "const": "EvalCondition" },
222                "expr": { "type": "string", "description": "Boolean expression to evaluate." }
223            },
224            "additionalProperties": false
225        }));
226
227        // Always — no fields required
228        variants.push(json!({
229            "type": "object",
230            "description": "Always evaluates to true immediately. Use as `expect` on steps where success is guaranteed by the action (e.g. Capture, NoOp).",
231            "required": ["type"],
232            "properties": {
233                "type": { "const": "Always" }
234            },
235            "additionalProperties": false
236        }));
237
238        // AllOf / AnyOf — array of conditions
239        for (type_name, desc) in &[
240            (
241                "AllOf",
242                "Short-circuit AND: true when every sub-condition is true.",
243            ),
244            (
245                "AnyOf",
246                "Short-circuit OR: true when at least one sub-condition is true.",
247            ),
248        ] {
249            variants.push(json!({
250                "type": "object",
251                "description": desc,
252                "required": ["type", "conditions"],
253                "properties": {
254                    "type": { "const": type_name },
255                    "conditions": cond_arr.clone()
256                },
257                "additionalProperties": false
258            }));
259        }
260
261        // Not — single nested condition
262        variants.push(json!({
263            "type": "object",
264            "description": "Negation: true when the inner condition is false.",
265            "required": ["type", "condition"],
266            "properties": {
267                "type": { "const": "Not" },
268                "condition": cond_ref
269            },
270            "additionalProperties": false
271        }));
272
273        // TabWithAttribute — checks title/url of a browser tab
274        variants.push(json!({
275            "type": "object",
276            "description": "True when the browser tab anchored to `scope` matches all specified attribute filters. Requires at least one of: title, url.",
277            "required": ["type", "scope"],
278            "properties": {
279                "type": { "const": "TabWithAttribute" },
280                "scope": { "type": "string", "description": "Name of a mounted Tab anchor." },
281                "title": text_match.clone(),
282                "url": text_match.clone()
283            },
284            "additionalProperties": false
285        }));
286
287        // TabWithState — evaluates a JS expression in a browser tab
288        variants.push(json!({
289            "type": "object",
290            "description": "True when the JS expression `expr` evaluates to a truthy value in the browser tab anchored to `scope`. Use to wait for tab readiness, e.g. `document.readyState === 'complete'`.",
291            "required": ["type", "scope", "expr"],
292            "properties": {
293                "type": { "const": "TabWithState" },
294                "scope": { "type": "string", "description": "Name of a mounted Tab anchor." },
295                "expr": { "type": "string", "description": "JS expression to evaluate in the tab. Returns true when the result is truthy." }
296            },
297            "additionalProperties": false
298        }));
299
300        json!({ "oneOf": variants }).try_into().unwrap()
301    }
302}
303
304// ── RetryPolicy ───────────────────────────────────────────────────────────────
305
306impl JsonSchema for RetryPolicy {
307    fn schema_name() -> Cow<'static, str> {
308        "RetryPolicy".into()
309    }
310
311    fn json_schema(_sg: &mut SchemaGenerator) -> Schema {
312        use serde_json::json;
313        json!({
314            "oneOf": [
315                {
316                    "const": "none",
317                    "description": "No retries. The step fails immediately when the expect condition times out."
318                },
319                {
320                    "const": "with_recovery",
321                    "description": "Opts out of fixed retries for this step — the phase default retry policy does not apply. Recovery handlers still fire as normal on timeout. Fails immediately when no handler matches."
322                },
323                {
324                    "type": "object",
325                    "description": "Retry a fixed number of times with a constant delay between attempts.",
326                    "required": ["fixed"],
327                    "properties": {
328                        "fixed": {
329                            "type": "object",
330                            "required": ["count", "delay"],
331                            "properties": {
332                                "count": { "type": "integer", "minimum": 1, "description": "Number of additional attempts after the first failure." },
333                                "delay": { "type": "string", "description": "Wait between retries, e.g. \"300ms\" or \"2s\"." }
334                            },
335                            "additionalProperties": false
336                        }
337                    },
338                    "additionalProperties": false
339                }
340            ]
341        }).try_into().unwrap()
342    }
343}