proj-janitor 0.2.8

Scan, back up, restore, and clean old projects safely
use cap_std::{ambient_authority, fs::Dir};
use globset::{Glob, GlobSetBuilder};
use std::{
    collections::{HashMap, HashSet},
    fs,
    io::{self, IsTerminal, Write},
    path::{Path, PathBuf},
};
use walkdir::WalkDir;

use crate::{RULES, human, project::discover};

pub(crate) fn clean_cmd(
    roots: &[PathBuf],
    selected: &[String],
    apply: bool,
    yes: bool,
    json: bool,
    list: bool,
) -> anyhow::Result<()> {
    if list {
        for (n, p) in RULES {
            println!("{n}: {}", p.join(", "));
        }
        return Ok(());
    }
    anyhow::ensure!(!roots.is_empty(), "at least one root is required");
    let chosen: Vec<_> = if selected.is_empty() {
        RULES.iter().collect()
    } else {
        let mut v = Vec::new();
        for s in selected {
            v.push(
                RULES
                    .iter()
                    .find(|(n, _)| n == s)
                    .ok_or_else(|| anyhow::anyhow!("unknown rule: {s}"))?,
            );
        }
        v
    };
    let mut rule_patterns = Vec::new();
    for (rule, ps) in &chosen {
        let mut builder = GlobSetBuilder::new();
        for p in *ps {
            builder.add(Glob::new(p)?);
        }
        rule_patterns.push(((*rule).to_owned(), builder.build()?));
    }
    let mut actions = Vec::new();
    let mut failed = false;
    let mut seen = HashSet::new();
    let mut project_dirs = HashMap::new();
    for root in roots {
        match discover(root, false) {
            Ok(ps) => {
                for p in ps {
                    if !seen.insert(p.path.clone()) {
                        continue;
                    }
                    if apply {
                        let identity = same_file::Handle::from_path(&p.path)?;
                        let dir = open_cleanup_project(&p.path, &identity)?;
                        project_dirs.insert(p.path.clone(), dir);
                    }
                    for entry in WalkDir::new(&p.path)
                        .follow_links(false)
                        .into_iter()
                        .filter_entry(|entry| entry.path() == p.path || entry.file_name() != ".git")
                    {
                        let e = match entry {
                            Ok(entry) => entry,
                            Err(error) => {
                                eprintln!("error: {error}");
                                failed = true;
                                continue;
                            }
                        };
                        let path = e.path();
                        if path == p.path || e.file_type().is_symlink() {
                            continue;
                        }
                        let name = path.file_name().unwrap_or_default().to_string_lossy();
                        if name == ".git" {
                            continue;
                        }
                        let cov = name == "coverage"
                            && path.parent() == Some(p.path.as_path())
                            && [
                                "lcov.info",
                                "coverage-final.json",
                                "coverage-summary.json",
                                "coverage.xml",
                                "coverage.json",
                                "coverage.out",
                                "clover.xml",
                                "jacoco.xml",
                                "jacoco.csv",
                                "status.json",
                                ".coverage",
                                "lcov-report",
                                "coverage.cobertura.xml",
                            ]
                            .iter()
                            .any(|m| path.join(m).exists());
                        let rule = if name == "coverage" && !cov {
                            None
                        } else {
                            rule_patterns
                                .iter()
                                .find(|(_, set)| set.is_match(name.as_ref()))
                                .map(|(rule, _)| rule.clone())
                        };
                        if let Some(rule) = rule {
                            let size = if e.file_type().is_dir() {
                                tree_size(path)
                            } else {
                                e.metadata().map(|m| m.len()).map_err(anyhow::Error::from)
                            };
                            let size = match size {
                                Ok(size) => size,
                                Err(error) => {
                                    eprintln!("error: {error}");
                                    failed = true;
                                    continue;
                                }
                            };
                            let kind = if e.file_type().is_dir() {
                                "dir"
                            } else {
                                "file"
                            };
                            let identity = match same_file::Handle::from_path(path) {
                                Ok(identity) => identity,
                                Err(error) => {
                                    eprintln!("error: {error}");
                                    failed = true;
                                    continue;
                                }
                            };
                            actions.push((
                                p.path.clone(),
                                path.to_owned(),
                                size,
                                rule,
                                kind,
                                identity,
                            ));
                        }
                    }
                }
            }
            Err(e) => {
                eprintln!("error: {e}");
                failed = true
            }
        }
    }
    if failed {
        anyhow::bail!("scan failures; no cleanup was applied")
    }
    actions.sort_by_key(|action| action.1.components().count());
    let mut selected_paths: Vec<PathBuf> = Vec::new();
    actions.retain(|(_, path, _, _, _, _)| {
        if selected_paths.iter().any(|parent| path.starts_with(parent)) {
            false
        } else {
            selected_paths.push(path.clone());
            true
        }
    });
    actions.sort_by(|a, b| a.1.cmp(&b.1));
    if json {
        let vals: Vec<_> = actions
            .iter()
            .map(|(p, path, size, rule, kind, _)| serde_json::json!({"project":p,"path":path,"rule":rule,"kind":kind,"size_bytes":size}))
            .collect();
        println!("{}", serde_json::to_string_pretty(&vals)?);
    } else if actions.is_empty() {
        println!("Nothing to clean.")
    } else {
        for (_, p, s, _, _, _) in &actions {
            println!("{}  {}", human(*s), p.display())
        }
        println!(
            "{} item(s), {} reclaimable.",
            actions.len(),
            human(actions.iter().map(|a| a.2).sum())
        );
        if !apply {
            println!("Dry run. Re-run with --apply to delete these items.")
        }
    }
    if !apply || actions.is_empty() {
        return if failed {
            anyhow::bail!("scan failures")
        } else {
            Ok(())
        };
    }
    if !yes {
        if !io::stdin().is_terminal() {
            anyhow::bail!("refusing to delete without --yes in a non-interactive session")
        }
        eprint!("Delete {} item(s)? [y/N] ", actions.len());
        io::stderr().flush()?;
        let mut a = String::new();
        io::stdin().read_line(&mut a)?;
        if !matches!(a.trim().to_lowercase().as_str(), "y" | "yes") {
            anyhow::bail!("Aborted")
        }
    }
    for (project, path, _, _, _, identity) in actions {
        let dir = project_dirs
            .get(&project)
            .ok_or_else(|| anyhow::anyhow!("missing cleanup project handle"))?;
        remove_cleanup_target(dir, &project, &path, identity)?;
    }
    if failed {
        anyhow::bail!("scan failures")
    }
    Ok(())
}

pub(crate) fn open_cleanup_project(
    project: &Path,
    planned_identity: &same_file::Handle,
) -> anyhow::Result<Dir> {
    let dir = Dir::open_ambient_dir(project, ambient_authority())?;
    let opened_identity = same_file::Handle::from_file(dir.try_clone()?.into_std_file())?;
    anyhow::ensure!(
        &opened_identity == planned_identity,
        "project root changed during cleanup planning: {}",
        project.display()
    );
    anyhow::ensure!(
        fs::canonicalize(project)? == project,
        "project root changed during cleanup planning: {}",
        project.display()
    );
    Ok(dir)
}

pub(crate) fn remove_cleanup_target(
    dir: &Dir,
    project: &Path,
    path: &Path,
    planned_identity: same_file::Handle,
) -> anyhow::Result<()> {
    let relative = path
        .strip_prefix(project)
        .map_err(|_| anyhow::anyhow!("target escapes project root: {}", path.display()))?;
    anyhow::ensure!(
        !relative.as_os_str().is_empty()
            && !relative.is_absolute()
            && !relative
                .components()
                .any(|part| matches!(part, std::path::Component::ParentDir)),
        "unsafe cleanup target: {}",
        path.display()
    );
    let metadata = dir.symlink_metadata(relative)?;
    anyhow::ensure!(
        !metadata.file_type().is_symlink(),
        "refusing to follow symlink: {}",
        path.display()
    );
    let current_identity = if metadata.is_dir() {
        same_file::Handle::from_file(dir.open_dir(relative)?.into_std_file())?
    } else {
        same_file::Handle::from_file(dir.open(relative)?.into_std())?
    };
    anyhow::ensure!(
        current_identity == planned_identity,
        "cleanup target changed since cleanup plan: {}",
        path.display()
    );
    drop(current_identity);
    drop(planned_identity);
    if metadata.is_dir() {
        dir.remove_dir_all(relative)?;
    } else {
        dir.remove_file(relative)?;
    }
    Ok(())
}

pub(crate) fn tree_size(path: &Path) -> anyhow::Result<u64> {
    let mut total = 0;
    for entry in WalkDir::new(path).follow_links(false) {
        let entry = entry?;
        if entry.file_type().is_file() {
            total += entry.metadata()?.len();
        }
    }
    Ok(total)
}