use std::collections::BTreeMap; use std::time::Duration; use anyhow::Result; use crate::age::MinAge; use crate::cargo_home::{CargoHome, GlobalCache}; use crate::config::Config; use crate::discovery::{self, Project}; use crate::policy::Policy; use crate::progress::Reporter; use crate::reclaim::{Category, Provider, Reclaimable, Removed, Retained}; use crate::targets::Targets; /// Which provider a candidate came from, so a clean can hand it back. /// /// The two are genuinely different machines — one walks the filesystem, the /// other reads and writes cargo's database — so removal has to return to the /// one that produced the item. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum Owner { Targets, CargoHome, } impl Owner { const ALL: [Owner; 2] = [Owner::Targets, Owner::CargoHome]; } /// One item that may be deleted, and how long it has gone unused. #[derive(Debug, Clone)] pub struct Candidate { pub item: Reclaimable, pub age: Duration, owner: Owner, } /// A count of things, and their total size when that is known at all. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Held { pub count: usize, /// `None` once anything in the tally went unmeasured — a clean does not /// measure what it keeps, and a total that quietly omitted those would /// understate itself while looking exact. pub bytes: Option, } impl Default for Held { /// An empty tally holds zero bytes, and that is a known quantity — not an /// unknown one. Starting at `None` would mean the first thing recorded /// found `None` already there and latched the total off for good. fn default() -> Self { Self { count: 0, bytes: Some(0), } } } impl Held { fn record(&mut self, retained: &Retained) { self.count += 1; self.bytes = match (self.bytes, retained.measured) { (Some(total), Some(measured)) => Some(total + measured.size), // Either the total was already unknown, or this item was never // measured. Once one is missing, the sum cannot be stated. _ => None, }; } } /// What one category adds up to, and the rule it was judged by. #[derive(Debug, Clone, Copy)] pub struct CategorySummary { pub reclaimable: Removed, pub retained: Held, pub threshold: MinAge, } impl CategorySummary { pub fn count(&self) -> usize { self.reclaimable.count + self.retained.count } } /// Everything found, judged but not yet touched. #[derive(Debug, Default)] pub struct Plan { pub candidates: Vec, pub retained: Vec, pub warnings: Vec, thresholds: BTreeMap, } impl Plan { /// Per-category totals, split by whether the age threshold lets them go. pub fn by_category(&self) -> BTreeMap { let mut totals: BTreeMap = BTreeMap::new(); let summary = |category: Category| CategorySummary { reclaimable: Removed::default(), retained: Held::default(), threshold: self .thresholds .get(&category) .copied() .unwrap_or(MinAge::new(Duration::ZERO)), }; for candidate in &self.candidates { totals .entry(candidate.item.category) .or_insert_with(|| summary(candidate.item.category)) .reclaimable .record(&candidate.item); } for retained in &self.retained { totals .entry(retained.category) .or_insert_with(|| summary(retained.category)) .retained .record(retained); } totals } pub fn eligible_total(&self) -> Removed { self.candidates.iter().fold(Removed::default(), |mut t, c| { t.record(&c.item); t }) } pub fn retained_total(&self) -> Held { self.retained.iter().fold(Held::default(), |mut t, r| { t.record(r); t }) } } #[cfg(test)] mod held_tests { use super::*; use std::path::PathBuf; use std::time::SystemTime; use crate::fsutil::Measurement; fn retained(size: Option) -> Retained { Retained { category: Category::Target, path: PathBuf::from("/somewhere/target"), measured: size.map(|size| Measurement { size, newest: SystemTime::UNIX_EPOCH, }), } } #[test] fn an_empty_tally_is_zero_bytes_not_unknown_bytes() { assert_eq!(Held::default().bytes, Some(0)); } #[test] fn measured_items_add_up() { let mut held = Held::default(); held.record(&retained(Some(10))); held.record(&retained(Some(32))); assert_eq!(held.count, 2); assert_eq!(held.bytes, Some(42)); } #[test] fn one_unmeasured_item_makes_the_whole_total_unknown() { // Reporting 10 bytes here would be worse than reporting nothing: it // reads as exact while silently omitting whatever the other item holds. let mut held = Held::default(); held.record(&retained(Some(10))); held.record(&retained(None)); assert_eq!(held.count, 2); assert_eq!(held.bytes, None); } #[test] fn a_total_stays_unknown_once_it_is_unknown() { let mut held = Held::default(); held.record(&retained(None)); held.record(&retained(Some(10))); assert_eq!(held.count, 2); assert_eq!(held.bytes, None); } } /// What a clean actually did. #[derive(Debug, Default)] pub struct Report { pub by_category: BTreeMap, pub total: Removed, } /// The whole tool: the projects it manages, the cargo home, and the policy. pub struct Grunk { targets: Targets, cache: GlobalCache, cargo_home: CargoHome, /// Warnings raised while working out what to manage. warnings: Vec, } impl Grunk { pub fn new(config: &Config) -> Result { let discovery = discovery::discover(config)?; let cargo_home = CargoHome::open()?; let mut warnings = discovery.warnings; if !cargo_home.tracks_usage() { warnings.push(format!( "{} has no cache tracking database, so nothing in it can be aged \ or cleaned; cargo 1.76 or newer records one as it works", cargo_home.root().display() )); } Ok(Self { targets: Targets::new(discovery.projects), cache: GlobalCache::new(cargo_home.clone()), cargo_home, warnings, }) } pub fn projects(&self) -> &[Project] { self.targets.projects() } pub fn cargo_home(&self) -> &CargoHome { &self.cargo_home } fn provider(&self, owner: Owner) -> &dyn Provider { match owner { Owner::Targets => &self.targets, Owner::CargoHome => &self.cache, } } fn provider_mut(&mut self, owner: Owner) -> &mut dyn Provider { match owner { Owner::Targets => &mut self.targets, Owner::CargoHome => &mut self.cache, } } /// Ask every provider what it has, under the given policy. pub fn plan(&self, policy: &Policy, progress: &Reporter) -> Result { let mut plan = Plan { warnings: self.warnings.clone(), thresholds: policy .categories() .map(|category| (category, policy.min_age(category))) .collect(), ..Plan::default() }; for owner in Owner::ALL { let findings = self.provider(owner).scan(policy, progress)?; for item in findings.reclaimable { let age = policy .now() .duration_since(item.last_used) .unwrap_or(Duration::ZERO); plan.candidates.push(Candidate { item, age, owner }); } plan.retained.extend(findings.retained); } // Biggest first: the most useful thing a report can lead with is where // the space actually went. plan.candidates.sort_by(|a, b| { b.item .size .cmp(&a.item.size) .then(a.item.path.cmp(&b.item.path)) }); Ok(plan) } /// Delete everything a plan found eligible. pub fn execute(&mut self, plan: &Plan, progress: &Reporter) -> Result { let mut by_owner: BTreeMap> = BTreeMap::new(); for candidate in &plan.candidates { by_owner .entry(candidate.owner) .or_default() .push(candidate.item.clone()); } // The tally comes from what each provider reports it removed, not from // what it was asked to remove. let mut report = Report::default(); for (owner, items) in by_owner { for item in self.provider_mut(owner).remove(&items, progress)? { report .by_category .entry(item.category) .or_insert_with(Removed::default) .record(&item); report.total.record(&item); } } Ok(report) } }