1use 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
12pub 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
23impl 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
42impl 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
304impl 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}