1use std::path::{Path, PathBuf};
26
27use thiserror::Error;
28
29use cabin_core::{PackageName, ProfileName};
30
31#[derive(Debug, Clone)]
33pub enum CleanScope {
34 Whole,
37 Profile(ProfileName),
40 Packages {
43 profiles: Vec<ProfileName>,
44 packages: Vec<PackageName>,
45 },
46}
47
48#[derive(Debug, Clone)]
50pub struct CleanRequest<'a> {
51 pub build_dir: &'a Path,
53 pub workspace_root: &'a Path,
57 pub package_roots: &'a [PathBuf],
61 pub protected_source_paths: &'a [PathBuf],
66 pub scope: CleanScope,
68}
69
70#[derive(Debug, Clone)]
74pub struct CleanPlan {
75 pub build_dir: PathBuf,
77 pub removals: Vec<PathBuf>,
80}
81
82#[derive(Debug, Clone, Default)]
84pub struct CleanReport {
85 pub removed: Vec<PathBuf>,
89}
90
91#[derive(Debug, Error)]
94pub enum CleanError {
95 #[error("build directory path is empty")]
96 EmptyBuildDir,
97
98 #[error("refusing to clean root path {}", .0.display())]
99 RootBuildDir(PathBuf),
100
101 #[error("refusing to clean home directory {}", .0.display())]
102 HomeBuildDir(PathBuf),
103
104 #[error("refusing to clean workspace root {}; the build directory must point at a separate output directory", .0.display())]
105 WorkspaceRootBuildDir(PathBuf),
106
107 #[error("refusing to clean package source directory {}; the build directory must point at a separate output directory", .0.display())]
108 PackageRootBuildDir(PathBuf),
109
110 #[error("refusing to clean build directory {} because it overlaps source file or directory {}", build_dir.display(), source_path.display())]
111 SourcePathBuildDir {
112 build_dir: PathBuf,
113 source_path: PathBuf,
114 },
115
116 #[error("refusing to clean symlink {}; replace it with a real directory before re-running `cabin clean`", .0.display())]
117 SymlinkBuildDir(PathBuf),
118
119 #[error("computed deletion path {} is not inside build directory {}", path.display(), build_dir.display())]
120 PathEscapesBuildDir { path: PathBuf, build_dir: PathBuf },
121
122 #[error("failed to remove {}: {source}", path.display())]
123 Io {
124 path: PathBuf,
125 #[source]
126 source: std::io::Error,
127 },
128}
129
130pub fn plan_clean(req: &CleanRequest<'_>) -> Result<CleanPlan, CleanError> {
149 validate_safe_build_dir(
150 req.build_dir,
151 req.workspace_root,
152 req.package_roots,
153 req.protected_source_paths,
154 )?;
155
156 let candidates = match &req.scope {
157 CleanScope::Whole => vec![req.build_dir.to_path_buf()],
158 CleanScope::Profile(profile) => vec![req.build_dir.join(profile.as_str())],
159 CleanScope::Packages { profiles, packages } => {
160 let mut out = Vec::with_capacity(profiles.len().saturating_mul(packages.len()));
161 for profile in profiles {
162 let profile_root = req.build_dir.join(profile.as_str());
163 for pkg in packages {
164 out.push(profile_root.join("packages").join(pkg.as_str()));
165 }
166 }
167 out
168 }
169 };
170
171 for candidate in &candidates {
172 if !is_within(candidate, req.build_dir) {
173 return Err(CleanError::PathEscapesBuildDir {
174 path: candidate.clone(),
175 build_dir: req.build_dir.to_path_buf(),
176 });
177 }
178 }
179
180 let mut existing: Vec<PathBuf> = candidates.into_iter().filter(|p| p.exists()).collect();
181 existing.sort();
182 existing.dedup();
183
184 Ok(CleanPlan {
185 build_dir: req.build_dir.to_path_buf(),
186 removals: existing,
187 })
188}
189
190pub fn execute_clean(plan: &CleanPlan) -> Result<CleanReport, CleanError> {
203 let mut removed = Vec::with_capacity(plan.removals.len());
204 for path in &plan.removals {
205 let metadata = match std::fs::symlink_metadata(path) {
206 Ok(m) => m,
207 Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
208 Err(source) => {
209 return Err(CleanError::Io {
210 path: path.clone(),
211 source,
212 });
213 }
214 };
215 let file_type = metadata.file_type();
216 if file_type.is_dir() {
217 std::fs::remove_dir_all(path).map_err(|source| CleanError::Io {
218 path: path.clone(),
219 source,
220 })?;
221 } else {
222 std::fs::remove_file(path).map_err(|source| CleanError::Io {
223 path: path.clone(),
224 source,
225 })?;
226 }
227 removed.push(path.clone());
228 }
229 Ok(CleanReport { removed })
230}
231
232fn validate_safe_build_dir(
233 build_dir: &Path,
234 workspace_root: &Path,
235 package_roots: &[PathBuf],
236 protected_source_paths: &[PathBuf],
237) -> Result<(), CleanError> {
238 if build_dir.as_os_str().is_empty() {
239 return Err(CleanError::EmptyBuildDir);
240 }
241 if build_dir.parent().is_none() {
242 return Err(CleanError::RootBuildDir(build_dir.to_path_buf()));
243 }
244 if let Some(home) = home_dir()
245 && same_path(build_dir, &home)
246 {
247 return Err(CleanError::HomeBuildDir(build_dir.to_path_buf()));
248 }
249 if same_path(build_dir, workspace_root) {
250 return Err(CleanError::WorkspaceRootBuildDir(build_dir.to_path_buf()));
251 }
252 for root in package_roots {
253 if same_path(build_dir, root) {
254 return Err(CleanError::PackageRootBuildDir(build_dir.to_path_buf()));
255 }
256 }
257 for source_path in protected_source_paths {
258 if overlaps_source_path(build_dir, source_path) {
259 return Err(CleanError::SourcePathBuildDir {
260 build_dir: build_dir.to_path_buf(),
261 source_path: source_path.clone(),
262 });
263 }
264 }
265 if let Ok(meta) = std::fs::symlink_metadata(build_dir)
266 && meta.file_type().is_symlink()
267 {
268 return Err(CleanError::SymlinkBuildDir(build_dir.to_path_buf()));
269 }
270 Ok(())
271}
272
273fn same_path(a: &Path, b: &Path) -> bool {
279 if a == b {
280 return true;
281 }
282 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
283 (Ok(ca), Ok(cb)) => ca == cb,
284 _ => false,
285 }
286}
287
288fn is_within(candidate: &Path, base: &Path) -> bool {
293 candidate.starts_with(base)
294}
295
296fn overlaps_source_path(build_dir: &Path, source_path: &Path) -> bool {
297 if build_dir.starts_with(source_path) || source_path.starts_with(build_dir) {
298 return true;
299 }
300 match (
311 cabin_fs::canonicalize(build_dir),
312 cabin_fs::canonicalize(source_path),
313 ) {
314 (Ok(cb), Ok(cs)) => cb.starts_with(&cs) || cs.starts_with(&cb),
315 _ => false,
316 }
317}
318
319fn home_dir() -> Option<PathBuf> {
320 let key = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
321 std::env::var_os(key).map(PathBuf::from)
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use assert_fs::TempDir;
328 use assert_fs::prelude::*;
329 use predicates::prelude::*;
330
331 fn profile(name: &str) -> ProfileName {
332 ProfileName::new(name.to_owned()).unwrap()
333 }
334
335 fn package(name: &str) -> PackageName {
336 PackageName::new(name.to_owned()).unwrap()
337 }
338
339 fn populate_layout(build_dir: &assert_fs::fixture::ChildPath) {
340 build_dir.child("dev/build.ninja").write_str("x").unwrap();
342 build_dir
343 .child("dev/packages/hello/hello")
344 .write_str("x")
345 .unwrap();
346 build_dir
347 .child("dev/packages/util/libutil.a")
348 .write_str("x")
349 .unwrap();
350 build_dir
352 .child("release/build.ninja")
353 .write_str("x")
354 .unwrap();
355 build_dir
356 .child("release/packages/hello/hello")
357 .write_str("x")
358 .unwrap();
359 }
360
361 fn req<'a>(
362 build_dir: &'a Path,
363 workspace_root: &'a Path,
364 scope: CleanScope,
365 ) -> CleanRequest<'a> {
366 CleanRequest {
367 build_dir,
368 workspace_root,
369 package_roots: &[],
370 protected_source_paths: &[],
371 scope,
372 }
373 }
374
375 #[test]
376 fn plan_whole_lists_build_dir() {
377 let tmp = TempDir::new().unwrap();
378 let build_dir = tmp.child("build");
379 populate_layout(&build_dir);
380 let plan = plan_clean(&req(build_dir.path(), tmp.path(), CleanScope::Whole)).unwrap();
381 assert_eq!(plan.removals, vec![build_dir.to_path_buf()]);
382 }
383
384 #[test]
385 fn plan_profile_lists_only_that_profile() {
386 let tmp = TempDir::new().unwrap();
387 let build_dir = tmp.child("build");
388 populate_layout(&build_dir);
389 let plan = plan_clean(&req(
390 build_dir.path(),
391 tmp.path(),
392 CleanScope::Profile(profile("dev")),
393 ))
394 .unwrap();
395 assert_eq!(plan.removals, vec![build_dir.path().join("dev")]);
396 }
397
398 #[test]
399 fn plan_packages_includes_each_existing_path() {
400 let tmp = TempDir::new().unwrap();
401 let build_dir = tmp.child("build");
402 populate_layout(&build_dir);
403 let plan = plan_clean(&req(
404 build_dir.path(),
405 tmp.path(),
406 CleanScope::Packages {
407 profiles: vec![profile("dev"), profile("release")],
408 packages: vec![package("hello")],
409 },
410 ))
411 .unwrap();
412 let expected = {
413 let mut v = vec![
414 build_dir.path().join("dev").join("packages").join("hello"),
415 build_dir
416 .path()
417 .join("release")
418 .join("packages")
419 .join("hello"),
420 ];
421 v.sort();
422 v
423 };
424 assert_eq!(plan.removals, expected);
425 }
426
427 #[test]
428 fn plan_skips_missing_candidates() {
429 let tmp = TempDir::new().unwrap();
430 let build_dir = tmp.child("build");
431 let plan = plan_clean(&req(build_dir.path(), tmp.path(), CleanScope::Whole)).unwrap();
433 assert!(plan.removals.is_empty());
434 }
435
436 #[test]
437 fn plan_is_deterministic_and_deduplicated() {
438 let tmp = TempDir::new().unwrap();
439 let build_dir = tmp.child("build");
440 populate_layout(&build_dir);
441 let plan = plan_clean(&req(
442 build_dir.path(),
443 tmp.path(),
444 CleanScope::Packages {
445 profiles: vec![profile("release"), profile("dev"), profile("dev")],
446 packages: vec![package("hello"), package("hello")],
447 },
448 ))
449 .unwrap();
450 let mut sorted = plan.removals.clone();
451 sorted.sort();
452 sorted.dedup();
453 assert_eq!(plan.removals, sorted);
454 }
455
456 #[test]
457 fn execute_removes_planned_paths() {
458 let tmp = TempDir::new().unwrap();
459 let build_dir = tmp.child("build");
460 populate_layout(&build_dir);
461 let plan = plan_clean(&req(build_dir.path(), tmp.path(), CleanScope::Whole)).unwrap();
462 let report = execute_clean(&plan).unwrap();
463 assert_eq!(report.removed, vec![build_dir.to_path_buf()]);
464 build_dir.assert(predicate::path::missing());
465 }
466
467 #[test]
468 fn execute_tolerates_concurrent_removal() {
469 let tmp = TempDir::new().unwrap();
470 let build_dir = tmp.child("build");
471 populate_layout(&build_dir);
472 let plan = plan_clean(&req(build_dir.path(), tmp.path(), CleanScope::Whole)).unwrap();
473 std::fs::remove_dir_all(build_dir.path()).unwrap();
474 let report = execute_clean(&plan).unwrap();
475 assert!(report.removed.is_empty());
476 }
477
478 #[test]
479 fn rejects_root_build_dir() {
480 let workspace = PathBuf::from("/tmp/x");
481 let err = plan_clean(&req(Path::new("/"), &workspace, CleanScope::Whole)).unwrap_err();
482 assert!(matches!(err, CleanError::RootBuildDir(_)));
483 }
484
485 #[test]
486 fn rejects_empty_build_dir() {
487 let workspace = PathBuf::from("/tmp/x");
488 let err = plan_clean(&req(Path::new(""), &workspace, CleanScope::Whole)).unwrap_err();
489 assert!(matches!(err, CleanError::EmptyBuildDir));
490 }
491
492 #[test]
493 fn rejects_workspace_root_build_dir() {
494 let tmp = TempDir::new().unwrap();
495 let err = plan_clean(&req(tmp.path(), tmp.path(), CleanScope::Whole)).unwrap_err();
496 assert!(matches!(err, CleanError::WorkspaceRootBuildDir(_)));
497 }
498
499 #[test]
500 fn rejects_package_root_build_dir() {
501 let tmp = TempDir::new().unwrap();
502 let pkg = tmp.child("pkg");
503 pkg.create_dir_all().unwrap();
504 let pkg_path = pkg.to_path_buf();
505 let request = CleanRequest {
506 build_dir: pkg.path(),
507 workspace_root: tmp.path(),
508 package_roots: std::slice::from_ref(&pkg_path),
509 protected_source_paths: &[],
510 scope: CleanScope::Whole,
511 };
512 let err = plan_clean(&request).unwrap_err();
513 assert!(matches!(err, CleanError::PackageRootBuildDir(_)));
514 }
515
516 #[test]
517 fn rejects_build_dir_that_contains_source_path() {
518 let tmp = TempDir::new().unwrap();
519 let build_dir = tmp.child("pkg/src");
520 let source = build_dir.child("main.cc");
521 source.write_str("int main(){return 0;}").unwrap();
522 let source_path = source.to_path_buf();
523 let request = CleanRequest {
524 build_dir: build_dir.path(),
525 workspace_root: tmp.path(),
526 package_roots: &[],
527 protected_source_paths: std::slice::from_ref(&source_path),
528 scope: CleanScope::Whole,
529 };
530 let err = plan_clean(&request).unwrap_err();
531 assert!(matches!(err, CleanError::SourcePathBuildDir { .. }));
532 }
533
534 #[test]
535 fn rejects_build_dir_overlapping_source_by_divergent_spelling() {
536 let tmp = TempDir::new().unwrap();
544 let source = tmp.child("pkg/src/main.cc");
545 source.write_str("int main(){return 0;}").unwrap();
546 tmp.child("pkg/extra").create_dir_all().unwrap();
547 let source_path = source.to_path_buf();
548 let build_dir = tmp.path().join("pkg").join("extra").join("..").join("src");
550 let request = CleanRequest {
551 build_dir: &build_dir,
552 workspace_root: tmp.path(),
553 package_roots: &[],
554 protected_source_paths: std::slice::from_ref(&source_path),
555 scope: CleanScope::Whole,
556 };
557 let err = plan_clean(&request).unwrap_err();
558 assert!(matches!(err, CleanError::SourcePathBuildDir { .. }));
559 }
560
561 #[cfg(unix)]
562 #[test]
563 fn rejects_symlink_build_dir() {
564 let tmp = TempDir::new().unwrap();
565 let target = tmp.child("real");
566 target.create_dir_all().unwrap();
567 let link = tmp.child("link");
568 std::os::unix::fs::symlink(target.path(), link.path()).unwrap();
569 let err = plan_clean(&req(link.path(), tmp.path(), CleanScope::Whole)).unwrap_err();
570 assert!(matches!(err, CleanError::SymlinkBuildDir(_)));
571 }
572}