use std::collections::HashSet;
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, SystemTime};
use cap_primitives::ambient_authority;
use cap_primitives::fs::{
FollowSymlinks, Metadata, open_ambient_dir, open_dir_nofollow, read_base_dir, remove_dir,
remove_file, stat,
};
use crate::git;
use crate::size::{Size, Stat, allocated, device, identity, multiply_linked};
use crate::walk::Hit;
const OVERSUBSCRIPTION: usize = 4;
const MAX_THREADS: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
pub path: PathBuf,
pub size: Size,
}
impl Target {
#[must_use]
pub fn at(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
size: Size::Unmeasured,
}
}
}
impl From<&Hit> for Target {
fn from(hit: &Hit) -> Self {
Self {
path: hit.path.clone(),
size: hit.size,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
OutsideRoot,
AlreadyCovered(PathBuf),
RecentlyUsed {
age: Option<Duration>,
},
OtherFileSystem,
HoldsCheckout,
WorkTreeInUse,
WorkTreeDetached,
Unreadable(String),
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OutsideRoot => write!(f, "does not resolve to somewhere under the scan root"),
Self::AlreadyCovered(by) => write!(f, "already covered by {}", by.display()),
Self::RecentlyUsed { age: Some(age) } => {
write!(f, "touched {} ago", humanise(*age))
}
Self::RecentlyUsed { age: None } => write!(f, "touched in the future"),
Self::OtherFileSystem => write!(f, "on another filesystem"),
Self::HoldsCheckout => write!(f, "holds a git checkout"),
Self::WorkTreeInUse => write!(f, "has uncommitted or untracked work in it"),
Self::WorkTreeDetached => {
write!(
f,
"is on a detached HEAD, so its commits are reachable from nothing else"
)
}
Self::Unreadable(why) => write!(f, "{why}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refused {
pub path: PathBuf,
pub reason: Refusal,
}
#[derive(Debug, Clone)]
pub struct PlanTarget {
pub path: PathBuf,
pub requested: PathBuf,
pub size: Size,
pub is_symlink: bool,
pub checkout: bool,
}
#[derive(Debug, Clone)]
pub struct Plan {
root: PathBuf,
root_identity: Option<(u64, u64)>,
targets: Vec<PlanTarget>,
kept: Vec<Refused>,
boundary: u64,
one_file_system: bool,
}
impl Plan {
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn targets(&self) -> &[PlanTarget] {
&self.targets
}
#[must_use]
pub fn kept(&self) -> &[Refused] {
&self.kept
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.targets.is_empty()
}
#[must_use]
pub fn measured_bytes(&self) -> u64 {
self.targets
.iter()
.filter_map(|target| target.size.bytes())
.sum()
}
#[must_use]
pub fn unpriced(&self) -> usize {
self.targets
.iter()
.filter(|target| target.size.bytes().is_none())
.count()
}
}
#[derive(Debug, Clone)]
pub struct Planner {
root: PathBuf,
one_file_system: bool,
older_than: Option<Duration>,
}
impl Planner {
#[must_use]
pub fn new(root: impl AsRef<Path>) -> Self {
Self {
root: root.as_ref().to_path_buf(),
one_file_system: true,
older_than: None,
}
}
#[must_use]
pub fn one_file_system(mut self, one_file_system: bool) -> Self {
self.one_file_system = one_file_system;
self
}
#[must_use]
pub fn older_than(mut self, older_than: Option<Duration>) -> Self {
self.older_than = older_than;
self
}
#[must_use]
pub fn plan<I>(&self, targets: I) -> Plan
where
I: IntoIterator<Item = Target>,
{
let now = SystemTime::now();
let ValidatedRoot {
path: root,
device: boundary,
identity: root_identity,
} = match canonical_root(&self.root) {
Ok(resolved) => resolved,
Err(err) => {
let why = format!("{}: {err}", self.root.display());
return Plan {
root: self.root.clone(),
root_identity: None,
targets: Vec::new(),
kept: targets
.into_iter()
.map(|target| Refused {
path: target.path,
reason: Refusal::Unreadable(why.clone()),
})
.collect(),
boundary: 0,
one_file_system: self.one_file_system,
};
}
};
let mut accepted = Vec::new();
let mut kept = Vec::new();
for target in targets {
match self.judge(&target, &root, boundary, now) {
Ok(planned) => accepted.push(planned),
Err(reason) => kept.push(Refused {
path: target.path,
reason,
}),
}
}
accepted.sort_by(|a, b| a.path.cmp(&b.path));
let mut targets: Vec<PlanTarget> = Vec::with_capacity(accepted.len());
for target in accepted {
match targets.last() {
Some(outer) if target.path.starts_with(&outer.path) => kept.push(Refused {
path: target.requested,
reason: Refusal::AlreadyCovered(outer.path.clone()),
}),
_ => targets.push(target),
}
}
Plan {
root,
root_identity,
targets,
kept,
boundary,
one_file_system: self.one_file_system,
}
}
fn judge(
&self,
target: &Target,
root: &Path,
boundary: u64,
now: SystemTime,
) -> Result<PlanTarget, Refusal> {
let path = resolve(&target.path, root)?;
let metadata = path
.symlink_metadata()
.map_err(|err| Refusal::Unreadable(err.to_string()))?;
if crosses_boundary(self.one_file_system, boundary, &metadata) {
return Err(Refusal::OtherFileSystem);
}
if let Some(floor) = self.older_than {
let age = metadata
.modified()
.ok()
.and_then(|modified| now.duration_since(modified).ok());
if age.is_none_or(|age| age < floor) {
return Err(Refusal::RecentlyUsed { age });
}
}
Ok(PlanTarget {
requested: target.path.clone(),
is_symlink: metadata.is_symlink(),
checkout: approve_checkout(&path)?,
size: target.size,
path,
})
}
}
fn approve_checkout(path: &Path) -> Result<bool, Refusal> {
if !git::is_work_tree_root(path) {
return Ok(false);
}
if git::checkout_at(path) != Some(git::Checkout::Linked) {
return Err(Refusal::HoldsCheckout);
}
if !git::head_on_branch(path) {
return Err(Refusal::WorkTreeDetached);
}
if !git::is_clean(path) {
return Err(Refusal::WorkTreeInUse);
}
Ok(true)
}
type Watcher = Arc<dyn Fn(&Step) + Send + Sync>;
#[derive(Debug, Clone)]
pub enum Step {
Freeing(Freeing),
Finished(Removed),
Swept(PathBuf),
}
#[derive(Debug, Clone)]
pub struct Freeing {
pub path: PathBuf,
pub bytes: u64,
pub entries: u64,
}
const REPORT_EVERY: u64 = 64;
const REPORT_BYTES: u64 = 8 * 1024 * 1024;
#[derive(Clone, Default)]
pub struct Deleter {
threads: Option<usize>,
watching: Option<Watcher>,
}
impl fmt::Debug for Deleter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Deleter")
.field("threads", &self.threads)
.field("watching", &self.watching.is_some())
.finish()
}
}
impl Deleter {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn threads(mut self, threads: usize) -> Self {
self.threads = Some(threads);
self
}
#[must_use]
pub fn watching(mut self, sink: impl Fn(&Step) + Send + Sync + 'static) -> Self {
self.watching = Some(Arc::new(sink));
self
}
#[must_use]
pub fn remove(&self, plan: &Plan) -> Removal {
let mut removal = Removal {
kept: plan.kept.clone(),
..Removal::default()
};
if plan.targets.is_empty() {
return removal;
}
let root = match open_root(plan) {
Ok(root) => root,
Err(err) => {
removal.failures.push(Failure {
path: plan.root.clone(),
message: err.to_string(),
});
return removal;
}
};
let threads = self
.threads
.unwrap_or_else(default_threads)
.clamp(1, plan.targets.len());
let cursor = AtomicUsize::new(0);
let collected = Mutex::new(Vec::new());
std::thread::scope(|scope| {
for _ in 0..threads {
scope.spawn(|| {
let mut mine = Vec::new();
loop {
let at = cursor.fetch_add(1, Ordering::Relaxed);
let Some(target) = plan.targets.get(at) else {
break;
};
let sweep = Sweep::new(plan, &root, self.watching.as_ref()).run(target);
if let Some(watching) = self.watching.as_ref() {
if let Some(removed) = sweep.reported() {
watching(&Step::Finished(removed));
}
watching(&Step::Swept(target.requested.clone()));
}
mine.push(sweep);
}
lock(&collected).append(&mut mine);
});
}
});
for mut sweep in collected
.into_inner()
.unwrap_or_else(PoisonError::into_inner)
{
removal.removed.extend(sweep.reported());
removal.kept.append(&mut sweep.kept);
removal.failures.append(&mut sweep.failures);
}
removal.removed.sort_by(|a, b| a.path.cmp(&b.path));
removal.kept.sort_by(|a, b| a.path.cmp(&b.path));
removal.failures.sort_by(|a, b| a.path.cmp(&b.path));
removal
}
}
#[derive(Debug, Clone)]
pub struct Removed {
pub path: PathBuf,
pub bytes: u64,
pub entries: u64,
pub complete: bool,
}
#[derive(Debug, Clone)]
pub struct Failure {
pub path: PathBuf,
pub message: String,
}
#[derive(Debug, Clone, Default)]
pub struct Removal {
pub removed: Vec<Removed>,
pub kept: Vec<Refused>,
pub failures: Vec<Failure>,
}
impl Removal {
#[must_use]
pub fn bytes_freed(&self) -> u64 {
self.removed.iter().map(|removed| removed.bytes).sum()
}
#[must_use]
pub fn entries_removed(&self) -> u64 {
self.removed.iter().map(|removed| removed.entries).sum()
}
#[must_use]
pub fn is_clean(&self) -> bool {
self.failures.is_empty()
}
}
struct Sweep<'a> {
plan: &'a Plan,
root: &'a fs::File,
path: PathBuf,
bytes: u64,
entries: u64,
complete: bool,
linked: HashSet<(u64, u64)>,
kept: Vec<Refused>,
failures: Vec<Failure>,
watching: Option<&'a Watcher>,
told_bytes: u64,
told_entries: u64,
approved: Option<PathBuf>,
}
impl<'a> Sweep<'a> {
fn new(plan: &'a Plan, root: &'a fs::File, watching: Option<&'a Watcher>) -> Self {
Self {
plan,
root,
path: PathBuf::new(),
bytes: 0,
entries: 0,
complete: false,
linked: HashSet::new(),
kept: Vec::new(),
failures: Vec::new(),
watching,
told_bytes: 0,
told_entries: 0,
approved: None,
}
}
fn run(mut self, target: &PlanTarget) -> Self {
self.path.clone_from(&target.requested);
self.approved = target.checkout.then(|| target.requested.clone());
let Some((parent, name)) = self.parent_of(target) else {
return self;
};
self.complete = self.entry(&parent, &name, &target.requested);
self
}
fn reported(&self) -> Option<Removed> {
(self.entries > 0 || self.complete).then(|| Removed {
path: self.path.clone(),
bytes: self.bytes,
entries: self.entries,
complete: self.complete,
})
}
fn parent_of(&mut self, target: &PlanTarget) -> Option<(fs::File, OsString)> {
let Ok(relative) = target.path.strip_prefix(&self.plan.root) else {
self.failures.push(Failure {
path: target.requested.clone(),
message: format!("is not under {}", self.plan.root.display()),
});
return None;
};
let mut names: Vec<&OsStr> = relative.components().map(Component::as_os_str).collect();
let name = names.pop()?;
let mut walked = target.requested.clone();
for _ in 0..relative.components().count() {
walked.pop();
}
let mut dir = match self.root.try_clone() {
Ok(dir) => dir,
Err(err) => {
self.failed(&walked, &err);
return None;
}
};
for component in names {
walked.push(component);
dir = match open_dir_nofollow(&dir, Path::new(component)) {
Ok(next) => next,
Err(err) => {
self.failed(&walked, &err);
return None;
}
};
}
Some((dir, name.to_owned()))
}
fn entry(&mut self, parent: &fs::File, name: &OsStr, path: &Path) -> bool {
let metadata = match stat(parent, Path::new(name), FollowSymlinks::No) {
Ok(metadata) => metadata,
Err(err) => {
self.failed(path, &err);
return false;
}
};
if crosses_boundary(self.plan.one_file_system, self.plan.boundary, &metadata) {
self.kept.push(Refused {
path: path.to_path_buf(),
reason: Refusal::OtherFileSystem,
});
return false;
}
if metadata.is_dir() {
self.directory(parent, name, path, &metadata)
} else {
self.unlink(parent, name, path, &metadata)
}
}
fn directory(
&mut self,
parent: &fs::File,
name: &OsStr,
path: &Path,
metadata: &Metadata,
) -> bool {
let dir = match open_dir_nofollow(parent, Path::new(name)) {
Ok(dir) => dir,
Err(err) => {
self.failed(path, &err);
return false;
}
};
let listing = match read_base_dir(&dir) {
Ok(listing) => listing,
Err(err) => {
self.failed(path, &err);
return false;
}
};
let mut children = Vec::new();
let mut complete = true;
for child in listing {
match child {
Ok(child) => children.push(child.file_name()),
Err(err) => {
self.failed(path, &err);
complete = false;
}
}
}
let approved = self.approved.as_deref() == Some(path);
if !approved && children.iter().any(|child| child == ".git") {
self.kept.push(Refused {
path: path.to_path_buf(),
reason: Refusal::HoldsCheckout,
});
return false;
}
for child in children {
complete &= self.entry(&dir, &child, &path.join(&child));
}
if !complete {
return false;
}
match remove_dir(parent, Path::new(name)) {
Ok(()) => {
self.count(metadata);
true
}
Err(err) => {
self.failed(path, &err);
false
}
}
}
fn unlink(
&mut self,
parent: &fs::File,
name: &OsStr,
path: &Path,
metadata: &Metadata,
) -> bool {
match remove_file(parent, Path::new(name)) {
Ok(()) => {
self.count(metadata);
true
}
Err(err) => {
self.failed(path, &err);
false
}
}
}
fn count(&mut self, metadata: &Metadata) {
self.entries += 1;
if let Some(identity) = multiply_linked(metadata) {
if !self.linked.insert(identity) {
self.tell();
return;
}
}
self.bytes += allocated(metadata);
self.tell();
}
fn tell(&mut self) {
let Some(watching) = self.watching else {
return;
};
if self.entries - self.told_entries < REPORT_EVERY
&& self.bytes - self.told_bytes < REPORT_BYTES
{
return;
}
self.told_entries = self.entries;
self.told_bytes = self.bytes;
watching(&Step::Freeing(Freeing {
path: self.path.clone(),
bytes: self.bytes,
entries: self.entries,
}));
}
fn failed(&mut self, path: &Path, err: &impl fmt::Display) {
self.failures.push(Failure {
path: path.to_path_buf(),
message: err.to_string(),
});
}
}
pub fn confirm(
question: &str,
input: &mut impl BufRead,
output: &mut impl Write,
) -> io::Result<bool> {
write!(output, "{question} [y/N] ")?;
output.flush()?;
let mut answer = String::new();
if input.read_line(&mut answer)? == 0 {
return Ok(false);
}
Ok(matches!(
answer.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
struct ValidatedRoot {
path: PathBuf,
device: u64,
identity: Option<(u64, u64)>,
}
fn canonical_root(root: &Path) -> io::Result<ValidatedRoot> {
let canonical = fs::canonicalize(root)?;
let metadata = canonical.symlink_metadata()?;
Ok(ValidatedRoot {
device: device(&metadata),
identity: identity(&metadata),
path: canonical,
})
}
fn open_root(plan: &Plan) -> io::Result<fs::File> {
let opened = match (plan.root.parent(), plan.root.file_name()) {
(Some(parent), Some(name)) => {
let parent = open_ambient_dir(parent, ambient_authority())?;
open_dir_nofollow(&parent, Path::new(name))?
}
_ => open_ambient_dir(&plan.root, ambient_authority())?,
};
if identity(&opened.metadata()?) != plan.root_identity {
return Err(io::Error::other(
"the scan root is no longer the directory the plan was built against",
));
}
Ok(opened)
}
fn resolve(path: &Path, root: &Path) -> Result<PathBuf, Refusal> {
let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
return Err(Refusal::OutsideRoot);
};
let parent = fs::canonicalize(parent).map_err(|err| Refusal::Unreadable(err.to_string()))?;
let resolved = parent.join(name);
if resolved == root || !resolved.starts_with(root) {
return Err(Refusal::OutsideRoot);
}
Ok(resolved)
}
fn crosses_boundary(one_file_system: bool, boundary: u64, metadata: &impl Stat) -> bool {
one_file_system && device(metadata) != boundary
}
fn default_threads() -> usize {
let cores = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
cores.saturating_mul(OVERSUBSCRIPTION).min(MAX_THREADS)
}
fn humanise(duration: Duration) -> String {
const HOUR: u64 = 60 * 60;
const DAY: u64 = 24 * HOUR;
let seconds = duration.as_secs();
let (value, unit) = match seconds {
0..HOUR => (seconds / 60, "minute"),
HOUR..DAY => (seconds / HOUR, "hour"),
_ => (seconds / DAY, "day"),
};
format!("{value} {unit}{}", if value == 1 { "" } else { "s" })
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}
#[cfg(test)]
mod tests {
use super::{confirm, humanise};
use std::time::Duration;
fn ask(input: &str) -> (bool, String) {
let mut output = Vec::new();
let answered = confirm("Remove 12 directories?", &mut input.as_bytes(), &mut output)
.expect("a byte slice cannot fail to be read");
(answered, String::from_utf8(output).expect("ASCII prompt"))
}
#[test]
fn the_confirmation_defaults_to_no() {
assert!(!ask("\n").0);
assert!(ask("\n").1.ends_with("[y/N] "));
}
#[test]
fn end_of_input_is_not_consent() {
assert!(!ask("").0);
}
#[test]
fn only_yes_means_yes() {
for yes in ["y", "Y", "yes", "YES", " yes \n"] {
assert!(ask(yes).0, "`{yes}` was read as no");
}
for no in ["n", "no", "\n", " ", "sure", "yep", "yes please", "1"] {
assert!(!ask(no).0, "`{no}` was read as yes");
}
}
#[test]
fn an_age_is_reported_in_the_coarsest_unit_that_fits() {
assert_eq!(humanise(Duration::from_secs(90)), "1 minute");
assert_eq!(humanise(Duration::from_secs(2 * 60 * 60)), "2 hours");
assert_eq!(humanise(Duration::from_secs(36 * 60 * 60)), "1 day");
assert_eq!(humanise(Duration::from_secs(90 * 24 * 60 * 60)), "90 days");
}
}