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 (Phase F).
21//! - **Arg shape** — reserved for future codemod arg-transform lookup;
22//!   currently informational.
23
24#![forbid(unsafe_code)]
25
26/// One verb in the canonical table.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub struct VerbEntry {
29    /// Maestro yaml surface name (`tapOn`, `assertVisible`, ...).
30    pub maestro_name: &'static str,
31    /// smix-canonical surface name (`tap`, `expect`, ...).
32    pub smix_name: &'static str,
33    /// Grouping category. Used by the future cross-platform parity
34    /// audit table (Phase F).
35    pub category: VerbCategory,
36    /// Shape of the argument — informational; used by codemod's
37    /// argument transform lookup + parser's shape validation.
38    pub arg_shape: ArgShape,
39}
40
41/// Semantic grouping for a verb.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum VerbCategory {
44    /// Tap-family (tap, tapOn, doubleTap, longPress, tapByCoord).
45    Tap,
46    /// Text-input family (inputText / fill, eraseText / clear,
47    /// pasteText, setClipboard).
48    Input,
49    /// Assertion / expectation (assertVisible / expect,
50    /// assertNotVisible, assertTrue, extendedWaitUntil, expect.signal).
51    Assert,
52    /// Navigation / control flow (runFlow, retry, repeat,
53    /// pressKey, back).
54    ControlFlow,
55    /// App lifecycle (launchApp, stopApp / terminate, killApp,
56    /// clearState / reset, clearKeychain / resetKeychain).
57    Lifecycle,
58    /// Screen / media (takeScreenshot, assertScreenshot,
59    /// startRecording, stopRecording, addMedia).
60    Media,
61    /// Gesture / motion (scroll, scrollUntilVisible, swipe,
62    /// swipeOnce, hideKeyboard).
63    Gesture,
64    /// Device-level control (openLink / openUrl, setLocation,
65    /// travel, setPermissions, setOrientation, toggleAirplaneMode).
66    Device,
67    /// smix-native extensions (ocrText, anchorRelative, tapById,
68    /// fixture, webviewEval).
69    SmixNative,
70    /// Utility / diagnostic (waitForAnimationToEnd, evalScript,
71    /// copyTextFrom, assertWithAI).
72    Utility,
73}
74
75/// Shape of a verb's argument. Determines how the parser + codemod
76/// treat the yaml body.
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum ArgShape {
79    /// No argument (`- back`, `- stopApp`, `- waitForAnimationToEnd`).
80    None,
81    /// Bare string (`- inputText: hello`, `- pressKey: enter`).
82    BareString,
83    /// Selector — short-form string OR long-form mapping
84    /// (`{ text | id | label ... }`).
85    Selector,
86    /// Mapping with domain-specific keys (`launchApp`, `extendedWaitUntil`,
87    /// `assertScreenshot`, `expect.signal`).
88    Mapping,
89    /// Path string (`- runFlow: path/to.yaml`).
90    Path,
91    /// Coordinate pair or normalized 0..1 point
92    /// (`tapOn: { point: "X%,Y%" }`).
93    Coord,
94    /// Integer count (`eraseText: 5`).
95    Integer,
96}
97
98/// Static canonical verb table. Ordering: category-alphabetized
99/// for grep-ability; each row is `(maestro, smix, category, shape)`.
100pub static VERB_TABLE: &[VerbEntry] = &[
101    // ----- Tap family -----
102    v("tapOn", "tap", VerbCategory::Tap, ArgShape::Selector),
103    v(
104        "doubleTapOn",
105        "doubleTap",
106        VerbCategory::Tap,
107        ArgShape::Selector,
108    ),
109    v(
110        "longPressOn",
111        "longPress",
112        VerbCategory::Tap,
113        ArgShape::Selector,
114    ),
115    v(
116        "tapByCoord",
117        "tapByCoord",
118        VerbCategory::Tap,
119        ArgShape::Coord,
120    ),
121    // ----- Input family -----
122    v(
123        "inputText",
124        "fill",
125        VerbCategory::Input,
126        ArgShape::BareString,
127    ),
128    v("eraseText", "clear", VerbCategory::Input, ArgShape::Integer),
129    v(
130        "pasteText",
131        "pasteText",
132        VerbCategory::Input,
133        ArgShape::None,
134    ),
135    v(
136        "setClipboard",
137        "setClipboard",
138        VerbCategory::Input,
139        ArgShape::BareString,
140    ),
141    v(
142        "copyTextFrom",
143        "copyTextFrom",
144        VerbCategory::Input,
145        ArgShape::Selector,
146    ),
147    // ----- Assert family -----
148    v(
149        "assertVisible",
150        "expect",
151        VerbCategory::Assert,
152        ArgShape::Selector,
153    ),
154    v(
155        "assertNotVisible",
156        "expectNotVisible",
157        VerbCategory::Assert,
158        ArgShape::Selector,
159    ),
160    v(
161        "extendedWaitUntil",
162        "expect",
163        VerbCategory::Assert,
164        ArgShape::Mapping,
165    ),
166    v(
167        "assertTrue",
168        "assertTrue",
169        VerbCategory::Assert,
170        ArgShape::BareString,
171    ),
172    v(
173        "assertScreenshot",
174        "assertScreenshot",
175        VerbCategory::Assert,
176        ArgShape::Mapping,
177    ),
178    // ----- Control flow -----
179    v(
180        "runFlow",
181        "runFlow",
182        VerbCategory::ControlFlow,
183        ArgShape::Path,
184    ),
185    v(
186        "retry",
187        "retry",
188        VerbCategory::ControlFlow,
189        ArgShape::Mapping,
190    ),
191    v(
192        "repeat",
193        "repeat",
194        VerbCategory::ControlFlow,
195        ArgShape::Mapping,
196    ),
197    v(
198        "pressKey",
199        "pressKey",
200        VerbCategory::ControlFlow,
201        ArgShape::BareString,
202    ),
203    v(
204        "back",
205        "pressKey",
206        VerbCategory::ControlFlow,
207        ArgShape::None,
208    ),
209    // ----- Lifecycle -----
210    v(
211        "launchApp",
212        "launchApp",
213        VerbCategory::Lifecycle,
214        ArgShape::Mapping,
215    ),
216    v(
217        "stopApp",
218        "terminate",
219        VerbCategory::Lifecycle,
220        ArgShape::None,
221    ),
222    v(
223        "killApp",
224        "terminate",
225        VerbCategory::Lifecycle,
226        ArgShape::BareString,
227    ),
228    v(
229        "clearState",
230        "reset",
231        VerbCategory::Lifecycle,
232        ArgShape::None,
233    ),
234    v(
235        "clearKeychain",
236        "resetKeychain",
237        VerbCategory::Lifecycle,
238        ArgShape::None,
239    ),
240    // ----- Media -----
241    v(
242        "takeScreenshot",
243        "takeScreenshot",
244        VerbCategory::Media,
245        ArgShape::BareString,
246    ),
247    v(
248        "startRecording",
249        "startRecording",
250        VerbCategory::Media,
251        ArgShape::BareString,
252    ),
253    v(
254        "stopRecording",
255        "stopRecording",
256        VerbCategory::Media,
257        ArgShape::None,
258    ),
259    v(
260        "addMedia",
261        "addMedia",
262        VerbCategory::Media,
263        ArgShape::Mapping,
264    ),
265    // ----- Gesture -----
266    v("scroll", "scroll", VerbCategory::Gesture, ArgShape::None),
267    v(
268        "scrollUntilVisible",
269        "scroll",
270        VerbCategory::Gesture,
271        ArgShape::Mapping,
272    ),
273    v("swipe", "swipe", VerbCategory::Gesture, ArgShape::Mapping),
274    v(
275        "hideKeyboard",
276        "hideKeyboard",
277        VerbCategory::Gesture,
278        ArgShape::None,
279    ),
280    // ----- Device -----
281    v(
282        "openLink",
283        "openUrl",
284        VerbCategory::Device,
285        ArgShape::BareString,
286    ),
287    v(
288        "setLocation",
289        "setLocation",
290        VerbCategory::Device,
291        ArgShape::Mapping,
292    ),
293    v("travel", "travel", VerbCategory::Device, ArgShape::Mapping),
294    v(
295        "setPermissions",
296        "setPermissions",
297        VerbCategory::Device,
298        ArgShape::Mapping,
299    ),
300    v(
301        "setOrientation",
302        "setOrientation",
303        VerbCategory::Device,
304        ArgShape::BareString,
305    ),
306    v(
307        "toggleAirplaneMode",
308        "toggleAirplaneMode",
309        VerbCategory::Device,
310        ArgShape::None,
311    ),
312    // ----- smix-native extensions (parity with parser dispatch_step) -----
313    v(
314        "webview_eval",
315        "webviewEval",
316        VerbCategory::SmixNative,
317        ArgShape::BareString,
318    ),
319    v(
320        "webviewEval",
321        "webviewEval",
322        VerbCategory::SmixNative,
323        ArgShape::BareString,
324    ),
325    v(
326        "fixture",
327        "fixture",
328        VerbCategory::SmixNative,
329        ArgShape::Mapping,
330    ),
331    v(
332        "tapById",
333        "tapById",
334        VerbCategory::SmixNative,
335        ArgShape::BareString,
336    ),
337    v(
338        "tapAtCoord",
339        "tapAtCoord",
340        VerbCategory::SmixNative,
341        ArgShape::Coord,
342    ),
343    v(
344        "swipeAtCoord",
345        "swipeAtCoord",
346        VerbCategory::SmixNative,
347        ArgShape::Coord,
348    ),
349    v(
350        "doubleTap",
351        "doubleTap",
352        VerbCategory::SmixNative,
353        ArgShape::Selector,
354    ),
355    v(
356        "longPress",
357        "longPress",
358        VerbCategory::SmixNative,
359        ArgShape::Selector,
360    ),
361    v(
362        "ocrText",
363        "ocrText",
364        VerbCategory::SmixNative,
365        ArgShape::BareString,
366    ),
367    v(
368        "anchorRelative",
369        "anchorRelative",
370        VerbCategory::SmixNative,
371        ArgShape::Mapping,
372    ),
373    v(
374        "findTextByOcr",
375        "findTextByOcr",
376        VerbCategory::SmixNative,
377        ArgShape::Mapping,
378    ),
379    v(
380        "expect",
381        "expect",
382        VerbCategory::SmixNative,
383        ArgShape::Mapping,
384    ),
385    // ----- Utility -----
386    v(
387        "waitForAnimationToEnd",
388        "waitForAnimationToEnd",
389        VerbCategory::Utility,
390        ArgShape::None,
391    ),
392    // Note: `evalScript` / `runScript` / `assertWithAI` deliberately
393    // NOT in the table — they're valid maestro verbs but flagged as
394    // unknown by migrate to warn corpus maintainers that porting
395    // requires manual review (typical case: script bodies contain
396    // maestro-specific APIs). Consumers who want them silently
397    // preserved can add rows locally.
398];
399
400const fn v(
401    maestro_name: &'static str,
402    smix_name: &'static str,
403    category: VerbCategory,
404    arg_shape: ArgShape,
405) -> VerbEntry {
406    VerbEntry {
407        maestro_name,
408        smix_name,
409        category,
410        arg_shape,
411    }
412}
413
414/// Look up a verb entry by its maestro-canonical name.
415pub fn find_by_maestro(name: &str) -> Option<&'static VerbEntry> {
416    VERB_TABLE.iter().find(|e| e.maestro_name == name)
417}
418
419/// Look up a verb entry by its smix-canonical name. May return
420/// multiple different entries because smix names are not unique
421/// (e.g. `pressKey` is the smix name for both `pressKey` and
422/// `back`); we return the first match. Consumers that need
423/// disambiguation should use [`find_by_maestro`] or scan
424/// [`VERB_TABLE`] directly.
425pub fn find_by_smix(name: &str) -> Option<&'static VerbEntry> {
426    VERB_TABLE.iter().find(|e| e.smix_name == name)
427}
428
429/// True if `name` matches either the maestro or smix form of any
430/// known verb.
431pub fn is_known_verb(name: &str) -> bool {
432    find_by_maestro(name).is_some() || find_by_smix(name).is_some()
433}
434
435/// Return every entry in a given category. Iteration order is the
436/// insertion order in [`VERB_TABLE`].
437pub fn verbs_in_category(category: VerbCategory) -> impl Iterator<Item = &'static VerbEntry> {
438    VERB_TABLE.iter().filter(move |e| e.category == category)
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn find_by_maestro_hits() {
447        let e = find_by_maestro("tapOn").unwrap();
448        assert_eq!(e.smix_name, "tap");
449        assert_eq!(e.category, VerbCategory::Tap);
450    }
451
452    #[test]
453    fn find_by_smix_hits() {
454        let e = find_by_smix("tap").unwrap();
455        assert_eq!(e.maestro_name, "tapOn");
456    }
457
458    #[test]
459    fn find_by_smix_returns_first_when_multiple_match() {
460        // `pressKey` is the smix name for both `pressKey` and `back`
461        // entries. find_by_smix returns the FIRST hit — that's
462        // documented behavior; consumers wanting disambiguation
463        // must scan VERB_TABLE directly.
464        let e = find_by_smix("pressKey").unwrap();
465        assert!(e.maestro_name == "pressKey" || e.maestro_name == "back");
466    }
467
468    #[test]
469    fn is_known_covers_both_forms() {
470        assert!(is_known_verb("tapOn"));
471        assert!(is_known_verb("tap"));
472        assert!(is_known_verb("assertVisible"));
473        assert!(is_known_verb("expect"));
474        assert!(!is_known_verb("nonexistent"));
475    }
476
477    #[test]
478    fn extendedwait_maps_to_expect() {
479        let e = find_by_maestro("extendedWaitUntil").unwrap();
480        assert_eq!(e.smix_name, "expect");
481        assert_eq!(e.category, VerbCategory::Assert);
482        assert_eq!(e.arg_shape, ArgShape::Mapping);
483    }
484
485    #[test]
486    fn table_has_no_duplicate_maestro_names() {
487        let mut seen = Vec::new();
488        for e in VERB_TABLE {
489            if seen.contains(&e.maestro_name) {
490                panic!("duplicate maestro_name: {}", e.maestro_name);
491            }
492            seen.push(e.maestro_name);
493        }
494    }
495
496    #[test]
497    fn each_category_has_at_least_one_entry() {
498        for cat in [
499            VerbCategory::Tap,
500            VerbCategory::Input,
501            VerbCategory::Assert,
502            VerbCategory::ControlFlow,
503            VerbCategory::Lifecycle,
504            VerbCategory::Media,
505            VerbCategory::Gesture,
506            VerbCategory::Device,
507            VerbCategory::SmixNative,
508            VerbCategory::Utility,
509        ] {
510            let count = verbs_in_category(cat).count();
511            assert!(count > 0, "category {cat:?} has no entries");
512        }
513    }
514
515    #[test]
516    fn table_covers_v0_2_5_migrate_default_rules() {
517        // Sanity: every maestro name from smix-migrate's original
518        // DEFAULT_RULES table is present here.
519        for name in [
520            "tapOn",
521            "assertVisible",
522            "assertNotVisible",
523            "extendedWaitUntil",
524            "inputText",
525            "eraseText",
526            "clearState",
527            "clearKeychain",
528            "runFlow",
529            "launchApp",
530            "stopApp",
531            "killApp",
532            "openLink",
533            "back",
534            "hideKeyboard",
535            "pressKey",
536            "scroll",
537            "scrollUntilVisible",
538            "swipe",
539            "retry",
540        ] {
541            assert!(
542                find_by_maestro(name).is_some(),
543                "v0.2.5 DEFAULT_RULES verb {name:?} missing from VERB_TABLE"
544            );
545        }
546    }
547
548    #[test]
549    fn table_covers_smix_native_verbs() {
550        for name in ["fixture", "webviewEval"] {
551            assert!(
552                find_by_smix(name).is_some() || find_by_maestro(name).is_some(),
553                "smix-native verb {name:?} missing from VERB_TABLE"
554            );
555        }
556    }
557}