Skip to main content

smix_verbs/
lib.rs

1//! smix-verbs — canonical verb table.
2//!
3//! # Reviewer invariant
4//!
5//! Any new yaml verb supported by smix MUST land here first as a
6//! [`VerbEntry`] in [`VERB_TABLE`]. Both `smix-adapter-maestro` parser
7//! and `smix-migrate` codemod depend on this crate; adding an entry
8//! automatically makes the parser accept both canonical forms and the
9//! codemod handle the rename.
10//!
11//! # Design
12//!
13//! - **Pure data crate** — no runtime state, no async, no I/O.
14//! - **Static table** — `&'static [VerbEntry]` for zero-alloc lookup.
15//! - **Two names per entry** — `maestro_name` (the yaml surface maestro
16//!   test authors know) and `smix_name` (the smix-canonical form). If
17//!   they're identical (e.g. `runFlow`), the entry still appears with
18//!   both fields set to the same string.
19//! - **Category** — grouping for reviewer sanity checks + future
20//!   auto-generated cross-platform parity tables.
21//! - **Arg shape** — the verb's *primary* yaml shape, and only that.
22//!   Informational, reserved for a future codemod arg-transform lookup.
23//!
24//! # What this table does and does not guarantee
25//!
26//! Membership is enforced: a test in `smix-adapter-maestro` fails if the
27//! parser dispatches a verb with no [`VerbEntry`], so the table cannot
28//! silently fall behind the parser again.
29//!
30//! `arg_shape` is **not** enforced, and cannot be. Many verbs accept more
31//! than one shape — `waitForAnimationToEnd` takes bare, numeric, and
32//! `{ timeout }` — while [`ArgShape`] holds a single value. Teaching it
33//! unions would mean encoding the yaml grammar in an enum that exists to
34//! label rows. So it names the shape a reader should expect first, and the
35//! parser remains the authority on what actually parses.
36
37#![forbid(unsafe_code)]
38
39/// Keys a selector mapping may carry in yaml.
40///
41/// The parser refuses anything else, and `smix migrate` warns about it
42/// before a user discovers the refusal at run time — so the two must
43/// read one list. They did not: the parser owned it privately, migrate
44/// knew only about verbs, and a flow with `enabled:` migrated cleanly,
45/// exited 0, and then failed to parse.
46///
47/// Covers the selector discriminators, the spatial modifiers, and the
48/// per-verb options that ride in the same mapping.
49pub const SELECTOR_KEYS: &[&str] = &[
50    // discriminators
51    "text",
52    "id",
53    "label",
54    "role",
55    "name",
56    "point",
57    "localized_text",
58    "localizedText",
59    "ocrText",
60    "anchored",
61    "anchorRelative",
62    "fallback",
63    "focused",
64    // spatial + positional modifiers
65    "near",
66    "below",
67    "above",
68    "leftOf",
69    "rightOf",
70    "inside",
71    "ancestor",
72    "nth",
73    "index",
74    "first",
75    "last",
76    // per-verb options sharing the mapping
77    "optional",
78    "dispatch",
79    "pattern",
80    "recognition_level",
81    "locales",
82    "timeout",
83    "requireOnScreen",
84    "duration",
85    // repeatTap
86    "times",
87    "intervalMs",
88    "holdMs",
89    // longPressOn
90    "captureDuring",
91];
92
93/// One verb in the canonical table.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub struct VerbEntry {
96    /// Maestro yaml surface name (`tapOn`, `assertVisible`, ...).
97    pub maestro_name: &'static str,
98    /// smix-canonical surface name (`tap`, `expect`, ...).
99    pub smix_name: &'static str,
100    /// Grouping category. Used by the future cross-platform parity
101    /// audit table.
102    pub category: VerbCategory,
103    /// Shape of the argument — informational; used by codemod's
104    /// argument transform lookup + parser's shape validation.
105    pub arg_shape: ArgShape,
106}
107
108/// Semantic grouping for a verb.
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum VerbCategory {
111    /// Tap-family (tap, tapOn, doubleTap, longPress, tapByCoord).
112    Tap,
113    /// Text-input family (inputText / fill, eraseText / clear,
114    /// pasteText, setClipboard).
115    Input,
116    /// Assertion / expectation (assertVisible / expect,
117    /// assertNotVisible, assertTrue, extendedWaitUntil, expect.signal).
118    Assert,
119    /// Navigation / control flow (runFlow, retry, repeat,
120    /// pressKey, back).
121    ControlFlow,
122    /// App lifecycle (launchApp, stopApp / terminate, killApp,
123    /// clearState / reset, clearKeychain / resetKeychain).
124    Lifecycle,
125    /// Screen / media (takeScreenshot, assertScreenshot,
126    /// startRecording, stopRecording, addMedia).
127    Media,
128    /// Gesture / motion (scroll, scrollUntilVisible, swipe,
129    /// swipeOnce, hideKeyboard).
130    Gesture,
131    /// Device-level control (openLink / openUrl, setLocation,
132    /// travel, setPermissions, setOrientation).
133    Device,
134    /// smix-native extensions (ocrText, anchorRelative, tapById,
135    /// fixture, webviewEval).
136    SmixNative,
137    /// Utility / diagnostic (waitForAnimationToEnd, evalScript,
138    /// copyTextFrom, assertWithAI).
139    Utility,
140}
141
142/// A verb's primary yaml argument shape.
143///
144/// One value per verb, so a verb accepting several shapes is recorded by
145/// its most common one — see the crate header. The parser decides what
146/// actually parses.
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub enum ArgShape {
149    /// No argument (`- back`, `- stopApp`, `- waitForAnimationToEnd`).
150    None,
151    /// Bare string (`- inputText: hello`, `- pressKey: enter`).
152    BareString,
153    /// Selector — short-form string OR long-form mapping
154    /// (`{ text | id | label ... }`).
155    Selector,
156    /// Mapping with domain-specific keys (`launchApp`, `extendedWaitUntil`,
157    /// `assertScreenshot`, `expect.signal`).
158    Mapping,
159    /// Path string (`- runFlow: path/to.yaml`).
160    Path,
161    /// Coordinate pair or normalized 0..1 point
162    /// (`tapOn: { point: "X%,Y%" }`).
163    Coord,
164    /// Integer count (`eraseText: 5`).
165    Integer,
166}
167
168/// Static canonical verb table. Ordering: category-alphabetized
169/// for grep-ability; each row is `(maestro, smix, category, shape)`.
170pub static VERB_TABLE: &[VerbEntry] = &[
171    // ----- Tap family -----
172    v("tapOn", "tap", VerbCategory::Tap, ArgShape::Selector),
173    v(
174        "doubleTapOn",
175        "doubleTap",
176        VerbCategory::Tap,
177        ArgShape::Selector,
178    ),
179    v(
180        "longPressOn",
181        "longPress",
182        VerbCategory::Tap,
183        ArgShape::Selector,
184    ),
185    // No maestro counterpart: maestro drives repeated taps with
186    // `repeat`, which sends one request per tap. That is the thing this
187    // exists to stop doing.
188    v(
189        "repeatTap",
190        "repeatTap",
191        VerbCategory::Tap,
192        ArgShape::Mapping,
193    ),
194    // ----- Input family -----
195    v(
196        "inputText",
197        "fill",
198        VerbCategory::Input,
199        ArgShape::BareString,
200    ),
201    v("eraseText", "clear", VerbCategory::Input, ArgShape::Integer),
202    v(
203        "pasteText",
204        "pasteText",
205        VerbCategory::Input,
206        ArgShape::None,
207    ),
208    v(
209        "setClipboard",
210        "setClipboard",
211        VerbCategory::Input,
212        ArgShape::BareString,
213    ),
214    v(
215        "copyTextFrom",
216        "copyTextFrom",
217        VerbCategory::Input,
218        ArgShape::Selector,
219    ),
220    // ----- Assert family -----
221    v(
222        "assertVisible",
223        "expect",
224        VerbCategory::Assert,
225        ArgShape::Selector,
226    ),
227    v(
228        "assertNotVisible",
229        "expectNotVisible",
230        VerbCategory::Assert,
231        ArgShape::Selector,
232    ),
233    v(
234        "extendedWaitUntil",
235        "expect",
236        VerbCategory::Assert,
237        ArgShape::Mapping,
238    ),
239    v(
240        "assertTrue",
241        "assertTrue",
242        VerbCategory::Assert,
243        ArgShape::BareString,
244    ),
245    v(
246        "assertScreenshot",
247        "assertScreenshot",
248        VerbCategory::Assert,
249        ArgShape::Mapping,
250    ),
251    // The AI-assertion tier. Opt-in and non-deterministic: a verdict comes
252    // from a local `claude` CLI judging a screenshot, so these two are the
253    // only verbs in the table whose result is a judgement rather than a
254    // measurement.
255    v(
256        "assertWithAI",
257        "assertCondition",
258        VerbCategory::Assert,
259        ArgShape::BareString,
260    ),
261    v(
262        "extractTextWithAI",
263        "extractWithAI",
264        VerbCategory::Assert,
265        ArgShape::Mapping,
266    ),
267    // ----- Control flow -----
268    v(
269        "runFlow",
270        "runFlow",
271        VerbCategory::ControlFlow,
272        ArgShape::Path,
273    ),
274    v(
275        "retry",
276        "retry",
277        VerbCategory::ControlFlow,
278        ArgShape::Mapping,
279    ),
280    v(
281        "repeat",
282        "repeat",
283        VerbCategory::ControlFlow,
284        ArgShape::Mapping,
285    ),
286    v(
287        "pressKey",
288        "pressKey",
289        VerbCategory::ControlFlow,
290        ArgShape::BareString,
291    ),
292    v(
293        "back",
294        "pressKey",
295        VerbCategory::ControlFlow,
296        ArgShape::None,
297    ),
298    // ----- Lifecycle -----
299    v(
300        "launchApp",
301        "launchApp",
302        VerbCategory::Lifecycle,
303        ArgShape::Mapping,
304    ),
305    v(
306        "stopApp",
307        "terminate",
308        VerbCategory::Lifecycle,
309        ArgShape::None,
310    ),
311    v(
312        "killApp",
313        "terminate",
314        VerbCategory::Lifecycle,
315        ArgShape::BareString,
316    ),
317    v(
318        "clearState",
319        "reset",
320        VerbCategory::Lifecycle,
321        ArgShape::None,
322    ),
323    v(
324        "clearKeychain",
325        "resetKeychain",
326        VerbCategory::Lifecycle,
327        ArgShape::None,
328    ),
329    // ----- Media -----
330    v(
331        "takeScreenshot",
332        "takeScreenshot",
333        VerbCategory::Media,
334        ArgShape::BareString,
335    ),
336    v(
337        "startRecording",
338        "startRecording",
339        VerbCategory::Media,
340        ArgShape::BareString,
341    ),
342    v(
343        "stopRecording",
344        "stopRecording",
345        VerbCategory::Media,
346        ArgShape::None,
347    ),
348    v(
349        "addMedia",
350        "addMedia",
351        VerbCategory::Media,
352        ArgShape::Mapping,
353    ),
354    // ----- Gesture -----
355    v("scroll", "scroll", VerbCategory::Gesture, ArgShape::None),
356    v(
357        "scrollUntilVisible",
358        "scroll",
359        VerbCategory::Gesture,
360        ArgShape::Mapping,
361    ),
362    v("swipe", "swipe", VerbCategory::Gesture, ArgShape::Mapping),
363    v(
364        "hideKeyboard",
365        "hideKeyboard",
366        VerbCategory::Gesture,
367        ArgShape::None,
368    ),
369    // ----- Device -----
370    v(
371        "openLink",
372        "openUrl",
373        VerbCategory::Device,
374        ArgShape::BareString,
375    ),
376    v(
377        "setLocation",
378        "setLocation",
379        VerbCategory::Device,
380        ArgShape::Mapping,
381    ),
382    v("travel", "travel", VerbCategory::Device, ArgShape::Mapping),
383    v(
384        "setPermissions",
385        "setPermissions",
386        VerbCategory::Device,
387        ArgShape::Mapping,
388    ),
389    v(
390        "setOrientation",
391        "setOrientation",
392        VerbCategory::Device,
393        ArgShape::BareString,
394    ),
395    // ----- smix-native extensions (parity with parser dispatch_step) -----
396    v(
397        "webview_eval",
398        "webviewEval",
399        VerbCategory::SmixNative,
400        ArgShape::BareString,
401    ),
402    // The spelling three of four guides use — WebView camelized as one
403    // word. Same canonical name; the parser accepts all three.
404    v(
405        "webViewEval",
406        "webviewEval",
407        VerbCategory::SmixNative,
408        ArgShape::BareString,
409    ),
410    v(
411        "webviewEval",
412        "webviewEval",
413        VerbCategory::SmixNative,
414        ArgShape::BareString,
415    ),
416    v(
417        "fixture",
418        "fixture",
419        VerbCategory::SmixNative,
420        ArgShape::Mapping,
421    ),
422    v(
423        "expect",
424        "expect",
425        VerbCategory::SmixNative,
426        ArgShape::Mapping,
427    ),
428    // ----- Utility -----
429    v(
430        "waitForAnimationToEnd",
431        "waitForAnimationToEnd",
432        VerbCategory::Utility,
433        ArgShape::None,
434    ),
435    // ----- Lifecycle / Assert additions: parser-accepted verbs that
436    // were absent from the table. VERB_TABLE is the enforced single
437    // source of truth (a parser-dispatch ⊆ VERB_TABLE test guards it);
438    // these have Step:: variants + parser dispatch but no entry until now.
439    v(
440        "clearAppData",
441        "clearAppData",
442        VerbCategory::Lifecycle,
443        ArgShape::Mapping,
444    ),
445    v(
446        "resetAppData",
447        "resetAppData",
448        VerbCategory::Lifecycle,
449        ArgShape::Mapping,
450    ),
451    v(
452        "clearUserDefaults",
453        "clearUserDefaults",
454        VerbCategory::Lifecycle,
455        ArgShape::Mapping,
456    ),
457    v(
458        "expectLogClean",
459        "expectLogClean",
460        VerbCategory::Assert,
461        ArgShape::None,
462    ),
463    // Note: `evalScript` / `runScript` deliberately NOT in the table —
464    // they're valid maestro verbs but flagged as unknown by migrate to
465    // warn corpus maintainers that porting requires manual review
466    // (typical case: script bodies contain maestro-specific APIs).
467    // Consumers who want them silently preserved can add rows locally.
468];
469
470const fn v(
471    maestro_name: &'static str,
472    smix_name: &'static str,
473    category: VerbCategory,
474    arg_shape: ArgShape,
475) -> VerbEntry {
476    VerbEntry {
477        maestro_name,
478        smix_name,
479        category,
480        arg_shape,
481    }
482}
483
484/// Look up a verb entry by its maestro-canonical name.
485pub fn find_by_maestro(name: &str) -> Option<&'static VerbEntry> {
486    VERB_TABLE.iter().find(|e| e.maestro_name == name)
487}
488
489/// Look up a verb entry by its smix-canonical name. May return
490/// multiple different entries because smix names are not unique
491/// (e.g. `pressKey` is the smix name for both `pressKey` and
492/// `back`); we return the first match. Consumers that need
493/// disambiguation should use [`find_by_maestro`] or scan
494/// [`VERB_TABLE`] directly.
495pub fn find_by_smix(name: &str) -> Option<&'static VerbEntry> {
496    VERB_TABLE.iter().find(|e| e.smix_name == name)
497}
498
499/// True if `name` matches either the maestro or smix form of any
500/// known verb.
501pub fn is_known_verb(name: &str) -> bool {
502    find_by_maestro(name).is_some() || find_by_smix(name).is_some()
503}
504
505/// Return every entry in a given category. Iteration order is the
506/// insertion order in [`VERB_TABLE`].
507pub fn verbs_in_category(category: VerbCategory) -> impl Iterator<Item = &'static VerbEntry> {
508    VERB_TABLE.iter().filter(move |e| e.category == category)
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn find_by_maestro_hits() {
517        let e = find_by_maestro("tapOn").unwrap();
518        assert_eq!(e.smix_name, "tap");
519        assert_eq!(e.category, VerbCategory::Tap);
520    }
521
522    #[test]
523    fn find_by_smix_hits() {
524        let e = find_by_smix("tap").unwrap();
525        assert_eq!(e.maestro_name, "tapOn");
526    }
527
528    #[test]
529    fn find_by_smix_returns_first_when_multiple_match() {
530        // `pressKey` is the smix name for both `pressKey` and `back`
531        // entries. find_by_smix returns the FIRST hit — that's
532        // documented behavior; consumers wanting disambiguation
533        // must scan VERB_TABLE directly.
534        let e = find_by_smix("pressKey").unwrap();
535        assert!(e.maestro_name == "pressKey" || e.maestro_name == "back");
536    }
537
538    #[test]
539    fn is_known_covers_both_forms() {
540        assert!(is_known_verb("tapOn"));
541        assert!(is_known_verb("tap"));
542        assert!(is_known_verb("assertVisible"));
543        assert!(is_known_verb("expect"));
544        assert!(!is_known_verb("nonexistent"));
545    }
546
547    #[test]
548    fn extendedwait_maps_to_expect() {
549        let e = find_by_maestro("extendedWaitUntil").unwrap();
550        assert_eq!(e.smix_name, "expect");
551        assert_eq!(e.category, VerbCategory::Assert);
552        assert_eq!(e.arg_shape, ArgShape::Mapping);
553    }
554
555    #[test]
556    fn table_has_no_duplicate_maestro_names() {
557        let mut seen = Vec::new();
558        for e in VERB_TABLE {
559            if seen.contains(&e.maestro_name) {
560                panic!("duplicate maestro_name: {}", e.maestro_name);
561            }
562            seen.push(e.maestro_name);
563        }
564    }
565
566    #[test]
567    fn each_category_has_at_least_one_entry() {
568        for cat in [
569            VerbCategory::Tap,
570            VerbCategory::Input,
571            VerbCategory::Assert,
572            VerbCategory::ControlFlow,
573            VerbCategory::Lifecycle,
574            VerbCategory::Media,
575            VerbCategory::Gesture,
576            VerbCategory::Device,
577            VerbCategory::SmixNative,
578            VerbCategory::Utility,
579        ] {
580            let count = verbs_in_category(cat).count();
581            assert!(count > 0, "category {cat:?} has no entries");
582        }
583    }
584
585    #[test]
586    fn table_covers_v0_2_5_migrate_default_rules() {
587        // Sanity: every maestro name from smix-migrate's original
588        // DEFAULT_RULES table is present here.
589        for name in [
590            "tapOn",
591            "assertVisible",
592            "assertNotVisible",
593            "extendedWaitUntil",
594            "inputText",
595            "eraseText",
596            "clearState",
597            "clearKeychain",
598            "runFlow",
599            "launchApp",
600            "stopApp",
601            "killApp",
602            "openLink",
603            "back",
604            "hideKeyboard",
605            "pressKey",
606            "scroll",
607            "scrollUntilVisible",
608            "swipe",
609            "retry",
610        ] {
611            assert!(
612                find_by_maestro(name).is_some(),
613                "v0.2.5 DEFAULT_RULES verb {name:?} missing from VERB_TABLE"
614            );
615        }
616    }
617
618    #[test]
619    fn table_covers_smix_native_verbs() {
620        for name in ["fixture", "webviewEval"] {
621            assert!(
622                find_by_smix(name).is_some() || find_by_maestro(name).is_some(),
623                "smix-native verb {name:?} missing from VERB_TABLE"
624            );
625        }
626    }
627}