1use std::path::Path;
2use std::process::{Command, ExitCode, Stdio};
3
4use crate::cli::{CleanCommand, Cli};
5use crate::config::{LoadOptions, load_config};
6use crate::error::SboxError;
7
8pub fn execute(cli: &Cli, command: &CleanCommand) -> Result<ExitCode, SboxError> {
9 if command.global_scope {
10 return execute_global();
11 }
12
13 let scope = CleanScope::from_command(command);
14 let loaded = load_config(&LoadOptions {
15 workspace: cli.workspace.clone(),
16 config: cli.config.clone(),
17 })?;
18
19 let mut removed = Vec::new();
20
21 if scope.sessions {
22 let session_names = reusable_session_names(&loaded.config, &loaded.workspace_root);
23 if session_names.is_empty() {
24 println!("sessions: no reusable sessions configured for this workspace");
25 } else {
26 for name in session_names {
27 remove_podman_container(&name)?;
28 removed.push(format!("session:{name}"));
29 }
30 }
31 }
32
33 if scope.images {
34 if let Some(tag) = derived_image_tag(&loaded.config, &loaded.workspace_root) {
35 remove_podman_image(&tag)?;
36 removed.push(format!("image:{tag}"));
37 } else {
38 println!("images: no workspace-derived image configured");
39 }
40 }
41
42 if scope.caches {
43 let names = cache_volume_names(&loaded.config.caches, &loaded.workspace_root);
44 if names.is_empty() {
45 println!("caches: no workspace caches configured");
46 } else {
47 for name in names {
48 remove_podman_volume(&name)?;
49 removed.push(format!("cache:{name}"));
50 }
51 }
52 }
53
54 if removed.is_empty() {
55 println!("clean: nothing removed");
56 } else {
57 println!("clean: removed {}", removed.join(", "));
58 }
59
60 Ok(ExitCode::SUCCESS)
61}
62
63fn execute_global() -> Result<ExitCode, SboxError> {
66 let containers = list_podman_names(&["ps", "-a", "--format", "{{.Names}}"], "sbox-")?;
67 let volumes = list_podman_names(&["volume", "ls", "--format", "{{.Name}}"], "sbox-")?;
68 let images = list_podman_names(
69 &["images", "--format", "{{.Repository}}:{{.Tag}}"],
70 "sbox-build-",
71 )?;
72
73 let mut removed = Vec::new();
74
75 for name in &containers {
76 remove_podman_container(name)?;
77 removed.push(format!("container:{name}"));
78 }
79 for name in &volumes {
80 remove_podman_volume(name)?;
81 removed.push(format!("volume:{name}"));
82 }
83 for tag in &images {
84 remove_podman_image(tag)?;
85 removed.push(format!("image:{tag}"));
86 }
87
88 if removed.is_empty() {
89 println!("clean --global: no sbox-managed resources found");
90 } else {
91 println!("clean --global: removed {}", removed.join(", "));
92 }
93
94 Ok(ExitCode::SUCCESS)
95}
96
97fn list_podman_names(args: &[&str], prefix: &str) -> Result<Vec<String>, SboxError> {
98 let output = Command::new("podman")
99 .args(args)
100 .stdin(Stdio::null())
101 .stderr(Stdio::null())
102 .output()
103 .map_err(|source| SboxError::BackendUnavailable {
104 backend: "podman".to_string(),
105 source,
106 })?;
107
108 Ok(String::from_utf8_lossy(&output.stdout)
109 .lines()
110 .map(str::trim)
111 .filter(|name| name.starts_with(prefix))
112 .map(String::from)
113 .collect())
114}
115
116#[derive(Debug, Clone, Copy)]
117struct CleanScope {
118 sessions: bool,
119 images: bool,
120 caches: bool,
121}
122
123impl CleanScope {
124 fn from_command(command: &CleanCommand) -> Self {
125 if command.all {
126 return Self {
127 sessions: true,
128 images: true,
129 caches: true,
130 };
131 }
132
133 if !command.sessions && !command.images && !command.caches {
134 return Self {
135 sessions: true,
136 images: false,
137 caches: false,
138 };
139 }
140
141 Self {
142 sessions: command.sessions,
143 images: command.images,
144 caches: command.caches,
145 }
146 }
147}
148
149fn derived_image_tag(
150 config: &crate::config::model::Config,
151 workspace_root: &Path,
152) -> Option<String> {
153 let image = config.image.as_ref()?;
154 let build = image.build.as_ref()?;
155 if let Some(tag) = &image.tag {
156 return Some(tag.clone());
157 }
158
159 let recipe_path = if build.is_absolute() {
160 build.clone()
161 } else {
162 workspace_root.join(build)
163 };
164
165 Some(format!(
166 "sbox-build-{}",
167 stable_hash(&recipe_path.display().to_string())
168 ))
169}
170
171fn cache_volume_names(
172 caches: &[crate::config::model::CacheConfig],
173 workspace_root: &Path,
174) -> Vec<String> {
175 caches
176 .iter()
177 .filter(|cache| cache.source.is_none())
178 .map(|cache| {
179 format!(
180 "sbox-cache-{}-{}",
181 stable_hash(&workspace_root.display().to_string()),
182 sanitize_volume_name(&cache.name)
183 )
184 })
185 .collect()
186}
187
188fn reusable_session_names(
189 config: &crate::config::model::Config,
190 workspace_root: &Path,
191) -> Vec<String> {
192 let runtime_reuse = config
193 .runtime
194 .as_ref()
195 .and_then(|runtime| runtime.reuse_container)
196 .unwrap_or(false);
197 let template = config
198 .runtime
199 .as_ref()
200 .and_then(|runtime| runtime.container_name.as_ref());
201
202 config
203 .profiles
204 .iter()
205 .filter(|(_, profile)| profile.reuse_container.unwrap_or(runtime_reuse))
206 .map(|(profile_name, _)| reusable_session_name(template, workspace_root, profile_name))
207 .collect()
208}
209
210fn remove_podman_image(tag: &str) -> Result<(), SboxError> {
211 let status = Command::new("podman")
212 .args(["image", "rm", "-f", tag])
213 .stdin(Stdio::null())
214 .stdout(Stdio::null())
215 .stderr(Stdio::null())
216 .status()
217 .map_err(|source| SboxError::BackendUnavailable {
218 backend: "podman".to_string(),
219 source,
220 })?;
221
222 if status.success() || status.code() == Some(1) {
223 Ok(())
224 } else {
225 Err(SboxError::BackendCommandFailed {
226 backend: "podman".to_string(),
227 command: format!("podman image rm -f {tag}"),
228 status: status.code().unwrap_or(1),
229 })
230 }
231}
232
233fn remove_podman_volume(name: &str) -> Result<(), SboxError> {
234 let status = Command::new("podman")
235 .args(["volume", "rm", "-f", name])
236 .stdin(Stdio::null())
237 .stdout(Stdio::null())
238 .stderr(Stdio::null())
239 .status()
240 .map_err(|source| SboxError::BackendUnavailable {
241 backend: "podman".to_string(),
242 source,
243 })?;
244
245 if status.success() || status.code() == Some(1) {
246 Ok(())
247 } else {
248 Err(SboxError::BackendCommandFailed {
249 backend: "podman".to_string(),
250 command: format!("podman volume rm -f {name}"),
251 status: status.code().unwrap_or(1),
252 })
253 }
254}
255
256fn remove_podman_container(name: &str) -> Result<(), SboxError> {
257 let status = Command::new("podman")
258 .args(["rm", "-f", name])
259 .stdin(Stdio::null())
260 .stdout(Stdio::null())
261 .stderr(Stdio::null())
262 .status()
263 .map_err(|source| SboxError::BackendUnavailable {
264 backend: "podman".to_string(),
265 source,
266 })?;
267
268 if status.success() || status.code() == Some(1) {
269 Ok(())
270 } else {
271 Err(SboxError::BackendCommandFailed {
272 backend: "podman".to_string(),
273 command: format!("podman rm -f {name}"),
274 status: status.code().unwrap_or(1),
275 })
276 }
277}
278
279fn sanitize_volume_name(name: &str) -> String {
280 name.chars()
281 .map(|ch| {
282 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-' {
283 ch
284 } else {
285 '-'
286 }
287 })
288 .collect()
289}
290
291fn stable_hash(input: &str) -> String {
292 let mut hash = 0xcbf29ce484222325u64;
293 for byte in input.as_bytes() {
294 hash ^= u64::from(*byte);
295 hash = hash.wrapping_mul(0x100000001b3);
296 }
297 format!("{hash:016x}")
298}
299
300fn reusable_session_name(
301 template: Option<&String>,
302 workspace_root: &Path,
303 profile_name: &str,
304) -> String {
305 let workspace_hash = stable_hash(&workspace_root.display().to_string());
306 let base = template
307 .map(|template| {
308 template
309 .replace("{profile}", profile_name)
310 .replace("{workspace_hash}", &workspace_hash)
311 })
312 .unwrap_or_else(|| format!("sbox-{workspace_hash}-{profile_name}"));
313
314 sanitize_volume_name(&base)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::{CleanScope, cache_volume_names, reusable_session_names};
320 use crate::cli::CleanCommand;
321 use crate::config::model::{
322 BackendKind, CacheConfig, Config, ExecutionMode, ProfileConfig, RuntimeConfig,
323 };
324 use indexmap::IndexMap;
325 use std::path::Path;
326
327 #[test]
328 fn defaults_to_sessions_only() {
329 let scope = CleanScope::from_command(&CleanCommand::default());
330 assert!(scope.sessions);
331 assert!(!scope.images);
332 assert!(!scope.caches);
333 }
334
335 #[test]
336 fn all_flag_enables_every_cleanup_target() {
337 let scope = CleanScope::from_command(&CleanCommand {
338 all: true,
339 ..CleanCommand::default()
340 });
341 assert!(scope.sessions);
342 assert!(scope.images);
343 assert!(scope.caches);
344 }
345
346 #[test]
347 fn cache_volume_names_only_include_implicit_volumes() {
348 let caches = vec![
349 CacheConfig {
350 name: "first".into(),
351 target: "/cache".into(),
352 source: None,
353 read_only: None,
354 },
355 CacheConfig {
356 name: "host".into(),
357 target: "/host".into(),
358 source: Some("./cache".into()),
359 read_only: None,
360 },
361 ];
362
363 let names = cache_volume_names(&caches, Path::new("/tmp/workspace"));
364 assert_eq!(names.len(), 1);
365 assert!(names[0].contains("sbox-cache-"));
366 }
367
368 #[test]
369 fn reusable_session_names_follow_runtime_defaults() {
370 let mut profiles = IndexMap::new();
371 profiles.insert(
372 "default".to_string(),
373 ProfileConfig {
374 mode: ExecutionMode::Sandbox,
375 image: None,
376 network: Some("off".into()),
377 writable: Some(true),
378 require_pinned_image: None,
379 require_lockfile: None,
380 role: None,
381 lockfile_files: Vec::new(),
382 pre_run: Vec::new(),
383 network_allow: Vec::new(),
384 ports: Vec::new(),
385 capabilities: None,
386 no_new_privileges: Some(true),
387 read_only_rootfs: None,
388 reuse_container: None,
389 shell: None,
390
391 writable_paths: None,
392 },
393 );
394 let config = Config {
395 version: 1,
396 runtime: Some(RuntimeConfig {
397 backend: Some(BackendKind::Podman),
398 rootless: Some(true),
399 reuse_container: Some(true),
400 container_name: None,
401 pull_policy: None,
402 strict_security: None,
403 require_pinned_image: None,
404 }),
405 workspace: None,
406 identity: None,
407 image: None,
408 environment: None,
409 mounts: Vec::new(),
410 caches: Vec::new(),
411 secrets: Vec::new(),
412 profiles,
413 dispatch: IndexMap::new(),
414
415 package_manager: None,
416 };
417
418 let names = reusable_session_names(&config, Path::new("/tmp/workspace"));
419 assert_eq!(names.len(), 1);
420 assert!(names[0].starts_with("sbox-"));
421 }
422}