Skip to main content

smix_recorder/
cleanup.rs

1//! Claude CLI cleanup pass — sweetener for generated test source.
2//!
3//! Calls `claude --tools "" -p <prompt> --output-format text` with the
4//! raw generated source embedded. The prompt is heavily constrained
5//! (see `build_prompt`); cleanup is a sweetener, **not** a critical
6//! path — callers should fall back to the raw output if cleanup throws.
7
8use thiserror::Error;
9use tokio::process::Command;
10
11/// Configuration for the cleanup pass.
12#[derive(Clone, Debug)]
13pub struct CleanupConfig {
14    /// Path to the `claude` CLI binary. Defaults to `claude` (PATH lookup).
15    pub claude_bin: String,
16    /// Timeout (seconds) for the cleanup subprocess. Default: 120s.
17    pub timeout_secs: u64,
18    /// Target language for the cleanup prompt context.
19    pub target: CleanupTarget,
20}
21
22impl Default for CleanupConfig {
23    fn default() -> Self {
24        Self {
25            claude_bin: "claude".into(),
26            timeout_secs: 120,
27            target: CleanupTarget::Rust,
28        }
29    }
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum CleanupTarget {
34    /// Rust source (smix-recorder generator_rust output).
35    Rust,
36    /// Maestro yaml (smix-recorder generator_maestro_yaml output).
37    MaestroYaml,
38}
39
40#[derive(Debug, Error)]
41pub enum CleanupError {
42    #[error("claude CLI spawn failed: {0}")]
43    Spawn(#[from] std::io::Error),
44    #[error("claude CLI exited {code}: {stderr}")]
45    NonZeroExit { code: i32, stderr: String },
46    #[error("claude CLI returned empty stdout")]
47    EmptyOutput,
48    #[error("claude CLI returned invalid output: {detail}")]
49    InvalidOutput { detail: String },
50    #[error("claude CLI cleanup timed out after {0}s")]
51    Timeout(u64),
52}
53
54/// Pipe `raw_source` through the cleanup pass. Returns the cleaned
55/// source on success. Caller's expected fallback on `Err`: log + emit
56/// the raw source unchanged (cleanup is a sweetener, not a critical path).
57pub async fn cleanup(raw_source: &str, cfg: &CleanupConfig) -> Result<String, CleanupError> {
58    let prompt = build_prompt(raw_source, cfg.target);
59    let mut cmd = Command::new(&cfg.claude_bin);
60    cmd.arg("--tools")
61        .arg("")
62        .arg("-p")
63        .arg(&prompt)
64        .arg("--output-format")
65        .arg("text");
66    cmd.stdin(std::process::Stdio::null());
67
68    let timeout = std::time::Duration::from_secs(cfg.timeout_secs);
69    let child = tokio::time::timeout(timeout, cmd.output())
70        .await
71        .map_err(|_| CleanupError::Timeout(cfg.timeout_secs))??;
72
73    if !child.status.success() {
74        let stderr = String::from_utf8_lossy(&child.stderr).into_owned();
75        return Err(CleanupError::NonZeroExit {
76            code: child.status.code().unwrap_or(-1),
77            stderr,
78        });
79    }
80
81    let stdout = String::from_utf8_lossy(&child.stdout).into_owned();
82    let cleaned = strip_markdown_fence(&stdout);
83    if cleaned.trim().is_empty() {
84        return Err(CleanupError::EmptyOutput);
85    }
86    validate(&cleaned, cfg.target).map_err(|detail| CleanupError::InvalidOutput { detail })?;
87    Ok(cleaned)
88}
89
90/// Build the cleanup prompt for the given target language.
91fn build_prompt(raw_source: &str, target: CleanupTarget) -> String {
92    match target {
93        CleanupTarget::Rust => format!(
94            r#"You are cleaning up a Rust test source generated by smix-recorder.
95Rules:
961. DO NOT change any selector literal (text/id/label/role/name).
972. DO NOT change any fill() argument text.
983. DO NOT reorder actions; tap/fill/clear/press_key/swipe_once/go_back/wait_for/hide_keyboard appearance order is canonical.
994. Output cleaned Rust code only — no markdown fence, no commentary.
100
101What you ARE allowed to do:
102- Give the test function a sensible name inferred from the selector flow.
103- Insert `app.wait_for(&<next-screen selector>, Duration::from_secs(5)).await?;` between phases.
104- Add 1-2 lines of file-head doc comment describing the recorded flow.
105
106Input source:
107{raw_source}
108
109Cleaned source:
110"#
111        ),
112        CleanupTarget::MaestroYaml => format!(
113            r#"You are cleaning up a maestro yaml flow generated by smix-recorder.
114Rules:
1151. DO NOT change any tapOn / inputText / pressKey value.
1162. DO NOT reorder steps.
1173. Output cleaned yaml only — no markdown fence, no commentary.
118
119What you ARE allowed to do:
120- Add a `# header comment` describing the flow.
121- Insert `extendedWaitUntil` steps between phases for stability.
122
123Input yaml:
124{raw_source}
125
126Cleaned yaml:
127"#
128        ),
129    }
130}
131
132/// Strip markdown code fence if claude wrapped output in ```rust ... ```.
133fn strip_markdown_fence(s: &str) -> String {
134    let trimmed = s.trim();
135    if trimmed.starts_with("```") {
136        // Find the inner content between opening and closing fences.
137        let lines: Vec<&str> = trimmed.lines().collect();
138        if lines.len() >= 2 && lines[0].starts_with("```") {
139            let body_lines = if lines.last().map(|l| l.starts_with("```")).unwrap_or(false) {
140                &lines[1..lines.len() - 1]
141            } else {
142                &lines[1..]
143            };
144            return body_lines.join("\n");
145        }
146    }
147    trimmed.to_string()
148}
149
150/// Sanity-validate the cleaned output. Light validator; catches the
151/// "claude returned a completely off-spec blob" mode.
152fn validate(s: &str, target: CleanupTarget) -> Result<(), String> {
153    match target {
154        CleanupTarget::Rust => {
155            if !s.contains("use smix_sdk") {
156                return Err("missing `use smix_sdk` import".into());
157            }
158            if !s.contains("#[tokio::test") {
159                return Err("missing `#[tokio::test]` attribute".into());
160            }
161            if !s.contains("async fn ") {
162                return Err("missing `async fn` declaration".into());
163            }
164            Ok(())
165        }
166        CleanupTarget::MaestroYaml => {
167            if !s.contains("appId:") {
168                return Err("missing `appId:` field".into());
169            }
170            if !s.contains("---") {
171                return Err("missing `---` separator".into());
172            }
173            Ok(())
174        }
175    }
176}