proj-janitor 0.2.3

Scan, back up, restore, and clean old projects safely
use clap::{Parser, Subcommand};
use globset::Glob;
use serde::{Deserialize, Serialize};
use std::{
    collections::HashSet,
    env, fs,
    path::{Component, Path, PathBuf},
};
mod archive;
mod backup;
mod clean;
mod project;
mod restore;
use backup::backup_cmd;
use clean::clean_cmd;
use project::discover;
use restore::restore_cmd;

pub(crate) const RULES: &[(&str, &[&str])] = &[
    (
        "python-cache",
        &[
            "__pycache__",
            ".pytest_cache",
            ".mypy_cache",
            ".ruff_cache",
            "*.pyc",
            ".coverage",
            "htmlcov",
        ],
    ),
    (
        "python-env",
        &[".venv", "venv", ".tox", ".nox", "*.egg-info"],
    ),
    (
        "node",
        &[
            "node_modules",
            ".next",
            ".nuxt",
            ".svelte-kit",
            ".turbo",
            ".parcel-cache",
            "coverage",
        ],
    ),
    (
        "build",
        &["build", "dist", "out", "target", "CMakeFiles", ".gradle"],
    ),
    ("editor", &[".cache", ".DS_Store", "Thumbs.db"]),
];

#[derive(Parser)]
#[command(
    name = "projjan",
    version,
    about = "Scan, back up, restore, and clean old projects safely"
)]
struct Cli {
    #[command(subcommand)]
    command: Action,
}

#[derive(Subcommand)]
enum Action {
    Scan {
        #[arg(required = true)]
        roots: Vec<PathBuf>,
        #[arg(long)]
        all: bool,
        #[arg(long)]
        json: bool,
    },
    Backup {
        #[arg(required = true)]
        roots: Vec<PathBuf>,
        #[arg(long, env = "BACKUP_DEST")]
        backup_dest: Option<PathBuf>,
        #[arg(long)]
        dry_run: bool,
        #[arg(long)]
        include_artifacts: bool,
        #[arg(long)]
        always_archive: bool,
    },
    Clean {
        roots: Vec<PathBuf>,
        #[arg(long, value_delimiter = ',')]
        rules: Vec<String>,
        #[arg(long)]
        apply: bool,
        #[arg(long)]
        yes: bool,
        #[arg(long)]
        json: bool,
        #[arg(long)]
        list_rules: bool,
    },
    Restore {
        manifest: PathBuf,
        #[arg(long)]
        dest: Option<PathBuf>,
        #[arg(long = "project")]
        projects: Vec<String>,
        #[arg(long)]
        dry_run: bool,
        #[arg(long)]
        no_verify: bool,
        #[arg(long)]
        yes: bool,
        #[arg(long)]
        json: bool,
    },
}

#[derive(Serialize, Deserialize)]
pub(crate) struct Manifest {
    pub(crate) schema_version: u32,
    pub(crate) created_at: String,
    pub(crate) tool: String,
    pub(crate) version: String,
    pub(crate) include_artifacts: bool,
    pub(crate) always_archive: bool,
    pub(crate) records: Vec<Record>,
}
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct Record {
    pub(crate) project: String,
    pub(crate) source: String,
    pub(crate) archive: Option<String>,
    pub(crate) archive_bytes: u64,
    pub(crate) source_bytes: u64,
    pub(crate) sha256: Option<String>,
    pub(crate) strategy: String,
    pub(crate) remote: Option<String>,
    pub(crate) remote_url: Option<String>,
    pub(crate) branch: Option<String>,
    pub(crate) commit: Option<String>,
    pub(crate) upstream: Option<String>,
    pub(crate) verified: Option<String>,
    pub(crate) fallback_reason: Option<String>,
}

fn main() {
    if let Err(e) = run() {
        if let Some(interrupted) = e.downcast_ref::<restore::RestoreInterrupted>() {
            std::process::exit(128 + interrupted.0);
        }
        eprintln!("error: {e}");
        std::process::exit(1);
    }
}
fn run() -> anyhow::Result<()> {
    match Cli::parse().command {
        Action::Scan { roots, all, json } => scan_cmd(&roots, all, json),
        Action::Backup {
            roots,
            backup_dest,
            dry_run,
            include_artifacts,
            always_archive,
        } => backup_cmd(
            &roots,
            backup_dest,
            dry_run,
            include_artifacts,
            always_archive,
        ),
        Action::Clean {
            roots,
            rules,
            apply,
            yes,
            json,
            list_rules,
        } => clean_cmd(&roots, &rules, apply, yes, json, list_rules),
        Action::Restore {
            manifest,
            dest,
            projects,
            dry_run,
            no_verify,
            yes,
            json,
        } => restore_cmd(&manifest, dest, &projects, dry_run, no_verify, yes, json),
    }
}

fn scan_cmd(roots: &[PathBuf], all: bool, json: bool) -> anyhow::Result<()> {
    let mut projects = Vec::new();
    let mut seen = HashSet::new();
    let mut failed = false;
    for root in roots {
        match discover(root, all) {
            Ok(ps) => {
                for p in ps {
                    if seen.insert(p.path.clone()) {
                        projects.push(p)
                    }
                }
            }
            Err(e) => {
                eprintln!("error: {e}");
                failed = true
            }
        }
    }
    if json {
        println!("{}", serde_json::to_string_pretty(&projects)?);
    } else if projects.is_empty() {
        eprintln!("No projects found.")
    } else {
        println!(
            "{:<24} {:>10}  {:<20} {:<8} {:<42} MODIFIED",
            "PROJECT", "SIZE", "BRANCH", "STATE", "MARKERS"
        );
        for p in projects {
            let (branch, state): (String, String) = match p.git {
                None => ("-".into(), "-".into()),
                Some(g) => (
                    g.branch.unwrap_or_else(|| "-".into()),
                    g.dirty
                        .map(|d| if d { "dirty" } else { "clean" }.into())
                        .unwrap_or_else(|| "unknown".into()),
                ),
            };
            println!(
                "{:<24} {:>10}  {:<20} {:<8} {:<42} {}",
                p.name,
                human(p.size_bytes),
                branch,
                state,
                p.markers.join(", "),
                p.last_modified
                    .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
                    .unwrap_or_else(|| "-".into())
            );
        }
    }
    if failed {
        anyhow::bail!("one or more roots could not be scanned")
    }
    Ok(())
}
pub(crate) fn human(n: u64) -> String {
    let mut v = n as f64;
    let mut unit = "B";
    for u in ["KiB", "MiB", "GiB", "TiB"] {
        if v < 1024.0 {
            break;
        }
        v /= 1024.0;
        unit = u;
    }
    if unit == "B" {
        format!("{n} B")
    } else {
        format!("{v:.1} {unit}")
    }
}
pub(crate) fn expand(p: &Path) -> PathBuf {
    let s = p.to_string_lossy();
    let home = || {
        #[cfg(windows)]
        {
            env::var_os("USERPROFILE").or_else(|| env::var_os("HOME"))
        }
        #[cfg(not(windows))]
        {
            env::var_os("HOME")
        }
    };
    if s == "~" {
        home().map(PathBuf::from).unwrap_or_else(|| p.to_owned())
    } else {
        let rest = if cfg!(windows) {
            s.strip_prefix("~/").or_else(|| s.strip_prefix("~\\"))
        } else {
            s.strip_prefix("~/")
        };
        rest.map(|rest| home().map(PathBuf::from).unwrap_or_default().join(rest))
            .unwrap_or_else(|| p.to_owned())
    }
}
fn resolve_for_creation(path: &Path) -> anyhow::Result<PathBuf> {
    let absolute = if path.is_absolute() {
        path.to_owned()
    } else {
        env::current_dir()?.join(path)
    };
    let mut resolved = PathBuf::new();
    for component in absolute.components() {
        match component {
            Component::Prefix(_) | Component::RootDir => resolved.push(component.as_os_str()),
            Component::CurDir => {}
            Component::ParentDir => {
                match fs::metadata(&resolved) {
                    Ok(metadata) => anyhow::ensure!(
                        metadata.is_dir(),
                        "path component is not a directory: {}",
                        resolved.display()
                    ),
                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
                    Err(error) => return Err(error.into()),
                }
                resolved.pop();
            }
            Component::Normal(name) => {
                resolved.push(name);
                match fs::symlink_metadata(&resolved) {
                    Ok(_) => resolved = fs::canonicalize(&resolved)?,
                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
                    Err(error) => return Err(error.into()),
                }
            }
        }
    }
    Ok(resolved)
}

pub(crate) fn is_artifact(s: &str) -> bool {
    RULES.iter().flat_map(|(_, p)| *p).any(|pat| {
        Glob::new(pat)
            .ok()
            .is_some_and(|g| g.compile_matcher().is_match(s))
    })
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;