1use thiserror::Error;
9use tokio::process::Command;
10
11#[derive(Clone, Debug)]
13pub struct CleanupConfig {
14 pub claude_bin: String,
16 pub timeout_secs: u64,
18 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,
36 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
54pub 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
90fn 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
132fn strip_markdown_fence(s: &str) -> String {
134 let trimmed = s.trim();
135 if trimmed.starts_with("```") {
136 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
150fn 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}