1use std::fmt;
47
48use serde_norway::Value;
49use thiserror::Error;
50
51#[derive(Clone, Debug, Default)]
53pub struct MigrateReport {
54 pub renamed: Vec<Rename>,
57 pub unknown_verbs: Vec<String>,
61 pub step_count: usize,
63 pub subflow_refs: usize,
66}
67
68#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct Rename {
71 pub from: &'static str,
72 pub to: &'static str,
73 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#[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 pub fn migrate(&self, yaml: &str) -> Result<(String, MigrateReport), MigrateError> {
135 {
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 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 for line in input.split('\n') {
160 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 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 if let Some((arg_indent, maestro_name)) = pending_arg_rules {
177 if indent > arg_indent {
178 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 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 }
202 }
203 continue;
204 } else {
205 pending_arg_rules = None;
206 }
207 }
208 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 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 out.push_str(line);
224 out.push('\n');
225 }
226 if out.ends_with("\n\n") && !input.ends_with("\n\n") {
229 out.pop();
230 }
231 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 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 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 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 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#[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 if let Value::String(s) = item {
329 let name = s.clone();
330 let mut shim = Value::Mapping(
333 [(Value::String(name.clone()), Value::Null)]
334 .into_iter()
335 .collect(),
336 );
337 visit(&mut shim);
338 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 *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 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#[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#[allow(dead_code)]
400fn transform_extended_wait_until(arg: &mut Value) {
401 rename_key(arg, "timeout", "timeoutMs");
402}
403
404#[allow(dead_code)]
406fn transform_retry(arg: &mut Value) {
407 rename_key(arg, "max", "maxRetries");
408}
409
410#[allow(dead_code)]
413fn transform_launch_app(arg: &mut Value) {
414 if let Value::Mapping(m) = arg {
415 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#[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#[derive(Debug, Clone)]
437struct StepLineInfo {
438 step_indent: usize,
440 verb_start: usize,
442 verb_end: usize,
444 verb_name: String,
446 has_colon: bool,
449}
450
451struct RewrittenStep {
454 line: String,
455 pending_arg_maestro_name: Option<&'static str>,
456}
457
458fn 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 if !trimmed.starts_with("- ") {
466 return None;
467 }
468 let verb_start = step_indent + 2;
470 let mut pos = verb_start;
472 while pos < bytes.len() && bytes[pos] == b' ' {
473 pos += 1;
474 }
475 let real_verb_start = pos;
476 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; }
489 let verb_name = line[real_verb_start..end].to_string();
490 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
502fn rewrite_step_line(
504 line: &str,
505 info: &StepLineInfo,
506 migrator: &Migrator,
507 report: &mut MigrateReport,
508 idx: usize,
509) -> RewrittenStep {
510 let rule = migrator.rules.iter().find(|r| r.from == info.verb_name);
512 let Some(rule) = rule else {
513 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 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 if rule.from == "runFlow" || rule.to == "runFlow" {
544 report.subflow_refs += 1;
545 }
546 report.step_count += 1;
547 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
561enum ArgLineResult {
563 Rewritten(String),
565 Strip,
568}
569
570fn 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 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
598fn verb_has_arg_transform(maestro_name: &str) -> bool {
602 matches!(maestro_name, "extendedWaitUntil" | "retry" | "launchApp")
603}
604
605#[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
618fn 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#[allow(dead_code)]
638fn is_known_verb(v: &str) -> bool {
639 matches!(
640 v,
641 "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 | "ocrText" | "anchorRelative" | "tapById" | "tapAtCoord"
666 | "swipeAtCoord" | "doubleTap" | "longPress"
667 | "setOrientation" | "findTextByOcr"
668 )
669}
670
671#[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 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 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}