use thiserror::Error;
use tokio::process::Command;
#[derive(Clone, Debug)]
pub struct CleanupConfig {
pub claude_bin: String,
pub timeout_secs: u64,
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,
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),
}
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)
}
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:
"#
),
}
}
fn strip_markdown_fence(s: &str) -> String {
let trimmed = s.trim();
if trimmed.starts_with("```") {
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()
}
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(())
}
}
}