use std::path::{Path, PathBuf};
use thiserror::Error;
use cabin_core::{PackageName, ProfileName};
#[derive(Debug, Clone)]
pub enum CleanScope {
Whole,
Profile(ProfileName),
Packages {
profiles: Vec<ProfileName>,
packages: Vec<PackageName>,
},
}
#[derive(Debug, Clone)]
pub struct CleanRequest<'a> {
pub build_dir: &'a Path,
pub workspace_root: &'a Path,
pub package_roots: &'a [PathBuf],
pub protected_source_paths: &'a [PathBuf],
pub scope: CleanScope,
}
#[derive(Debug, Clone)]
pub struct CleanPlan {
pub build_dir: PathBuf,
pub removals: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default)]
pub struct CleanReport {
pub removed: Vec<PathBuf>,
}
#[derive(Debug, Error)]
pub enum CleanError {
#[error("build directory path is empty")]
EmptyBuildDir,
#[error("refusing to clean root path {}", .0.display())]
RootBuildDir(PathBuf),
#[error("refusing to clean home directory {}", .0.display())]
HomeBuildDir(PathBuf),
#[error("refusing to clean workspace root {}; the build directory must point at a separate output directory", .0.display())]
WorkspaceRootBuildDir(PathBuf),
#[error("refusing to clean package source directory {}; the build directory must point at a separate output directory", .0.display())]
PackageRootBuildDir(PathBuf),
#[error("refusing to clean build directory {} because it overlaps source file or directory {}", build_dir.display(), source_path.display())]
SourcePathBuildDir {
build_dir: PathBuf,
source_path: PathBuf,
},
#[error("refusing to clean symlink {}; replace it with a real directory before re-running `cabin clean`", .0.display())]
SymlinkBuildDir(PathBuf),
#[error("computed deletion path {} is not inside build directory {}", path.display(), build_dir.display())]
PathEscapesBuildDir { path: PathBuf, build_dir: PathBuf },
#[error("failed to remove {}: {source}", path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
pub fn plan_clean(req: &CleanRequest<'_>) -> Result<CleanPlan, CleanError> {
validate_safe_build_dir(
req.build_dir,
req.workspace_root,
req.package_roots,
req.protected_source_paths,
)?;
let candidates = match &req.scope {
CleanScope::Whole => vec![req.build_dir.to_path_buf()],
CleanScope::Profile(profile) => vec![req.build_dir.join(profile.as_str())],
CleanScope::Packages { profiles, packages } => {
let mut out = Vec::with_capacity(profiles.len().saturating_mul(packages.len()));
for profile in profiles {
let profile_root = req.build_dir.join(profile.as_str());
for pkg in packages {
out.push(profile_root.join("packages").join(pkg.as_str()));
}
}
out
}
};
for candidate in &candidates {
if !is_within(candidate, req.build_dir) {
return Err(CleanError::PathEscapesBuildDir {
path: candidate.clone(),
build_dir: req.build_dir.to_path_buf(),
});
}
}
let mut existing: Vec<PathBuf> = candidates.into_iter().filter(|p| p.exists()).collect();
existing.sort();
existing.dedup();
Ok(CleanPlan {
build_dir: req.build_dir.to_path_buf(),
removals: existing,
})
}
pub fn execute_clean(plan: &CleanPlan) -> Result<CleanReport, CleanError> {
let mut removed = Vec::with_capacity(plan.removals.len());
for path in &plan.removals {
let metadata = match std::fs::symlink_metadata(path) {
Ok(m) => m,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
Err(source) => {
return Err(CleanError::Io {
path: path.clone(),
source,
});
}
};
let file_type = metadata.file_type();
if file_type.is_dir() {
std::fs::remove_dir_all(path).map_err(|source| CleanError::Io {
path: path.clone(),
source,
})?;
} else {
std::fs::remove_file(path).map_err(|source| CleanError::Io {
path: path.clone(),
source,
})?;
}
removed.push(path.clone());
}
Ok(CleanReport { removed })
}
fn validate_safe_build_dir(
build_dir: &Path,
workspace_root: &Path,
package_roots: &[PathBuf],
protected_source_paths: &[PathBuf],
) -> Result<(), CleanError> {
if build_dir.as_os_str().is_empty() {
return Err(CleanError::EmptyBuildDir);
}
if build_dir.parent().is_none() {
return Err(CleanError::RootBuildDir(build_dir.to_path_buf()));
}
if let Some(home) = home_dir()
&& same_path(build_dir, &home)
{
return Err(CleanError::HomeBuildDir(build_dir.to_path_buf()));
}
if same_path(build_dir, workspace_root) {
return Err(CleanError::WorkspaceRootBuildDir(build_dir.to_path_buf()));
}
for root in package_roots {
if same_path(build_dir, root) {
return Err(CleanError::PackageRootBuildDir(build_dir.to_path_buf()));
}
}
for source_path in protected_source_paths {
if overlaps_source_path(build_dir, source_path) {
return Err(CleanError::SourcePathBuildDir {
build_dir: build_dir.to_path_buf(),
source_path: source_path.clone(),
});
}
}
if let Ok(meta) = std::fs::symlink_metadata(build_dir)
&& meta.file_type().is_symlink()
{
return Err(CleanError::SymlinkBuildDir(build_dir.to_path_buf()));
}
Ok(())
}
fn same_path(a: &Path, b: &Path) -> bool {
if a == b {
return true;
}
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => false,
}
}
fn is_within(candidate: &Path, base: &Path) -> bool {
candidate.starts_with(base)
}
fn overlaps_source_path(build_dir: &Path, source_path: &Path) -> bool {
build_dir.starts_with(source_path) || source_path.starts_with(build_dir)
}
fn home_dir() -> Option<PathBuf> {
let key = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
std::env::var_os(key).map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
use assert_fs::TempDir;
use assert_fs::prelude::*;
use predicates::prelude::*;
fn profile(name: &str) -> ProfileName {
ProfileName::new(name.to_owned()).unwrap()
}
fn package(name: &str) -> PackageName {
PackageName::new(name.to_owned()).unwrap()
}
fn populate_layout(build_dir: &assert_fs::fixture::ChildPath) {
build_dir.child("dev/build.ninja").write_str("x").unwrap();
build_dir
.child("dev/packages/hello/hello")
.write_str("x")
.unwrap();
build_dir
.child("dev/packages/util/libutil.a")
.write_str("x")
.unwrap();
build_dir
.child("release/build.ninja")
.write_str("x")
.unwrap();
build_dir
.child("release/packages/hello/hello")
.write_str("x")
.unwrap();
}
fn req<'a>(
build_dir: &'a Path,
workspace_root: &'a Path,
scope: CleanScope,
) -> CleanRequest<'a> {
CleanRequest {
build_dir,
workspace_root,
package_roots: &[],
protected_source_paths: &[],
scope,
}
}
#[test]
fn plan_whole_lists_build_dir() {
let tmp = TempDir::new().unwrap();
let build_dir = tmp.child("build");
populate_layout(&build_dir);
let plan = plan_clean(&req(build_dir.path(), tmp.path(), CleanScope::Whole)).unwrap();
assert_eq!(plan.removals, vec![build_dir.to_path_buf()]);
}
#[test]
fn plan_profile_lists_only_that_profile() {
let tmp = TempDir::new().unwrap();
let build_dir = tmp.child("build");
populate_layout(&build_dir);
let plan = plan_clean(&req(
build_dir.path(),
tmp.path(),
CleanScope::Profile(profile("dev")),
))
.unwrap();
assert_eq!(plan.removals, vec![build_dir.path().join("dev")]);
}
#[test]
fn plan_packages_includes_each_existing_path() {
let tmp = TempDir::new().unwrap();
let build_dir = tmp.child("build");
populate_layout(&build_dir);
let plan = plan_clean(&req(
build_dir.path(),
tmp.path(),
CleanScope::Packages {
profiles: vec![profile("dev"), profile("release")],
packages: vec![package("hello")],
},
))
.unwrap();
let expected = {
let mut v = vec![
build_dir.path().join("dev").join("packages").join("hello"),
build_dir
.path()
.join("release")
.join("packages")
.join("hello"),
];
v.sort();
v
};
assert_eq!(plan.removals, expected);
}
#[test]
fn plan_skips_missing_candidates() {
let tmp = TempDir::new().unwrap();
let build_dir = tmp.child("build");
let plan = plan_clean(&req(build_dir.path(), tmp.path(), CleanScope::Whole)).unwrap();
assert!(plan.removals.is_empty());
}
#[test]
fn plan_is_deterministic_and_deduplicated() {
let tmp = TempDir::new().unwrap();
let build_dir = tmp.child("build");
populate_layout(&build_dir);
let plan = plan_clean(&req(
build_dir.path(),
tmp.path(),
CleanScope::Packages {
profiles: vec![profile("release"), profile("dev"), profile("dev")],
packages: vec![package("hello"), package("hello")],
},
))
.unwrap();
let mut sorted = plan.removals.clone();
sorted.sort();
sorted.dedup();
assert_eq!(plan.removals, sorted);
}
#[test]
fn execute_removes_planned_paths() {
let tmp = TempDir::new().unwrap();
let build_dir = tmp.child("build");
populate_layout(&build_dir);
let plan = plan_clean(&req(build_dir.path(), tmp.path(), CleanScope::Whole)).unwrap();
let report = execute_clean(&plan).unwrap();
assert_eq!(report.removed, vec![build_dir.to_path_buf()]);
build_dir.assert(predicate::path::missing());
}
#[test]
fn execute_tolerates_concurrent_removal() {
let tmp = TempDir::new().unwrap();
let build_dir = tmp.child("build");
populate_layout(&build_dir);
let plan = plan_clean(&req(build_dir.path(), tmp.path(), CleanScope::Whole)).unwrap();
std::fs::remove_dir_all(build_dir.path()).unwrap();
let report = execute_clean(&plan).unwrap();
assert!(report.removed.is_empty());
}
#[test]
fn rejects_root_build_dir() {
let workspace = PathBuf::from("/tmp/x");
let err = plan_clean(&req(Path::new("/"), &workspace, CleanScope::Whole)).unwrap_err();
assert!(matches!(err, CleanError::RootBuildDir(_)));
}
#[test]
fn rejects_empty_build_dir() {
let workspace = PathBuf::from("/tmp/x");
let err = plan_clean(&req(Path::new(""), &workspace, CleanScope::Whole)).unwrap_err();
assert!(matches!(err, CleanError::EmptyBuildDir));
}
#[test]
fn rejects_workspace_root_build_dir() {
let tmp = TempDir::new().unwrap();
let err = plan_clean(&req(tmp.path(), tmp.path(), CleanScope::Whole)).unwrap_err();
assert!(matches!(err, CleanError::WorkspaceRootBuildDir(_)));
}
#[test]
fn rejects_package_root_build_dir() {
let tmp = TempDir::new().unwrap();
let pkg = tmp.child("pkg");
pkg.create_dir_all().unwrap();
let pkg_path = pkg.to_path_buf();
let request = CleanRequest {
build_dir: pkg.path(),
workspace_root: tmp.path(),
package_roots: std::slice::from_ref(&pkg_path),
protected_source_paths: &[],
scope: CleanScope::Whole,
};
let err = plan_clean(&request).unwrap_err();
assert!(matches!(err, CleanError::PackageRootBuildDir(_)));
}
#[test]
fn rejects_build_dir_that_contains_source_path() {
let tmp = TempDir::new().unwrap();
let build_dir = tmp.child("pkg/src");
let source = build_dir.child("main.cc");
source.write_str("int main(){return 0;}").unwrap();
let source_path = source.to_path_buf();
let request = CleanRequest {
build_dir: build_dir.path(),
workspace_root: tmp.path(),
package_roots: &[],
protected_source_paths: std::slice::from_ref(&source_path),
scope: CleanScope::Whole,
};
let err = plan_clean(&request).unwrap_err();
assert!(matches!(err, CleanError::SourcePathBuildDir { .. }));
}
#[cfg(unix)]
#[test]
fn rejects_symlink_build_dir() {
let tmp = TempDir::new().unwrap();
let target = tmp.child("real");
target.create_dir_all().unwrap();
let link = tmp.child("link");
std::os::unix::fs::symlink(target.path(), link.path()).unwrap();
let err = plan_clean(&req(link.path(), tmp.path(), CleanScope::Whole)).unwrap_err();
assert!(matches!(err, CleanError::SymlinkBuildDir(_)));
}
}