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) -> 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, } impl Targets { pub fn new(projects: Vec) -> 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 { 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> { 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::>>() .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::>(), survey .reclaimable .iter() .map(|r| &r.path) .collect::>() ); assert_eq!( clean.retained.iter().map(|r| &r.path).collect::>(), survey.retained.iter().map(|r| &r.path).collect::>() ); } } #[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![managed.target] ); } }