1use anyhow::{Result, anyhow};
12use tracing::debug;
13
14use crate::jobstore::{InvalidJobState, JobDir, resolve_root};
15use crate::run::resolve_effective_cwd;
16use crate::schema::{DeleteData, DeleteJobResult, JobStatus, Response};
17
18#[derive(Debug)]
20pub struct DeleteOpts<'a> {
21 pub root: Option<&'a str>,
22 pub job_id: Option<&'a str>,
24 pub all: bool,
26 pub dry_run: bool,
28}
29
30pub fn execute(opts: DeleteOpts) -> Result<()> {
32 let root = resolve_root(opts.root);
33 let root_str = root.display().to_string();
34
35 if let Some(job_id) = opts.job_id {
36 delete_single(&root, &root_str, job_id, opts.dry_run)
37 } else {
38 delete_all(&root, &root_str, opts.dry_run)
39 }
40}
41
42fn delete_single(
48 root: &std::path::Path,
49 root_str: &str,
50 job_id: &str,
51 dry_run: bool,
52) -> Result<()> {
53 let job_dir = JobDir::open(root, job_id)?;
56 let job_path = job_dir.path;
57 let resolved_id = job_dir.job_id;
59
60 let state_path = job_path.join("state.json");
62 let state_opt: Option<crate::schema::JobState> = std::fs::read(&state_path)
63 .ok()
64 .and_then(|b| serde_json::from_slice(&b).ok());
65
66 let state_str = match &state_opt {
67 Some(s) => s.status().as_str().to_string(),
68 None => "unknown".to_string(),
69 };
70
71 if state_opt
73 .as_ref()
74 .map(|s| *s.status() == JobStatus::Running)
75 .unwrap_or(false)
76 {
77 return Err(anyhow::Error::new(InvalidJobState(format!(
78 "cannot delete job {job_id}: job is currently running"
79 ))));
80 }
81
82 let action = if dry_run {
83 debug!(job_id, "delete: dry-run would delete job");
84 "would_delete"
85 } else {
86 std::fs::remove_dir_all(&job_path).map_err(|e| {
87 anyhow!(
88 "failed to delete job directory {}: {}",
89 job_path.display(),
90 e
91 )
92 })?;
93 debug!(job_id, "delete: deleted job");
94 "deleted"
95 };
96
97 Response::new(
98 "delete",
99 DeleteData {
100 root: root_str.to_string(),
101 dry_run,
102 deleted: if action == "deleted" { 1 } else { 0 },
103 skipped: 0,
104 jobs: vec![DeleteJobResult {
105 job_id: resolved_id,
106 state: state_str,
107 action: action.to_string(),
108 reason: "explicit_delete".to_string(),
109 }],
110 },
111 )
112 .print();
113
114 Ok(())
115}
116
117fn delete_all(root: &std::path::Path, root_str: &str, dry_run: bool) -> Result<()> {
120 let current_cwd = resolve_effective_cwd(None);
121
122 debug!(
123 root = %root_str,
124 cwd = %current_cwd,
125 dry_run,
126 "delete --all: starting"
127 );
128
129 if !root.exists() {
131 debug!(root = %root_str, "delete --all: root does not exist; nothing to delete");
132 Response::new(
133 "delete",
134 DeleteData {
135 root: root_str.to_string(),
136 dry_run,
137 deleted: 0,
138 skipped: 0,
139 jobs: vec![],
140 },
141 )
142 .print();
143 return Ok(());
144 }
145
146 let read_dir = std::fs::read_dir(root)
147 .map_err(|e| anyhow!("failed to read root directory {}: {}", root_str, e))?;
148
149 let mut job_results: Vec<DeleteJobResult> = Vec::new();
150 let mut deleted_count: u64 = 0;
151 let mut skipped_count: u64 = 0;
152
153 for entry in read_dir {
154 let entry = match entry {
155 Ok(e) => e,
156 Err(e) => {
157 debug!(error = %e, "delete --all: failed to read directory entry; skipping");
158 skipped_count += 1;
159 continue;
160 }
161 };
162
163 let path = entry.path();
164 if !path.is_dir() {
165 continue;
166 }
167
168 let job_id = match path.file_name().and_then(|n| n.to_str()) {
169 Some(n) => n.to_string(),
170 None => {
171 debug!(path = %path.display(), "delete --all: cannot get dir name; skipping");
172 skipped_count += 1;
173 continue;
174 }
175 };
176
177 let meta_path = path.join("meta.json");
179 let meta: Option<crate::schema::JobMeta> = std::fs::read(&meta_path)
180 .ok()
181 .and_then(|b| serde_json::from_slice(&b).ok());
182
183 match meta.as_ref().and_then(|m| m.cwd.as_deref()) {
185 Some(job_cwd) if job_cwd == current_cwd => {
186 }
188 _ => {
189 debug!(
190 job_id = %job_id,
191 job_cwd = ?meta.as_ref().and_then(|m| m.cwd.as_deref()),
192 current_cwd = %current_cwd,
193 "delete --all: skipping job (cwd mismatch or absent)"
194 );
195 continue;
197 }
198 }
199
200 let state_path = path.join("state.json");
202 let state_opt: Option<crate::schema::JobState> = std::fs::read(&state_path)
203 .ok()
204 .and_then(|b| serde_json::from_slice(&b).ok());
205
206 let (state_str, status) = match &state_opt {
207 Some(s) => (s.status().as_str().to_string(), Some(s.status().clone())),
208 None => {
209 debug!(job_id = %job_id, "delete --all: state.json missing or unreadable; skipping");
210 skipped_count += 1;
211 job_results.push(DeleteJobResult {
212 job_id,
213 state: "unknown".to_string(),
214 action: "skipped".to_string(),
215 reason: "state_unreadable".to_string(),
216 });
217 continue;
218 }
219 };
220
221 let is_terminal = matches!(
223 status.as_ref(),
224 Some(JobStatus::Exited) | Some(JobStatus::Killed) | Some(JobStatus::Failed)
225 );
226
227 if !is_terminal {
228 let reason = match status.as_ref() {
229 Some(JobStatus::Running) => "running",
230 Some(JobStatus::Created) => "created",
231 _ => "non_terminal",
232 };
233 debug!(job_id = %job_id, state = %state_str, "delete --all: non-terminal job; skipping");
234 skipped_count += 1;
235 job_results.push(DeleteJobResult {
236 job_id,
237 state: state_str,
238 action: "skipped".to_string(),
239 reason: reason.to_string(),
240 });
241 continue;
242 }
243
244 let action = if dry_run {
246 debug!(job_id = %job_id, "delete --all: dry-run would delete");
247 "would_delete"
248 } else {
249 match std::fs::remove_dir_all(&path) {
250 Ok(()) => {
251 debug!(job_id = %job_id, "delete --all: deleted");
252 deleted_count += 1;
253 "deleted"
254 }
255 Err(e) => {
256 debug!(job_id = %job_id, error = %e, "delete --all: failed to delete; skipping");
257 skipped_count += 1;
258 job_results.push(DeleteJobResult {
259 job_id,
260 state: state_str,
261 action: "skipped".to_string(),
262 reason: format!("delete_failed: {e}"),
263 });
264 continue;
265 }
266 }
267 };
268
269 job_results.push(DeleteJobResult {
270 job_id,
271 state: state_str,
272 action: action.to_string(),
273 reason: "terminal_in_cwd".to_string(),
274 });
275 }
276
277 debug!(
278 deleted = deleted_count,
279 skipped = skipped_count,
280 "delete --all: complete"
281 );
282
283 Response::new(
284 "delete",
285 DeleteData {
286 root: root_str.to_string(),
287 dry_run,
288 deleted: deleted_count,
289 skipped: skipped_count,
290 jobs: job_results,
291 },
292 )
293 .print();
294
295 Ok(())
296}