use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use crate::active::{self, ActiveBuild};
use crate::config::{self, ConfigError};
use crate::discovery::{self, DiscoveryError};
use crate::target::{self, Category, TargetError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Removal {
pub path: PathBuf,
pub category: Category,
pub bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CleanPlan {
pub project_root: PathBuf,
pub roots: Vec<PathBuf>,
pub removals: Vec<Removal>,
pub max_reclaim_bytes: Option<u64>,
}
impl CleanPlan {
pub fn total_bytes(&self) -> u64 {
self.removals.iter().map(|r| r.bytes).sum()
}
}
#[derive(Debug)]
pub enum CleanError {
Discovery(DiscoveryError),
Config(ConfigError),
Target(TargetError),
Unsafe { path: PathBuf },
Remove { path: PathBuf, source: io::Error },
ActiveBuild { process: ActiveBuild },
ReclaimLimit { planned: u64, limit: u64 },
}
impl fmt::Display for CleanError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CleanError::Discovery(e) => write!(f, "{e}"),
CleanError::Config(e) => write!(f, "{e}"),
CleanError::Target(e) => write!(f, "{e}"),
CleanError::Unsafe { path } => write!(
f,
"refusing to remove {}: not inside a validated cargo target/ root",
path.display()
),
CleanError::Remove { path, source } => {
write!(f, "failed to remove {}: {source}", path.display())
}
CleanError::ActiveBuild { process } => write!(
f,
"refusing to clean while an active build is detected: {process}. \
Stop the build or rerun with --force-active"
),
CleanError::ReclaimLimit { planned, limit } => write!(
f,
"refusing to reclaim {} because it exceeds the limit {}; \
rerun with a larger --max-reclaim value if this is intentional",
crate::report::human(*planned),
crate::report::human(*limit)
),
}
}
}
impl std::error::Error for CleanError {}
impl From<DiscoveryError> for CleanError {
fn from(e: DiscoveryError) -> Self {
CleanError::Discovery(e)
}
}
impl From<ConfigError> for CleanError {
fn from(e: ConfigError) -> Self {
CleanError::Config(e)
}
}
impl From<TargetError> for CleanError {
fn from(e: TargetError) -> Self {
CleanError::Target(e)
}
}
pub fn plan(
path: &Path,
include_stale: bool,
include_profile_cache: bool,
) -> Result<CleanPlan, CleanError> {
let project = discovery::discover(path)?;
let cfg = config::load(&project.root)?;
let roots = target::locate_roots(&project, &cfg.crate_path)?;
let mut removals = Vec::new();
for root in &roots {
let analysis = target::analyze(root, cfg.retention_days, cfg.incremental_retention_hours)?;
for artifact in analysis.reclaimable(include_stale, include_profile_cache) {
guard_inside_root(&artifact.path, root)?;
removals.push(Removal {
path: artifact.path.clone(),
category: artifact.category,
bytes: artifact.bytes,
});
}
}
Ok(CleanPlan {
project_root: project.root,
roots,
removals,
max_reclaim_bytes: cfg.max_reclaim_bytes,
})
}
fn guard_inside_root(path: &Path, root: &Path) -> Result<(), CleanError> {
let unsafe_err = || CleanError::Unsafe {
path: path.to_path_buf(),
};
if !target::is_target_dir(root) {
return Err(unsafe_err());
}
let root_canon = std::fs::canonicalize(root).map_err(|_| unsafe_err())?;
let path_canon = std::fs::canonicalize(path).map_err(|_| unsafe_err())?;
if path_canon == root_canon || !path_canon.starts_with(&root_canon) {
return Err(unsafe_err());
}
Ok(())
}
pub fn execute(
plan: &CleanPlan,
force_active: bool,
max_reclaim_override: Option<u64>,
) -> Result<u64, CleanError> {
let planned = plan.total_bytes();
if let Some(limit) = max_reclaim_override.or(plan.max_reclaim_bytes) {
if planned > limit {
return Err(CleanError::ReclaimLimit { planned, limit });
}
}
if !force_active {
if let Some(process) = active::detect(&plan.roots).into_iter().next() {
return Err(CleanError::ActiveBuild { process });
}
}
let mut freed = 0u64;
for removal in &plan.removals {
let root = plan
.roots
.iter()
.find(|r| removal.path.starts_with(r))
.ok_or_else(|| CleanError::Unsafe {
path: removal.path.clone(),
})?;
guard_inside_root(&removal.path, root)?;
remove(&removal.path)?;
freed += removal.bytes;
}
Ok(freed)
}
fn remove(path: &Path) -> Result<(), CleanError> {
let meta = std::fs::symlink_metadata(path).map_err(|source| CleanError::Remove {
path: path.to_path_buf(),
source,
})?;
let result = if meta.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
};
result.map_err(|source| CleanError::Remove {
path: path.to_path_buf(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{cargo_project, write_aged};
use std::fs;
fn project(tag: &str) -> PathBuf {
let root = cargo_project("clean", tag);
let target = root.join("target");
write_aged(&target.join("debug/deps/lib.rlib"), 1000, 0); write_aged(&target.join("debug/incremental/seg/x.o"), 500, 2); write_aged(&target.join("release/deps/lib.rlib"), 2000, 100); root
}
#[test]
fn plan_default_targets_incremental_only() {
let root = project("incr");
let plan = plan(&root, false, false).expect("plan");
assert_eq!(plan.total_bytes(), 500);
assert!(plan
.removals
.iter()
.all(|r| r.category == Category::Incremental));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn plan_with_stale_includes_stale() {
let root = project("stale");
let plan = plan(&root, true, false).expect("plan");
assert_eq!(plan.total_bytes(), 500 + 2000);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn plan_with_profile_cache_includes_fresh_profile_dirs() {
let root = project("profile-cache");
let plan = plan(&root, false, true).expect("plan");
assert_eq!(plan.total_bytes(), 500 + 1000);
assert!(plan
.removals
.iter()
.any(|r| r.category == Category::ProfileCache));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn execute_removes_reclaimable_preserves_retained() {
let root = project("exec");
let target = root.join("target");
let plan = plan(&root, true, false).expect("plan");
let freed = execute(&plan, false, None).expect("execute");
assert_eq!(freed, 500 + 2000);
assert!(target.join("debug/deps/lib.rlib").exists());
assert!(!target.join("debug/incremental").exists());
assert!(!target.join("release/deps/lib.rlib").exists());
assert!(target.join("CACHEDIR.TAG").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn execute_refuses_above_reclaim_limit() {
let root = project("limit");
let plan = plan(&root, true, false).expect("plan");
let result = execute(&plan, false, Some(100));
assert!(matches!(
result,
Err(CleanError::ReclaimLimit {
planned: 2500,
limit: 100
})
));
assert!(root.join("target/debug/incremental").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn guard_rejects_path_outside_target() {
let root = project("guard");
let target = root.join("target");
let outside = root.join("Cargo.toml");
assert!(matches!(
guard_inside_root(&outside, &target),
Err(CleanError::Unsafe { .. })
));
assert!(matches!(
guard_inside_root(&target, &target),
Err(CleanError::Unsafe { .. })
));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn guard_rejects_non_target_root() {
let root = project("nontarget");
let inside = root.join("Cargo.toml");
assert!(matches!(
guard_inside_root(&inside, &root),
Err(CleanError::Unsafe { .. })
));
let _ = fs::remove_dir_all(&root);
}
}