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]
        );
    }
}