1use anyhow::{Result, anyhow};
8use tracing::debug;
9
10use crate::jobstore::resolve_root;
11use crate::schema::{GcData, GcJobResult, JobStatus, Response};
12
13const DEFAULT_OLDER_THAN: &str = "30d";
14
15#[derive(Debug)]
17pub struct GcOpts<'a> {
18 pub root: Option<&'a str>,
19 pub older_than: Option<&'a str>,
21 pub dry_run: bool,
22}
23
24pub fn execute(opts: GcOpts) -> Result<()> {
26 let root = resolve_root(opts.root);
27 let root_str = root.display().to_string();
28
29 let (older_than_str, older_than_source) = match opts.older_than {
30 Some(s) => (s.to_string(), "flag"),
31 None => (DEFAULT_OLDER_THAN.to_string(), "default"),
32 };
33
34 let retention_secs = parse_duration(&older_than_str).ok_or_else(|| {
35 anyhow!("invalid duration: {older_than_str}; expected formats: 30d, 24h, 60m, 3600s")
36 })?;
37
38 let now_secs = std::time::SystemTime::now()
40 .duration_since(std::time::UNIX_EPOCH)
41 .unwrap_or_default()
42 .as_secs();
43 let cutoff_secs = now_secs.saturating_sub(retention_secs);
44 let cutoff_rfc3339 = format_rfc3339(cutoff_secs);
45
46 debug!(
47 root = %root_str,
48 older_than = %older_than_str,
49 older_than_source,
50 dry_run = opts.dry_run,
51 cutoff = %cutoff_rfc3339,
52 "gc: starting"
53 );
54
55 if !root.exists() {
57 debug!(root = %root_str, "gc: root does not exist; nothing to collect");
58 Response::new(
59 "gc",
60 GcData {
61 root: root_str,
62 dry_run: opts.dry_run,
63 older_than: older_than_str,
64 older_than_source: older_than_source.to_string(),
65 deleted: 0,
66 skipped: 0,
67 freed_bytes: 0,
68 jobs: vec![],
69 },
70 )
71 .print();
72 return Ok(());
73 }
74
75 let read_dir = std::fs::read_dir(&root)
76 .map_err(|e| anyhow!("failed to read root directory {}: {}", root_str, e))?;
77
78 let mut job_results: Vec<GcJobResult> = Vec::new();
79 let mut deleted_count: u64 = 0;
80 let mut skipped_count: u64 = 0;
81 let mut freed_bytes: u64 = 0;
82
83 for entry in read_dir {
84 let entry = match entry {
85 Ok(e) => e,
86 Err(e) => {
87 debug!(error = %e, "gc: failed to read directory entry; skipping");
88 skipped_count += 1;
89 continue;
90 }
91 };
92
93 let path = entry.path();
94 if !path.is_dir() {
95 continue;
96 }
97
98 let job_id = match path.file_name().and_then(|n| n.to_str()) {
99 Some(n) => n.to_string(),
100 None => {
101 debug!(path = %path.display(), "gc: cannot get dir name; skipping");
102 skipped_count += 1;
103 continue;
104 }
105 };
106
107 let state_path = path.join("state.json");
109 let state = match std::fs::read(&state_path)
110 .ok()
111 .and_then(|b| serde_json::from_slice::<crate::schema::JobState>(&b).ok())
112 {
113 Some(s) => s,
114 None => {
115 debug!(path = %path.display(), "gc: state.json missing or unreadable; skipping");
116 skipped_count += 1;
117 job_results.push(GcJobResult {
118 job_id,
119 state: "unknown".to_string(),
120 action: "skipped".to_string(),
121 reason: "state_unreadable".to_string(),
122 bytes: 0,
123 });
124 continue;
125 }
126 };
127
128 let status = state.status();
129
130 if *status == JobStatus::Running {
132 debug!(job_id = %job_id, "gc: running job; skipping");
133 skipped_count += 1;
134 job_results.push(GcJobResult {
135 job_id,
136 state: "running".to_string(),
137 action: "skipped".to_string(),
138 reason: "running".to_string(),
139 bytes: 0,
140 });
141 continue;
142 }
143
144 if !matches!(
146 status,
147 JobStatus::Exited | JobStatus::Killed | JobStatus::Failed
148 ) {
149 debug!(job_id = %job_id, status = ?status, "gc: unknown status; skipping");
150 skipped_count += 1;
151 job_results.push(GcJobResult {
152 job_id,
153 state: status.as_str().to_string(),
154 action: "skipped".to_string(),
155 reason: "non_terminal_status".to_string(),
156 bytes: 0,
157 });
158 continue;
159 }
160
161 let gc_ts = match state
163 .finished_at
164 .as_deref()
165 .or(Some(state.updated_at.as_str()))
166 {
167 Some(ts) if !ts.is_empty() => ts.to_string(),
168 _ => {
169 debug!(job_id = %job_id, "gc: no usable timestamp; skipping");
170 skipped_count += 1;
171 job_results.push(GcJobResult {
172 job_id,
173 state: status.as_str().to_string(),
174 action: "skipped".to_string(),
175 reason: "no_timestamp".to_string(),
176 bytes: 0,
177 });
178 continue;
179 }
180 };
181
182 if !is_older_than(&gc_ts, &cutoff_rfc3339) {
184 debug!(job_id = %job_id, gc_ts = %gc_ts, cutoff = %cutoff_rfc3339, "gc: too recent; skipping");
185 skipped_count += 1;
186 job_results.push(GcJobResult {
187 job_id,
188 state: status.as_str().to_string(),
189 action: "skipped".to_string(),
190 reason: "too_recent".to_string(),
191 bytes: 0,
192 });
193 continue;
194 }
195
196 let dir_bytes = dir_size_bytes(&path);
198
199 if opts.dry_run {
200 debug!(job_id = %job_id, bytes = dir_bytes, "gc: dry-run would delete");
201 freed_bytes += dir_bytes;
202 job_results.push(GcJobResult {
203 job_id,
204 state: status.as_str().to_string(),
205 action: "would_delete".to_string(),
206 reason: format!("older_than_{older_than_str}"),
207 bytes: dir_bytes,
208 });
209 } else {
210 match std::fs::remove_dir_all(&path) {
211 Ok(()) => {
212 debug!(job_id = %job_id, bytes = dir_bytes, "gc: deleted");
213 deleted_count += 1;
214 freed_bytes += dir_bytes;
215 job_results.push(GcJobResult {
216 job_id,
217 state: status.as_str().to_string(),
218 action: "deleted".to_string(),
219 reason: format!("older_than_{older_than_str}"),
220 bytes: dir_bytes,
221 });
222 }
223 Err(e) => {
224 debug!(job_id = %job_id, error = %e, "gc: failed to delete; skipping");
225 skipped_count += 1;
226 job_results.push(GcJobResult {
227 job_id,
228 state: status.as_str().to_string(),
229 action: "skipped".to_string(),
230 reason: format!("delete_failed: {e}"),
231 bytes: dir_bytes,
232 });
233 }
234 }
235 }
236 }
237
238 debug!(
239 deleted = deleted_count,
240 skipped = skipped_count,
241 freed_bytes,
242 "gc: complete"
243 );
244
245 Response::new(
246 "gc",
247 GcData {
248 root: root_str,
249 dry_run: opts.dry_run,
250 older_than: older_than_str,
251 older_than_source: older_than_source.to_string(),
252 deleted: deleted_count,
253 skipped: skipped_count,
254 freed_bytes,
255 jobs: job_results,
256 },
257 )
258 .print();
259
260 Ok(())
261}
262
263pub fn parse_duration(s: &str) -> Option<u64> {
267 let s = s.trim();
268 if let Some(n) = s.strip_suffix('d') {
269 n.parse::<u64>().ok().map(|v| v * 86_400)
270 } else if let Some(n) = s.strip_suffix('h') {
271 n.parse::<u64>().ok().map(|v| v * 3_600)
272 } else if let Some(n) = s.strip_suffix('m') {
273 n.parse::<u64>().ok().map(|v| v * 60)
274 } else if let Some(n) = s.strip_suffix('s') {
275 n.parse::<u64>().ok()
276 } else {
277 s.parse::<u64>().ok()
279 }
280}
281
282fn is_older_than(ts: &str, cutoff: &str) -> bool {
288 let ts_prefix = &ts[..ts.len().min(19)];
292 let cutoff_prefix = &cutoff[..cutoff.len().min(19)];
293 ts_prefix < cutoff_prefix
294}
295
296pub fn dir_size_bytes(path: &std::path::Path) -> u64 {
301 let mut total = 0u64;
302 let Ok(entries) = std::fs::read_dir(path) else {
303 return 0;
304 };
305 for entry in entries.flatten() {
306 let entry_path = entry.path();
307 if let Ok(meta) = entry_path.metadata() {
308 if meta.is_file() {
309 total += meta.len();
310 } else if meta.is_dir() {
311 total += dir_size_bytes(&entry_path);
312 }
313 }
314 }
315 total
316}
317
318fn format_rfc3339(secs: u64) -> String {
322 let mut s = secs;
323 let seconds = s % 60;
324 s /= 60;
325 let minutes = s % 60;
326 s /= 60;
327 let hours = s % 24;
328 s /= 24;
329
330 let mut days = s;
331 let mut year = 1970u64;
332 loop {
333 let days_in_year = if is_leap(year) { 366 } else { 365 };
334 if days < days_in_year {
335 break;
336 }
337 days -= days_in_year;
338 year += 1;
339 }
340
341 let leap = is_leap(year);
342 let month_days: [u64; 12] = [
343 31,
344 if leap { 29 } else { 28 },
345 31,
346 30,
347 31,
348 30,
349 31,
350 31,
351 30,
352 31,
353 30,
354 31,
355 ];
356 let mut month = 0usize;
357 for (i, &d) in month_days.iter().enumerate() {
358 if days < d {
359 month = i;
360 break;
361 }
362 days -= d;
363 }
364 let day = days + 1;
365
366 format!(
367 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
368 year,
369 month + 1,
370 day,
371 hours,
372 minutes,
373 seconds
374 )
375}
376
377fn is_leap(year: u64) -> bool {
378 (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn parse_duration_days() {
387 assert_eq!(parse_duration("30d"), Some(30 * 86_400));
388 assert_eq!(parse_duration("7d"), Some(7 * 86_400));
389 assert_eq!(parse_duration("1d"), Some(86_400));
390 }
391
392 #[test]
393 fn parse_duration_hours() {
394 assert_eq!(parse_duration("24h"), Some(24 * 3_600));
395 assert_eq!(parse_duration("1h"), Some(3_600));
396 }
397
398 #[test]
399 fn parse_duration_minutes() {
400 assert_eq!(parse_duration("60m"), Some(3_600));
401 }
402
403 #[test]
404 fn parse_duration_seconds() {
405 assert_eq!(parse_duration("3600s"), Some(3_600));
406 assert_eq!(parse_duration("0s"), Some(0));
407 }
408
409 #[test]
410 fn parse_duration_invalid() {
411 assert!(parse_duration("abc").is_none());
412 assert!(parse_duration("").is_none());
413 }
414
415 #[test]
416 fn is_older_than_true() {
417 assert!(is_older_than(
418 "2020-01-01T00:00:00Z",
419 "2024-01-01T00:00:00Z"
420 ));
421 }
422
423 #[test]
424 fn is_older_than_false_equal() {
425 assert!(!is_older_than(
426 "2024-01-01T00:00:00Z",
427 "2024-01-01T00:00:00Z"
428 ));
429 }
430
431 #[test]
432 fn is_older_than_false_newer() {
433 assert!(!is_older_than(
434 "2025-01-01T00:00:00Z",
435 "2024-01-01T00:00:00Z"
436 ));
437 }
438
439 #[test]
440 fn format_rfc3339_epoch() {
441 assert_eq!(format_rfc3339(0), "1970-01-01T00:00:00Z");
443 }
444
445 #[test]
446 fn format_rfc3339_known() {
447 assert_eq!(format_rfc3339(1_704_067_200), "2024-01-01T00:00:00Z");
449 }
450
451 #[test]
452 fn dir_size_bytes_empty_dir() {
453 let tmp = tempfile::tempdir().unwrap();
454 assert_eq!(dir_size_bytes(tmp.path()), 0);
455 }
456
457 #[test]
458 fn dir_size_bytes_with_file() {
459 let tmp = tempfile::tempdir().unwrap();
460 let file = tmp.path().join("test.txt");
461 std::fs::write(&file, b"hello world").unwrap();
462 assert_eq!(dir_size_bytes(tmp.path()), 11);
463 }
464}