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("integration-scratch-config");
std::fs::create_dir_all(&dir).expect("create scratch config dir");
dir
}
#[test]
fn test_cli_help() {
let output = devp()
.arg("--help")
.output()
.expect("Failed to execute dev-prune --help");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Universal, lockfile-safe workspace pruner"));
}
#[test]
fn test_cli_version_audit() {
let output = devp()
.arg("-V")
.output()
.expect("Failed to execute dev-prune -V");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("dev-prune (devp) v"));
assert!(stdout.contains("Target OS:"));
assert!(stdout.contains("PATH Audit:"));
}
#[test]
fn test_cli_init_and_status() {
let base = Path::new(env!("CARGO_MANIFEST_DIR")).join("target/init-fixtures");
fs::create_dir_all(&base).unwrap();
let tmp = TempDir::new_in(&base).unwrap();
let repo_dir = tmp.path().join("my-test-repo");
fs::create_dir_all(&repo_dir).unwrap();
Command::new("git")
.args(["init"])
.current_dir(&repo_dir)
.output()
.unwrap();
let output = devp()
.env("DEV_PRUNE_CONFIG_DIR", tmp.path().join("config"))
.args(["--dry-run", "init", repo_dir.to_str().unwrap()])
.output()
.expect("Failed to run init");
if !output.status.success() {
panic!(
"CLI failed with exit code {:?}\nstdout: {}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Would register:"), "{stdout}");
assert!(stdout.contains("Nothing was written"), "{stdout}");
assert!(
!tmp.path().join("config").join("registry.json").exists(),
"a dry run must not create the registry"
);
let real = devp()
.env("DEV_PRUNE_CONFIG_DIR", tmp.path().join("config"))
.env("DEV_PRUNE_NO_AUTO_SETUP", "1")
.args(["init", repo_dir.to_str().unwrap()])
.output()
.expect("Failed to run init");
assert!(real.status.success());
let stdout = String::from_utf8_lossy(&real.stdout);
assert!(stdout.contains("Registered:"), "{stdout}");
assert!(tmp.path().join("config").join("registry.json").exists());
}
#[test]
fn test_cli_link_unlink_undo() {
let tmp = TempDir::new().unwrap();
let repo_dir = tmp.path().join("link-repo");
fs::create_dir_all(&repo_dir).unwrap();
Command::new("git")
.args(["init"])
.current_dir(&repo_dir)
.output()
.unwrap();
let config_dir = tmp.path().join("config");
let link_out = devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["link", repo_dir.to_str().unwrap()])
.output()
.expect("Failed link");
assert!(link_out.status.success());
let stdout = String::from_utf8_lossy(&link_out.stdout);
assert!(stdout.contains("Linked:"));
let undo_out = devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["undo"])
.output()
.expect("Failed undo");
assert!(undo_out.status.success());
let stdout_undo = String::from_utf8_lossy(&undo_out.stdout);
assert!(
stdout_undo.contains("Unregistered 1 repository."),
"{stdout_undo}"
);
devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["link", repo_dir.to_str().unwrap()])
.output()
.unwrap();
let unlink_out = devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["unlink", repo_dir.to_str().unwrap()])
.output()
.expect("Failed unlink");
assert!(unlink_out.status.success());
let stdout_unlink = String::from_utf8_lossy(&unlink_out.stdout);
assert!(stdout_unlink.contains("Unlinked:"));
}
#[test]
fn test_cli_config_get_set_show() {
let tmp = TempDir::new().unwrap();
let config_dir = tmp.path().join("config");
let get_out = devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["config", "get", "idle_days"])
.output()
.unwrap();
assert!(get_out.status.success());
assert!(String::from_utf8_lossy(&get_out.stdout).contains("idle_days"));
let set_out = devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["config", "set", "idle_days", "30"])
.output()
.unwrap();
assert!(set_out.status.success());
assert!(String::from_utf8_lossy(&set_out.stdout).contains("idle_days = 30"));
let show_out = devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["config", "show"])
.output()
.unwrap();
assert!(show_out.status.success());
assert!(String::from_utf8_lossy(&show_out.stdout).contains("Global Configuration"));
}
#[test]
fn test_cli_run_dry_run_across_ecosystems() {
let tmp = TempDir::new().unwrap();
let config_dir = tmp.path().join("config");
let npm_repo = tmp.path().join("npm-repo");
fs::create_dir_all(&npm_repo).unwrap();
Command::new("git")
.args(["init"])
.current_dir(&npm_repo)
.output()
.unwrap();
fs::write(npm_repo.join("package.json"), "{}").unwrap();
fs::write(npm_repo.join("package-lock.json"), "{}").unwrap();
let node_modules = npm_repo.join("node_modules");
fs::create_dir(&node_modules).unwrap();
fs::write(node_modules.join("dummy.js"), "content").unwrap();
devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["link", npm_repo.to_str().unwrap()])
.output()
.unwrap();
let run_out = devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["--dry-run", "--force", "-y", "run"])
.output()
.unwrap();
assert!(run_out.status.success());
let stdout = String::from_utf8_lossy(&run_out.stdout);
assert!(stdout.contains("DRY RUN"));
}
#[test]
fn test_cli_restore_no_lockfiles() {
let tmp = TempDir::new().unwrap();
let config_dir = tmp.path().join("config");
let repo = tmp.path().join("empty-repo");
fs::create_dir_all(&repo).unwrap();
Command::new("git")
.args(["init"])
.current_dir(&repo)
.output()
.unwrap();
let restore_out = devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["restore", repo.to_str().unwrap()])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&restore_out.stdout);
let stderr = String::from_utf8_lossy(&restore_out.stderr);
let combined = format!("{stdout}\n{stderr}");
assert!(
combined.contains("dev-prune restore")
|| combined.contains("No matching package manager lockfiles")
);
}
#[test]
fn test_bare_hook_subcommand_routes_to_config_hook() {
for args in [vec!["hook"], vec!["config", "hook"], vec!["status", "hook"]] {
let output = devp()
.args(&args)
.output()
.unwrap_or_else(|e| panic!("failed to run {args:?}: {e}"));
assert!(output.status.success(), "{args:?} exited non-zero");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("dev-prune Git Hooks Status"),
"{args:?} did not reach the hook status handler:\n{stdout}"
);
}
}
#[test]
fn test_bare_daemon_subcommand_routes_to_config_daemon() {
for args in [vec!["daemon"], vec!["config", "daemon"]] {
let output = devp()
.args(&args)
.output()
.unwrap_or_else(|e| panic!("failed to run {args:?}: {e}"));
assert!(output.status.success(), "{args:?} exited non-zero");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("dev-prune daemon status"),
"{args:?} did not reach the daemon status handler:\n{stdout}"
);
}
}
#[test]
fn test_setup_status_reports_without_installing() {
let tmp = TempDir::new().unwrap();
let config_dir = tmp.path().join("config");
let output = devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["setup", "--status"])
.output()
.expect("Failed to run setup --status");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
for row in [
"devp alias:",
"SKILL.md:",
"Git hooks:",
"Background scheduler:",
"auto_setup",
] {
assert!(stdout.contains(row), "missing `{row}` in:\n{stdout}");
}
assert!(
!config_dir.join("SKILL.md").exists(),
"`setup --status` must not write anything"
);
}
#[test]
fn test_auto_setup_opt_out_installs_nothing() {
let tmp = TempDir::new().unwrap();
let config_dir = tmp.path().join("config");
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo).unwrap();
Command::new("git")
.args(["init"])
.current_dir(&repo)
.output()
.unwrap();
let output = devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["init", repo.to_str().unwrap()])
.output()
.expect("Failed to run init");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("Integrations"),
"init installed integrations despite the opt-out:\n{stdout}"
);
assert!(
!config_dir.join("SKILL.md").exists(),
"the opt-out must cover the SKILL.md export too"
);
assert!(config_dir.join("registry.json").exists());
}
#[test]
fn test_a_repo_with_a_broken_config_is_reported_and_fails_the_run() {
let tmp = TempDir::new().unwrap();
let config_dir = tmp.path().join("config");
let repo = tmp.path().join("broken-repo");
fs::create_dir_all(&repo).unwrap();
Command::new("git")
.args(["init"])
.current_dir(&repo)
.output()
.unwrap();
let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
fs::write(repo.join(".devprune.json"), broken).unwrap();
devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["link", repo.to_str().unwrap()])
.output()
.unwrap();
let out = devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["--force", "-y", "run"])
.output()
.expect("Failed to run");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(!out.status.success(), "a skipped repo must fail the run");
assert!(
!stdout.contains("No idle repositories or pruneable bloat directories found."),
"the run claimed there was nothing to do:\n{stdout}"
);
assert!(stdout.contains("Could Not Be Examined"), "{stdout}");
assert!(stdout.contains("--update"), "{stdout}");
assert_eq!(
fs::read_to_string(repo.join(".devprune.json")).unwrap(),
broken
);
}
#[test]
fn test_a_broken_config_is_a_config_error_in_the_json_document() {
let tmp = TempDir::new().unwrap();
let config_dir = tmp.path().join("config");
let repo = tmp.path().join("broken-repo");
fs::create_dir_all(&repo).unwrap();
Command::new("git")
.args(["init"])
.current_dir(&repo)
.output()
.unwrap();
fs::write(repo.join(".devprune.json"), "{ not json").unwrap();
devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["link", repo.to_str().unwrap()])
.output()
.unwrap();
let out = devp()
.env("DEV_PRUNE_CONFIG_DIR", &config_dir)
.args(["--force", "-y", "run", "--json"])
.output()
.expect("Failed to run");
assert!(!out.status.success());
let doc: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("stdout was not one JSON document");
assert_eq!(doc["results"][0]["status"], "config_error");
assert!(
doc["results"][0]["message"]
.as_str()
.unwrap()
.contains("Syntax error")
);
assert_eq!(doc["summary"]["errors"], 1);
}
#[test]
fn test_mistyped_toggle_action_is_rejected() {
let output = devp()
.args(["config", "daemon", "enabel"])
.output()
.expect("Failed to execute dev-prune");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("enabel"), "{stderr}");
}