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: Vec<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 de = serde_norway::Deserializer::from_str(yaml);
for doc in de {
let _: Value = serde_norway::with::singleton_map_recursive::deserialize(doc)?;
}
}
let mut report = MigrateReport::default();
let out = self.migrate_lines(yaml, &mut report);
Ok((out, report))
}
fn migrate_lines(&self, input: &str, report: &mut MigrateReport) -> String {
let mut out = String::with_capacity(input.len());
let mut step_counter = 0usize;
let mut pending_arg_rules: Option<(usize, &'static str)> = None;
for line in input.split('\n') {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
out.push_str(line);
out.push('\n');
continue;
}
if trimmed == "---" || trimmed.starts_with("---") {
pending_arg_rules = None;
out.push_str(line);
out.push('\n');
continue;
}
let indent = line.len() - trimmed.len();
if let Some((arg_indent, maestro_name)) = pending_arg_rules {
if indent > arg_indent {
if let Some(step_info) = parse_step_line(line) {
step_counter += 1;
let rewritten =
rewrite_step_line(line, &step_info, self, report, step_counter);
out.push_str(&rewritten.line);
out.push('\n');
continue;
}
match rewrite_arg_line(line, maestro_name) {
ArgLineResult::Rewritten(rewritten) => {
out.push_str(&rewritten);
out.push('\n');
}
ArgLineResult::Strip => {
}
}
continue;
} else {
pending_arg_rules = None;
}
}
if let Some(step_info) = parse_step_line(line) {
step_counter += 1;
let rewritten = rewrite_step_line(line, &step_info, self, report, step_counter);
out.push_str(&rewritten.line);
out.push('\n');
if let Some(maestro_name) = rewritten.pending_arg_maestro_name {
pending_arg_rules = Some((step_info.step_indent, maestro_name));
}
continue;
}
out.push_str(line);
out.push('\n');
}
if out.ends_with("\n\n") && !input.ends_with("\n\n") {
out.pop();
}
if !input.ends_with('\n') && out.ends_with('\n') {
out.pop();
}
out
}
#[allow(dead_code)]
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);
}
});
}
#[allow(dead_code)]
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
&& let Some((Value::String(key), _)) = map.iter().next()
&& !is_known_verb(key)
&& !is_ignored_key(key)
&& !report.unknown_verbs.iter().any(|v| v == key)
{
report.unknown_verbs.push(key.clone());
}
}
}
#[allow(dead_code)]
fn walk<F: FnMut(&mut Value)>(value: &mut Value, mut visit: F) {
walk_inner(value, &mut visit);
}
#[allow(dead_code)]
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
&& 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());
}
}
continue;
}
if let Value::Mapping(m) = item
&& 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(Clone, Debug)]
struct Rule {
from: &'static str,
to: &'static str,
#[allow(dead_code)]
transform: fn(&mut Value),
}
#[allow(dead_code)]
fn id_transform(_: &mut Value) {}
#[allow(dead_code)]
fn transform_extended_wait_until(arg: &mut Value) {
rename_key(arg, "timeout", "timeoutMs");
}
#[allow(dead_code)]
fn transform_retry(arg: &mut Value) {
rename_key(arg, "max", "maxRetries");
}
#[allow(dead_code)]
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);
}
}
}
#[allow(dead_code)]
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);
}
}
}
#[derive(Debug, Clone)]
struct StepLineInfo {
step_indent: usize,
verb_start: usize,
verb_end: usize,
verb_name: String,
has_colon: bool,
}
struct RewrittenStep {
line: String,
pending_arg_maestro_name: Option<&'static str>,
}
fn parse_step_line(line: &str) -> Option<StepLineInfo> {
let bytes = line.as_bytes();
let step_indent = line.len() - line.trim_start().len();
let trimmed = &line[step_indent..];
if !trimmed.starts_with("- ") {
return None;
}
let verb_start = step_indent + 2;
let mut pos = verb_start;
while pos < bytes.len() && bytes[pos] == b' ' {
pos += 1;
}
let real_verb_start = pos;
let mut end = real_verb_start;
while end < bytes.len() {
let c = bytes[end];
if c.is_ascii_alphanumeric() || c == b'_' {
end += 1;
} else {
break;
}
}
if end == real_verb_start {
return None; }
let verb_name = line[real_verb_start..end].to_string();
let has_colon = bytes.get(end) == Some(&b':');
Some(StepLineInfo {
step_indent,
verb_start: real_verb_start,
verb_end: end,
verb_name,
has_colon,
})
}
fn rewrite_step_line(
line: &str,
info: &StepLineInfo,
migrator: &Migrator,
report: &mut MigrateReport,
idx: usize,
) -> RewrittenStep {
let rule = migrator.rules.iter().find(|r| r.from == info.verb_name);
let Some(rule) = rule else {
if !smix_verbs::is_known_verb(&info.verb_name)
&& !matches!(
info.verb_name.as_str(),
"when" | "file" | "commands" | "label" | "config"
)
&& !report.unknown_verbs.iter().any(|v| v == &info.verb_name)
{
report.unknown_verbs.push(info.verb_name.clone());
}
return RewrittenStep {
line: line.to_string(),
pending_arg_maestro_name: None,
};
};
let new_verb = rule.to;
let old_verb = &info.verb_name;
let mut rewritten = String::with_capacity(line.len() + 4);
rewritten.push_str(&line[..info.verb_start]);
rewritten.push_str(new_verb);
rewritten.push_str(&line[info.verb_end..]);
if new_verb != old_verb {
report.renamed.push(Rename {
from: rule.from,
to: rule.to,
step_index: idx,
});
}
if rule.from == "runFlow" || rule.to == "runFlow" {
report.subflow_refs += 1;
}
report.step_count += 1;
let pending = if info.has_colon && verb_has_arg_transform(info.verb_name.as_str()) {
Some(rule.from)
} else {
None
};
RewrittenStep {
line: rewritten,
pending_arg_maestro_name: pending,
}
}
enum ArgLineResult {
Rewritten(String),
Strip,
}
fn rewrite_arg_line(line: &str, maestro_name: &str) -> ArgLineResult {
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
let Some(colon_pos) = trimmed.find(':') else {
return ArgLineResult::Rewritten(line.to_string());
};
let key = &trimmed[..colon_pos];
let value = trimmed[colon_pos + 1..].trim();
if maestro_name == "launchApp" && key == "clearState" && value == "false" {
return ArgLineResult::Strip;
}
let new_key = match (maestro_name, key) {
("extendedWaitUntil", "timeout") => "timeoutMs",
("retry", "max") => "maxRetries",
_ => return ArgLineResult::Rewritten(line.to_string()),
};
let rest = &trimmed[colon_pos..];
let mut out = String::with_capacity(line.len() + 4);
for _ in 0..indent {
out.push(' ');
}
out.push_str(new_key);
out.push_str(rest);
ArgLineResult::Rewritten(out)
}
fn verb_has_arg_transform(maestro_name: &str) -> bool {
matches!(maestro_name, "extendedWaitUntil" | "retry" | "launchApp")
}
#[allow(dead_code)]
fn transform_for(maestro_name: &str) -> fn(&mut Value) {
match maestro_name {
"extendedWaitUntil" => transform_extended_wait_until,
"retry" => transform_retry,
"launchApp" => transform_launch_app,
_ => id_transform,
}
}
fn default_rules() -> Vec<Rule> {
smix_verbs::VERB_TABLE
.iter()
.map(|e| Rule {
from: e.maestro_name,
to: e.smix_name,
transform: transform_for(e.maestro_name),
})
.collect()
}
#[allow(dead_code)]
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"
)
}
#[allow(dead_code)]
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);
}
}