clean.rs raw

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<u64>,
}

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<Candidate>,
    pub retained: Vec<Retained>,
    pub warnings: Vec<String>,
    thresholds: BTreeMap<Category, MinAge>,
}

impl Plan {
    /// Per-category totals, split by whether the age threshold lets them go.
    pub fn by_category(&self) -> BTreeMap<Category, CategorySummary> {
        let mut totals: BTreeMap<Category, CategorySummary> = 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<u64>) -> 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<Category, Removed>,
    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<String>,
}

impl Grunk {
    pub fn new(config: &Config) -> Result<Self> {
        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<Plan> {
        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<Report> {
        let mut by_owner: BTreeMap<Owner, Vec<Reclaimable>> = 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)
    }
}