1use crate::docker_cleanup::docker_command;
16use crate::docker_snapshot_safety::SNAPSHOT_IMAGE_LABEL;
17use crate::execution_control::{CommandRunOutput, CommandRunner};
18use crate::execution_resume::build_snapshot_image_name;
19use crate::execution_store::{ExecutionRecord, ExecutionStore};
20use crate::query_commands::QueryOutcome;
21use serde_json::Value;
22
23pub fn is_snapshot_image(image: &str) -> bool {
25 let Some((name, attempt)) = image
26 .strip_prefix("start-command-resume/")
27 .and_then(|rest| rest.split_once(':'))
28 else {
29 return false;
30 };
31 !name.is_empty()
32 && name
33 .chars()
34 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c))
35 && !attempt.is_empty()
36 && attempt.chars().all(|c| c.is_ascii_digit())
37}
38
39pub fn chain_images(container_name: &str) -> Vec<String> {
41 let mut images = Vec::new();
42 let mut current = container_name;
43 while let Some((base, attempt)) = current.rsplit_once("-resume-") {
44 let Ok(number) = attempt.parse::<u64>() else {
45 break;
46 };
47 if base.is_empty() || !attempt.chars().all(|c| c.is_ascii_digit()) {
48 break;
49 }
50 images.push(build_snapshot_image_name(base, number));
51 current = base;
52 }
53 images
54}
55
56fn attempt_of(image: &str) -> u64 {
57 image
58 .rsplit_once(':')
59 .and_then(|(_, attempt)| attempt.parse().ok())
60 .unwrap_or(0)
61}
62
63pub fn plan_execution_cleanup<R: CommandRunner + ?Sized>(
65 record: &ExecutionRecord,
66 runner: &R,
67) -> (Vec<String>, Vec<String>) {
68 let option = |key: &str| {
69 record
70 .options
71 .get(key)
72 .and_then(Value::as_str)
73 .filter(|value| !value.is_empty())
74 };
75 let history = record
76 .options
77 .get("sessionNameHistory")
78 .and_then(Value::as_array)
79 .map(|names| names.iter().filter_map(Value::as_str).collect::<Vec<_>>())
80 .unwrap_or_default();
81 let mut containers: Vec<String> = Vec::new();
82 for name in option("sessionName")
83 .or_else(|| option("containerId"))
84 .into_iter()
85 .chain(history.into_iter().rev())
86 {
87 if !name.is_empty() && !containers.iter().any(|known| known == name) {
88 containers.push(name.to_string());
89 }
90 }
91 let docker = docker_command().to_string_lossy().to_string();
92 let mut images: Vec<String> = option("snapshotImage")
93 .map(String::from)
94 .into_iter()
95 .collect();
96 for container in &containers {
97 let labelled = runner.run(
98 &docker,
99 &[
100 "inspect".into(),
101 "-f".into(),
102 format!("{{{{index .Config.Labels \"{}\"}}}}", SNAPSHOT_IMAGE_LABEL),
103 container.clone(),
104 ],
105 );
106 if labelled.success {
107 images.push(labelled.stdout.trim().to_string());
108 }
109 images.extend(chain_images(container));
110 }
111 let mut unique: Vec<String> = Vec::new();
112 for image in images {
113 if is_snapshot_image(&image) && !unique.contains(&image) {
114 unique.push(image);
115 }
116 }
117 unique.sort_by_key(|image| std::cmp::Reverse(attempt_of(image)));
119 (containers, unique)
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum CleanupState {
125 Removed,
126 Absent,
127 Retained,
128 Present,
130}
131
132pub struct CleanupOutcome {
133 pub kind: &'static str,
134 pub name: String,
135 pub state: CleanupState,
136 pub detail: Option<String>,
137}
138
139fn error_text(result: &CommandRunOutput) -> String {
140 let stderr = result.stderr.trim();
141 if !stderr.is_empty() {
142 return stderr.to_string();
143 }
144 result.error.clone().unwrap_or_else(|| {
145 format!(
146 "exit code {}",
147 result
148 .status
149 .map(|code| code.to_string())
150 .unwrap_or_else(|| "null".into())
151 )
152 })
153}
154
155fn is_not_found(detail: &str) -> bool {
156 let lower = detail.to_lowercase();
157 ["no such container", "no such image", "no such object"]
158 .iter()
159 .any(|pattern| lower.contains(pattern))
160}
161
162fn remove_object<R: CommandRunner + ?Sized>(
164 kind: &'static str,
165 name: &str,
166 runner: &R,
167 dry_run: bool,
168) -> CleanupOutcome {
169 let args: Vec<String> = match (dry_run, kind) {
170 (true, _) => vec![
171 if kind == "image" {
172 "image"
173 } else {
174 "container"
175 }
176 .into(),
177 "inspect".into(),
178 "--format".into(),
179 "{{.Id}}".into(),
180 name.into(),
181 ],
182 (false, "image") => vec!["rmi".into(), name.into()],
183 (false, _) => vec!["rm".into(), name.into()],
184 };
185 let result = runner.run(&docker_command().to_string_lossy(), &args);
186 let outcome = |state, detail| CleanupOutcome {
187 kind,
188 name: name.to_string(),
189 state,
190 detail,
191 };
192 if result.success {
193 return outcome(
194 if dry_run {
195 CleanupState::Present
196 } else {
197 CleanupState::Removed
198 },
199 None,
200 );
201 }
202 let detail = error_text(&result);
203 if result.error.is_none() && is_not_found(&detail) {
204 return outcome(CleanupState::Absent, None);
205 }
206 outcome(CleanupState::Retained, Some(detail))
207}
208
209fn format_outcome(outcome: &CleanupOutcome) -> String {
210 let label = match outcome.state {
211 CleanupState::Removed => "Removed",
212 CleanupState::Absent => "Already gone:",
213 CleanupState::Retained => "Retained",
214 CleanupState::Present => "Would remove",
215 };
216 let line = format!("{} {} {}", label, outcome.kind, outcome.name);
217 match &outcome.detail {
218 Some(detail) => format!("{} ({})", line, detail),
219 None => line,
220 }
221}
222
223fn failure(error: String) -> QueryOutcome {
224 QueryOutcome {
225 success: false,
226 output: None,
227 error: Some(error),
228 exit_code: None,
229 }
230}
231
232pub fn cleanup_execution<R: CommandRunner + ?Sized>(
234 store: Option<&ExecutionStore>,
235 identifier: &str,
236 dry_run: bool,
237 runner: &R,
238) -> QueryOutcome {
239 let Some(store) = store else {
240 return failure("Execution tracking is disabled.".into());
241 };
242 let Some(record) = store.get(identifier) else {
243 return failure(format!(
244 "No execution found with UUID or session name: {}",
245 identifier
246 ));
247 };
248 if record.options.get("isolated").and_then(Value::as_str) != Some("docker") {
249 return failure(format!(
250 "--cleanup <execution> only removes Docker containers and images; {} is not a Docker execution.",
251 identifier
252 ));
253 }
254 let (containers, images) = plan_execution_cleanup(&record, runner);
255 let mut outcomes: Vec<CleanupOutcome> = containers
256 .iter()
257 .map(|name| remove_object("container", name, runner, dry_run))
258 .collect();
259 for image in &images {
261 outcomes.push(remove_object("image", image, runner, dry_run));
262 }
263 if !dry_run
264 && outcomes.first().is_some_and(|current| {
265 matches!(current.state, CleanupState::Removed | CleanupState::Absent)
266 })
267 {
268 let _ = store.mark_container_removed(&record.uuid, &chrono::Utc::now().to_rfc3339());
269 }
270 let retained = outcomes
271 .iter()
272 .filter(|outcome| outcome.state == CleanupState::Retained)
273 .count();
274 let output = if outcomes.is_empty() {
275 "No containers or snapshot images recorded for this execution.".to_string()
276 } else {
277 outcomes
278 .iter()
279 .map(format_outcome)
280 .collect::<Vec<_>>()
281 .join("\n")
282 };
283 QueryOutcome {
284 success: retained == 0,
285 output: Some(output),
286 error: (retained > 0).then(|| {
287 format!(
288 "{} Docker object(s) retained; nothing was forced.",
289 retained
290 )
291 }),
292 exit_code: None,
293 }
294}