1use std::fs;
8use std::path::Path;
9
10pub const PROMOTED_MARKER: &str = ".promoted.json";
14
15pub const SNAPSHOT_META: &str = ".snapshot-meta.json";
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct KeptIteration {
23 pub iteration: String,
24 pub reason: String,
25}
26
27#[derive(Debug, Default, Clone, PartialEq, Eq)]
29pub struct WorkspaceCleanupSummary {
30 pub removed_iterations: Vec<String>,
32 pub kept_iterations: Vec<KeptIteration>,
34 pub removed_snapshots: Vec<String>,
36 pub kept_snapshots: Vec<String>,
38 pub workspace_removed: bool,
40}
41
42const UNCOMMITTED_REASON: &str = "uncommitted results — not promoted to evals/baseline/";
44
45pub fn cleanup_workspace(workspace_root: &Path, skill_name: &str) -> WorkspaceCleanupSummary {
53 let mut summary = WorkspaceCleanupSummary::default();
54
55 let skill_dir = workspace_root.join(skill_name);
56 if !skill_dir.exists() {
57 return summary;
58 }
59
60 for name in sorted_entry_names(&skill_dir) {
61 if !name.starts_with("iteration-") {
62 continue;
63 }
64 let iter_dir = skill_dir.join(&name);
65 if !iter_dir.is_dir() {
66 continue;
67 }
68 if iter_dir.join(PROMOTED_MARKER).exists() {
69 let _ = fs::remove_dir_all(&iter_dir);
70 summary.removed_iterations.push(name);
71 } else if iteration_has_results(&iter_dir) {
72 summary.kept_iterations.push(KeptIteration {
73 iteration: name,
74 reason: UNCOMMITTED_REASON.to_string(),
75 });
76 } else {
77 let _ = fs::remove_dir_all(&iter_dir);
78 summary.removed_iterations.push(name);
79 }
80 }
81
82 let snapshots_dir = skill_dir.join("snapshots");
83 if snapshots_dir.exists() {
84 for name in sorted_entry_names(&snapshots_dir) {
85 let snap_dir = snapshots_dir.join(&name);
86 if !snap_dir.is_dir() {
87 continue;
88 }
89 if snapshot_source(&snap_dir).as_deref() == Some("ref") {
90 let _ = fs::remove_dir_all(&snap_dir);
91 summary.removed_snapshots.push(name);
92 } else {
93 summary.kept_snapshots.push(name);
94 }
95 }
96 prune_if_empty(&snapshots_dir);
97 }
98
99 prune_if_empty(&skill_dir);
100 summary.workspace_removed = !skill_dir.exists();
101 prune_if_empty(workspace_root);
102
103 summary
104}
105
106fn sorted_entry_names(dir: &Path) -> Vec<String> {
109 let mut names: Vec<String> = match fs::read_dir(dir) {
110 Ok(rd) => rd
111 .filter_map(Result::ok)
112 .map(|e| e.file_name().to_string_lossy().into_owned())
113 .collect(),
114 Err(_) => Vec::new(),
115 };
116 names.sort();
117 names
118}
119
120fn prune_if_empty(dir: &Path) {
122 if let Ok(mut entries) = fs::read_dir(dir)
123 && entries.next().is_none()
124 {
125 let _ = fs::remove_dir(dir);
126 }
127}
128
129fn iteration_has_results(iter_dir: &Path) -> bool {
133 if iter_dir.join("benchmark.json").exists() {
134 return true;
135 }
136 let Ok(entries) = fs::read_dir(iter_dir) else {
137 return false;
138 };
139 for entry in entries.filter_map(Result::ok) {
140 let name = entry.file_name().to_string_lossy().into_owned();
141 if !name.starts_with("eval-") {
142 continue;
143 }
144 let eval_dir = entry.path();
145 if !eval_dir.is_dir() {
146 continue;
147 }
148 let Ok(conds) = fs::read_dir(&eval_dir) else {
149 continue;
150 };
151 for cond in conds.filter_map(Result::ok) {
152 let cond_dir = cond.path();
153 if !cond_dir.is_dir() {
154 continue;
155 }
156 if cond_dir.join("run.json").exists() || cond_dir.join("grading.json").exists() {
157 return true;
158 }
159 }
160 }
161 false
162}
163
164fn snapshot_source(snap_dir: &Path) -> Option<String> {
167 let text = fs::read_to_string(snap_dir.join(SNAPSHOT_META)).ok()?;
168 let value: serde_json::Value = serde_json::from_str(&text).ok()?;
169 value
170 .get("source")
171 .and_then(|s| s.as_str())
172 .map(str::to_owned)
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use std::path::PathBuf;
179 use tempfile::TempDir;
180
181 fn write(path: &Path, body: &str) {
183 fs::create_dir_all(path.parent().unwrap()).unwrap();
184 fs::write(path, body).unwrap();
185 }
186
187 #[derive(Default)]
188 struct IterOpts {
189 promoted: bool,
190 benchmark: bool,
191 run_record: bool,
192 grading: bool,
193 scaffolding_only: bool,
194 }
195
196 fn make_iteration(ws: &Path, skill: &str, iteration: &str, opts: IterOpts) -> PathBuf {
197 let dir = ws.join(skill).join(iteration);
198 fs::create_dir_all(&dir).unwrap();
199 if opts.scaffolding_only {
200 write(&dir.join("dispatch.json"), "[]\n");
201 }
202 if opts.benchmark {
203 write(
204 &dir.join("benchmark.json"),
205 "{\"delta\":{\"pass_rate\":0.5}}\n",
206 );
207 }
208 if opts.run_record {
209 write(
210 &dir.join("eval-e1/with_skill/run.json"),
211 "{\"eval_id\":\"e1\"}\n",
212 );
213 }
214 if opts.grading {
215 write(
216 &dir.join("eval-e1/with_skill/grading.json"),
217 "{\"summary\":{\"pass_rate\":1}}\n",
218 );
219 }
220 if opts.promoted {
221 write(&dir.join(PROMOTED_MARKER), "{\"commit\":\"abc1234\"}\n");
222 }
223 dir
224 }
225
226 fn make_snapshot(ws: &Path, skill: &str, label: &str, source: Option<&str>) -> PathBuf {
227 let dir = ws.join(skill).join("snapshots").join(label);
228 fs::create_dir_all(&dir).unwrap();
229 write(&dir.join("SKILL.md"), "snapshot body\n");
230 match source {
231 Some("ref") => write(
232 &dir.join(SNAPSHOT_META),
233 "{\"source\":\"ref\",\"ref\":\"HEAD~1\"}\n",
234 ),
235 Some(s) => write(
236 &dir.join(SNAPSHOT_META),
237 &format!("{{\"source\":\"{s}\"}}\n"),
238 ),
239 None => {}
240 }
241 dir
242 }
243
244 fn kept_names(s: &WorkspaceCleanupSummary) -> Vec<String> {
245 s.kept_iterations
246 .iter()
247 .map(|k| k.iteration.clone())
248 .collect()
249 }
250
251 #[test]
252 fn removes_promoted_iteration_and_prunes_workspace() {
253 let tmp = TempDir::new().unwrap();
254 let ws = tmp.path().join(".eval-magic");
255 fs::create_dir_all(&ws).unwrap();
256 let iter = make_iteration(
257 &ws,
258 "mr-review",
259 "iteration-1",
260 IterOpts {
261 promoted: true,
262 benchmark: true,
263 grading: true,
264 ..Default::default()
265 },
266 );
267
268 let summary = cleanup_workspace(&ws, "mr-review");
269
270 assert!(!iter.exists());
271 assert_eq!(summary.removed_iterations, vec!["iteration-1"]);
272 assert!(summary.workspace_removed);
273 assert!(!ws.join("mr-review").exists());
274 assert!(!ws.exists());
275 }
276
277 #[test]
278 fn keeps_unpromoted_iteration_with_benchmark_and_reports_it() {
279 let tmp = TempDir::new().unwrap();
280 let ws = tmp.path().join(".eval-magic");
281 fs::create_dir_all(&ws).unwrap();
282 let iter = make_iteration(
283 &ws,
284 "mr-review",
285 "iteration-1",
286 IterOpts {
287 benchmark: true,
288 ..Default::default()
289 },
290 );
291
292 let summary = cleanup_workspace(&ws, "mr-review");
293
294 assert!(iter.exists());
295 assert_eq!(summary.removed_iterations, Vec::<String>::new());
296 assert_eq!(kept_names(&summary), vec!["iteration-1"]);
297 assert!(ws.exists());
298 }
299
300 #[test]
301 fn keeps_unpromoted_iteration_with_only_a_run_record() {
302 let tmp = TempDir::new().unwrap();
303 let ws = tmp.path().join(".eval-magic");
304 fs::create_dir_all(&ws).unwrap();
305 let iter = make_iteration(
306 &ws,
307 "mr-review",
308 "iteration-1",
309 IterOpts {
310 run_record: true,
311 ..Default::default()
312 },
313 );
314
315 let summary = cleanup_workspace(&ws, "mr-review");
316
317 assert!(iter.exists());
318 assert_eq!(kept_names(&summary), vec!["iteration-1"]);
319 }
320
321 #[test]
322 fn removes_unpromoted_scaffolding_only_iteration() {
323 let tmp = TempDir::new().unwrap();
324 let ws = tmp.path().join(".eval-magic");
325 fs::create_dir_all(&ws).unwrap();
326 let iter = make_iteration(
327 &ws,
328 "mr-review",
329 "iteration-1",
330 IterOpts {
331 scaffolding_only: true,
332 ..Default::default()
333 },
334 );
335
336 let summary = cleanup_workspace(&ws, "mr-review");
337
338 assert!(!iter.exists());
339 assert_eq!(summary.removed_iterations, vec!["iteration-1"]);
340 }
341
342 #[test]
343 fn mixed_promoted_removed_kept_with_results_skill_dir_not_pruned() {
344 let tmp = TempDir::new().unwrap();
345 let ws = tmp.path().join(".eval-magic");
346 fs::create_dir_all(&ws).unwrap();
347 let promoted = make_iteration(
348 &ws,
349 "mr-review",
350 "iteration-1",
351 IterOpts {
352 promoted: true,
353 benchmark: true,
354 ..Default::default()
355 },
356 );
357 let kept = make_iteration(
358 &ws,
359 "mr-review",
360 "iteration-2",
361 IterOpts {
362 benchmark: true,
363 ..Default::default()
364 },
365 );
366
367 let summary = cleanup_workspace(&ws, "mr-review");
368
369 assert!(!promoted.exists());
370 assert!(kept.exists());
371 assert_eq!(summary.removed_iterations, vec!["iteration-1"]);
372 assert_eq!(kept_names(&summary), vec!["iteration-2"]);
373 assert!(!summary.workspace_removed);
374 assert!(ws.join("mr-review").exists());
375 }
376
377 #[test]
378 fn removes_ref_snapshots_keeps_working_tree_and_legacy() {
379 let tmp = TempDir::new().unwrap();
380 let ws = tmp.path().join(".eval-magic");
381 fs::create_dir_all(&ws).unwrap();
382 let ref_snap = make_snapshot(&ws, "mr-review", "old-ref", Some("ref"));
383 let wt_snap = make_snapshot(&ws, "mr-review", "wt", Some("working-tree"));
384 let legacy_snap = make_snapshot(&ws, "mr-review", "legacy", None);
385
386 let summary = cleanup_workspace(&ws, "mr-review");
387
388 assert!(!ref_snap.exists());
389 assert!(wt_snap.exists());
390 assert!(legacy_snap.exists());
391 assert_eq!(summary.removed_snapshots, vec!["old-ref"]);
392 assert_eq!(summary.kept_snapshots, vec!["legacy", "wt"]);
393 }
394
395 #[test]
396 fn never_touches_another_skill_and_leaves_workspace_root_intact() {
397 let tmp = TempDir::new().unwrap();
398 let ws = tmp.path().join(".eval-magic");
399 fs::create_dir_all(&ws).unwrap();
400 make_iteration(
401 &ws,
402 "mr-review",
403 "iteration-1",
404 IterOpts {
405 promoted: true,
406 ..Default::default()
407 },
408 );
409 let other_iter = make_iteration(
410 &ws,
411 "other-skill",
412 "iteration-1",
413 IterOpts {
414 benchmark: true,
415 ..Default::default()
416 },
417 );
418
419 cleanup_workspace(&ws, "mr-review");
420
421 assert!(!ws.join("mr-review").exists());
422 assert!(other_iter.exists());
423 assert!(ws.exists());
424 }
425
426 #[test]
427 fn empty_summary_when_skill_has_no_workspace() {
428 let tmp = TempDir::new().unwrap();
429 let ws = tmp.path().join(".eval-magic");
430 fs::create_dir_all(&ws).unwrap();
431
432 let summary = cleanup_workspace(&ws, "never-ran");
433
434 assert_eq!(summary, WorkspaceCleanupSummary::default());
435 }
436}