Skip to main content

smix_migrate/
lib.rs

1//! smix-migrate — static maestro→smix YAML codemod.
2//!
3//! # Why this crate exists
4//!
5//! `smix run` already accepts maestro-flavored yaml directly (smix is a
6//! superset of maestro). Migration is not required for correctness. But
7//! when a consumer commits their yaml flows for long-term maintenance,
8//! they want:
9//!
10//! 1. **Canonical verb names** — grep for `- tap:` finds every tap, not
11//!    a mix of `- tapOn:` (maestro) / `- tap:` (smix). Better code
12//!    review, better tooling.
13//! 2. **smix-native argument shapes** — e.g. `extendedWaitUntil.timeout`
14//!    becomes `expect.timeoutMs`, aligning with smix's `expect` verb
15//!    family.
16//! 3. **Deprecated-verb removal** — maestro-only forms flagged
17//!    (`WARN:` to stderr) so consumers can decide whether to hand-edit.
18//!
19//! # Scope
20//!
21//! - **Top-20 verb transforms** (list below in [`Migrator::default`])
22//! - **Comment preservation** — line-based rewriter keeps copyright
23//!   headers, audit-trail comments, and blank lines byte-identical.
24//! - **Unrecognized verbs preserved verbatim** — e.g. `runScript`,
25//!   `evalScript` — a `WARN:` per unknown verb, one line to stderr.
26//! - **Library + CLI** — this crate exposes `Migrator` + `MigrateReport`;
27//!   `smix migrate` in the CLI is a thin wrapper.
28//!
29//! # Design decisions
30//!
31//! The codemod operates on `serde_norway::Value` (loose YAML AST) for a
32//! syntactic sanity check, then applies changes with a line-based
33//! rewriter to preserve comments. We do NOT deserialize into a
34//! strongly-typed maestro schema because:
35//! - Preserving unknown / smix-native verbs verbatim is required
36//! - We only touch the specific keys we care about (whitelist approach)
37//! - Round-tripping through a typed schema would drop any field we
38//!   don't know about, breaking already-smix-native yamls
39//!
40//! # Anti-goals
41//!
42//! - Not a linter (no correctness checks; malformed yaml surfaces as a
43//!   parse error)
44//! - Not a formatter (indentation / trailing spaces preserved verbatim)
45
46use std::fmt;
47
48use serde_norway::Value;
49use thiserror::Error;
50
51/// Result of migrating one flow file.
52#[derive(Clone, Debug, Default)]
53pub struct MigrateReport {
54    /// Verbs that were successfully renamed / restructured. Keys are
55    /// original verb name; values are `smix-native` verb name.
56    pub renamed: Vec<Rename>,
57    /// Verbs the migrator does not recognize. They were left verbatim
58    /// in the output. Consumers should see the `WARN:` line at the CLI
59    /// layer.
60    pub unknown_verbs: Vec<String>,
61    /// Number of top-level steps in the flow.
62    pub step_count: usize,
63    /// Number of `runFlow` invocations discovered in the flow. Not
64    /// migrated recursively (nested files are user's responsibility).
65    pub subflow_refs: usize,
66}
67
68/// One verb rename recorded during a migration pass.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct Rename {
71    pub from: &'static str,
72    pub to: &'static str,
73    /// 1-indexed step position where the rename was applied.
74    pub step_index: usize,
75}
76
77impl fmt::Display for Rename {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        write!(f, "step #{}: {} → {}", self.step_index, self.from, self.to)
80    }
81}
82
83#[derive(Debug, Error)]
84pub enum MigrateError {
85    #[error("failed to parse maestro yaml: {0}")]
86    Parse(#[from] serde_norway::Error),
87    #[error("failed to serialize smix yaml: {source}")]
88    Serialize {
89        #[source]
90        source: serde_norway::Error,
91    },
92    #[error("input yaml has no top-level list (need `[header, steps...]` or `[steps...]`)")]
93    Shape,
94}
95
96/// The migrator. Stateless by design — `run` is a pure fn of input.
97///
98/// Use [`Migrator::default`] for the standard transform table. Custom
99/// consumers can construct with `Migrator::new(&[...])` to add / omit
100/// rules for their yaml corpus (currently unused; opened for future
101/// extension without wire-format changes).
102#[derive(Clone, Debug)]
103pub struct Migrator {
104    rules: Vec<Rule>,
105}
106
107impl Default for Migrator {
108    fn default() -> Self {
109        Self {
110            rules: default_rules(),
111        }
112    }
113}
114
115impl Migrator {
116    /// Comment-preserving line-based codemod. Copyright headers, audit
117    /// trails, and `# Reason:` blocks survive the migrate round-trip
118    /// byte-identical.
119    ///
120    /// Strategy:
121    /// - Iterate input line by line
122    /// - Preserve comment / blank / doc-header lines verbatim
123    /// - Detect step lines (`^\s*-\s+<verb>[:.]?`) and rewrite the
124    ///   verb portion using [`smix_verbs::VERB_TABLE`]
125    /// - Argument transforms (e.g. `timeout` → `timeoutMs`,
126    ///   `max` → `maxRetries`) run against the arg mapping only when
127    ///   the arg is inline on the same step line — for multi-line
128    ///   mappings we scan following-indented lines
129    ///
130    /// The prior serde_norway::Value roundtrip is retained as a
131    /// fallback for edge cases: when the line-based rewriter can't
132    /// confidently identify a step (deeply nested / complex flow-
133    /// style), fall back to the AST path for that yaml doc.
134    pub fn migrate(&self, yaml: &str) -> Result<(String, MigrateReport), MigrateError> {
135        // Light upfront parse gate: use serde_norway as a syntactic
136        // sanity check. If yaml is malformed, surface Parse error early
137        // so it never gets past migrate.
138        {
139            let de = serde_norway::Deserializer::from_str(yaml);
140            for doc in de {
141                let _: Value = serde_norway::with::singleton_map_recursive::deserialize(doc)?;
142            }
143        }
144        let mut report = MigrateReport::default();
145        let out = self.migrate_lines(yaml, &mut report);
146        Ok((out, report))
147    }
148
149    /// Line-based rewriter. Returns migrated yaml preserving all
150    /// non-step content verbatim.
151    fn migrate_lines(&self, input: &str, report: &mut MigrateReport) -> String {
152        let mut out = String::with_capacity(input.len());
153        let mut step_counter = 0usize;
154        let mut pending_arg_rules: Option<(usize, &'static str)> = None;
155        // pending_arg_rules = (arg_indent, maestro_name) — when Some,
156        // subsequent lines with `indent > arg_indent` are arg-mapping
157        // lines for that verb; apply arg-key renames per verb's
158        // transform. Cleared when line indent <= arg_indent.
159        for line in input.split('\n') {
160            // Empty line or comment — preserve verbatim.
161            let trimmed = line.trim_start();
162            if trimmed.is_empty() || trimmed.starts_with('#') {
163                out.push_str(line);
164                out.push('\n');
165                continue;
166            }
167            // Doc separator or leading `---` — preserve.
168            if trimmed == "---" || trimmed.starts_with("---") {
169                pending_arg_rules = None;
170                out.push_str(line);
171                out.push('\n');
172                continue;
173            }
174            let indent = line.len() - trimmed.len();
175            // Check if this is a continuation of a pending arg mapping.
176            if let Some((arg_indent, maestro_name)) = pending_arg_rules {
177                if indent > arg_indent {
178                    // Line is part of the arg mapping. But it may still
179                    // be a nested step-line (e.g. `commands: - tapOn:`)
180                    // in which case we route to step rewrite even
181                    // inside the pending state.
182                    if let Some(step_info) = parse_step_line(line) {
183                        step_counter += 1;
184                        let rewritten =
185                            rewrite_step_line(line, &step_info, self, report, step_counter);
186                        out.push_str(&rewritten.line);
187                        out.push('\n');
188                        continue;
189                    }
190                    // Not a nested step — rewrite arg key if it's one
191                    // of the transforms we support.
192                    match rewrite_arg_line(line, maestro_name) {
193                        ArgLineResult::Rewritten(rewritten) => {
194                            out.push_str(&rewritten);
195                            out.push('\n');
196                        }
197                        ArgLineResult::Strip => {
198                            // e.g. `launchApp: { clearState: false }` —
199                            // drop the line entirely (deprecated
200                            // maestro default, adds noise).
201                        }
202                    }
203                    continue;
204                } else {
205                    pending_arg_rules = None;
206                }
207            }
208            // Try to parse as a step line: `\s*-\s+<verb>...`.
209            if let Some(step_info) = parse_step_line(line) {
210                step_counter += 1;
211                let rewritten = rewrite_step_line(line, &step_info, self, report, step_counter);
212                out.push_str(&rewritten.line);
213                out.push('\n');
214                // Track pending arg mapping if verb has follow-up
215                // indented keys.
216                if let Some(maestro_name) = rewritten.pending_arg_maestro_name {
217                    pending_arg_rules = Some((step_info.step_indent, maestro_name));
218                }
219                continue;
220            }
221            // Not a step line, not a comment — preserve verbatim
222            // (yaml header like `appId:`, etc.).
223            out.push_str(line);
224            out.push('\n');
225        }
226        // Remove trailing empty line inserted by split('\n') on
227        // trailing newline input.
228        if out.ends_with("\n\n") && !input.ends_with("\n\n") {
229            out.pop();
230        }
231        // If input didn't end with newline, trim ours.
232        if !input.ends_with('\n') && out.ends_with('\n') {
233            out.pop();
234        }
235        out
236    }
237
238    #[allow(dead_code)]
239    fn transform_value(
240        &self,
241        value: &mut Value,
242        report: &mut MigrateReport,
243        step_counter: &mut usize,
244    ) {
245        // Legacy AST-based walker — retained as reference / potential
246        // future fallback for edge cases the line-based rewriter can't
247        // handle (deeply nested / flow-style yaml). Currently unused.
248        walk(value, |step| {
249            *step_counter += 1;
250            report.step_count += 1;
251            let idx = *step_counter;
252            if let Value::Mapping(map) = step {
253                self.apply_step_transform(map, report, idx);
254            }
255        });
256    }
257
258    #[allow(dead_code)]
259    fn apply_step_transform(
260        &self,
261        map: &mut serde_norway::Mapping,
262        report: &mut MigrateReport,
263        idx: usize,
264    ) {
265        // A step is a single-key mapping in the canonical form. We look
266        // at each rule's `from` verb; when present, apply.
267        let mut applicable: Vec<&Rule> = Vec::new();
268        for rule in &self.rules {
269            let key = Value::String(rule.from.to_string());
270            if map.contains_key(&key) {
271                applicable.push(rule);
272            }
273        }
274        for rule in applicable {
275            let from_key = Value::String(rule.from.to_string());
276            let to_key = Value::String(rule.to.to_string());
277            // Remove-then-insert preserves original value; apply
278            // rule-specific argument transform if any.
279            if let Some(mut arg_val) = map.remove(&from_key) {
280                (rule.transform)(&mut arg_val);
281                map.insert(to_key, arg_val);
282                report.renamed.push(Rename {
283                    from: rule.from,
284                    to: rule.to,
285                    step_index: idx,
286                });
287                if rule.from == "runFlow" || rule.to == "runFlow" {
288                    report.subflow_refs += 1;
289                }
290            }
291        }
292        // Unknown-verb tracking: any single-key top-level map whose key
293        // is NOT in a rules `from` set + NOT in the smix-native known
294        // list is reported.
295        if map.len() == 1
296            && let Some((Value::String(key), _)) = map.iter().next()
297            && !is_known_verb(key)
298            && !is_ignored_key(key)
299            && !report.unknown_verbs.iter().any(|v| v == key)
300        {
301            report.unknown_verbs.push(key.clone());
302        }
303    }
304}
305
306/// Walk a Value tree, invoking `visit` on every Mapping node that
307/// looks like a "step" (single-key mapping with a string key).
308#[allow(dead_code)]
309fn walk<F: FnMut(&mut Value)>(value: &mut Value, mut visit: F) {
310    walk_inner(value, &mut visit);
311}
312
313#[allow(dead_code)]
314fn walk_inner<F: FnMut(&mut Value)>(value: &mut Value, visit: &mut F) {
315    match value {
316        Value::Sequence(seq) => {
317            for item in seq {
318                // A step can be:
319                //   (a) `Value::Mapping` with a single verb-key (canonical)
320                //   (b) `Value::String("verb")` bare-string form (maestro
321                //       accepts `- back`, `- scroll`, `- hideKeyboard`,
322                //       `- waitForAnimationToEnd` etc.)
323                //
324                // Both need canonicalization. For (b), we visit a
325                // temporary single-key mapping shim so all rules apply
326                // uniformly. If the visitor renames the verb, we keep
327                // bare-string shape (bare-in, bare-out — more idiomatic).
328                if let Value::String(s) = item {
329                    let name = s.clone();
330                    // Temporarily promote to `{name: null}` so
331                    // `apply_step_transform` matches rules by key.
332                    let mut shim = Value::Mapping(
333                        [(Value::String(name.clone()), Value::Null)]
334                            .into_iter()
335                            .collect(),
336                    );
337                    visit(&mut shim);
338                    // Detect whether the visit renamed the key. If so,
339                    // reproject back to bare-string.
340                    if let Value::Mapping(m) = &shim
341                        && m.len() == 1
342                    {
343                        if let Some((Value::String(new_key), Value::Null)) = m.iter().next() {
344                            if new_key != &name {
345                                *item = Value::String(new_key.clone());
346                            }
347                        } else if let Some((Value::String(_new_key), _)) = m.iter().next() {
348                            // Value became non-null (unlikely for
349                            // string-shape input); reproject as mapping.
350                            *item = Value::Mapping(m.clone());
351                        }
352                    }
353                    continue;
354                }
355                if let Value::Mapping(m) = item
356                    && m.len() == 1
357                {
358                    visit(item);
359                    // After the step visitor ran, walk the inner
360                    // value in case it's a `runFlow` etc. that
361                    // contains `commands: [...]`.
362                    if let Value::Mapping(m) = item {
363                        for (_, v) in m.iter_mut() {
364                            walk_inner(v, visit);
365                        }
366                    }
367                    continue;
368                }
369                walk_inner(item, visit);
370            }
371        }
372        Value::Mapping(m) => {
373            for (_, v) in m.iter_mut() {
374                walk_inner(v, visit);
375            }
376        }
377        _ => {}
378    }
379}
380
381/// Top-20 verb rename + argument transform rules.
382///
383/// Each rule = (maestro verb name, smix verb name, arg transform fn).
384/// Argument transform runs on the value BEFORE re-insertion under the
385/// new key. Identity transform = arg carried verbatim.
386#[derive(Clone, Debug)]
387struct Rule {
388    from: &'static str,
389    to: &'static str,
390    #[allow(dead_code)]
391    transform: fn(&mut Value),
392}
393
394#[allow(dead_code)]
395fn id_transform(_: &mut Value) {}
396
397/// `extendedWaitUntil: { visible: X, timeout: 3000 }`
398/// → `expect: { visible: X, timeoutMs: 3000 }`
399#[allow(dead_code)]
400fn transform_extended_wait_until(arg: &mut Value) {
401    rename_key(arg, "timeout", "timeoutMs");
402}
403
404/// `retry: { max: 3, ... }` → `retry: { maxRetries: 3, ... }`
405#[allow(dead_code)]
406fn transform_retry(arg: &mut Value) {
407    rename_key(arg, "max", "maxRetries");
408}
409
410/// `launchApp: { clearState: true, ... }` — smix-native accepts the same
411/// shape but strips deprecated `clearState: false` (no-op maestro form).
412#[allow(dead_code)]
413fn transform_launch_app(arg: &mut Value) {
414    if let Value::Mapping(m) = arg {
415        // Drop `clearState: false` — it's the maestro default and adds noise
416        let cs_key = Value::String("clearState".to_string());
417        if let Some(Value::Bool(false)) = m.get(&cs_key) {
418            m.remove(&cs_key);
419        }
420    }
421}
422
423/// Rename an inner mapping key when it exists.
424#[allow(dead_code)]
425fn rename_key(val: &mut Value, from: &str, to: &str) {
426    if let Value::Mapping(m) = val {
427        let from_key = Value::String(from.to_string());
428        let to_key = Value::String(to.to_string());
429        if let Some(v) = m.remove(&from_key) {
430            m.insert(to_key, v);
431        }
432    }
433}
434
435/// Parsed step-line shape used by the line-based rewriter.
436#[derive(Debug, Clone)]
437struct StepLineInfo {
438    /// Number of leading spaces before the `-`.
439    step_indent: usize,
440    /// Column where the verb name starts (after `-` and space).
441    verb_start: usize,
442    /// End column of the verb name (exclusive; points at `:` or end).
443    verb_end: usize,
444    /// Verb name as it appeared in the input.
445    verb_name: String,
446    /// True if verb was followed by `:` (has inline arg or arg on
447    /// subsequent indented lines).
448    has_colon: bool,
449}
450
451/// Rewritten-step output plus optional pending-arg tracker for the
452/// follow-up arg mapping (multi-line args).
453struct RewrittenStep {
454    line: String,
455    pending_arg_maestro_name: Option<&'static str>,
456}
457
458/// Parse a yaml line as a step-line (`\s*-\s+<verb>[:.]?`).
459/// Returns `None` for non-step lines (comments handled upstream).
460fn parse_step_line(line: &str) -> Option<StepLineInfo> {
461    let bytes = line.as_bytes();
462    let step_indent = line.len() - line.trim_start().len();
463    let trimmed = &line[step_indent..];
464    // Must start with `- ` (dash + space).
465    if !trimmed.starts_with("- ") {
466        return None;
467    }
468    // Skip past `- `.
469    let verb_start = step_indent + 2;
470    // Skip additional whitespace between `-` and verb.
471    let mut pos = verb_start;
472    while pos < bytes.len() && bytes[pos] == b' ' {
473        pos += 1;
474    }
475    let real_verb_start = pos;
476    // Verb name = alphanumeric + `_` chars until `:` or space or end.
477    let mut end = real_verb_start;
478    while end < bytes.len() {
479        let c = bytes[end];
480        if c.is_ascii_alphanumeric() || c == b'_' {
481            end += 1;
482        } else {
483            break;
484        }
485    }
486    if end == real_verb_start {
487        return None; // no verb name
488    }
489    let verb_name = line[real_verb_start..end].to_string();
490    // Reject if it's not a known verb per smix_verbs (unknown verbs
491    // preserved verbatim per unknown-verb reporting later).
492    let has_colon = bytes.get(end) == Some(&b':');
493    Some(StepLineInfo {
494        step_indent,
495        verb_start: real_verb_start,
496        verb_end: end,
497        verb_name,
498        has_colon,
499    })
500}
501
502/// Rewrite the verb portion of a step line.
503fn rewrite_step_line(
504    line: &str,
505    info: &StepLineInfo,
506    migrator: &Migrator,
507    report: &mut MigrateReport,
508    idx: usize,
509) -> RewrittenStep {
510    // Find rule for the maestro verb name.
511    let rule = migrator.rules.iter().find(|r| r.from == info.verb_name);
512    let Some(rule) = rule else {
513        // Unknown verb — track and preserve verbatim.
514        if !smix_verbs::is_known_verb(&info.verb_name)
515            && !matches!(
516                info.verb_name.as_str(),
517                "when" | "file" | "commands" | "label" | "config"
518            )
519            && !report.unknown_verbs.iter().any(|v| v == &info.verb_name)
520        {
521            report.unknown_verbs.push(info.verb_name.clone());
522        }
523        return RewrittenStep {
524            line: line.to_string(),
525            pending_arg_maestro_name: None,
526        };
527    };
528    let new_verb = rule.to;
529    let old_verb = &info.verb_name;
530    // Splice the verb portion.
531    let mut rewritten = String::with_capacity(line.len() + 4);
532    rewritten.push_str(&line[..info.verb_start]);
533    rewritten.push_str(new_verb);
534    rewritten.push_str(&line[info.verb_end..]);
535    if new_verb != old_verb {
536        report.renamed.push(Rename {
537            from: rule.from,
538            to: rule.to,
539            step_index: idx,
540        });
541    }
542    // Track subflow refs.
543    if rule.from == "runFlow" || rule.to == "runFlow" {
544        report.subflow_refs += 1;
545    }
546    report.step_count += 1;
547    // If this verb has a transform_for arg-mapping renamer (i.e.
548    // not id_transform), and it has a `:` (indicating either inline
549    // arg or nested arg mapping), track pending arg lines.
550    let pending = if info.has_colon && verb_has_arg_transform(info.verb_name.as_str()) {
551        Some(rule.from)
552    } else {
553        None
554    };
555    RewrittenStep {
556        line: rewritten,
557        pending_arg_maestro_name: pending,
558    }
559}
560
561/// Result of `rewrite_arg_line`.
562enum ArgLineResult {
563    /// Line rewritten (or preserved verbatim).
564    Rewritten(String),
565    /// Line should be dropped entirely (e.g. `clearState: false` under
566    /// launchApp — a deprecated maestro default that adds noise).
567    Strip,
568}
569
570/// Rewrite an arg-mapping continuation line for the named verb.
571fn rewrite_arg_line(line: &str, maestro_name: &str) -> ArgLineResult {
572    let trimmed = line.trim_start();
573    let indent = line.len() - trimmed.len();
574    let Some(colon_pos) = trimmed.find(':') else {
575        return ArgLineResult::Rewritten(line.to_string());
576    };
577    let key = &trimmed[..colon_pos];
578    let value = trimmed[colon_pos + 1..].trim();
579    // Strip case: launchApp clearState: false → drop entirely.
580    if maestro_name == "launchApp" && key == "clearState" && value == "false" {
581        return ArgLineResult::Strip;
582    }
583    let new_key = match (maestro_name, key) {
584        ("extendedWaitUntil", "timeout") => "timeoutMs",
585        ("retry", "max") => "maxRetries",
586        _ => return ArgLineResult::Rewritten(line.to_string()),
587    };
588    let rest = &trimmed[colon_pos..];
589    let mut out = String::with_capacity(line.len() + 4);
590    for _ in 0..indent {
591        out.push(' ');
592    }
593    out.push_str(new_key);
594    out.push_str(rest);
595    ArgLineResult::Rewritten(out)
596}
597
598/// Quickly test whether a verb has an arg-mapping transform (used by
599/// the line-based rewriter to decide whether to track pending-arg
600/// continuation lines).
601fn verb_has_arg_transform(maestro_name: &str) -> bool {
602    matches!(maestro_name, "extendedWaitUntil" | "retry" | "launchApp")
603}
604
605/// Argument transform lookup. Kept for the legacy AST-based path
606/// (currently unused since the line-based rewriter took over). Retained
607/// as reference for future fallback.
608#[allow(dead_code)]
609fn transform_for(maestro_name: &str) -> fn(&mut Value) {
610    match maestro_name {
611        "extendedWaitUntil" => transform_extended_wait_until,
612        "retry" => transform_retry,
613        "launchApp" => transform_launch_app,
614        _ => id_transform,
615    }
616}
617
618/// Default rules derived from `smix_verbs::VERB_TABLE` — the single
619/// source of truth. Adding a new verb entry in smix-verbs automatically
620/// flows through here.
621///
622/// Ordering: same as `VERB_TABLE` (category-alphabetized).
623fn default_rules() -> Vec<Rule> {
624    smix_verbs::VERB_TABLE
625        .iter()
626        .map(|e| Rule {
627            from: e.maestro_name,
628            to: e.smix_name,
629            transform: transform_for(e.maestro_name),
630        })
631        .collect()
632}
633
634/// Verbs smix natively recognizes — used by the unknown-verb detector.
635/// Any single-key top-level map whose key is NOT here + NOT in the
636/// rename table → `WARN` line.
637#[allow(dead_code)]
638fn is_known_verb(v: &str) -> bool {
639    matches!(
640        v,
641        // smix canonical + maestro-canonical that stays verbatim
642        "tap" | "tapOn"
643        | "expect" | "expectNotVisible" | "assertVisible" | "assertNotVisible"
644        | "extendedWaitUntil"
645        | "fill" | "inputText" | "clear" | "eraseText"
646        | "reset" | "clearState"
647        | "resetKeychain" | "clearKeychain"
648        | "runFlow"
649        | "launchApp" | "terminate" | "stopApp" | "killApp"
650        | "openUrl" | "openLink"
651        | "pressKey" | "back"
652        | "hideKeyboard"
653        | "scroll" | "scrollUntilVisible"
654        | "swipe" | "swipeOnce"
655        | "retry"
656        | "takeScreenshot" | "screenshot"
657        | "setClipboard" | "readClipboard"
658        | "waitForAnimationToEnd"
659        | "assertTrue" | "assertFalse" | "assertNotEqual" | "assertEqual"
660        | "toggleAirplaneMode" | "travel"
661        | "setLocation" | "startRecording" | "stopRecording"
662        | "addMedia" | "assertWithAI"
663        | "when"
664        // smix-native
665        | "ocrText" | "anchorRelative" | "tapById" | "tapAtCoord"
666        | "swipeAtCoord" | "doubleTap" | "longPress"
667        | "setOrientation" | "findTextByOcr"
668    )
669}
670
671/// Non-verb keys we ignore for unknown-verb reporting (e.g. `when`
672/// nested inside a runFlow inline).
673#[allow(dead_code)]
674fn is_ignored_key(v: &str) -> bool {
675    matches!(v, "when" | "file" | "commands" | "label" | "config")
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    #[test]
683    fn rename_tap_on_to_tap() {
684        let yaml = "appId: com.example\n---\n- tapOn: Login\n- tapOn:\n    text: Submit\n";
685        let (out, report) = Migrator::default().migrate(yaml).unwrap();
686        assert!(out.contains("tap: Login"));
687        assert!(out.contains("tap:"));
688        assert!(!out.contains("tapOn:"));
689        assert_eq!(report.step_count, 2);
690        assert_eq!(report.renamed.len(), 2);
691    }
692
693    #[test]
694    fn rename_extended_wait_until() {
695        let yaml = "appId: com.example\n---\n- extendedWaitUntil:\n    visible: Ready\n    timeout: 3000\n";
696        let (out, _) = Migrator::default().migrate(yaml).unwrap();
697        assert!(out.contains("expect:"));
698        assert!(out.contains("timeoutMs: 3000"));
699        assert!(!out.contains("extendedWaitUntil"));
700        assert!(!out.contains("timeout: 3000"));
701    }
702
703    #[test]
704    fn rename_retry_max() {
705        let yaml = "appId: com.example\n---\n- retry:\n    max: 3\n    commands:\n    - tapOn: X\n";
706        let (out, report) = Migrator::default().migrate(yaml).unwrap();
707        assert!(out.contains("maxRetries: 3"));
708        assert!(!out.contains("max: 3"));
709        assert!(out.contains("tap: X"));
710        assert_eq!(report.step_count, 2);
711    }
712
713    #[test]
714    fn strip_launchapp_clearstate_false() {
715        let yaml = "appId: com.example\n---\n- launchApp:\n    clearState: false\n    permissions:\n      camera: allow\n";
716        let (out, _) = Migrator::default().migrate(yaml).unwrap();
717        assert!(!out.contains("clearState: false"));
718        assert!(out.contains("permissions:"));
719    }
720
721    #[test]
722    fn unknown_verb_reported_and_preserved() {
723        let yaml = "appId: com.example\n---\n- runScript:\n    script: foo.js\n- tapOn: X\n";
724        let (out, report) = Migrator::default().migrate(yaml).unwrap();
725        assert!(out.contains("runScript:"));
726        assert!(out.contains("tap: X"));
727        assert_eq!(report.unknown_verbs, vec!["runScript"]);
728    }
729
730    #[test]
731    fn native_smix_verbs_untouched() {
732        let yaml = "appId: com.example\n---\n- ocrText: Sign In\n- tapById: submit\n";
733        let (out, report) = Migrator::default().migrate(yaml).unwrap();
734        assert!(out.contains("ocrText: Sign In"));
735        assert!(out.contains("tapById: submit"));
736        assert!(report.unknown_verbs.is_empty());
737        assert!(report.renamed.is_empty());
738    }
739
740    #[test]
741    fn nested_runflow_inline_walked() {
742        let yaml = "appId: com.example\n---\n- runFlow:\n    when:\n      visible: Splash\n    commands:\n    - tapOn: Skip\n    - inputText: hello\n";
743        let (out, report) = Migrator::default().migrate(yaml).unwrap();
744        assert!(out.contains("tap: Skip"));
745        assert!(out.contains("fill: hello"));
746        // runFlow stays runFlow — only inner steps rename.
747        assert!(out.contains("runFlow:"));
748        assert!(report.renamed.iter().any(|r| r.to == "tap"));
749        assert!(report.renamed.iter().any(|r| r.to == "fill"));
750    }
751
752    #[test]
753    fn stop_app_becomes_terminate() {
754        let yaml = "appId: com.example\n---\n- stopApp: com.other\n";
755        let (out, _) = Migrator::default().migrate(yaml).unwrap();
756        assert!(out.contains("terminate: com.other"));
757        assert!(!out.contains("stopApp"));
758    }
759
760    #[test]
761    fn multi_doc_yaml_preserves_split() {
762        let yaml = "appId: com.example\n---\n- tapOn: A\n";
763        let (out, _) = Migrator::default().migrate(yaml).unwrap();
764        // Two docs → separator preserved
765        assert!(out.contains("---"));
766        assert!(out.contains("tap: A"));
767    }
768
769    #[test]
770    fn parse_error_surfaces() {
771        let yaml = "appId: com.example\n---\n- [unclosed\n";
772        let err = Migrator::default().migrate(yaml).unwrap_err();
773        matches!(err, MigrateError::Parse(_));
774    }
775
776    #[test]
777    fn empty_flow_no_ops() {
778        let yaml = "appId: com.example\n---\n";
779        let (out, report) = Migrator::default().migrate(yaml).unwrap();
780        assert!(out.contains("appId: com.example"));
781        assert_eq!(report.step_count, 0);
782    }
783}