use crate::error::{MinoError, MinoResult};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::debug;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Ecosystem {
Npm,
Yarn,
Pnpm,
Cargo,
Pip,
Poetry,
Uv,
Go,
}
impl Ecosystem {
pub fn cache_dir(&self) -> &'static str {
match self {
Self::Npm | Self::Yarn | Self::Pnpm => "npm",
Self::Cargo => "cargo",
Self::Pip | Self::Poetry => "pip",
Self::Uv => "uv",
Self::Go => "go",
}
}
pub fn cache_env_vars(&self) -> Vec<(&'static str, &'static str)> {
match self {
Self::Npm => vec![("npm_config_cache", "/cache/npm")],
Self::Yarn => vec![
("YARN_CACHE_FOLDER", "/cache/yarn"),
("npm_config_cache", "/cache/npm"),
],
Self::Pnpm => vec![
("PNPM_HOME", "/cache/pnpm"),
("npm_config_cache", "/cache/npm"),
],
Self::Cargo => vec![
("CARGO_HOME", "/cache/cargo"),
("SCCACHE_DIR", "/cache/sccache"),
],
Self::Pip => vec![("PIP_CACHE_DIR", "/cache/pip")],
Self::Poetry => vec![
("POETRY_CACHE_DIR", "/cache/poetry"),
("PIP_CACHE_DIR", "/cache/pip"),
],
Self::Uv => vec![("UV_CACHE_DIR", "/cache/uv")],
Self::Go => vec![
("GOMODCACHE", "/cache/go/mod"),
("GOCACHE", "/cache/go/build"),
],
}
}
fn lockfile_patterns(&self) -> &'static [&'static str] {
match self {
Self::Npm => &["package-lock.json", "npm-shrinkwrap.json"],
Self::Yarn => &["yarn.lock"],
Self::Pnpm => &["pnpm-lock.yaml"],
Self::Cargo => &["Cargo.lock"],
Self::Pip => &["requirements.txt", "Pipfile.lock"],
Self::Poetry => &["poetry.lock"],
Self::Uv => &["uv.lock"],
Self::Go => &["go.sum"],
}
}
fn all() -> &'static [Self] {
&[
Self::Npm,
Self::Yarn,
Self::Pnpm,
Self::Cargo,
Self::Pip,
Self::Poetry,
Self::Uv,
Self::Go,
]
}
}
impl fmt::Display for Ecosystem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
Self::Npm => "npm",
Self::Yarn => "yarn",
Self::Pnpm => "pnpm",
Self::Cargo => "cargo",
Self::Pip => "pip",
Self::Poetry => "poetry",
Self::Uv => "uv",
Self::Go => "go",
};
write!(f, "{}", name)
}
}
#[derive(Debug, Clone)]
pub struct LockfileInfo {
pub ecosystem: Ecosystem,
pub path: PathBuf,
pub hash: String,
}
impl LockfileInfo {
pub fn volume_name(&self) -> String {
format!("mino-cache-{}-{}", self.ecosystem, self.hash)
}
}
fn hash_file_contents(path: &Path) -> MinoResult<String> {
let contents = fs::read(path).map_err(|e| MinoError::Io {
context: format!("reading lockfile {}", path.display()),
source: e,
})?;
let mut hasher = Sha256::new();
hasher.update(&contents);
let result = hasher.finalize();
let hash = hex::encode(&result[..6]);
Ok(hash)
}
pub fn detect_lockfiles(project_dir: &Path) -> MinoResult<Vec<LockfileInfo>> {
let mut lockfiles = Vec::new();
for ecosystem in Ecosystem::all() {
for pattern in ecosystem.lockfile_patterns() {
let lockfile_path = project_dir.join(pattern);
if lockfile_path.exists() && lockfile_path.is_file() {
debug!("Found {} lockfile: {}", ecosystem, lockfile_path.display());
let hash = hash_file_contents(&lockfile_path)?;
lockfiles.push(LockfileInfo {
ecosystem: *ecosystem,
path: lockfile_path,
hash,
});
break;
}
}
}
debug!("Detected {} lockfiles", lockfiles.len());
Ok(lockfiles)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn ecosystem_display() {
assert_eq!(Ecosystem::Npm.to_string(), "npm");
assert_eq!(Ecosystem::Cargo.to_string(), "cargo");
assert_eq!(Ecosystem::Uv.to_string(), "uv");
}
#[test]
fn ecosystem_cache_dir() {
assert_eq!(Ecosystem::Npm.cache_dir(), "npm");
assert_eq!(Ecosystem::Yarn.cache_dir(), "npm");
assert_eq!(Ecosystem::Cargo.cache_dir(), "cargo");
assert_eq!(Ecosystem::Uv.cache_dir(), "uv");
}
#[test]
fn hash_deterministic() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.lock");
let mut file = fs::File::create(&path).unwrap();
file.write_all(b"test content").unwrap();
let hash1 = hash_file_contents(&path).unwrap();
let hash2 = hash_file_contents(&path).unwrap();
assert_eq!(hash1, hash2);
assert_eq!(hash1.len(), 12);
}
#[test]
fn hash_different_content() {
let dir = TempDir::new().unwrap();
let path1 = dir.path().join("test1.lock");
fs::write(&path1, b"content 1").unwrap();
let path2 = dir.path().join("test2.lock");
fs::write(&path2, b"content 2").unwrap();
let hash1 = hash_file_contents(&path1).unwrap();
let hash2 = hash_file_contents(&path2).unwrap();
assert_ne!(hash1, hash2);
}
#[test]
fn detect_npm_lockfile() {
let dir = TempDir::new().unwrap();
let lockfile = dir.path().join("package-lock.json");
fs::write(&lockfile, r#"{"name": "test"}"#).unwrap();
let lockfiles = detect_lockfiles(dir.path()).unwrap();
assert_eq!(lockfiles.len(), 1);
assert_eq!(lockfiles[0].ecosystem, Ecosystem::Npm);
assert_eq!(lockfiles[0].path, lockfile);
}
#[test]
fn detect_multiple_ecosystems() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("package-lock.json"), "{}").unwrap();
fs::write(dir.path().join("Cargo.lock"), "").unwrap();
let lockfiles = detect_lockfiles(dir.path()).unwrap();
assert_eq!(lockfiles.len(), 2);
let ecosystems: Vec<_> = lockfiles.iter().map(|l| l.ecosystem).collect();
assert!(ecosystems.contains(&Ecosystem::Npm));
assert!(ecosystems.contains(&Ecosystem::Cargo));
}
#[test]
fn lockfile_volume_name() {
let info = LockfileInfo {
ecosystem: Ecosystem::Npm,
path: PathBuf::from("/test/package-lock.json"),
hash: "a1b2c3d4e5f6".to_string(),
};
assert_eq!(info.volume_name(), "mino-cache-npm-a1b2c3d4e5f6");
}
#[test]
fn detect_empty_dir() {
let dir = TempDir::new().unwrap();
let lockfiles = detect_lockfiles(dir.path()).unwrap();
assert!(lockfiles.is_empty());
}
#[test]
fn detect_uv_lockfile() {
let dir = TempDir::new().unwrap();
let lockfile = dir.path().join("uv.lock");
fs::write(&lockfile, "version = 1\n[[package]]\nname = \"test\"").unwrap();
let lockfiles = detect_lockfiles(dir.path()).unwrap();
assert_eq!(lockfiles.len(), 1);
assert_eq!(lockfiles[0].ecosystem, Ecosystem::Uv);
assert_eq!(lockfiles[0].path, lockfile);
}
#[test]
fn uv_cache_env_vars() {
let env_vars = Ecosystem::Uv.cache_env_vars();
assert_eq!(env_vars, vec![("UV_CACHE_DIR", "/cache/uv")]);
}
}