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}