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 — every step, not only the
62    /// ones a rule matched.
63    pub step_count: usize,
64    /// Number of `runFlow` invocations discovered in the flow. Not
65    /// migrated recursively (nested files are user's responsibility).
66    pub subflow_refs: usize,
67    /// Keys found inside selector mappings that v2's parser refuses.
68    ///
69    /// Migration used to end at verbs, so a flow carrying `enabled:` —
70    /// documented for a filter smix never implemented — migrated
71    /// cleanly, exited 0, and failed at parse time. Reported here so
72    /// the CLI can warn while the user is still holding the file.
73    pub unknown_selector_keys: Vec<String>,
74}
75
76/// Collect selector-mapping keys the v2 parser will refuse.
77///
78/// Line-based, like the rest of this migrator: an indented `key:` under
79/// a step is a candidate, and anything `smix_verbs::SELECTOR_KEYS` does
80/// not list is reported. Reading the same list the parser reads is the
81/// point — a second copy here would drift from the refusal it predicts.
82fn scan_selector_keys(yaml: &str, report: &mut MigrateReport) {
83    for line in yaml.lines() {
84        let trimmed = line.trim_start();
85        // Indented `key:` — a bare `- verb:` at step level is a verb,
86        // handled by the verb pass.
87        if trimmed.starts_with('-') || line.starts_with(|c: char| !c.is_whitespace()) {
88            continue;
89        }
90        let Some((key, _)) = trimmed.split_once(':') else {
91            continue;
92        };
93        let key = key.trim();
94        if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
95            continue;
96        }
97        // Verb-level argument keys (`appId`, `commands`, `keys`, ...)
98        // are not selector keys; only flag what looks like a selector
99        // mapping member and is refused.
100        if smix_verbs::SELECTOR_KEYS.contains(&key) || VERB_ARGUMENT_KEYS.contains(&key) {
101            continue;
102        }
103        if !report.unknown_selector_keys.iter().any(|k| k == key) {
104            report.unknown_selector_keys.push(key.to_string());
105        }
106    }
107}
108
109/// Keys that belong to a verb's own argument mapping rather than to a
110/// selector, and so are not the parser's business to refuse here.
111const VERB_ARGUMENT_KEYS: &[&str] = &[
112    "appId",
113    "app",
114    "commands",
115    "when",
116    "visible",
117    "notVisible",
118    "keys",
119    "bundleId",
120    "clearState",
121    "clearKeychain",
122    "arguments",
123    "launchArgs",
124    "launchEnv",
125    "link",
126    "browser",
127    "maxRetries",
128    "times",
129    "while",
130    "whileNotVisible",
131    "direction",
132    "from",
133    "to",
134    "start",
135    "end",
136    "path",
137    "file",
138    "label",
139    "value",
140    "env",
141    "tags",
142    "state",
143    "permissions",
144    "speed",
145    "latitude",
146    "longitude",
147    "url",
148    "pattern",
149    "timeoutMs",
150    "logLinePattern",
151    "nx",
152    "ny",
153    "dx",
154    "dy",
155    "anchor",
156    "condition",
157    "output",
158    "prompt",
159];
160
161/// One verb rename recorded during a migration pass.
162#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct Rename {
164    pub from: &'static str,
165    pub to: &'static str,
166    /// 1-indexed step position where the rename was applied.
167    pub step_index: usize,
168}
169
170impl fmt::Display for Rename {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        write!(f, "step #{}: {} → {}", self.step_index, self.from, self.to)
173    }
174}
175
176#[derive(Debug, Error)]
177pub enum MigrateError {
178    #[error("failed to parse maestro yaml: {0}")]
179    Parse(#[from] serde_norway::Error),
180    #[error("failed to serialize smix yaml: {source}")]
181    Serialize {
182        #[source]
183        source: serde_norway::Error,
184    },
185    #[error("input yaml has no top-level list (need `[header, steps...]` or `[steps...]`)")]
186    Shape,
187}
188
189/// The migrator. Stateless by design — `run` is a pure fn of input.
190///
191/// Use [`Migrator::default`] for the standard transform table. Custom
192/// consumers can construct with `Migrator::new(&[...])` to add / omit
193/// rules for their yaml corpus (currently unused; opened for future
194/// extension without wire-format changes).
195#[derive(Clone, Debug)]
196pub struct Migrator {
197    rules: Vec<Rule>,
198}
199
200impl Default for Migrator {
201    fn default() -> Self {
202        Self {
203            rules: default_rules(),
204        }
205    }
206}
207
208impl Migrator {
209    /// Comment-preserving line-based codemod. Copyright headers, audit
210    /// trails, and `# Reason:` blocks survive the migrate round-trip
211    /// byte-identical.
212    ///
213    /// Strategy:
214    /// - Iterate input line by line
215    /// - Preserve comment / blank / doc-header lines verbatim
216    /// - Detect step lines (`^\s*-\s+<verb>[:.]?`) and rewrite the
217    ///   verb portion using [`smix_verbs::VERB_TABLE`]
218    /// - Argument transforms (e.g. `timeout` → `timeoutMs`,
219    ///   `max` → `maxRetries`) run against the arg mapping only when
220    ///   the arg is inline on the same step line — for multi-line
221    ///   mappings we scan following-indented lines
222    ///
223    /// The prior serde_norway::Value roundtrip is retained as a
224    /// fallback for edge cases: when the line-based rewriter can't
225    /// confidently identify a step (deeply nested / complex flow-
226    /// style), fall back to the AST path for that yaml doc.
227    pub fn migrate(&self, yaml: &str) -> Result<(String, MigrateReport), MigrateError> {
228        // Light upfront parse gate: use serde_norway as a syntactic
229        // sanity check. If yaml is malformed, surface Parse error early
230        // so it never gets past migrate.
231        {
232            let de = serde_norway::Deserializer::from_str(yaml);
233            for doc in de {
234                let _: Value = serde_norway::with::singleton_map_recursive::deserialize(doc)?;
235            }
236        }
237        let mut report = MigrateReport::default();
238        let out = self.migrate_lines(yaml, &mut report);
239        scan_selector_keys(yaml, &mut report);
240        Ok((out, report))
241    }
242
243    /// Line-based rewriter. Returns migrated yaml preserving all
244    /// non-step content verbatim.
245    fn migrate_lines(&self, input: &str, report: &mut MigrateReport) -> String {
246        let mut out = String::with_capacity(input.len());
247        let mut step_counter = 0usize;
248        let mut pending_arg_rules: Option<(usize, &'static str)> = None;
249        // pending_arg_rules = (arg_indent, maestro_name) — when Some,
250        // subsequent lines with `indent > arg_indent` are arg-mapping
251        // lines for that verb; apply arg-key renames per verb's
252        // transform. Cleared when line indent <= arg_indent.
253        for line in input.split('\n') {
254            // Empty line or comment — preserve verbatim.
255            let trimmed = line.trim_start();
256            if trimmed.is_empty() || trimmed.starts_with('#') {
257                out.push_str(line);
258                out.push('\n');
259                continue;
260            }
261            // Doc separator or leading `---` — preserve.
262            if trimmed == "---" || trimmed.starts_with("---") {
263                pending_arg_rules = None;
264                out.push_str(line);
265                out.push('\n');
266                continue;
267            }
268            let indent = line.len() - trimmed.len();
269            // Check if this is a continuation of a pending arg mapping.
270            if let Some((arg_indent, maestro_name)) = pending_arg_rules {
271                if indent > arg_indent {
272                    // Line is part of the arg mapping. But it may still
273                    // be a nested step-line (e.g. `commands: - tapOn:`)
274                    // in which case we route to step rewrite even
275                    // inside the pending state.
276                    if let Some(step_info) = parse_step_line(line) {
277                        step_counter += 1;
278                        let rewritten =
279                            rewrite_step_line(line, &step_info, self, report, step_counter);
280                        out.push_str(&rewritten.line);
281                        out.push('\n');
282                        continue;
283                    }
284                    // Not a nested step — rewrite arg key if it's one
285                    // of the transforms we support.
286                    match rewrite_arg_line(line, maestro_name) {
287                        ArgLineResult::Rewritten(rewritten) => {
288                            out.push_str(&rewritten);
289                            out.push('\n');
290                        }
291                        ArgLineResult::Strip => {
292                            // e.g. `launchApp: { clearState: false }` —
293                            // drop the line entirely (deprecated
294                            // maestro default, adds noise).
295                        }
296                    }
297                    continue;
298                } else {
299                    pending_arg_rules = None;
300                }
301            }
302            // Try to parse as a step line: `\s*-\s+<verb>...`.
303            if let Some(step_info) = parse_step_line(line) {
304                step_counter += 1;
305                let rewritten = rewrite_step_line(line, &step_info, self, report, step_counter);
306                out.push_str(&rewritten.line);
307                out.push('\n');
308                // Track pending arg mapping if verb has follow-up
309                // indented keys.
310                if let Some(maestro_name) = rewritten.pending_arg_maestro_name {
311                    pending_arg_rules = Some((step_info.step_indent, maestro_name));
312                }
313                continue;
314            }
315            // Not a step line, not a comment — preserve verbatim
316            // (yaml header like `appId:`, etc.).
317            out.push_str(line);
318            out.push('\n');
319        }
320        // Remove trailing empty line inserted by split('\n') on
321        // trailing newline input.
322        if out.ends_with("\n\n") && !input.ends_with("\n\n") {
323            out.pop();
324        }
325        // If input didn't end with newline, trim ours.
326        if !input.ends_with('\n') && out.ends_with('\n') {
327            out.pop();
328        }
329        out
330    }
331
332    #[allow(dead_code)]
333    fn transform_value(
334        &self,
335        value: &mut Value,
336        report: &mut MigrateReport,
337        step_counter: &mut usize,
338    ) {
339        // Legacy AST-based walker — retained as reference / potential
340        // future fallback for edge cases the line-based rewriter can't
341        // handle (deeply nested / flow-style yaml). Currently unused.
342        walk(value, |step| {
343            *step_counter += 1;
344            report.step_count += 1;
345            let idx = *step_counter;
346            if let Value::Mapping(map) = step {
347                self.apply_step_transform(map, report, idx);
348            }
349        });
350    }
351
352    #[allow(dead_code)]
353    fn apply_step_transform(
354        &self,
355        map: &mut serde_norway::Mapping,
356        report: &mut MigrateReport,
357        idx: usize,
358    ) {
359        // A step is a single-key mapping in the canonical form. We look
360        // at each rule's `from` verb; when present, apply.
361        let mut applicable: Vec<&Rule> = Vec::new();
362        for rule in &self.rules {
363            let key = Value::String(rule.from.to_string());
364            if map.contains_key(&key) {
365                applicable.push(rule);
366            }
367        }
368        for rule in applicable {
369            let from_key = Value::String(rule.from.to_string());
370            let to_key = Value::String(rule.to.to_string());
371            // Remove-then-insert preserves original value; apply
372            // rule-specific argument transform if any.
373            if let Some(mut arg_val) = map.remove(&from_key) {
374                (rule.transform)(&mut arg_val);
375                map.insert(to_key, arg_val);
376                report.renamed.push(Rename {
377                    from: rule.from,
378                    to: rule.to,
379                    step_index: idx,
380                });
381                if rule.from == "runFlow" || rule.to == "runFlow" {
382                    report.subflow_refs += 1;
383                }
384            }
385        }
386        // Unknown-verb tracking: any single-key top-level map whose key
387        // is NOT in a rules `from` set + NOT in the smix-native known
388        // list is reported.
389        if map.len() == 1
390            && let Some((Value::String(key), _)) = map.iter().next()
391            && !smix_verbs::is_known_verb(key)
392            && !is_ignored_key(key)
393            && !report.unknown_verbs.iter().any(|v| v == key)
394        {
395            report.unknown_verbs.push(key.clone());
396        }
397    }
398}
399
400/// Walk a Value tree, invoking `visit` on every Mapping node that
401/// looks like a "step" (single-key mapping with a string key).
402#[allow(dead_code)]
403fn walk<F: FnMut(&mut Value)>(value: &mut Value, mut visit: F) {
404    walk_inner(value, &mut visit);
405}
406
407#[allow(dead_code)]
408fn walk_inner<F: FnMut(&mut Value)>(value: &mut Value, visit: &mut F) {
409    match value {
410        Value::Sequence(seq) => {
411            for item in seq {
412                // A step can be:
413                //   (a) `Value::Mapping` with a single verb-key (canonical)
414                //   (b) `Value::String("verb")` bare-string form (maestro
415                //       accepts `- back`, `- scroll`, `- hideKeyboard`,
416                //       `- waitForAnimationToEnd` etc.)
417                //
418                // Both need canonicalization. For (b), we visit a
419                // temporary single-key mapping shim so all rules apply
420                // uniformly. If the visitor renames the verb, we keep
421                // bare-string shape (bare-in, bare-out — more idiomatic).
422                if let Value::String(s) = item {
423                    let name = s.clone();
424                    // Temporarily promote to `{name: null}` so
425                    // `apply_step_transform` matches rules by key.
426                    let mut shim = Value::Mapping(
427                        [(Value::String(name.clone()), Value::Null)]
428                            .into_iter()
429                            .collect(),
430                    );
431                    visit(&mut shim);
432                    // Detect whether the visit renamed the key. If so,
433                    // reproject back to bare-string.
434                    if let Value::Mapping(m) = &shim
435                        && m.len() == 1
436                    {
437                        if let Some((Value::String(new_key), Value::Null)) = m.iter().next() {
438                            if new_key != &name {
439                                *item = Value::String(new_key.clone());
440                            }
441                        } else if let Some((Value::String(_new_key), _)) = m.iter().next() {
442                            // Value became non-null (unlikely for
443                            // string-shape input); reproject as mapping.
444                            *item = Value::Mapping(m.clone());
445                        }
446                    }
447                    continue;
448                }
449                if let Value::Mapping(m) = item
450                    && m.len() == 1
451                {
452                    visit(item);
453                    // After the step visitor ran, walk the inner
454                    // value in case it's a `runFlow` etc. that
455                    // contains `commands: [...]`.
456                    if let Value::Mapping(m) = item {
457                        for (_, v) in m.iter_mut() {
458                            walk_inner(v, visit);
459                        }
460                    }
461                    continue;
462                }
463                walk_inner(item, visit);
464            }
465        }
466        Value::Mapping(m) => {
467            for (_, v) in m.iter_mut() {
468                walk_inner(v, visit);
469            }
470        }
471        _ => {}
472    }
473}
474
475/// Top-20 verb rename + argument transform rules.
476///
477/// Each rule = (maestro verb name, smix verb name, arg transform fn).
478/// Argument transform runs on the value BEFORE re-insertion under the
479/// new key. Identity transform = arg carried verbatim.
480#[derive(Clone, Debug)]
481struct Rule {
482    from: &'static str,
483    to: &'static str,
484    #[allow(dead_code)]
485    transform: fn(&mut Value),
486    /// What to write after the new verb when the old one carried its
487    /// meaning in its name.
488    ///
489    /// maestro's `back` is smix's `pressKey: back` — rewriting the name
490    /// alone emits a bare `pressKey`, which the parser rejects for want of
491    /// a key to press. The argument is not in the source line to carry
492    /// over; it was the verb.
493    supplies_arg: Option<&'static str>,
494}
495
496#[allow(dead_code)]
497fn id_transform(_: &mut Value) {}
498
499/// `extendedWaitUntil: { visible: X, timeout: 3000 }`
500/// → `expect: { visible: X, timeoutMs: 3000 }`
501#[allow(dead_code)]
502fn transform_extended_wait_until(arg: &mut Value) {
503    rename_key(arg, "timeout", "timeoutMs");
504}
505
506/// `retry: { max: 3, ... }` → `retry: { maxRetries: 3, ... }`
507#[allow(dead_code)]
508fn transform_retry(arg: &mut Value) {
509    rename_key(arg, "max", "maxRetries");
510}
511
512/// `launchApp: { clearState: true, ... }` — smix-native accepts the same
513/// shape but strips deprecated `clearState: false` (no-op maestro form).
514#[allow(dead_code)]
515fn transform_launch_app(arg: &mut Value) {
516    if let Value::Mapping(m) = arg {
517        // Drop `clearState: false` — it's the maestro default and adds noise
518        let cs_key = Value::String("clearState".to_string());
519        if let Some(Value::Bool(false)) = m.get(&cs_key) {
520            m.remove(&cs_key);
521        }
522    }
523}
524
525/// Rename an inner mapping key when it exists.
526#[allow(dead_code)]
527fn rename_key(val: &mut Value, from: &str, to: &str) {
528    if let Value::Mapping(m) = val {
529        let from_key = Value::String(from.to_string());
530        let to_key = Value::String(to.to_string());
531        if let Some(v) = m.remove(&from_key) {
532            m.insert(to_key, v);
533        }
534    }
535}
536
537/// Parsed step-line shape used by the line-based rewriter.
538#[derive(Debug, Clone)]
539struct StepLineInfo {
540    /// Number of leading spaces before the `-`.
541    step_indent: usize,
542    /// Column where the verb name starts (after `-` and space).
543    verb_start: usize,
544    /// End column of the verb name (exclusive; points at `:` or end).
545    verb_end: usize,
546    /// Verb name as it appeared in the input.
547    verb_name: String,
548    /// True if verb was followed by `:` (has inline arg or arg on
549    /// subsequent indented lines).
550    has_colon: bool,
551}
552
553/// Rewritten-step output plus optional pending-arg tracker for the
554/// follow-up arg mapping (multi-line args).
555struct RewrittenStep {
556    line: String,
557    pending_arg_maestro_name: Option<&'static str>,
558}
559
560/// Parse a yaml line as a step-line (`\s*-\s+<verb>[:.]?`).
561/// Returns `None` for non-step lines (comments handled upstream).
562fn parse_step_line(line: &str) -> Option<StepLineInfo> {
563    let bytes = line.as_bytes();
564    let step_indent = line.len() - line.trim_start().len();
565    let trimmed = &line[step_indent..];
566    // Must start with `- ` (dash + space).
567    if !trimmed.starts_with("- ") {
568        return None;
569    }
570    // Skip past `- `.
571    let verb_start = step_indent + 2;
572    // Skip additional whitespace between `-` and verb.
573    let mut pos = verb_start;
574    while pos < bytes.len() && bytes[pos] == b' ' {
575        pos += 1;
576    }
577    let real_verb_start = pos;
578    // Verb name = alphanumeric + `_` chars until `:` or space or end.
579    let mut end = real_verb_start;
580    while end < bytes.len() {
581        let c = bytes[end];
582        if c.is_ascii_alphanumeric() || c == b'_' {
583            end += 1;
584        } else {
585            break;
586        }
587    }
588    if end == real_verb_start {
589        return None; // no verb name
590    }
591    let verb_name = line[real_verb_start..end].to_string();
592    // Reject if it's not a known verb per smix_verbs (unknown verbs
593    // preserved verbatim per unknown-verb reporting later).
594    let has_colon = bytes.get(end) == Some(&b':');
595    Some(StepLineInfo {
596        step_indent,
597        verb_start: real_verb_start,
598        verb_end: end,
599        verb_name,
600        has_colon,
601    })
602}
603
604/// Rewrite the verb portion of a step line.
605fn rewrite_step_line(
606    line: &str,
607    info: &StepLineInfo,
608    migrator: &Migrator,
609    report: &mut MigrateReport,
610    idx: usize,
611) -> RewrittenStep {
612    // Counted before the rule lookup, not inside it. Steps whose verb no
613    // rule matches — a smix-native name, or one the codemod does not know —
614    // are still steps, and the field claims to hold the flow's step count.
615    report.step_count += 1;
616
617    let rule = migrator.rules.iter().find(|r| r.from == info.verb_name);
618    let Some(rule) = rule else {
619        // Unknown verb — track and preserve verbatim.
620        if !smix_verbs::is_known_verb(&info.verb_name)
621            && !matches!(
622                info.verb_name.as_str(),
623                "when" | "file" | "commands" | "label" | "config"
624            )
625            && !report.unknown_verbs.iter().any(|v| v == &info.verb_name)
626        {
627            report.unknown_verbs.push(info.verb_name.clone());
628        }
629        return RewrittenStep {
630            line: line.to_string(),
631            pending_arg_maestro_name: None,
632        };
633    };
634    let new_verb = rule.to;
635    let old_verb = &info.verb_name;
636    // Splice the verb portion.
637    let mut rewritten = String::with_capacity(line.len() + 8);
638    rewritten.push_str(&line[..info.verb_start]);
639    rewritten.push_str(new_verb);
640    match rule.supplies_arg {
641        // Only when the step carried no argument of its own; `back: true`
642        // is not a thing, but a line-based codemod should not assume.
643        Some(arg) if !info.has_colon => {
644            rewritten.push_str(": ");
645            rewritten.push_str(arg);
646            rewritten.push_str(&line[info.verb_end..]);
647        }
648        _ => rewritten.push_str(&line[info.verb_end..]),
649    }
650    if new_verb != old_verb {
651        report.renamed.push(Rename {
652            from: rule.from,
653            to: rule.to,
654            step_index: idx,
655        });
656    }
657    // Track subflow refs.
658    if rule.from == "runFlow" || rule.to == "runFlow" {
659        report.subflow_refs += 1;
660    }
661    // If this verb has a transform_for arg-mapping renamer (i.e.
662    // not id_transform), and it has a `:` (indicating either inline
663    // arg or nested arg mapping), track pending arg lines.
664    let pending = if info.has_colon && verb_has_arg_transform(info.verb_name.as_str()) {
665        Some(rule.from)
666    } else {
667        None
668    };
669    RewrittenStep {
670        line: rewritten,
671        pending_arg_maestro_name: pending,
672    }
673}
674
675/// Result of `rewrite_arg_line`.
676enum ArgLineResult {
677    /// Line rewritten (or preserved verbatim).
678    Rewritten(String),
679    /// Line should be dropped entirely (e.g. `clearState: false` under
680    /// launchApp — a deprecated maestro default that adds noise).
681    Strip,
682}
683
684/// Rewrite an arg-mapping continuation line for the named verb.
685fn rewrite_arg_line(line: &str, maestro_name: &str) -> ArgLineResult {
686    let trimmed = line.trim_start();
687    let indent = line.len() - trimmed.len();
688    let Some(colon_pos) = trimmed.find(':') else {
689        return ArgLineResult::Rewritten(line.to_string());
690    };
691    let key = &trimmed[..colon_pos];
692    let value = trimmed[colon_pos + 1..].trim();
693    // Strip case: launchApp clearState: false → drop entirely.
694    if maestro_name == "launchApp" && key == "clearState" && value == "false" {
695        return ArgLineResult::Strip;
696    }
697    let new_key = match (maestro_name, key) {
698        ("extendedWaitUntil", "timeout") => "timeoutMs",
699        ("retry", "max") => "maxRetries",
700        _ => return ArgLineResult::Rewritten(line.to_string()),
701    };
702    let rest = &trimmed[colon_pos..];
703    let mut out = String::with_capacity(line.len() + 4);
704    for _ in 0..indent {
705        out.push(' ');
706    }
707    out.push_str(new_key);
708    out.push_str(rest);
709    ArgLineResult::Rewritten(out)
710}
711
712/// Quickly test whether a verb has an arg-mapping transform (used by
713/// the line-based rewriter to decide whether to track pending-arg
714/// continuation lines).
715fn verb_has_arg_transform(maestro_name: &str) -> bool {
716    matches!(maestro_name, "extendedWaitUntil" | "retry" | "launchApp")
717}
718
719/// Argument transform lookup. Kept for the legacy AST-based path
720/// (currently unused since the line-based rewriter took over). Retained
721/// as reference for future fallback.
722#[allow(dead_code)]
723/// The argument a rewrite has to supply because the maestro verb encoded it
724/// in its own name.
725fn arg_supplied_by_rename(maestro_name: &str) -> Option<&'static str> {
726    match maestro_name {
727        "back" => Some("back"),
728        _ => None,
729    }
730}
731
732fn transform_for(maestro_name: &str) -> fn(&mut Value) {
733    match maestro_name {
734        "extendedWaitUntil" => transform_extended_wait_until,
735        "retry" => transform_retry,
736        "launchApp" => transform_launch_app,
737        _ => id_transform,
738    }
739}
740
741/// Default rules derived from `smix_verbs::VERB_TABLE` — the single
742/// source of truth. Adding a new verb entry in smix-verbs automatically
743/// flows through here.
744///
745/// Ordering: same as `VERB_TABLE` (category-alphabetized).
746fn default_rules() -> Vec<Rule> {
747    smix_verbs::VERB_TABLE
748        .iter()
749        .map(|e| Rule {
750            from: e.maestro_name,
751            to: e.smix_name,
752            transform: transform_for(e.maestro_name),
753            supplies_arg: arg_supplied_by_rename(e.maestro_name),
754        })
755        .collect()
756}
757
758fn is_ignored_key(v: &str) -> bool {
759    matches!(v, "when" | "file" | "commands" | "label" | "config")
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765
766    #[test]
767    fn rename_tap_on_to_tap() {
768        let yaml = "appId: com.example\n---\n- tapOn: Login\n- tapOn:\n    text: Submit\n";
769        let (out, report) = Migrator::default().migrate(yaml).unwrap();
770        assert!(out.contains("tap: Login"));
771        assert!(out.contains("tap:"));
772        assert!(!out.contains("tapOn:"));
773        assert_eq!(report.step_count, 2);
774        assert_eq!(report.renamed.len(), 2);
775    }
776
777    #[test]
778    fn rename_extended_wait_until() {
779        let yaml = "appId: com.example\n---\n- extendedWaitUntil:\n    visible: Ready\n    timeout: 3000\n";
780        let (out, _) = Migrator::default().migrate(yaml).unwrap();
781        assert!(out.contains("expect:"));
782        assert!(out.contains("timeoutMs: 3000"));
783        assert!(!out.contains("extendedWaitUntil"));
784        assert!(!out.contains("timeout: 3000"));
785    }
786
787    #[test]
788    fn rename_retry_max() {
789        let yaml = "appId: com.example\n---\n- retry:\n    max: 3\n    commands:\n    - tapOn: X\n";
790        let (out, report) = Migrator::default().migrate(yaml).unwrap();
791        assert!(out.contains("maxRetries: 3"));
792        assert!(!out.contains("max: 3"));
793        assert!(out.contains("tap: X"));
794        assert_eq!(report.step_count, 2);
795    }
796
797    #[test]
798    fn strip_launchapp_clearstate_false() {
799        let yaml = "appId: com.example\n---\n- launchApp:\n    clearState: false\n    permissions:\n      camera: allow\n";
800        let (out, _) = Migrator::default().migrate(yaml).unwrap();
801        assert!(!out.contains("clearState: false"));
802        assert!(out.contains("permissions:"));
803    }
804
805    #[test]
806    fn unknown_verb_reported_and_preserved() {
807        let yaml = "appId: com.example\n---\n- runScript:\n    script: foo.js\n- tapOn: X\n";
808        let (out, report) = Migrator::default().migrate(yaml).unwrap();
809        assert!(out.contains("runScript:"));
810        assert!(out.contains("tap: X"));
811        assert_eq!(report.unknown_verbs, vec!["runScript"]);
812    }
813
814    /// The count is the flow's, so a step the codemod had no rule for still
815    /// counts. It used to be incremented inside the rule branch, which made
816    /// an already-migrated file report zero steps while rewriting it.
817    #[test]
818    fn step_count_counts_steps_the_codemod_had_nothing_to_do_with() {
819        let yaml = "appId: com.example\n---\n- tap: Already\n- swipe: up\n";
820        let (_, report) = Migrator::default().migrate(yaml).unwrap();
821        assert!(report.renamed.is_empty(), "nothing to rename here");
822        assert_eq!(report.step_count, 2);
823    }
824
825    #[test]
826    fn things_that_are_not_verbs_are_reported_rather_than_passed_through() {
827        // These were in VERB_TABLE, so this asserted the codemod carried them
828        // through in silence. Neither is a verb: `ocrText` is a selector
829        // field (`tapOn: {ocrText: "Sign In"}`) and `tapById` is written
830        // `tapOn: {id: submit}`. The parser rejects both, so passing them
831        // through quietly hands the author yaml that will not run.
832        let yaml = "appId: com.example\n---\n- ocrText: Sign In\n- tapById: submit\n";
833        let (out, report) = Migrator::default().migrate(yaml).unwrap();
834        assert!(
835            out.contains("ocrText: Sign In"),
836            "unknown verbs stay verbatim"
837        );
838        assert!(out.contains("tapById: submit"));
839        assert_eq!(report.unknown_verbs, vec!["ocrText", "tapById"]);
840        assert!(report.renamed.is_empty());
841    }
842
843    #[test]
844    fn nested_runflow_inline_walked() {
845        let yaml = "appId: com.example\n---\n- runFlow:\n    when:\n      visible: Splash\n    commands:\n    - tapOn: Skip\n    - inputText: hello\n";
846        let (out, report) = Migrator::default().migrate(yaml).unwrap();
847        assert!(out.contains("tap: Skip"));
848        assert!(out.contains("fill: hello"));
849        // runFlow stays runFlow — only inner steps rename.
850        assert!(out.contains("runFlow:"));
851        assert!(report.renamed.iter().any(|r| r.to == "tap"));
852        assert!(report.renamed.iter().any(|r| r.to == "fill"));
853    }
854
855    #[test]
856    fn stop_app_becomes_terminate() {
857        let yaml = "appId: com.example\n---\n- stopApp: com.other\n";
858        let (out, _) = Migrator::default().migrate(yaml).unwrap();
859        assert!(out.contains("terminate: com.other"));
860        assert!(!out.contains("stopApp"));
861    }
862
863    #[test]
864    fn multi_doc_yaml_preserves_split() {
865        let yaml = "appId: com.example\n---\n- tapOn: A\n";
866        let (out, _) = Migrator::default().migrate(yaml).unwrap();
867        // Two docs → separator preserved
868        assert!(out.contains("---"));
869        assert!(out.contains("tap: A"));
870    }
871
872    #[test]
873    fn parse_error_surfaces() {
874        let yaml = "appId: com.example\n---\n- [unclosed\n";
875        let err = Migrator::default().migrate(yaml).unwrap_err();
876        matches!(err, MigrateError::Parse(_));
877    }
878
879    #[test]
880    fn empty_flow_no_ops() {
881        let yaml = "appId: com.example\n---\n";
882        let (out, report) = Migrator::default().migrate(yaml).unwrap();
883        assert!(out.contains("appId: com.example"));
884        assert_eq!(report.step_count, 0);
885    }
886}