smix-recorder 2.0.0

smix-recorder — host-side recorder (RecordSession + RecordingApp + Rust/maestro yaml generators + claude CLI cleanup).
Documentation
//! Claude CLI cleanup pass — sweetener for generated test source.
//!
//! Calls `claude --tools "" -p <prompt> --output-format text` with the
//! raw generated source embedded. The prompt is heavily constrained
//! (see `build_prompt`); cleanup is a sweetener, **not** a critical
//! path — callers should fall back to the raw output if cleanup throws.

use thiserror::Error;
use tokio::process::Command;

/// Configuration for the cleanup pass.
#[derive(Clone, Debug)]
pub struct CleanupConfig {
    /// Path to the `claude` CLI binary. Defaults to `claude` (PATH lookup).
    pub claude_bin: String,
    /// Timeout (seconds) for the cleanup subprocess. Default: 120s.
    pub timeout_secs: u64,
    /// Target language for the cleanup prompt context.
    pub target: CleanupTarget,
}

impl Default for CleanupConfig {
    fn default() -> Self {
        Self {
            claude_bin: "claude".into(),
            timeout_secs: 120,
            target: CleanupTarget::Rust,
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CleanupTarget {
    /// Rust source (smix-recorder generator_rust output).
    Rust,
    /// Maestro yaml (smix-recorder generator_maestro_yaml output).
    MaestroYaml,
}

#[derive(Debug, Error)]
pub enum CleanupError {
    #[error("claude CLI spawn failed: {0}")]
    Spawn(#[from] std::io::Error),
    #[error("claude CLI exited {code}: {stderr}")]
    NonZeroExit { code: i32, stderr: String },
    #[error("claude CLI returned empty stdout")]
    EmptyOutput,
    #[error("claude CLI returned invalid output: {detail}")]
    InvalidOutput { detail: String },
    #[error("claude CLI cleanup timed out after {0}s")]
    Timeout(u64),
}

/// Pipe `raw_source` through the cleanup pass. Returns the cleaned
/// source on success. Caller's expected fallback on `Err`: log + emit
/// the raw source unchanged (cleanup is a sweetener, not a critical path).
pub async fn cleanup(raw_source: &str, cfg: &CleanupConfig) -> Result<String, CleanupError> {
    let prompt = build_prompt(raw_source, cfg.target);
    let mut cmd = Command::new(&cfg.claude_bin);
    cmd.arg("--tools")
        .arg("")
        .arg("-p")
        .arg(&prompt)
        .arg("--output-format")
        .arg("text");
    cmd.stdin(std::process::Stdio::null());

    let timeout = std::time::Duration::from_secs(cfg.timeout_secs);
    let child = tokio::time::timeout(timeout, cmd.output())
        .await
        .map_err(|_| CleanupError::Timeout(cfg.timeout_secs))??;

    if !child.status.success() {
        let stderr = String::from_utf8_lossy(&child.stderr).into_owned();
        return Err(CleanupError::NonZeroExit {
            code: child.status.code().unwrap_or(-1),
            stderr,
        });
    }

    let stdout = String::from_utf8_lossy(&child.stdout).into_owned();
    let cleaned = strip_markdown_fence(&stdout);
    if cleaned.trim().is_empty() {
        return Err(CleanupError::EmptyOutput);
    }
    validate(&cleaned, cfg.target).map_err(|detail| CleanupError::InvalidOutput { detail })?;
    Ok(cleaned)
}

/// Build the cleanup prompt for the given target language.
fn build_prompt(raw_source: &str, target: CleanupTarget) -> String {
    match target {
        CleanupTarget::Rust => format!(
            r#"You are cleaning up a Rust test source generated by smix-recorder.
Rules:
1. DO NOT change any selector literal (text/id/label/role/name).
2. DO NOT change any fill() argument text.
3. DO NOT reorder actions; tap/fill/clear/press_key/swipe_once/go_back/wait_for/hide_keyboard appearance order is canonical.
4. Output cleaned Rust code only — no markdown fence, no commentary.

What you ARE allowed to do:
- Give the test function a sensible name inferred from the selector flow.
- Insert `app.wait_for(&<next-screen selector>, Duration::from_secs(5)).await?;` between phases.
- Add 1-2 lines of file-head doc comment describing the recorded flow.

Input source:
{raw_source}

Cleaned source:
"#
        ),
        CleanupTarget::MaestroYaml => format!(
            r#"You are cleaning up a maestro yaml flow generated by smix-recorder.
Rules:
1. DO NOT change any tapOn / inputText / pressKey value.
2. DO NOT reorder steps.
3. Output cleaned yaml only — no markdown fence, no commentary.

What you ARE allowed to do:
- Add a `# header comment` describing the flow.
- Insert `extendedWaitUntil` steps between phases for stability.

Input yaml:
{raw_source}

Cleaned yaml:
"#
        ),
    }
}

/// Strip markdown code fence if claude wrapped output in ```rust ... ```.
fn strip_markdown_fence(s: &str) -> String {
    let trimmed = s.trim();
    if trimmed.starts_with("```") {
        // Find the inner content between opening and closing fences.
        let lines: Vec<&str> = trimmed.lines().collect();
        if lines.len() >= 2 && lines[0].starts_with("```") {
            let body_lines = if lines.last().map(|l| l.starts_with("```")).unwrap_or(false) {
                &lines[1..lines.len() - 1]
            } else {
                &lines[1..]
            };
            return body_lines.join("\n");
        }
    }
    trimmed.to_string()
}

/// Sanity-validate the cleaned output. Light validator; catches the
/// "claude returned a completely off-spec blob" mode.
fn validate(s: &str, target: CleanupTarget) -> Result<(), String> {
    match target {
        CleanupTarget::Rust => {
            if !s.contains("use smix_sdk") {
                return Err("missing `use smix_sdk` import".into());
            }
            if !s.contains("#[tokio::test") {
                return Err("missing `#[tokio::test]` attribute".into());
            }
            if !s.contains("async fn ") {
                return Err("missing `async fn` declaration".into());
            }
            Ok(())
        }
        CleanupTarget::MaestroYaml => {
            if !s.contains("appId:") {
                return Err("missing `appId:` field".into());
            }
            if !s.contains("---") {
                return Err("missing `---` separator".into());
            }
            Ok(())
        }
    }
}