use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
fn devp() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_dev-prune"));
cmd.env("DEV_PRUNE_NO_AUTO_SETUP", "1");
cmd.env("DEV_PRUNE_OFFLINE", "1");
cmd.env("DEV_PRUNE_CONFIG_DIR", scratch_config_dir());
cmd
}
fn scratch_config_dir() -> PathBuf {
let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("monorepo-scratch-config");
std::fs::create_dir_all(&dir).expect("create scratch config dir");
dir
}
fn git(path: &Path) -> Command {
let mut cmd = Command::new("git");
cmd.current_dir(path)
.env("GIT_CONFIG_GLOBAL", path.join("no-such-gitconfig"))
.env("GIT_CONFIG_SYSTEM", path.join("no-such-gitconfig"));
cmd
}
fn git_repo(path: &Path) {
fs::create_dir_all(path).unwrap();
git(path).args(["init"]).output().unwrap();
fs::write(
path.join("README.md"),
format!("# {}", path.file_name().unwrap().to_string_lossy()),
)
.unwrap();
git(path).args(["add", "."]).output().unwrap();
git(path)
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@test.com",
"commit",
"-m",
"initial",
])
.output()
.unwrap();
}
fn write(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
fn npm_project(dir: &Path) {
write(&dir.join("package.json"), "{}");
write(&dir.join("package-lock.json"), "{}");
write(
&dir.join("node_modules/left-pad/index.js"),
"module.exports=1",
);
}
fn cargo_project(dir: &Path) {
write(
&dir.join("Cargo.toml"),
"[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
);
write(&dir.join("target/debug/artifact"), "binary");
}
fn venv_project(dir: &Path) {
write(&dir.join("requirements.txt"), "requests==2.32.3\n");
write(&dir.join(".venv/pyvenv.cfg"), "home = /usr\n");
write(&dir.join(".venv/lib/site.py"), "payload");
}
fn enable_cargo(config_dir: &Path) {
let out = devp()
.env("DEV_PRUNE_CONFIG_DIR", config_dir)
.args(["config", "set", "enable_cargo", "true"])
.output()
.expect("failed to run dev-prune config set");
assert!(
out.status.success(),
"could not enable cargo: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn dry_run(config_dir: &Path, repo: &Path) -> String {
let out = devp()
.env("DEV_PRUNE_CONFIG_DIR", config_dir)
.args(["--dry-run", "--force", "-y", "run", repo.to_str().unwrap()])
.output()
.expect("failed to run dev-prune");
assert!(
out.status.success(),
"dev-prune exited {:?}\n{}\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
#[test]
fn reports_three_ecosystems_sharing_one_root() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("all-in-root");
git_repo(&repo);
npm_project(&repo);
cargo_project(&repo);
venv_project(&repo);
enable_cargo(&tmp.path().join("config"));
let output = dry_run(&tmp.path().join("config"), &repo);
assert!(output.contains("node_modules"), "{output}");
assert!(output.contains("target"), "{output}");
assert!(output.contains(".venv"), "{output}");
}
#[test]
fn reports_three_ecosystems_at_three_different_depths() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("monorepo");
git_repo(&repo);
npm_project(&repo.join("frontend"));
venv_project(&repo.join("services/api"));
cargo_project(&repo.join("tools/cli"));
enable_cargo(&tmp.path().join("config"));
let output = dry_run(&tmp.path().join("config"), &repo);
assert!(output.contains("frontend/node_modules"), "{output}");
assert!(output.contains("services/api/.venv"), "{output}");
assert!(output.contains("tools/cli/target"), "{output}");
}
#[test]
fn reports_a_root_project_alongside_nested_ones() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("mixed");
git_repo(&repo);
cargo_project(&repo);
npm_project(&repo.join("web"));
enable_cargo(&tmp.path().join("config"));
let output = dry_run(&tmp.path().join("config"), &repo);
assert!(output.contains("web/node_modules"), "{output}");
assert!(
output.lines().any(|l| l.contains("→ target ")),
"root target missing from:\n{output}"
);
}
#[test]
fn never_reports_dependencies_of_dependencies() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("nested-deps");
git_repo(&repo);
npm_project(&repo);
npm_project(&repo.join("node_modules/some-dep"));
let output = dry_run(&tmp.path().join("config"), &repo);
assert!(
!output.contains("node_modules/some-dep"),
"walked into node_modules:\n{output}"
);
}
#[test]
fn prunes_every_nested_project_in_one_pass() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("py-monorepo");
git_repo(&repo);
venv_project(&repo.join("apps/worker"));
venv_project(&repo.join("libs/shared"));
let out = devp()
.env("DEV_PRUNE_CONFIG_DIR", tmp.path().join("config"))
.args(["--force", "-y", "run", repo.to_str().unwrap()])
.output()
.expect("failed to run dev-prune");
assert!(
out.status.success(),
"dev-prune exited {:?}\n{}\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(!repo.join("apps/worker/.venv").exists());
assert!(!repo.join("libs/shared/.venv").exists());
assert!(repo.join("apps/worker/requirements.txt").exists());
assert!(repo.join("libs/shared/requirements.txt").exists());
}
#[test]
fn refuses_to_prune_a_nested_project_with_an_empty_requirements_file() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("unrecoverable");
git_repo(&repo);
venv_project(&repo.join("apps/worker"));
write(&repo.join("apps/worker/requirements.txt"), "# empty\n");
let out = devp()
.env("DEV_PRUNE_CONFIG_DIR", tmp.path().join("config"))
.args(["--force", "-y", "run", repo.to_str().unwrap()])
.output()
.expect("failed to run dev-prune");
assert!(repo.join("apps/worker/.venv").exists());
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(combined.contains("lists no packages"), "{combined}");
}
#[test]
fn restore_covers_every_nested_project() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("restore-monorepo");
git_repo(&repo);
npm_project(&repo.join("frontend"));
cargo_project(&repo.join("tools/cli"));
enable_cargo(&tmp.path().join("config"));
let out = devp()
.env("DEV_PRUNE_CONFIG_DIR", tmp.path().join("config"))
.args(["restore", repo.to_str().unwrap()])
.output()
.expect("failed to run dev-prune restore");
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(combined.contains("frontend"), "{combined}");
assert!(combined.contains("tools/cli"), "{combined}");
}