targets.rs
raw
use anyhow::Result;
use rayon::prelude::*;
use crate::discovery::Project;
use crate::fsutil::{
Measurement, human_size, is_cargo_build_output, measure, measure_if_older_than, remove,
};
use crate::policy::{Intent, Policy};
use crate::progress::{Reporter, plural};
use crate::reclaim::{Category, EntryKey, Findings, Provider, Reclaimable, Retained};
fn reclaimable(project: &Project, measured: Measurement) -> Reclaimable {
Reclaimable {
category: Category::Target,
path: project.target.clone(),
size: measured.size,
last_used: measured.newest,
key: EntryKey::Path,
}
}
fn retained(project: &Project, measured: Option<Measurement>) -> Retained {
Retained {
category: Category::Target,
path: project.target.clone(),
measured,
}
}
/// The target directories of every project grunk knows about.
///
/// Unlike the cargo home, there is no database to consult here — a target
/// directory's age has to come off the filesystem. The newest mtime anywhere in
/// the tree is the right reading: it is the moment of the last build, which is
/// exactly the "last used" that matters. Cargo touches its output as it builds,
/// so a project compiled an hour ago reads as an hour old however long ago the
/// directory was first created.
pub struct Targets {
projects: Vec<Project>,
}
impl Targets {
pub fn new(projects: Vec<Project>) -> Self {
Self { projects }
}
pub fn projects(&self) -> &[Project] {
&self.projects
}
}
impl Provider for Targets {
/// Sort every managed target directory into what may go and what stays.
///
/// Deliberately one project at a time. The walk parallelises inside a
/// single tree, across the same global thread pool a `par_iter` here would
/// occupy; nesting the two starves the inner walk of threads and it returns
/// an empty tree, which reads as a target of zero bytes last touched
/// whenever its own directory was — old enough to delete. A fast wrong
/// answer here deletes a project someone is working on. Serial is also no
/// slower in practice: the work is dominated by the few largest targets,
/// and those are exactly the trees the walk parallelises best.
fn scan(&self, policy: &Policy, progress: &Reporter) -> Result<Findings> {
let mut findings = Findings::default();
if !policy.wants(Category::Target) {
return Ok(findings);
}
let cutoff = policy.cutoff(Category::Target);
let built: Vec<&Project> = self
.projects
.iter()
// Cargo metadata named this directory, so where it sits is not in
// doubt; the question is whether cargo has actually built here,
// which is what makes the contents regenerable and grunk's to
// delete. That keeps a project whose `target` path holds hand-made
// data — because nothing has ever been built into it — from being
// swept away, while still reaching old targets built before cargo
// began tagging them.
.filter(|project| is_cargo_build_output(&project.target))
.collect();
if built.is_empty() {
return Ok(findings);
}
progress.phase(format_args!(
"Examining {} target {}:",
built.len(),
plural(built.len(), "directory", "directories")
));
for project in built {
// Named before the walk rather than after it. This is the step that
// can sit on one project for minutes, and knowing which project is
// the entire point of saying anything at all.
progress.start(project.target.display());
// A survey has to produce a number either way, so it pays for the
// whole walk and judges afterwards. A clean asks the cheaper
// question, and usually gets to stop at the first recently built
// file rather than finishing a walk it has no use for.
if policy.intent() == Intent::Survey {
let measured = measure(&project.target)?;
if policy.is_eligible(Category::Target, measured.newest) {
progress.finish(format_args!("{} to reclaim", human_size(measured.size)));
findings.reclaimable.push(reclaimable(project, measured));
} else {
progress.finish(format_args!("{}, too new", human_size(measured.size)));
findings.retained.push(retained(project, Some(measured)));
}
} else {
match measure_if_older_than(&project.target, cutoff)? {
Some(measured) => {
progress.finish(format_args!("{} to reclaim", human_size(measured.size)));
findings.reclaimable.push(reclaimable(project, measured));
}
None => {
progress.finish("too new, left alone");
findings.retained.push(retained(project, None));
}
}
}
}
Ok(findings)
}
/// Delete the target trees, again several at a time.
///
/// `remove_dir_all` is a walk of its own, and these walks are the bulk of
/// what a first clean spends its time on.
fn remove(&mut self, items: &[Reclaimable], progress: &Reporter) -> Result<Vec<Reclaimable>> {
if items.is_empty() {
return Ok(Vec::new());
}
progress.phase(format_args!(
"Deleting {} target {}:",
items.len(),
plural(items.len(), "directory", "directories")
));
items
.par_iter()
.map(|item| {
let gone = remove(&item.path)?;
// One whole line per finished item: `start`/`finish` would
// interleave into nonsense across the pool. Order follows
// whichever deletion finishes first, which is honest — that is
// the order they happened in.
if gone {
progress.line(format_args!(
"deleted {} ({})",
item.path.display(),
human_size(item.size)
));
}
Ok(gone.then(|| item.clone()))
})
.collect::<Result<Vec<_>>>()
.map(|removed| removed.into_iter().flatten().collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use tempfile::TempDir;
use crate::discovery::SourceKind;
use crate::policy::{Intent, Policy, Selection};
/// A policy under which everything is old enough to delete.
fn take_everything(intent: Intent) -> Policy {
policy_with("min_age = \"0s\"\n", intent)
}
/// A policy under which nothing is old enough to delete.
fn keep_everything(intent: Intent) -> Policy {
policy_with("min_age = \"3650d\"\n", intent)
}
fn policy_with(toml: &str, intent: Intent) -> Policy {
let dir = TempDir::new().unwrap();
let path = dir.path().join("grunk.toml");
fs::write(&path, toml).unwrap();
Policy::new(
&crate::config::Config::load_from(&path).unwrap(),
&Selection::default(),
intent,
)
}
fn project_with_target(root: &Path, name: &str, tagged: bool) -> Project {
let project_root = root.join(name);
let target = project_root.join("target");
fs::create_dir_all(target.join("debug")).unwrap();
fs::write(project_root.join("Cargo.toml"), "[package]\n").unwrap();
fs::write(target.join("debug/binary"), "0123456789").unwrap();
if tagged {
fs::write(
target.join("CACHEDIR.TAG"),
"Signature: 8a477f597d28d172789f06886806bc55\n",
)
.unwrap();
}
Project {
root: project_root,
target,
found_by: BTreeSet::from([SourceKind::RootScan]),
}
}
#[test]
fn scans_a_built_target() {
let tmp = TempDir::new().unwrap();
let project = project_with_target(tmp.path(), "proj", true);
let targets = Targets::new(vec![project.clone()]);
let items = targets
.scan(&take_everything(Intent::Survey), &Reporter::silent())
.unwrap()
.reclaimable;
assert_eq!(items.len(), 1);
assert_eq!(items[0].category, Category::Target);
assert_eq!(items[0].path, project.target);
assert_eq!(items[0].key, EntryKey::Path);
assert!(items[0].size >= 10);
}
#[test]
fn ages_a_target_by_its_newest_file() {
let tmp = TempDir::new().unwrap();
let project = project_with_target(tmp.path(), "proj", true);
let targets = Targets::new(vec![project]);
let before = SystemTime::now() - Duration::from_secs(5);
let items = targets
.scan(&take_everything(Intent::Survey), &Reporter::silent())
.unwrap()
.reclaimable;
assert!(
items[0].last_used >= before,
"a target built just now should read as new"
);
}
#[test]
fn skips_a_target_that_does_not_exist() {
let tmp = TempDir::new().unwrap();
let project = Project {
root: tmp.path().join("proj"),
target: tmp.path().join("proj/target"),
found_by: BTreeSet::from([SourceKind::Explicit]),
};
let findings = Targets::new(vec![project])
.scan(&take_everything(Intent::Survey), &Reporter::silent())
.unwrap();
assert!(findings.reclaimable.is_empty());
assert!(findings.retained.is_empty());
}
#[test]
fn skips_a_target_cargo_did_not_create() {
let tmp = TempDir::new().unwrap();
let project = project_with_target(tmp.path(), "proj", false);
let findings = Targets::new(vec![project])
.scan(&take_everything(Intent::Survey), &Reporter::silent())
.unwrap();
assert!(findings.reclaimable.is_empty());
assert!(findings.retained.is_empty());
}
#[test]
fn a_clean_keeps_a_new_target_without_measuring_it() {
// The point of the exercise: deciding not to touch a target must not
// cost a walk of it. The unmeasured `None` is the evidence that the
// walk stopped at the first recent file instead of running to the end.
let tmp = TempDir::new().unwrap();
let project = project_with_target(tmp.path(), "proj", true);
let targets = Targets::new(vec![project.clone()]);
let findings = targets
.scan(&keep_everything(Intent::Clean), &Reporter::silent())
.unwrap();
assert!(findings.reclaimable.is_empty());
assert_eq!(findings.retained.len(), 1);
assert_eq!(findings.retained[0].path, project.target);
assert_eq!(findings.retained[0].category, Category::Target);
assert!(
findings.retained[0].measured.is_none(),
"a clean must not pay to measure what it is keeping"
);
}
#[test]
fn a_survey_keeps_a_new_target_and_measures_it_anyway() {
// A person is reading a survey, so the number has to be there.
let tmp = TempDir::new().unwrap();
let project = project_with_target(tmp.path(), "proj", true);
let targets = Targets::new(vec![project.clone()]);
let findings = targets
.scan(&keep_everything(Intent::Survey), &Reporter::silent())
.unwrap();
assert!(findings.reclaimable.is_empty());
assert_eq!(findings.retained.len(), 1);
let measured = findings.retained[0]
.measured
.expect("a survey measures what it keeps");
assert!(measured.size >= 10);
}
#[test]
fn a_clean_measures_the_target_it_is_about_to_delete() {
// The other half of the bargain: when the walk does run to the end, the
// size comes free, and the report of what went is exact.
let tmp = TempDir::new().unwrap();
let project = project_with_target(tmp.path(), "proj", true);
let targets = Targets::new(vec![project.clone()]);
let findings = targets
.scan(&take_everything(Intent::Clean), &Reporter::silent())
.unwrap();
assert!(findings.retained.is_empty());
assert_eq!(findings.reclaimable.len(), 1);
assert!(findings.reclaimable[0].size >= 10);
}
#[test]
fn a_clean_and_a_survey_agree_on_what_may_go() {
// Two different amounts of work, one verdict. If these ever disagreed,
// `status` would be describing a clean that does something else.
let tmp = TempDir::new().unwrap();
let stale = project_with_target(tmp.path(), "stale", true);
let targets = Targets::new(vec![stale.clone()]);
for policy in [take_everything, keep_everything] {
let clean = targets
.scan(&policy(Intent::Clean), &Reporter::silent())
.unwrap();
let survey = targets
.scan(&policy(Intent::Survey), &Reporter::silent())
.unwrap();
assert_eq!(
clean
.reclaimable
.iter()
.map(|r| &r.path)
.collect::<Vec<_>>(),
survey
.reclaimable
.iter()
.map(|r| &r.path)
.collect::<Vec<_>>()
);
assert_eq!(
clean.retained.iter().map(|r| &r.path).collect::<Vec<_>>(),
survey.retained.iter().map(|r| &r.path).collect::<Vec<_>>()
);
}
}
#[test]
fn removing_deletes_the_tree_and_leaves_the_sources() {
let tmp = TempDir::new().unwrap();
let project = project_with_target(tmp.path(), "proj", true);
let mut targets = Targets::new(vec![project.clone()]);
let items = targets
.scan(&take_everything(Intent::Survey), &Reporter::silent())
.unwrap()
.reclaimable;
let removed = targets.remove(&items, &Reporter::silent()).unwrap();
assert_eq!(removed.len(), 1);
assert_eq!(removed[0].size, items[0].size);
assert!(!project.target.exists());
assert!(
project.root.join("Cargo.toml").is_file(),
"cleaning must never touch the sources"
);
}
#[test]
fn removing_is_idempotent() {
let tmp = TempDir::new().unwrap();
let project = project_with_target(tmp.path(), "proj", true);
let mut targets = Targets::new(vec![project]);
let items = targets
.scan(&take_everything(Intent::Survey), &Reporter::silent())
.unwrap()
.reclaimable;
targets.remove(&items, &Reporter::silent()).unwrap();
assert!(
targets.remove(&items, &Reporter::silent()).is_ok(),
"a second pass must not fail"
);
assert!(
targets
.scan(&take_everything(Intent::Survey), &Reporter::silent())
.unwrap()
.reclaimable
.is_empty()
);
}
#[test]
fn scans_only_the_projects_it_was_given() {
let tmp = TempDir::new().unwrap();
let managed = project_with_target(tmp.path(), "managed", true);
project_with_target(tmp.path(), "unmanaged", true);
let items = Targets::new(vec![managed.clone()])
.scan(&take_everything(Intent::Survey), &Reporter::silent())
.unwrap()
.reclaimable;
assert_eq!(
items
.iter()
.map(|i| i.path.clone())
.collect::<Vec<PathBuf>>(),
vec![managed.target]
);
}
}