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