use std::fmt;
use serde_norway::Value;
use thiserror::Error;
#[derive(Clone, Debug, Default)]
pub struct MigrateReport {
pub renamed: Vec<Rename>,
pub unknown_verbs: Vec<String>,
pub step_count: usize,
pub subflow_refs: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rename {
pub from: &'static str,
pub to: &'static str,
pub step_index: usize,
}
impl fmt::Display for Rename {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "step #{}: {} → {}", self.step_index, self.from, self.to)
}
}
#[derive(Debug, Error)]
pub enum MigrateError {
#[error("failed to parse maestro yaml: {0}")]
Parse(#[from] serde_norway::Error),
#[error("failed to serialize smix yaml: {source}")]
Serialize {
#[source]
source: serde_norway::Error,
},
#[error("input yaml has no top-level list (need `[header, steps...]` or `[steps...]`)")]
Shape,
}
#[derive(Clone, Debug)]
pub struct Migrator {
rules: &'static [Rule],
}
impl Default for Migrator {
fn default() -> Self {
Self { rules: DEFAULT_RULES }
}
}
impl Migrator {
pub fn migrate(&self, yaml: &str) -> Result<(String, MigrateReport), MigrateError> {
let mut docs = serde_norway::Deserializer::from_str(yaml);
let mut out_parts: Vec<String> = Vec::new();
let mut report = MigrateReport::default();
let mut step_counter = 0usize;
while let Some(de) = docs.next() {
let mut val: Value =
serde_norway::with::singleton_map_recursive::deserialize(de)?;
self.transform_value(&mut val, &mut report, &mut step_counter);
let serialized = serde_norway::to_string(&val).map_err(|source| {
MigrateError::Serialize { source }
})?;
out_parts.push(serialized);
}
let out = out_parts
.into_iter()
.map(|s| s.trim_end().to_string())
.collect::<Vec<_>>()
.join("\n---\n")
+ "\n";
Ok((out, report))
}
fn transform_value(
&self,
value: &mut Value,
report: &mut MigrateReport,
step_counter: &mut usize,
) {
walk(value, |step| {
*step_counter += 1;
report.step_count += 1;
let idx = *step_counter;
if let Value::Mapping(map) = step {
self.apply_step_transform(map, report, idx);
}
});
}
fn apply_step_transform(
&self,
map: &mut serde_norway::Mapping,
report: &mut MigrateReport,
idx: usize,
) {
let mut applicable: Vec<&Rule> = Vec::new();
for rule in self.rules {
let key = Value::String(rule.from.to_string());
if map.contains_key(&key) {
applicable.push(rule);
}
}
for rule in applicable {
let from_key = Value::String(rule.from.to_string());
let to_key = Value::String(rule.to.to_string());
if let Some(mut arg_val) = map.remove(&from_key) {
(rule.transform)(&mut arg_val);
map.insert(to_key, arg_val);
report.renamed.push(Rename {
from: rule.from,
to: rule.to,
step_index: idx,
});
if rule.from == "runFlow" || rule.to == "runFlow" {
report.subflow_refs += 1;
}
}
}
if map.len() == 1 {
if let Some((Value::String(key), _)) = map.iter().next() {
if !is_known_verb(key) && !is_ignored_key(key) {
if !report.unknown_verbs.iter().any(|v| v == key) {
report.unknown_verbs.push(key.clone());
}
}
}
}
}
}
fn walk<F: FnMut(&mut Value)>(value: &mut Value, mut visit: F) {
walk_inner(value, &mut visit);
}
fn walk_inner<F: FnMut(&mut Value)>(value: &mut Value, visit: &mut F) {
match value {
Value::Sequence(seq) => {
for item in seq {
if let Value::String(s) = item {
let name = s.clone();
let mut shim = Value::Mapping(
[(Value::String(name.clone()), Value::Null)].into_iter().collect(),
);
visit(&mut shim);
if let Value::Mapping(m) = &shim {
if m.len() == 1 {
if let Some((Value::String(new_key), Value::Null)) = m.iter().next() {
if new_key != &name {
*item = Value::String(new_key.clone());
}
} else if let Some((Value::String(new_key), _)) = m.iter().next() {
*item = Value::Mapping(m.clone());
let _ = new_key;
}
}
}
continue;
}
if let Value::Mapping(m) = item {
if m.len() == 1 {
visit(item);
if let Value::Mapping(m) = item {
for (_, v) in m.iter_mut() {
walk_inner(v, visit);
}
}
continue;
}
}
walk_inner(item, visit);
}
}
Value::Mapping(m) => {
for (_, v) in m.iter_mut() {
walk_inner(v, visit);
}
}
_ => {}
}
}
#[derive(Debug)]
struct Rule {
from: &'static str,
to: &'static str,
transform: fn(&mut Value),
}
fn id_transform(_: &mut Value) {}
fn transform_extended_wait_until(arg: &mut Value) {
rename_key(arg, "timeout", "timeoutMs");
}
fn transform_retry(arg: &mut Value) {
rename_key(arg, "max", "maxRetries");
}
fn transform_launch_app(arg: &mut Value) {
if let Value::Mapping(m) = arg {
let cs_key = Value::String("clearState".to_string());
if let Some(Value::Bool(false)) = m.get(&cs_key) {
m.remove(&cs_key);
}
}
}
fn rename_key(val: &mut Value, from: &str, to: &str) {
if let Value::Mapping(m) = val {
let from_key = Value::String(from.to_string());
let to_key = Value::String(to.to_string());
if let Some(v) = m.remove(&from_key) {
m.insert(to_key, v);
}
}
}
static DEFAULT_RULES: &[Rule] = &[
Rule { from: "tapOn", to: "tap", transform: id_transform },
Rule { from: "assertVisible", to: "expect", transform: id_transform },
Rule { from: "assertNotVisible", to: "expectNotVisible", transform: id_transform },
Rule { from: "extendedWaitUntil", to: "expect", transform: transform_extended_wait_until },
Rule { from: "inputText", to: "fill", transform: id_transform },
Rule { from: "eraseText", to: "clear", transform: id_transform },
Rule { from: "clearState", to: "reset", transform: id_transform },
Rule { from: "clearKeychain", to: "resetKeychain", transform: id_transform },
Rule { from: "runFlow", to: "runFlow", transform: id_transform },
Rule { from: "launchApp", to: "launchApp", transform: transform_launch_app },
Rule { from: "stopApp", to: "terminate", transform: id_transform },
Rule { from: "killApp", to: "terminate", transform: id_transform },
Rule { from: "openLink", to: "openUrl", transform: id_transform },
Rule { from: "back", to: "pressKey", transform: id_transform },
Rule { from: "hideKeyboard", to: "hideKeyboard", transform: id_transform },
Rule { from: "pressKey", to: "pressKey", transform: id_transform },
Rule { from: "scroll", to: "scroll", transform: id_transform },
Rule { from: "scrollUntilVisible", to: "scroll", transform: id_transform },
Rule { from: "swipe", to: "swipe", transform: id_transform },
Rule { from: "retry", to: "retry", transform: transform_retry },
];
fn is_known_verb(v: &str) -> bool {
matches!(
v,
"tap" | "tapOn"
| "expect" | "expectNotVisible" | "assertVisible" | "assertNotVisible"
| "extendedWaitUntil"
| "fill" | "inputText" | "clear" | "eraseText"
| "reset" | "clearState"
| "resetKeychain" | "clearKeychain"
| "runFlow"
| "launchApp" | "terminate" | "stopApp" | "killApp"
| "openUrl" | "openLink"
| "pressKey" | "back"
| "hideKeyboard"
| "scroll" | "scrollUntilVisible"
| "swipe" | "swipeOnce"
| "retry"
| "takeScreenshot" | "screenshot"
| "setClipboard" | "readClipboard"
| "waitForAnimationToEnd"
| "assertTrue" | "assertFalse" | "assertNotEqual" | "assertEqual"
| "toggleAirplaneMode" | "travel"
| "setLocation" | "startRecording" | "stopRecording"
| "addMedia" | "assertWithAI"
| "when"
| "ocrText" | "anchorRelative" | "tapById" | "tapAtCoord"
| "swipeAtCoord" | "doubleTap" | "longPress"
| "setOrientation" | "findTextByOcr"
)
}
fn is_ignored_key(v: &str) -> bool {
matches!(v, "when" | "file" | "commands" | "label" | "config")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rename_tap_on_to_tap() {
let yaml = "appId: com.example\n---\n- tapOn: Login\n- tapOn:\n text: Submit\n";
let (out, report) = Migrator::default().migrate(yaml).unwrap();
assert!(out.contains("tap: Login"));
assert!(out.contains("tap:"));
assert!(!out.contains("tapOn:"));
assert_eq!(report.step_count, 2);
assert_eq!(report.renamed.len(), 2);
}
#[test]
fn rename_extended_wait_until() {
let yaml = "appId: com.example\n---\n- extendedWaitUntil:\n visible: Ready\n timeout: 3000\n";
let (out, _) = Migrator::default().migrate(yaml).unwrap();
assert!(out.contains("expect:"));
assert!(out.contains("timeoutMs: 3000"));
assert!(!out.contains("extendedWaitUntil"));
assert!(!out.contains("timeout: 3000"));
}
#[test]
fn rename_retry_max() {
let yaml = "appId: com.example\n---\n- retry:\n max: 3\n commands:\n - tapOn: X\n";
let (out, report) = Migrator::default().migrate(yaml).unwrap();
assert!(out.contains("maxRetries: 3"));
assert!(!out.contains("max: 3"));
assert!(out.contains("tap: X"));
assert_eq!(report.step_count, 2);
}
#[test]
fn strip_launchapp_clearstate_false() {
let yaml = "appId: com.example\n---\n- launchApp:\n clearState: false\n permissions:\n camera: allow\n";
let (out, _) = Migrator::default().migrate(yaml).unwrap();
assert!(!out.contains("clearState: false"));
assert!(out.contains("permissions:"));
}
#[test]
fn unknown_verb_reported_and_preserved() {
let yaml = "appId: com.example\n---\n- runScript:\n script: foo.js\n- tapOn: X\n";
let (out, report) = Migrator::default().migrate(yaml).unwrap();
assert!(out.contains("runScript:"));
assert!(out.contains("tap: X"));
assert_eq!(report.unknown_verbs, vec!["runScript"]);
}
#[test]
fn native_smix_verbs_untouched() {
let yaml = "appId: com.example\n---\n- ocrText: Sign In\n- tapById: submit\n";
let (out, report) = Migrator::default().migrate(yaml).unwrap();
assert!(out.contains("ocrText: Sign In"));
assert!(out.contains("tapById: submit"));
assert!(report.unknown_verbs.is_empty());
assert!(report.renamed.is_empty());
}
#[test]
fn nested_runflow_inline_walked() {
let yaml = "appId: com.example\n---\n- runFlow:\n when:\n visible: Splash\n commands:\n - tapOn: Skip\n - inputText: hello\n";
let (out, report) = Migrator::default().migrate(yaml).unwrap();
assert!(out.contains("tap: Skip"));
assert!(out.contains("fill: hello"));
assert!(out.contains("runFlow:"));
assert!(report.renamed.iter().any(|r| r.to == "tap"));
assert!(report.renamed.iter().any(|r| r.to == "fill"));
}
#[test]
fn stop_app_becomes_terminate() {
let yaml = "appId: com.example\n---\n- stopApp: com.other\n";
let (out, _) = Migrator::default().migrate(yaml).unwrap();
assert!(out.contains("terminate: com.other"));
assert!(!out.contains("stopApp"));
}
#[test]
fn multi_doc_yaml_preserves_split() {
let yaml = "appId: com.example\n---\n- tapOn: A\n";
let (out, _) = Migrator::default().migrate(yaml).unwrap();
assert!(out.contains("---"));
assert!(out.contains("tap: A"));
}
#[test]
fn parse_error_surfaces() {
let yaml = "appId: com.example\n---\n- [unclosed\n";
let err = Migrator::default().migrate(yaml).unwrap_err();
matches!(err, MigrateError::Parse(_));
}
#[test]
fn empty_flow_no_ops() {
let yaml = "appId: com.example\n---\n";
let (out, report) = Migrator::default().migrate(yaml).unwrap();
assert!(out.contains("appId: com.example"));
assert_eq!(report.step_count, 0);
}
}