pub mod bun;
pub mod bundler;
pub mod cargo_adapter;
pub mod cocoapods;
pub mod composer;
pub mod dart;
pub mod go;
pub mod gradle;
pub mod maven;
pub mod mix;
pub mod mix_build;
pub mod npm;
pub mod pdm;
pub mod pipenv;
pub mod pnpm;
pub mod poetry;
pub mod swift;
pub mod terraform;
pub mod uv;
pub mod venv;
pub mod yarn;
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use anyhow::{Context as _, Result};
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct BloatDir {
pub name: String,
pub path: PathBuf,
pub size_bytes: u64,
pub shared_bytes: u64,
}
impl fmt::Display for BloatDir {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} ({})", self.name, self.path.display())
}
}
#[derive(Debug, Clone)]
pub struct DriftReport {
pub directory: String,
pub unrecorded: Vec<String>,
pub record_command: &'static str,
}
pub trait PackageManager: Send + Sync {
fn name(&self) -> &'static str;
fn detect(&self, project_path: &Path) -> bool;
fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;
fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;
fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;
fn restore_named(
&self,
project_path: &Path,
dir_name: &str,
runtime: Option<&str>,
timeout: std::time::Duration,
) -> Result<()> {
let _ = (dir_name, runtime);
self.restore(project_path, timeout)
}
fn runtime_tag(&self, project_path: &Path, dir_name: &str) -> Option<String> {
let _ = (project_path, dir_name);
None
}
fn lockfiles(&self) -> &'static [&'static str] {
&[]
}
fn drift(&self, project_path: &Path) -> Vec<DriftReport> {
let _ = project_path;
Vec::new()
}
fn opt_in(&self) -> bool {
false
}
}
const JS_MANAGERS: [&str; 4] = ["npm", "pnpm", "yarn", "bun"];
const JS_INSTALL_MARKERS: [(&str, &[&str]); 3] = [
("pnpm", &[".pnpm", ".modules.yaml"]),
("yarn", &[".yarn-state.yml", ".yarn-integrity"]),
("npm", &[".package-lock.json"]),
];
pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
vec![
Box::new(npm::Npm),
Box::new(pnpm::Pnpm),
Box::new(yarn::Yarn),
Box::new(bun::Bun),
Box::new(uv::Uv),
Box::new(poetry::Poetry),
Box::new(pdm::Pdm),
Box::new(pipenv::Pipenv),
Box::new(venv::Venv),
Box::new(cargo_adapter::Cargo),
Box::new(go::Go),
Box::new(composer::Composer),
Box::new(bundler::Bundler),
Box::new(cocoapods::CocoaPods),
Box::new(mix::Mix),
Box::new(mix_build::MixBuild),
Box::new(gradle::Gradle),
Box::new(maven::Maven),
Box::new(swift::Swift),
Box::new(terraform::Terraform),
Box::new(dart::Dart),
]
}
fn opt_in_enabled() -> &'static [String] {
static ENABLED: OnceLock<Vec<String>> = OnceLock::new();
ENABLED.get_or_init(|| {
crate::config::Registry::load()
.map(|r| {
let mut names = Vec::new();
if r.settings.enable_cargo {
names.push("cargo".to_string());
}
if r.settings.enable_gradle {
names.push("gradle".to_string());
}
if r.settings.enable_maven {
names.push("maven".to_string());
}
if r.settings.enable_swift {
names.push("swift".to_string());
}
if r.settings.enable_dart {
names.push("dart".to_string());
}
if r.settings.enable_mix_build {
names.push("mix_build".to_string());
}
names
})
.unwrap_or_default()
})
}
fn user_disabled() -> &'static [String] {
static DISABLED: OnceLock<Vec<String>> = OnceLock::new();
DISABLED.get_or_init(|| {
crate::config::Registry::load()
.map(|r| {
r.settings
.disabled_adapters
.iter()
.map(|n| n.trim().to_ascii_lowercase())
.filter(|n| !n.is_empty())
.collect()
})
.unwrap_or_default()
})
}
pub fn is_adapter_name(name: &str) -> bool {
get_all_adapters().iter().any(|a| a.name() == name)
}
pub const ADAPTER_GROUPS: &[(&str, &[&str])] = &[
("JavaScript", &["npm", "pnpm", "yarn", "bun"]),
("Python", &["uv", "poetry", "pdm", "pipenv", "venv"]),
("Rust", &["cargo"]),
("Go", &["go"]),
("JVM", &["gradle", "maven"]),
("PHP", &["composer"]),
("Ruby", &["bundler"]),
("Swift & Objective-C", &["swift", "cocoapods"]),
("Elixir", &["mix", "mix_build"]),
("Infrastructure", &["terraform"]),
("Dart & Flutter", &["dart"]),
];
pub fn adapter_group(name: &str) -> &'static str {
ADAPTER_GROUPS
.iter()
.find(|(_, names)| names.contains(&name))
.map(|(group, _)| *group)
.unwrap_or("Other")
}
pub fn all_adapter_names() -> Vec<&'static str> {
get_all_adapters().iter().map(|a| a.name()).collect()
}
pub fn opt_in_adapter_names() -> Vec<&'static str> {
get_all_adapters()
.iter()
.filter(|a| a.opt_in())
.map(|a| a.name())
.collect()
}
pub fn detect_adapters(project_path: &Path) -> Vec<Box<dyn PackageManager>> {
detect_adapters_with(project_path, opt_in_enabled(), user_disabled())
}
fn detect_adapters_with(
project_path: &Path,
opt_in: &[String],
disabled: &[String],
) -> Vec<Box<dyn PackageManager>> {
let mut detected: Vec<Box<dyn PackageManager>> = get_all_adapters()
.into_iter()
.filter(|adapter| !adapter.opt_in() || opt_in.iter().any(|n| n == adapter.name()))
.filter(|adapter| !disabled.iter().any(|n| n == adapter.name()))
.filter(|adapter| adapter.detect(project_path))
.collect();
resolve_conflicts(project_path, &mut detected);
detected
}
fn resolve_conflicts(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
resolve_js_conflict(project_path, detected);
resolve_python_conflict(project_path, detected);
}
fn resolve_js_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
if detected
.iter()
.filter(|a| JS_MANAGERS.contains(&a.name()))
.count()
< 2
{
return;
}
let winner = declared_package_manager(project_path)
.filter(|name| detected.iter().any(|a| a.name() == name))
.or_else(|| installed_manager(project_path, detected))
.or_else(|| newest_lockfile_owner(project_path, detected));
let Some(winner) = winner else { return };
detected.retain(|a| !JS_MANAGERS.contains(&a.name()) || a.name() == winner);
}
const PYTHON_ENV_MANAGERS: [(&str, &str); 4] = [
("uv", "uv.lock"),
("poetry", "poetry.lock"),
("pdm", "pdm.lock"),
("pipenv", "Pipfile.lock"),
];
fn resolve_python_conflict(project_path: &Path, detected: &mut Vec<Box<dyn PackageManager>>) {
let claimants: Vec<(&str, &str)> = PYTHON_ENV_MANAGERS
.iter()
.copied()
.filter(|(name, _)| detected.iter().any(|a| a.name() == *name))
.collect();
let Some(&(first, _)) = claimants.first() else {
return;
};
detected.retain(|a| a.name() != "venv");
if claimants.len() < 2 {
return;
}
let winner = claimants
.iter()
.find(|(_, lockfile)| project_path.join(lockfile).exists())
.map_or(first, |(name, _)| *name);
detected.retain(|a| {
a.name() == winner
|| !PYTHON_ENV_MANAGERS
.iter()
.any(|(name, _)| *name == a.name())
});
}
fn installed_manager(project_path: &Path, detected: &[Box<dyn PackageManager>]) -> Option<String> {
let node_modules = project_path.join("node_modules");
if !node_modules.is_dir() {
return None;
}
JS_INSTALL_MARKERS
.iter()
.find(|(name, markers)| {
detected.iter().any(|a| a.name() == *name)
&& markers.iter().any(|m| node_modules.join(m).exists())
})
.map(|(name, _)| (*name).to_string())
}
fn declared_package_manager(project_path: &Path) -> Option<String> {
let raw = std::fs::read_to_string(project_path.join("package.json")).ok()?;
let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
let declared = json.get("packageManager")?.as_str()?;
let name = declared.split('@').next().unwrap_or_default();
JS_MANAGERS
.iter()
.find(|m| **m == name)
.map(|m| (*m).to_string())
}
fn newest_lockfile_owner(
project_path: &Path,
detected: &[Box<dyn PackageManager>],
) -> Option<String> {
detected
.iter()
.filter(|a| JS_MANAGERS.contains(&a.name()))
.filter_map(|a| {
let newest = a
.lockfiles()
.iter()
.filter_map(|f| std::fs::metadata(project_path.join(f)).ok()?.modified().ok())
.max()?;
Some((newest, a.name().to_string()))
})
.fold(None::<(std::time::SystemTime, String)>, |best, cur| {
match best {
Some(b) if b.0 >= cur.0 => Some(b),
_ => Some(cur),
}
})
.map(|(_, name)| name)
}
pub fn dir_size(path: &Path) -> u64 {
if !path.exists() {
return 0;
}
WalkDir::new(path)
.follow_links(false)
.into_iter()
.flatten()
.filter_map(|entry| entry.metadata().ok())
.filter(|meta| meta.is_file())
.map(|meta| meta.len())
.sum()
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DirSizeBreakdown {
pub freed_bytes: u64,
pub shared_bytes: u64,
}
pub fn dir_size_with_hardlinks(path: &Path) -> DirSizeBreakdown {
let mut out = DirSizeBreakdown::default();
if !path.exists() {
return out;
}
let mut linked: HashMap<(u64, u64), (u64, u64, u64)> = HashMap::new();
for entry in WalkDir::new(path).follow_links(false).into_iter().flatten() {
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_file() {
continue;
}
match file_link_identity(entry.path(), &meta) {
Some((dev, ino, nlink)) if nlink > 1 => {
linked.entry((dev, ino)).or_insert((meta.len(), nlink, 0)).2 += 1;
}
_ => out.freed_bytes += meta.len(),
}
}
for (bytes, nlink, seen) in linked.into_values() {
if seen >= nlink {
out.freed_bytes += bytes;
} else {
out.shared_bytes += bytes;
}
}
out
}
#[cfg(unix)]
fn file_link_identity(_path: &Path, meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
use std::os::unix::fs::MetadataExt as _;
Some((meta.dev(), meta.ino(), meta.nlink()))
}
#[cfg(windows)]
fn file_link_identity(path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
use std::os::windows::fs::OpenOptionsExt as _;
use std::os::windows::io::AsRawHandle as _;
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
};
let file = std::fs::OpenOptions::new().access_mode(0).open(path).ok()?;
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
return None;
}
Some((
u64::from(info.dwVolumeSerialNumber),
(u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow),
u64::from(info.nNumberOfLinks),
))
}
#[cfg(not(any(unix, windows)))]
fn file_link_identity(_path: &Path, _meta: &std::fs::Metadata) -> Option<(u64, u64, u64)> {
None
}
pub fn resolve_program(program: &str) -> String {
#[cfg(windows)]
{
if Path::new(program).components().count() > 1 {
return program.to_string();
}
let Some(path_var) = std::env::var_os("PATH") else {
return program.to_string();
};
for dir in std::env::split_paths(&path_var) {
for ext in ["exe", "cmd", "bat"] {
let candidate = dir.join(format!("{program}.{ext}"));
if candidate.is_file() {
return candidate.to_string_lossy().into_owned();
}
}
}
}
program.to_string()
}
pub fn binary_available(program: &str) -> bool {
static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut guard = match cache.lock() {
Ok(g) => g,
Err(_) => return probe_binary(program),
};
if let Some(known) = guard.get(program) {
return *known;
}
let available = probe_binary(program);
guard.insert(program.to_string(), available);
available
}
const VERSION_PROBE_ARGS: [(&str, &[&str]); 1] = [("go", &["version"])];
fn version_probe_args(program: &str) -> &'static [&'static str] {
VERSION_PROBE_ARGS
.iter()
.find(|(name, _)| *name == program)
.map_or(&["--version"], |(_, args)| *args)
}
fn probe_binary(program: &str) -> bool {
crate::spawn::command(resolve_program(program))
.args(version_probe_args(program))
.stdin(std::process::Stdio::null())
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
struct CommandOutput {
status: std::process::ExitStatus,
stdout: String,
stderr: String,
}
fn spawn_capture(
program: &str,
args: &[&str],
cwd: &Path,
timeout: std::time::Duration,
) -> Result<CommandOutput> {
use std::io::Read;
use std::process::Stdio;
use std::thread;
use std::time::Instant;
let resolved = resolve_program(program);
let mut child = crate::spawn::command(&resolved)
.args(args)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("Failed to execute: {program} {}", args.join(" ")))?;
let mut stdout_pipe = child.stdout.take();
let mut stderr_pipe = child.stderr.take();
let stdout_reader = thread::spawn(move || {
let mut buf = Vec::new();
if let Some(pipe) = stdout_pipe.as_mut() {
let _ = pipe.read_to_end(&mut buf);
}
buf
});
let stderr_reader = thread::spawn(move || {
let mut buf = Vec::new();
if let Some(pipe) = stderr_pipe.as_mut() {
let _ = pipe.read_to_end(&mut buf);
}
buf
});
let start = Instant::now();
let status = loop {
match child.try_wait()? {
Some(status) => break status,
None => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!(
"Command timed out after {}s: {} {}\n\
To increase the timeout, run: `devp config set command_timeout_secs <seconds>`",
timeout.as_secs(),
program,
args.join(" ")
);
}
thread::sleep(std::time::Duration::from_millis(100));
}
}
};
let stderr = stderr_reader
.join()
.map(|b| String::from_utf8_lossy(&b).into_owned())
.unwrap_or_default();
let stdout = stdout_reader
.join()
.map(|b| String::from_utf8_lossy(&b).into_owned())
.unwrap_or_default();
Ok(CommandOutput {
status,
stdout,
stderr,
})
}
pub fn run_command_with_timeout(
program: &str,
args: &[&str],
cwd: &Path,
timeout: std::time::Duration,
) -> Result<()> {
let out = spawn_capture(program, args, cwd, timeout)?;
if out.status.success() {
Ok(())
} else {
anyhow::bail!(
"{} {} failed (exit code {:?}):\n{}",
program,
args.join(" "),
out.status.code(),
crate::output::condense_tool_output(
&out.stderr,
crate::constants::TOOL_OUTPUT_MAX_LINES
)
)
}
}
pub fn capture_command_with_timeout(
program: &str,
args: &[&str],
cwd: &Path,
timeout: std::time::Duration,
) -> Result<String> {
let out = spawn_capture(program, args, cwd, timeout)?;
if out.status.success() {
Ok(out.stdout)
} else {
anyhow::bail!(
"{} {} failed (exit code {:?}):\n{}",
program,
args.join(" "),
out.status.code(),
crate::output::condense_tool_output(
&out.stderr,
crate::constants::TOOL_OUTPUT_MAX_LINES
)
)
}
}
pub fn try_run_command(program: &str, args: &[&str], cwd: &Path) -> bool {
crate::spawn::command(resolve_program(program))
.args(args)
.current_dir(cwd)
.stdin(std::process::Stdio::null())
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
const MANIFEST_MTIME_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(60);
fn refuse_if_manifest_newer(lockfile: &Path, program: &str, cwd: &Path) -> Result<()> {
let manifest_name = match lockfile.file_name().and_then(|n| n.to_str()) {
Some("Cargo.lock") => "Cargo.toml",
Some("package-lock.json")
| Some("yarn.lock")
| Some("pnpm-lock.yaml")
| Some("bun.lockb")
| Some("bun.lock") => "package.json",
Some("uv.lock") | Some("poetry.lock") | Some("pdm.lock") => "pyproject.toml",
Some("go.sum") => "go.mod",
Some("composer.lock") => "composer.json",
Some("Gemfile.lock") => "Gemfile",
Some("Pipfile.lock") => "Pipfile",
_ => return Ok(()),
};
let manifest = cwd.join(manifest_name);
let (Ok(manifest_meta), Ok(lock_meta)) =
(std::fs::metadata(&manifest), std::fs::metadata(lockfile))
else {
return Ok(());
};
if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
&& manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
{
anyhow::bail!(
"`{program}` is not available, and `{manifest_name}` has been edited more \
recently than `{}` — the lockfile may no longer record the current \
dependencies, and without `{program}` that cannot be verified. Install \
{program} and run its lockfile sync, then prune again.",
lockfile.display()
);
}
Ok(())
}
pub fn refuse_if_manifest_stale(
manifest: &Path,
lockfile: &Path,
sync_command: &str,
) -> Result<()> {
let (Ok(manifest_meta), Ok(lock_meta)) =
(std::fs::metadata(manifest), std::fs::metadata(lockfile))
else {
return Ok(());
};
if let (Ok(manifest_mtime), Ok(lock_mtime)) = (manifest_meta.modified(), lock_meta.modified())
&& manifest_mtime > lock_mtime + MANIFEST_MTIME_TOLERANCE
{
anyhow::bail!(
"`{}` has been edited more recently than `{}` — the lockfile may no longer \
record the current dependencies. Run `{sync_command}` and prune again.",
manifest.display(),
lockfile.display()
);
}
Ok(())
}
pub fn lock_sync_or_verify_with_timeout(
lockfile: &Path,
program: &str,
sync_args: &[&str],
cwd: &Path,
timeout: std::time::Duration,
) -> Result<()> {
let lockfile_exists = lockfile.exists();
if !binary_available(program) {
if lockfile_exists {
refuse_if_manifest_newer(lockfile, program, cwd)?;
return Ok(());
} else {
anyhow::bail!(
"`{program}` is not available and no lockfile was found at `{}`. \
Cannot safely delete dependencies — install {program} first, \
or commit a lockfile.",
lockfile.display()
);
}
}
run_command_with_timeout(program, sync_args, cwd, timeout)
}
#[derive(Debug, Clone, Copy)]
pub struct EnforcePolicy {
pub allow_rewrite: bool,
pub timeout: std::time::Duration,
}
impl Default for EnforcePolicy {
fn default() -> Self {
Self {
allow_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
timeout: std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
}
}
}
impl EnforcePolicy {
pub fn from_settings(settings: &crate::config::Settings) -> Self {
Self {
allow_rewrite: settings.allow_manifest_rewrite,
timeout: std::time::Duration::from_secs(settings.command_timeout_secs),
}
}
}
pub fn enforce_two_tier(
lockfile: &Path,
program: &str,
verify_args: &[&str],
write_args: &[&str],
cwd: &Path,
policy: EnforcePolicy,
) -> Result<()> {
if policy.allow_rewrite {
return lock_sync_or_verify_with_timeout(
lockfile,
program,
write_args,
cwd,
policy.timeout,
);
}
lock_verify_or_generate(
lockfile,
program,
verify_args,
write_args,
cwd,
policy.timeout,
)
}
pub fn lock_verify_or_generate(
lockfile: &Path,
program: &str,
verify_args: &[&str],
generate_args: &[&str],
cwd: &Path,
timeout: std::time::Duration,
) -> Result<()> {
let lockfile_exists = lockfile.exists();
if !binary_available(program) {
if lockfile_exists {
refuse_if_manifest_newer(lockfile, program, cwd)?;
return Ok(());
}
anyhow::bail!(
"`{program}` is not available and no lockfile was found at `{}`. \
Cannot safely delete dependencies — install {program} first, \
or commit a lockfile.",
lockfile.display()
);
}
if lockfile_exists {
run_command_with_timeout(program, verify_args, cwd, timeout)
} else {
run_command_with_timeout(program, generate_args, cwd, timeout)
}
}
pub fn lock_sync_or_verify(
lockfile: &Path,
program: &str,
sync_args: &[&str],
cwd: &Path,
) -> Result<()> {
lock_sync_or_verify_with_timeout(
lockfile,
program,
sync_args,
cwd,
std::time::Duration::from_secs(crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS),
)
}
const PYVENV_CFG: &str = "pyvenv.cfg";
pub(crate) fn venv_runtime_tag(venv: &Path) -> Option<String> {
let cfg = std::fs::read_to_string(venv.join(PYVENV_CFG)).ok()?;
for line in cfg.lines() {
let Some((key, value)) = line.split_once('=') else {
continue;
};
if matches!(key.trim(), "version" | "version_info") {
let mut parts = value.trim().split('.');
let major: u64 = parts.next()?.parse().ok()?;
let minor: u64 = parts.next()?.parse().ok()?;
return Some(format!("{major}.{minor}"));
}
}
None
}
pub(crate) fn is_valid_runtime_tag(tag: &str) -> bool {
let mut parts = tag.split('.');
let (Some(major), Some(minor), None) = (parts.next(), parts.next(), parts.next()) else {
return false;
};
!major.is_empty()
&& !minor.is_empty()
&& major.len() <= 2
&& minor.len() <= 3
&& major.bytes().all(|b| b.is_ascii_digit())
&& minor.bytes().all(|b| b.is_ascii_digit())
}
pub(crate) fn python_launcher(tag: &str) -> Option<(String, Vec<String>)> {
if !is_valid_runtime_tag(tag) {
return None;
}
#[cfg(windows)]
{
Some(("py".to_string(), vec![format!("-{tag}")]))
}
#[cfg(not(windows))]
{
Some((format!("python{tag}"), Vec::new()))
}
}
pub(crate) fn python_executable(tag: &str) -> Option<String> {
let (program, prefix) = python_launcher(tag)?;
let out = crate::spawn::command(resolve_program(&program))
.args(&prefix)
.args(["-c", "import sys; print(sys.executable)"])
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!path.is_empty()).then_some(path)
}
pub(crate) fn python_runtime_available(tag: &str) -> bool {
let Some((program, prefix)) = python_launcher(tag) else {
return false;
};
crate::spawn::command(resolve_program(&program))
.args(&prefix)
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
const NO_RESTORE_BINARY: [&str; 5] = ["venv", "gradle", "maven", "mix_build", "swift"];
const ADAPTER_BINARIES: [(&str, &str); 2] = [("bundler", "bundle"), ("cocoapods", "pod")];
pub fn adapter_binary(adapter: &str) -> &str {
ADAPTER_BINARIES
.iter()
.find(|(name, _)| *name == adapter)
.map_or(adapter, |(_, binary)| *binary)
}
const INSTALL_HINTS: [(&str, &str); 16] = [
("npm", "ships with Node.js — https://nodejs.org"),
(
"pnpm",
"`npm install -g pnpm` — https://pnpm.io/installation",
),
(
"yarn",
"`corepack enable` — https://yarnpkg.com/getting-started/install",
),
("bun", "https://bun.sh/docs/installation"),
(
"uv",
"https://docs.astral.sh/uv/getting-started/installation/",
),
("poetry", "https://python-poetry.org/docs/#installation"),
(
"pdm",
"`uv tool install pdm` — https://pdm-project.org/en/latest/#installation",
),
(
"pipenv",
"`uv tool install pipenv` — https://pipenv.pypa.io/en/latest/installation.html",
),
("cargo", "ships with Rust — https://rustup.rs"),
("go", "https://go.dev/dl/"),
("composer", "https://getcomposer.org/download/"),
("bundler", "`gem install bundler` — https://bundler.io"),
(
"cocoapods",
"`gem install cocoapods` — https://cocoapods.org",
),
(
"mix",
"ships with Elixir — https://elixir-lang.org/install.html",
),
(
"terraform",
"https://developer.hashicorp.com/terraform/install",
),
(
"dart",
"https://dart.dev/get-dart — or the Flutter SDK, which bundles it",
),
];
pub fn install_hint(adapter: &str) -> Option<&'static str> {
INSTALL_HINTS
.iter()
.find(|(name, _)| *name == adapter)
.map(|(_, hint)| *hint)
}
#[derive(Debug, Clone)]
pub struct BinaryCheckStatus {
pub name: String,
pub available: bool,
pub version: Option<String>,
}
pub fn scan_required_binaries(adapter_names: &[String]) -> Vec<BinaryCheckStatus> {
let mut unique: Vec<String> = adapter_names
.iter()
.filter(|&n| !NO_RESTORE_BINARY.contains(&n.as_str()) && n != "-")
.cloned()
.collect();
unique.sort();
unique.dedup();
unique
.into_iter()
.map(|name| {
let binary = adapter_binary(&name);
let output = crate::spawn::command(resolve_program(binary))
.args(version_probe_args(binary))
.stdin(std::process::Stdio::null())
.output();
match output {
Ok(out) if out.status.success() => {
let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
let first_line = ver.lines().next().unwrap_or(&ver).to_string();
BinaryCheckStatus {
name,
available: true,
version: if first_line.is_empty() {
None
} else {
Some(first_line)
},
}
}
_ => BinaryCheckStatus {
name,
available: false,
version: None,
},
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn every_adapter_is_grouped_exactly_once() {
let registered = all_adapter_names();
let grouped: Vec<&str> = ADAPTER_GROUPS
.iter()
.flat_map(|(_, names)| names.iter().copied())
.collect();
for name in ®istered {
assert_eq!(
grouped.iter().filter(|g| *g == name).count(),
1,
"`{name}` must appear in exactly one ADAPTER_GROUPS entry"
);
}
for name in &grouped {
assert!(
registered.contains(name),
"ADAPTER_GROUPS names `{name}`, which is not a registered adapter"
);
}
assert_eq!(registered.len(), grouped.len());
}
#[test]
fn the_opt_in_adapters_are_the_ones_that_hold_compiler_output() {
let mut opt_in = opt_in_adapter_names();
opt_in.sort_unstable();
assert_eq!(
opt_in,
vec!["cargo", "dart", "gradle", "maven", "mix_build", "swift"]
);
}
#[test]
fn go_is_probed_with_the_subcommand_it_actually_accepts() {
assert_eq!(version_probe_args("go"), &["version"]);
assert_eq!(version_probe_args("npm"), &["--version"]);
}
#[test]
fn every_probed_adapter_binary_has_somewhere_to_get_it() {
for adapter in get_all_adapters() {
let name = adapter.name();
if NO_RESTORE_BINARY.contains(&name) {
continue;
}
assert!(
install_hint(name).is_some(),
"adapter `{name}` has no install hint"
);
}
}
#[test]
fn test_bloat_dir_display() {
let bd = BloatDir {
name: "node_modules".to_string(),
path: PathBuf::from("/test/node_modules"),
size_bytes: 1024,
shared_bytes: 0,
};
assert!(bd.to_string().contains("node_modules"));
}
#[test]
fn test_hardlink_size_counts_a_plain_file_in_full() {
let tmp = TempDir::new().unwrap();
let tree = tmp.path().join("tree");
fs::create_dir(&tree).unwrap();
fs::write(tree.join("copied.txt"), "12345").unwrap();
let size = dir_size_with_hardlinks(&tree);
assert_eq!(size.freed_bytes, 5);
assert_eq!(size.shared_bytes, 0);
}
#[test]
fn test_hardlink_size_excludes_a_file_the_store_keeps() {
let tmp = TempDir::new().unwrap();
let store = tmp.path().join("store");
let tree = tmp.path().join("tree");
fs::create_dir(&store).unwrap();
fs::create_dir(&tree).unwrap();
fs::write(store.join("pkg.js"), "0123456789").unwrap();
fs::hard_link(store.join("pkg.js"), tree.join("pkg.js")).unwrap();
let size = dir_size_with_hardlinks(&tree);
assert_eq!(size.freed_bytes, 0);
assert_eq!(size.shared_bytes, 10);
}
#[test]
fn test_hardlink_size_counts_an_internal_pair_once() {
let tmp = TempDir::new().unwrap();
let tree = tmp.path().join("tree");
fs::create_dir(&tree).unwrap();
fs::write(tree.join("a.js"), "abcdefg").unwrap();
fs::hard_link(tree.join("a.js"), tree.join("b.js")).unwrap();
let size = dir_size_with_hardlinks(&tree);
assert_eq!(size.freed_bytes, 7);
assert_eq!(size.shared_bytes, 0);
}
#[test]
fn test_dir_size_empty() {
let tmp = TempDir::new().unwrap();
assert_eq!(dir_size(tmp.path()), 0);
}
#[test]
fn test_dir_size_with_files() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("file1.txt"), "hello").unwrap();
fs::write(tmp.path().join("file2.txt"), "world!").unwrap();
assert_eq!(dir_size(tmp.path()), 11); }
#[test]
fn test_dir_size_nonexistent() {
assert_eq!(dir_size(Path::new("/nonexistent/path")), 0);
}
#[test]
fn test_get_all_adapters_not_empty() {
let adapters = get_all_adapters();
assert!(adapters.len() >= 6);
}
#[test]
fn test_detect_adapters_npm() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("package.json"), "{}").unwrap();
fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
let adapters = detect_adapters(tmp.path());
let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
assert!(names.contains(&"npm"));
}
#[test]
fn test_detect_adapters_empty_dir() {
let tmp = TempDir::new().unwrap();
let adapters = detect_adapters(tmp.path());
assert!(adapters.is_empty());
}
fn detected_names(dir: &Path) -> Vec<&'static str> {
let mut names: Vec<&'static str> = detect_adapters_with(dir, &[], &[])
.iter()
.map(|a| a.name())
.collect();
names.sort_unstable();
names
}
#[test]
fn test_detect_adapters_multiple_ecosystems_coexist() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("package.json"), "{}").unwrap();
fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
fs::write(tmp.path().join("uv.lock"), "").unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
fs::write(tmp.path().join("go.mod"), "module x").unwrap();
assert_eq!(detected_names(tmp.path()), vec!["go", "npm", "uv"]);
}
#[test]
fn test_detect_adapters_opt_in_appears_once_enabled() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
let on = [String::from("cargo")];
let mut names: Vec<&str> = detect_adapters_with(tmp.path(), &on, &[])
.iter()
.map(|a| a.name())
.collect();
names.sort_unstable();
assert_eq!(names, vec!["cargo"]);
}
#[test]
fn test_detect_adapters_disabled_adapter_is_invisible() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("go.mod"), "module x").unwrap();
let off = [String::from("go")];
assert!(detect_adapters_with(tmp.path(), &[], &off).is_empty());
}
#[test]
fn test_js_conflict_resolved_by_package_manager_field() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("package.json"),
r#"{"packageManager":"yarn@4.1.0"}"#,
)
.unwrap();
fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
fs::write(tmp.path().join("yarn.lock"), "").unwrap();
assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
}
#[test]
fn test_js_conflict_resolved_by_what_installed_node_modules() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("package.json"), "{}").unwrap();
fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
fs::create_dir_all(tmp.path().join("node_modules/.pnpm")).unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
}
#[test]
fn test_js_conflict_prefers_yarn_state_over_leftover_npm_bookkeeping() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("package.json"), "{}").unwrap();
fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
fs::write(tmp.path().join("yarn.lock"), "").unwrap();
let nm = tmp.path().join("node_modules");
fs::create_dir_all(&nm).unwrap();
fs::write(nm.join(".package-lock.json"), "{}").unwrap();
fs::write(nm.join(".yarn-state.yml"), "").unwrap();
assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
}
#[test]
fn test_declared_package_manager_outranks_what_is_installed() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("package.json"),
r#"{"packageManager":"pnpm@9.1.0"}"#,
)
.unwrap();
fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
let nm = tmp.path().join("node_modules");
fs::create_dir_all(&nm).unwrap();
fs::write(nm.join(".package-lock.json"), "{}").unwrap();
assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
}
#[test]
fn test_uv_takes_precedence_over_plain_venv() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("pyproject.toml"),
"[project]\nname = \"x\"\n\n[tool.uv]\n",
)
.unwrap();
fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
let venv = tmp.path().join(".venv");
fs::create_dir_all(&venv).unwrap();
fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
assert_eq!(detected_names(tmp.path()), vec!["uv"]);
}
#[test]
fn test_plain_venv_handles_projects_uv_does_not_claim() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("requirements.txt"), "requests\n").unwrap();
let venv = tmp.path().join("venv");
fs::create_dir_all(&venv).unwrap();
fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
assert_eq!(detected_names(tmp.path()), vec!["venv"]);
}
#[test]
fn test_js_conflict_falls_back_to_newest_lockfile() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("package.json"), "{}").unwrap();
fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
}
#[test]
fn test_js_conflict_ignores_an_unrecognised_package_manager_field() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("package.json"),
r#"{"packageManager":"deno@2.0.0"}"#,
)
.unwrap();
fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
fs::write(tmp.path().join("yarn.lock"), "").unwrap();
assert_eq!(detected_names(tmp.path()), vec!["yarn"]);
}
#[test]
fn test_js_conflict_does_not_disturb_a_single_manager() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("package.json"), "{}").unwrap();
fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
assert_eq!(detected_names(tmp.path()), vec!["pnpm"]);
}
#[test]
fn test_js_adapters_declare_their_lockfiles() {
for adapter in get_all_adapters() {
if JS_MANAGERS.contains(&adapter.name()) {
assert!(
!adapter.lockfiles().is_empty(),
"{} shares node_modules and must declare its lockfiles for \
conflict resolution",
adapter.name()
);
}
}
}
#[test]
fn test_adapter_names_unique() {
let adapters = get_all_adapters();
let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
let mut unique = names.clone();
unique.sort();
unique.dedup();
assert_eq!(names.len(), unique.len(), "Adapter names must be unique");
}
#[test]
fn a_runtime_tag_is_a_version_number_and_nothing_else() {
assert!(is_valid_runtime_tag("3.12"));
assert!(is_valid_runtime_tag("3.9"));
for bad in [
"",
"3",
"3.12.1",
"3.x",
"3.12; rm -rf /",
"-3.12",
"../python",
"3.1234",
"300.1",
] {
assert!(!is_valid_runtime_tag(bad), "{bad} must be refused");
}
}
#[test]
fn the_interpreter_is_read_from_the_environments_own_pyvenv_cfg() {
let tmp = tempfile::tempdir().unwrap();
let venv = tmp.path().join(".venv");
std::fs::create_dir_all(&venv).unwrap();
std::fs::write(
venv.join("pyvenv.cfg"),
"home = /usr/bin\nversion = 3.12.4\ninclude-system-site-packages = false\n",
)
.unwrap();
assert_eq!(venv_runtime_tag(&venv), Some("3.12".to_string()));
}
#[test]
fn a_directory_that_is_not_an_environment_records_no_interpreter() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(venv_runtime_tag(tmp.path()), None);
}
}