smix-recorder 0.3.0

smix-recorder — host-side recorder (RecordSession + RecordingApp + Rust/maestro yaml generators + claude CLI cleanup). Cement crate, smix-specific. Ported from now-retired TS sources (was legacy/src/recorder/{session,generator-smix-ts,generator-maestro-yaml,cleanup}.ts, retired in v3.22).
Documentation
//! `IRAction[]` → Rust test-source string.
//!
//! Targets the smix-sdk public surface. Original TS recorder emitted
//! `.test.ts` files; this Rust port emits an idiomatic `#[tokio::test]`
//! source body consuming `smix_sdk::App`. The cleanup pass (`cleanup`
//! module) optionally pipes the output through `claude` for naming
//! polish + `wait_for` injection.
//!
//! No 1:1 TS port — this is a from-scratch generator for the Rust SDK
//! target. Spirit follows TS `generator-smix-ts.ts`.

use smix_input::{KeyName, SwipeDirection};
use smix_recorder_ir::{IRAction, RecorderError, RecorderErrorReason};
use smix_selector::{Modifiers, Pattern, Selector};

/// Emit a Rust async test body that replays the captured action stream.
///
/// The result is a complete source string with `use` statements + a
/// single `#[tokio::test]` function. Caller chooses where to drop it
/// (e.g. `tests/<name>.rs` or `crates/<name>/tests/<name>.rs`).
///
/// `test_fn_name` becomes the function identifier (must be a valid Rust
/// identifier — caller responsibility). `bundle_id` is the app under
/// test; an `App::connect_to_runner(22087) + .with_udid(...) + .launch`
/// preamble is rendered.
pub fn generate_rust(
    actions: &[IRAction],
    test_fn_name: &str,
    bundle_id: &str,
) -> Result<String, RecorderError> {
    if actions.is_empty() {
        return Err(RecorderError::new(
            RecorderErrorReason::EmptySession,
            "cannot emit Rust source from zero actions",
        ));
    }

    let mut out = String::with_capacity(2048);
    out.push_str("//! Generated by smix-recorder.\n//! Edit as needed; the AI cleanup pass\n//! may have already added wait_for hints between phases.\n\n");
    out.push_str("use smix_sdk::{App, KeyName, SwipeDirection, focused, id, label, role, role_named, text, text_regex};\n");
    out.push_str("use std::time::Duration;\n\n");
    out.push_str("#[tokio::test(flavor = \"multi_thread\", worker_threads = 2)]\n");
    out.push_str(&format!(
        "async fn {test_fn_name}() -> Result<(), smix_sdk::ExpectationFailure> {{\n"
    ));
    out.push_str("    let port: u16 = std::env::var(\"SMIX_RUNNER_PORT\")\n");
    out.push_str("        .ok()\n");
    out.push_str("        .and_then(|s| s.parse().ok())\n");
    out.push_str("        .unwrap_or(22087);\n");
    out.push_str("    let udid = std::env::var(\"SMIX_UDID\")\n");
    out.push_str("        .expect(\"SMIX_UDID env var required\");\n");
    out.push_str("    let app = App::connect_to_runner(port).await?.with_udid(udid);\n");
    out.push_str(&format!("    app.launch(\"{bundle_id}\").await?;\n"));
    out.push('\n');

    for a in actions {
        emit_action(&mut out, a);
    }

    out.push_str("\n    Ok(())\n}\n");
    Ok(out)
}

fn emit_action(out: &mut String, action: &IRAction) {
    match action {
        IRAction::Tap { selector, .. } => {
            out.push_str(&format!(
                "    app.tap(&{}).await?;\n",
                emit_selector(selector)
            ));
        }
        IRAction::Fill { selector, text, .. } => {
            out.push_str(&format!(
                "    app.fill(&{}, {}).await?;\n",
                emit_selector(selector),
                emit_rust_str_literal(text)
            ));
        }
        IRAction::Clear { selector, .. } => {
            out.push_str(&format!(
                "    app.clear(&{}).await?;\n",
                emit_selector(selector)
            ));
        }
        IRAction::PressKey { key, .. } => {
            out.push_str(&format!("    app.press_key({}).await?;\n", emit_key(*key)));
        }
        IRAction::Swipe {
            direction, from, ..
        } => match from {
            // smix_sdk.App API currently exposes `swipe_once(direction)`
            // without an explicit `from`. If the captured action carries
            // a `from` selector, surface it as a comment for the AI
            // cleanup pass to consider — but emit only the basic call.
            Some(from_sel) => {
                out.push_str(&format!(
                    "    // captured swipe from: {} — App.swipe_once does not accept a from selector;\n    // adjust the test manually or open an issue.\n    app.swipe_once({}).await?;\n",
                    emit_selector(from_sel),
                    emit_swipe(*direction),
                ));
            }
            None => {
                out.push_str(&format!(
                    "    app.swipe_once({}).await?;\n",
                    emit_swipe(*direction)
                ));
            }
        },
        IRAction::GoBack { .. } => {
            out.push_str("    app.go_back().await?;\n");
        }
        IRAction::WaitFor { selector, .. } => {
            out.push_str(&format!(
                "    app.wait_for(&{}, Duration::from_secs(5)).await?;\n",
                emit_selector(selector)
            ));
        }
        IRAction::HideKeyboard { .. } => {
            out.push_str("    app.hide_keyboard().await?;\n");
        }
    }
}

fn emit_selector(s: &Selector) -> String {
    match s {
        Selector::Text { text, modifiers } if is_empty_modifiers(modifiers) => match text {
            Pattern::Text(lit) => format!("text({})", emit_rust_str_literal(lit)),
            Pattern::Regex { regex, .. } => format!("text_regex({})", emit_rust_str_literal(regex)),
        },
        Selector::Id { id: x, modifiers } if is_empty_modifiers(modifiers) => {
            format!("id({})", emit_rust_str_literal(x))
        }
        Selector::Label {
            label: x,
            modifiers,
        } if is_empty_modifiers(modifiers) => {
            format!("label({})", emit_rust_str_literal(x))
        }
        Selector::Role {
            role: r,
            name: None,
            modifiers,
        } if is_empty_modifiers(modifiers) => {
            format!("role(smix_sdk::Role::{:?})", r)
        }
        Selector::Role {
            role: r,
            name: Some(Pattern::Text(n)),
            modifiers,
        } if is_empty_modifiers(modifiers) => {
            format!(
                "role_named(smix_sdk::Role::{:?}, {})",
                r,
                emit_rust_str_literal(n)
            )
        }
        Selector::Focused { .. } => "focused()".into(),
        // Complex / modifier-stacked selectors — emit JSON literal + parse
        // (sub-optimal but covers all variants without growing the
        // emitter combinatorial space). cleanup may rewrite this to a
        // typed form.
        _ => {
            let json = serde_json::to_string(s).unwrap_or_else(|_| "null".into());
            format!(
                "serde_json::from_str::<smix_sdk::Selector>({}).expect(\"selector\")",
                emit_rust_str_literal(&json)
            )
        }
    }
}

fn emit_key(k: KeyName) -> &'static str {
    match k {
        KeyName::Return => "KeyName::Return",
        KeyName::Delete => "KeyName::Delete",
        KeyName::Tab => "KeyName::Tab",
        KeyName::Space => "KeyName::Space",
        KeyName::Escape => "KeyName::Escape",
        KeyName::ArrowUp => "KeyName::ArrowUp",
        KeyName::ArrowDown => "KeyName::ArrowDown",
        KeyName::ArrowLeft => "KeyName::ArrowLeft",
        KeyName::ArrowRight => "KeyName::ArrowRight",
        KeyName::Home => "KeyName::Home",
        KeyName::Lock => "KeyName::Lock",
        KeyName::VolumeUp => "KeyName::VolumeUp",
        KeyName::VolumeDown => "KeyName::VolumeDown",
    }
}

fn emit_swipe(d: SwipeDirection) -> &'static str {
    match d {
        SwipeDirection::Up => "SwipeDirection::Up",
        SwipeDirection::Down => "SwipeDirection::Down",
        SwipeDirection::Left => "SwipeDirection::Left",
        SwipeDirection::Right => "SwipeDirection::Right",
    }
}

fn is_empty_modifiers(m: &Modifiers) -> bool {
    m.near.is_none()
        && m.below.is_none()
        && m.above.is_none()
        && m.left_of.is_none()
        && m.right_of.is_none()
        && m.inside.is_none()
        && m.nth.is_none()
        && m.first.is_none()
        && m.last.is_none()
}

/// Render a Rust string literal: wrap in `"..."` and escape `\` + `"`.
fn emit_rust_str_literal(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}