use sha2::{Digest, Sha256};
use std::{
collections::BTreeSet,
fs,
path::{Path, PathBuf},
process::Command,
sync::atomic::{AtomicU64, Ordering},
};
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
struct TestDir(PathBuf);
impl TestDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"projjan-cli-test-{}-{}",
std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).unwrap();
Self(path)
}
fn project(&self) -> PathBuf {
let path = self.0.join("demo");
fs::create_dir_all(&path).unwrap();
fs::write(path.join("pyproject.toml"), "[project]\n").unwrap();
path
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn dry_run(project: &Path, env_dest: &Path, cli_dest: Option<&Path>) -> String {
let mut command = Command::new(env!("CARGO_BIN_EXE_projjan"));
command
.arg("backup")
.arg("--dry-run")
.arg(project)
.env("BACKUP_DEST", env_dest);
if let Some(dest) = cli_dest {
command.arg("--backup-dest").arg(dest);
}
let output = command.output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
}
fn destination_marker(dest: &Path) -> String {
format!("{}/demo", dest.file_name().unwrap().to_string_lossy())
}
fn normalized_output(output: &str) -> String {
output.replace('\\', "/")
}
fn git(path: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_owned()
}
fn git_file_url(path: &Path) -> String {
url::Url::from_file_path(path).unwrap().to_string()
}
#[test]
fn cli_help_and_version_are_available_without_subcommands() {
for (argument, expected) in [
("--help", "Usage: projjan".to_owned()),
(
"--version",
format!("projjan {}", env!("CARGO_PKG_VERSION")),
),
] {
let output = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg(argument)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stdout).contains(&expected));
assert!(output.stderr.is_empty());
}
}
#[test]
fn backup_help_hides_backup_dest_environment_value() {
let temp = TestDir::new();
let backup_dest = temp.0.join("private-backup-location");
let output = Command::new(env!("CARGO_BIN_EXE_projjan"))
.args(["backup", "--help"])
.env("BACKUP_DEST", &backup_dest)
.output()
.unwrap();
assert!(output.status.success());
let help = String::from_utf8_lossy(&output.stdout);
assert!(help.contains("BACKUP_DEST"));
assert!(!help.contains(backup_dest.to_str().unwrap()));
}
#[test]
fn backup_and_restore_git_lfs_objects_with_git_strategy() {
if cfg!(windows) {
eprintln!("skipping local-file Git LFS integration on Windows");
return;
}
let version = Command::new("git")
.args(["lfs", "version"])
.output()
.unwrap();
if !version.status.success() {
eprintln!("skipping Git LFS integration: git-lfs is unavailable");
return;
}
let temp = TestDir::new();
let project = temp.0.join("lfs-app");
fs::create_dir_all(&project).unwrap();
fs::write(project.join("pyproject.toml"), "[project]\n").unwrap();
fs::write(project.join(".gitignore"), ".husky/_/\n").unwrap();
fs::create_dir_all(project.join(".husky/_")).unwrap();
fs::write(project.join(".husky/pre-commit"), "npm test\n").unwrap();
fs::write(
project.join(".husky/_/husky.sh"),
"# generated hook helper\n",
)
.unwrap();
git(&project, &["init", "-q", "-b", "main"]);
git(&project, &["config", "user.email", "test@example.com"]);
git(&project, &["config", "user.name", "Test User"]);
git(&project, &["lfs", "install", "--local"]);
git(&project, &["lfs", "track", "*.bin"]);
let payload = b"a tracked Git LFS payload\n";
fs::write(project.join("payload.bin"), payload).unwrap();
git(&project, &["add", "."]);
git(&project, &["commit", "-q", "-m", "LFS project"]);
let remote = temp.0.join("lfs-app-remote.git");
let output = Command::new("git")
.args(["init", "-q", "--bare"])
.arg(&remote)
.output()
.unwrap();
assert!(output.status.success());
git(
&project,
&["remote", "add", "origin", remote.to_str().unwrap()],
);
git(&project, &["push", "-q", "-u", "origin", "main"]);
git(
&project,
&[
"remote",
"set-url",
"origin",
"https://github.com/acme/lfs-app.git",
],
);
let backup_dest = temp.0.join("backups");
let backup = Command::new(env!("CARGO_BIN_EXE_projjan"))
.args(["backup"])
.arg(&project)
.arg("--backup-dest")
.arg(&backup_dest)
.output()
.unwrap();
assert!(
backup.status.success(),
"{}",
String::from_utf8_lossy(&backup.stderr)
);
let manifest = fs::read_dir(backup_dest.join("lfs-app"))
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.extension()
.is_some_and(|extension| extension == "json")
})
.unwrap();
let manifest_json: serde_json::Value =
serde_json::from_slice(&fs::read(&manifest).unwrap()).unwrap();
assert_eq!(manifest_json["records"][0]["strategy"], "git");
assert!(manifest_json["records"][0]["archive"].is_null());
let restored_dest = temp.0.join("restored");
let remote_url = git_file_url(&remote);
let restore = Command::new(env!("CARGO_BIN_EXE_projjan"))
.args(["restore"])
.arg(&manifest)
.arg("--dest")
.arg(&restored_dest)
.env("GIT_CONFIG_COUNT", "2")
.env("GIT_CONFIG_KEY_0", format!("url.{remote_url}.insteadOf"))
.env("GIT_CONFIG_VALUE_0", "https://github.com/acme/lfs-app.git")
.env("GIT_CONFIG_KEY_1", "protocol.file.allow")
.env("GIT_CONFIG_VALUE_1", "always")
.output()
.unwrap();
assert!(
restore.status.success(),
"{}",
String::from_utf8_lossy(&restore.stderr)
);
assert_eq!(
fs::read(restored_dest.join("lfs-app/payload.bin")).unwrap(),
payload
);
let object_hash = format!("{:x}", Sha256::digest(payload));
let lfs_object = remote
.join("lfs/objects")
.join(&object_hash[..2])
.join(&object_hash[2..4])
.join(&object_hash);
fs::remove_file(lfs_object).unwrap();
let failed_dest = temp.0.join("missing-lfs-restore");
let failed_restore = Command::new(env!("CARGO_BIN_EXE_projjan"))
.args(["restore"])
.arg(&manifest)
.arg("--dest")
.arg(&failed_dest)
.env("GIT_CONFIG_COUNT", "2")
.env("GIT_CONFIG_KEY_0", format!("url.{remote_url}.insteadOf"))
.env("GIT_CONFIG_VALUE_0", "https://github.com/acme/lfs-app.git")
.env("GIT_CONFIG_KEY_1", "protocol.file.allow")
.env("GIT_CONFIG_VALUE_1", "always")
.output()
.unwrap();
assert!(!failed_restore.status.success());
assert!(!failed_dest.join("lfs-app").exists());
}
#[test]
fn backup_refuses_to_archive_credentialed_submodule_urls() {
let temp = TestDir::new();
let project = temp.0.join("app");
fs::create_dir_all(&project).unwrap();
fs::write(project.join("pyproject.toml"), "[project]\n").unwrap();
fs::write(
project.join(".gitmodules"),
"[submodule \"private\"]\n\tpath = vendor/private\n\turl = ssh://git:ssh-secret@github.com/acme/private.git\n",
)
.unwrap();
git(&project, &["init", "-q", "-b", "main"]);
git(&project, &["config", "user.email", "test@example.com"]);
git(&project, &["config", "user.name", "Test User"]);
git(&project, &["add", "."]);
git(
&project,
&["commit", "-q", "-m", "credentialed submodule URL"],
);
let remote = temp.0.join("app-remote.git");
let output = Command::new("git")
.args(["init", "-q", "--bare"])
.arg(&remote)
.output()
.unwrap();
assert!(output.status.success());
git(
&project,
&["remote", "add", "origin", remote.to_str().unwrap()],
);
git(&project, &["push", "-q", "-u", "origin", "main"]);
git(
&project,
&[
"remote",
"set-url",
"origin",
"https://github.com/acme/app.git",
],
);
let backup_dest = temp.0.join("backups");
let backup = Command::new(env!("CARGO_BIN_EXE_projjan"))
.args(["backup"])
.arg(&project)
.arg("--backup-dest")
.arg(&backup_dest)
.output()
.unwrap();
assert!(
!backup.status.success(),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&backup.stdout),
String::from_utf8_lossy(&backup.stderr)
);
let stderr = String::from_utf8_lossy(&backup.stderr);
assert!(
stderr.contains("submodule URL contains credentials or query data"),
"{stderr}"
);
assert!(!stderr.contains("ssh-secret"));
assert!(!backup_dest.join("app").exists());
let dry_dest = temp.0.join("dry-backups");
let dry_run = Command::new(env!("CARGO_BIN_EXE_projjan"))
.args(["backup", "--dry-run"])
.arg(&project)
.arg("--backup-dest")
.arg(&dry_dest)
.output()
.unwrap();
assert!(!dry_run.status.success());
let stdout = String::from_utf8_lossy(&dry_run.stdout);
assert!(stdout.contains("[error] app: submodule URL contains credentials or query data"));
assert!(!stdout.contains("ssh-secret"));
assert!(!stdout.contains(".tar.gz"));
assert!(!dry_dest.exists());
}
#[test]
fn backup_and_restore_uninitialized_submodule_with_git_strategy() {
let temp = TestDir::new();
let child = temp.0.join("child");
fs::create_dir_all(&child).unwrap();
fs::write(child.join("main.py"), "print('child')\n").unwrap();
git(&child, &["init", "-q", "-b", "main"]);
git(&child, &["config", "user.email", "test@example.com"]);
git(&child, &["config", "user.name", "Test User"]);
git(&child, &["add", "."]);
git(&child, &["commit", "-q", "-m", "child"]);
let child_commit = git(&child, &["rev-parse", "HEAD"]);
let child_remote = temp.0.join("child-remote.git");
let output = Command::new("git")
.args(["clone", "-q", "--bare"])
.arg(&child)
.arg(&child_remote)
.output()
.unwrap();
assert!(output.status.success());
let project = temp.0.join("app");
fs::create_dir_all(&project).unwrap();
fs::write(project.join("pyproject.toml"), "[project]\n").unwrap();
fs::write(
project.join(".gitmodules"),
"[submodule \"vendor/child\"]\n\tpath = vendor/child\n\turl = https://github.com/acme/child.git\n",
)
.unwrap();
git(&project, &["init", "-q", "-b", "main"]);
git(&project, &["config", "user.email", "test@example.com"]);
git(&project, &["config", "user.name", "Test User"]);
git(&project, &["add", "."]);
git(&project, &["commit", "-q", "-m", "parent"]);
git(
&project,
&[
"update-index",
"--add",
"--cacheinfo",
&format!("160000,{child_commit},vendor/child"),
],
);
git(&project, &["commit", "-q", "-m", "add submodule"]);
fs::create_dir_all(project.join("vendor/child")).unwrap();
let local_submodule_data = project.join("vendor/child/local-data.txt");
fs::write(&local_submodule_data, "keep me\n").unwrap();
let parent_remote = temp.0.join("app-remote.git");
let output = Command::new("git")
.args(["init", "-q", "--bare"])
.arg(&parent_remote)
.output()
.unwrap();
assert!(output.status.success());
git(
&project,
&["remote", "add", "origin", parent_remote.to_str().unwrap()],
);
git(&project, &["push", "-q", "-u", "origin", "main"]);
git(
&project,
&[
"remote",
"set-url",
"origin",
"https://github.com/acme/app.git",
],
);
let stray_backup_dest = temp.0.join("stray-backups");
let stray_backup = Command::new(env!("CARGO_BIN_EXE_projjan"))
.args(["backup"])
.arg(&project)
.arg("--backup-dest")
.arg(&stray_backup_dest)
.output()
.unwrap();
assert!(stray_backup.status.success());
let stray_manifest = fs::read_dir(stray_backup_dest.join("app"))
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.extension()
.is_some_and(|extension| extension == "json")
})
.unwrap();
let stray_json: serde_json::Value =
serde_json::from_slice(&fs::read(stray_manifest).unwrap()).unwrap();
assert_eq!(stray_json["records"][0]["strategy"], "archive");
assert_eq!(
stray_json["records"][0]["fallback_reason"],
"uninitialized submodule path is not empty: vendor/child"
);
fs::remove_file(local_submodule_data).unwrap();
let backup_dest = temp.0.join("backups");
let backup = Command::new(env!("CARGO_BIN_EXE_projjan"))
.args(["backup"])
.arg(&project)
.arg("--backup-dest")
.arg(&backup_dest)
.output()
.unwrap();
assert!(
backup.status.success(),
"{}",
String::from_utf8_lossy(&backup.stderr)
);
let manifest = fs::read_dir(backup_dest.join("app"))
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.extension()
.is_some_and(|extension| extension == "json")
})
.unwrap();
let manifest_json: serde_json::Value =
serde_json::from_slice(&fs::read(&manifest).unwrap()).unwrap();
assert_eq!(manifest_json["records"][0]["strategy"], "git");
assert!(manifest_json["records"][0]["archive"].is_null());
let restored_dest = temp.0.join("restored");
let parent_url = git_file_url(&parent_remote);
let child_url = git_file_url(&child_remote);
let restore = Command::new(env!("CARGO_BIN_EXE_projjan"))
.args(["restore"])
.arg(&manifest)
.arg("--dest")
.arg(&restored_dest)
.env("GIT_CONFIG_COUNT", "3")
.env("GIT_CONFIG_KEY_0", format!("url.{parent_url}.insteadOf"))
.env("GIT_CONFIG_VALUE_0", "https://github.com/acme/app.git")
.env("GIT_CONFIG_KEY_1", format!("url.{child_url}.insteadOf"))
.env("GIT_CONFIG_VALUE_1", "https://github.com/acme/child.git")
.env("GIT_CONFIG_KEY_2", "protocol.file.allow")
.env("GIT_CONFIG_VALUE_2", "always")
.output()
.unwrap();
assert!(
restore.status.success(),
"{}",
String::from_utf8_lossy(&restore.stderr)
);
let restored_child = restored_dest.join("app/vendor/child");
assert_eq!(
fs::read_to_string(restored_child.join("main.py"))
.unwrap()
.replace("\r\n", "\n"),
"print('child')\n"
);
assert_eq!(git(&restored_child, &["rev-parse", "HEAD"]), child_commit);
git(
&restored_dest.join("app"),
&["switch", "--track", "-c", "main", "origin/main"],
);
let initialized_backup_dest = temp.0.join("initialized-backups");
let initialized_backup = Command::new(env!("CARGO_BIN_EXE_projjan"))
.args(["backup"])
.arg(restored_dest.join("app"))
.arg("--backup-dest")
.arg(&initialized_backup_dest)
.output()
.unwrap();
assert!(
initialized_backup.status.success(),
"{}",
String::from_utf8_lossy(&initialized_backup.stderr)
);
let initialized_manifest = fs::read_dir(initialized_backup_dest.join("app"))
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.extension()
.is_some_and(|extension| extension == "json")
})
.unwrap();
let initialized_json: serde_json::Value =
serde_json::from_slice(&fs::read(initialized_manifest).unwrap()).unwrap();
assert_eq!(initialized_json["records"][0]["strategy"], "git");
let failed_dest = temp.0.join("failed-restore");
let failed_restore = Command::new(env!("CARGO_BIN_EXE_projjan"))
.args(["restore"])
.arg(&manifest)
.arg("--dest")
.arg(&failed_dest)
.env("GIT_CONFIG_COUNT", "3")
.env("GIT_CONFIG_KEY_0", format!("url.{parent_url}.insteadOf"))
.env("GIT_CONFIG_VALUE_0", "https://github.com/acme/app.git")
.env(
"GIT_CONFIG_KEY_1",
"url.file:///missing-child.git.insteadOf",
)
.env("GIT_CONFIG_VALUE_1", "https://github.com/acme/child.git")
.env("GIT_CONFIG_KEY_2", "protocol.file.allow")
.env("GIT_CONFIG_VALUE_2", "always")
.output()
.unwrap();
assert!(!failed_restore.status.success());
assert!(!failed_dest.join("app").exists());
}
#[test]
fn clean_can_list_rules_without_roots() {
let output = Command::new(env!("CARGO_BIN_EXE_projjan"))
.args(["clean", "--list-rules"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stdout).contains("node:"));
assert!(output.stderr.is_empty());
}
#[test]
fn clean_filters_plans_by_selected_rules() {
let temp = TestDir::new();
let project = temp.project();
fs::create_dir_all(project.join("node_modules/pkg")).unwrap();
fs::write(project.join("node_modules/pkg/index.js"), "x").unwrap();
fs::create_dir_all(project.join("target/debug")).unwrap();
fs::write(project.join("target/debug/cache"), "x").unwrap();
let selected = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("clean")
.arg(&project)
.arg("--rules")
.arg("node,build")
.arg("--json")
.output()
.unwrap();
assert!(
selected.status.success(),
"{}",
String::from_utf8_lossy(&selected.stderr)
);
let actions: serde_json::Value = serde_json::from_slice(&selected.stdout).unwrap();
let rules: BTreeSet<_> = actions
.as_array()
.unwrap()
.iter()
.map(|action| action["rule"].as_str().unwrap())
.collect();
assert_eq!(rules, BTreeSet::from(["build", "node"]));
assert!(project.join("node_modules").is_dir());
assert!(project.join("target").is_dir());
let rejected = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("clean")
.arg(&project)
.arg("--rules")
.arg("unknown")
.arg("--json")
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("unknown rule: unknown"));
assert!(project.join("node_modules").is_dir());
assert!(project.join("target").is_dir());
let applied = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("clean")
.arg(&project)
.arg("--rules")
.arg("node")
.arg("--apply")
.arg("--yes")
.output()
.unwrap();
assert!(
applied.status.success(),
"{}",
String::from_utf8_lossy(&applied.stderr)
);
assert!(!project.join("node_modules").exists());
assert!(project.join("target").is_dir());
}
#[test]
fn backup_cli_creates_an_archive_and_manifest() {
let temp = TestDir::new();
let project = temp.project();
fs::create_dir(project.join(".git")).unwrap();
fs::write(project.join(".git/config"), "keep git history\n").unwrap();
let no_git_path = temp.0.join("no-git-path");
fs::create_dir(&no_git_path).unwrap();
let backup_dest = temp.0.join("backups");
let output = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("backup")
.arg(&project)
.arg("--backup-dest")
.arg(&backup_dest)
.env("PATH", &no_git_path)
.output()
.unwrap();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
panic!(
"backup failed ({:?}); stderr length {}; prefix: {:?}",
output.status.code(),
stderr.len(),
stderr.chars().take(256).collect::<String>()
);
}
assert!(String::from_utf8_lossy(&output.stdout).contains("(detached HEAD)"));
let project_backup = backup_dest.join(project.file_name().unwrap());
let entries: Vec<_> = fs::read_dir(&project_backup).unwrap().collect();
assert_eq!(entries.len(), 2, "expected one archive and one manifest");
let manifest = entries
.into_iter()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.extension()
.is_some_and(|extension| extension == "json")
})
.unwrap();
let manifest_json: serde_json::Value =
serde_json::from_slice(&fs::read(&manifest).unwrap()).unwrap();
assert_eq!(manifest_json["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(manifest_json["records"][0]["strategy"], "archive");
assert_eq!(
manifest_json["records"][0]["fallback_reason"],
"detached HEAD"
);
let restored_dest = temp.0.join("default-restore");
let restore = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("restore")
.arg(&manifest)
.arg("--dest")
.arg(&restored_dest)
.output()
.unwrap();
assert!(
restore.status.success(),
"{}",
String::from_utf8_lossy(&restore.stderr)
);
assert!(restored_dest.join("demo/.git/config").is_file());
}
#[test]
fn backup_can_archive_working_tree_without_git_metadata() {
let temp = TestDir::new();
let project = temp.project();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(project.join("src/main.py"), "print('hi')\n").unwrap();
let git_dir = project.join(".git");
fs::create_dir(&git_dir).unwrap();
fs::write(git_dir.join("config"), "private git metadata\n").unwrap();
let backup_dest = temp.0.join("backups");
let backup = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("backup")
.arg(&project)
.arg("--backup-dest")
.arg(&backup_dest)
.arg("--always-archive")
.arg("--exclude-git-metadata")
.output()
.unwrap();
assert!(
backup.status.success(),
"{}",
String::from_utf8_lossy(&backup.stderr)
);
let project_backup = backup_dest.join("demo");
let manifest = fs::read_dir(&project_backup)
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.extension()
.is_some_and(|extension| extension == "json")
})
.unwrap();
let manifest_json: serde_json::Value =
serde_json::from_slice(&fs::read(&manifest).unwrap()).unwrap();
assert_eq!(manifest_json["exclude_git_metadata"], true);
let restored_dest = temp.0.join("restored");
let restore = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("restore")
.arg(&manifest)
.arg("--dest")
.arg(&restored_dest)
.output()
.unwrap();
assert!(
restore.status.success(),
"{}",
String::from_utf8_lossy(&restore.stderr)
);
assert_eq!(
fs::read_to_string(restored_dest.join("demo/src/main.py")).unwrap(),
"print('hi')\n"
);
assert!(!restored_dest.join("demo/.git").exists());
assert!(git_dir.join("config").is_file());
}
#[test]
fn backup_records_git_exclusion_option_when_project_has_no_git_metadata() {
let temp = TestDir::new();
let project = temp.project();
let backup_dest = temp.0.join("backups");
let backup = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("backup")
.arg(&project)
.arg("--backup-dest")
.arg(&backup_dest)
.arg("--always-archive")
.arg("--exclude-git-metadata")
.output()
.unwrap();
assert!(
backup.status.success(),
"{}",
String::from_utf8_lossy(&backup.stderr)
);
assert!(
String::from_utf8_lossy(&backup.stdout).contains("Git metadata exclusion enabled"),
"stdout: {}",
String::from_utf8_lossy(&backup.stdout)
);
let manifest = fs::read_dir(backup_dest.join("demo"))
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.extension()
.is_some_and(|extension| extension == "json")
})
.unwrap();
let manifest_json: serde_json::Value =
serde_json::from_slice(&fs::read(&manifest).unwrap()).unwrap();
assert_eq!(manifest_json["exclude_git_metadata"], true);
assert!(!project.join(".git").exists());
}
#[test]
fn verify_checks_archive_hash_without_restoring() {
let temp = TestDir::new();
let project = temp.project();
let backup_dir = temp.0.join("backups/demo");
fs::create_dir_all(&backup_dir).unwrap();
let archive = backup_dir.join("demo.tar.gz");
let archive_bytes = b"archive fixture";
fs::write(&archive, archive_bytes).unwrap();
let manifest = backup_dir.join("manifest.json");
let payload = serde_json::json!({
"schema_version": 2,
"created_at": "2026-10-27T00:00:00Z",
"tool": "proj-janitor",
"version": "0.2.4",
"include_artifacts": false,
"always_archive": true,
"records": [{
"project": "demo",
"source": project.display().to_string(),
"archive": archive.display().to_string(),
"archive_bytes": archive_bytes.len(),
"source_bytes": 0,
"sha256": format!("{:x}", Sha256::digest(archive_bytes)),
"strategy": "archive"
}]
});
fs::write(&manifest, serde_json::to_vec(&payload).unwrap()).unwrap();
let verified = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("verify")
.arg(&manifest)
.arg("--json")
.output()
.unwrap();
assert!(
verified.status.success(),
"{}",
String::from_utf8_lossy(&verified.stderr)
);
let result: serde_json::Value = serde_json::from_slice(&verified.stdout).unwrap();
assert_eq!(result["projects"][0]["status"], "verified");
assert!(project.exists());
fs::write(&archive, b"corruption").unwrap();
let rejected = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("verify")
.arg(&manifest)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("sha256 mismatch"));
}
#[test]
fn cli_deduplicates_overlapping_roots_for_backup_and_clean() {
use std::time::{Duration, UNIX_EPOCH};
let temp = TestDir::new();
let project = temp.project();
fs::create_dir_all(project.join("target")).unwrap();
fs::write(project.join("target/cache"), "remove me\n").unwrap();
let backup_dest = temp.0.join("backups");
let backup = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("backup")
.arg(&temp.0)
.arg(&project)
.arg("--backup-dest")
.arg(&backup_dest)
.arg("--always-archive")
.output()
.unwrap();
assert!(
backup.status.success(),
"{}",
String::from_utf8_lossy(&backup.stderr)
);
assert_eq!(
String::from_utf8_lossy(&backup.stdout)
.matches("[archive] demo:")
.count(),
1
);
assert_eq!(
fs::read_dir(backup_dest.join("demo")).unwrap().count(),
2,
"one archive and one manifest should be written"
);
let clean = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("clean")
.arg(&temp.0)
.arg(&project)
.arg("--json")
.output()
.unwrap();
assert!(
clean.status.success(),
"{}",
String::from_utf8_lossy(&clean.stderr)
);
let plan: serde_json::Value = serde_json::from_slice(&clean.stdout).unwrap();
assert_eq!(plan.as_array().unwrap().len(), 1);
assert!(project.join("target/cache").is_file());
let hidden = temp.0.join(".hidden-project");
fs::create_dir(&hidden).unwrap();
fs::write(hidden.join("Cargo.toml"), "[package]\n").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&project, temp.0.join("linked-demo")).unwrap();
let markers = [
".git",
".hg",
".svn",
"pyproject.toml",
"setup.py",
"setup.cfg",
"requirements.txt",
"package.json",
"Cargo.toml",
"go.mod",
"CMakeLists.txt",
"Makefile",
"meson.build",
"build.gradle",
"build.gradle.kts",
"pom.xml",
];
let latest_modified = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
fs::OpenOptions::new()
.write(true)
.open(project.join("pyproject.toml"))
.unwrap()
.set_modified(UNIX_EPOCH + Duration::from_secs(1_600_000_000))
.unwrap();
fs::OpenOptions::new()
.write(true)
.open(project.join("target/cache"))
.unwrap()
.set_modified(latest_modified)
.unwrap();
for (index, marker) in markers.iter().enumerate() {
let marked = temp.0.join(format!("marker-{index}"));
fs::create_dir(&marked).unwrap();
if *marker == ".git" {
fs::create_dir(marked.join(marker)).unwrap();
} else {
fs::write(marked.join(marker), "").unwrap();
}
}
let scan = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("scan")
.arg(&temp.0)
.arg(&project)
.arg("--json")
.output()
.unwrap();
assert!(
scan.status.success(),
"{}",
String::from_utf8_lossy(&scan.stderr)
);
let projects: serde_json::Value = serde_json::from_slice(&scan.stdout).unwrap();
let names: BTreeSet<String> = projects
.as_array()
.unwrap()
.iter()
.map(|project| project["name"].as_str().unwrap().to_owned())
.collect();
let mut expected: BTreeSet<String> = (0..markers.len())
.map(|index| format!("marker-{index}"))
.collect();
expected.insert("demo".into());
assert_eq!(names, expected);
let projects = projects.as_array().unwrap();
let empty_git_project = projects
.iter()
.find(|project| project["name"] == "marker-0")
.unwrap();
let empty_git_fields = empty_git_project["git"].as_object().unwrap();
assert_eq!(empty_git_fields.len(), 2);
assert!(empty_git_fields.contains_key("branch"));
assert!(empty_git_fields.contains_key("dirty"));
assert!(empty_git_project["git"]["branch"].is_null());
assert!(empty_git_project["git"]["dirty"].is_null());
assert_eq!(empty_git_project["size_bytes"], 0);
assert!(empty_git_project["last_modified"].is_null());
let demo = projects
.iter()
.find(|project| project["name"] == "demo")
.unwrap();
for field in [
"name",
"path",
"size_bytes",
"last_modified",
"markers",
"git",
] {
assert!(demo.get(field).is_some(), "missing scan JSON field {field}");
}
assert_eq!(demo.as_object().unwrap().len(), 6);
assert_eq!(
demo["path"],
fs::canonicalize(&project)
.unwrap()
.to_string_lossy()
.as_ref()
);
assert_eq!(demo["markers"], serde_json::json!(["pyproject.toml"]));
assert!(demo["git"].is_null());
let actual_modified =
chrono::DateTime::parse_from_rfc3339(demo["last_modified"].as_str().unwrap())
.unwrap()
.timestamp();
assert_eq!(actual_modified, 1_700_000_000);
let text_scan = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("scan")
.arg(&temp.0)
.arg(&project)
.output()
.unwrap();
assert!(
text_scan.status.success(),
"{}",
String::from_utf8_lossy(&text_scan.stderr)
);
let text = String::from_utf8_lossy(&text_scan.stdout);
for column in ["PROJECT", "SIZE", "BRANCH", "STATE", "MARKERS", "MODIFIED"] {
assert!(text.contains(column));
}
assert!(text.contains("demo"));
let refused = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("clean")
.arg(&temp.0)
.arg("--json")
.arg("--apply")
.output()
.unwrap();
assert!(!refused.status.success());
let actions: serde_json::Value = serde_json::from_slice(&refused.stdout).unwrap();
assert_eq!(actions.as_array().unwrap().len(), 1);
assert!(String::from_utf8_lossy(&refused.stderr).contains("--yes"));
assert!(project.join("target/cache").is_file());
let applied = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("clean")
.arg(&temp.0)
.arg("--json")
.arg("--apply")
.arg("--yes")
.output()
.unwrap();
assert!(
applied.status.success(),
"{}",
String::from_utf8_lossy(&applied.stderr)
);
let actions: serde_json::Value = serde_json::from_slice(&applied.stdout).unwrap();
assert_eq!(actions.as_array().unwrap().len(), 1);
assert!(applied.stderr.is_empty());
assert!(!project.join("target/cache").exists());
}
#[test]
fn backup_reads_backup_dest_from_environment() {
let temp = TestDir::new();
let project = temp.project();
let env_dest = temp.0.join("from-env");
let output = dry_run(&project, &env_dest, None);
assert!(
normalized_output(&output).contains(&destination_marker(&env_dest)),
"unexpected dry-run output: {output}"
);
assert!(output.contains("(detached HEAD)"));
}
#[test]
fn backup_rejects_empty_backup_dest_environment() {
let temp = TestDir::new();
let project = temp.project();
let output = Command::new(env!("CARGO_BIN_EXE_projjan"))
.args(["backup", "--dry-run"])
.arg(&project)
.env("BACKUP_DEST", "")
.output()
.unwrap();
assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr).contains("a value is required for '--backup-dest")
);
}
#[test]
fn command_line_backup_dest_overrides_environment() {
let temp = TestDir::new();
let project = temp.project();
let env_dest = temp.0.join("from-env");
let cli_dest = temp.0.join("from-cli");
let output = dry_run(&project, &env_dest, Some(&cli_dest));
let output = normalized_output(&output);
assert!(
output.contains(&destination_marker(&cli_dest)),
"unexpected dry-run output: {output}"
);
assert!(!output.contains(&destination_marker(&env_dest)));
}
#[test]
fn backup_rejects_destination_inside_project() {
let temp = TestDir::new();
let project = temp.project();
let backup_dest = project.join("backups");
let output = Command::new(env!("CARGO_BIN_EXE_projjan"))
.arg("backup")
.arg("--dry-run")
.arg(&project)
.arg("--backup-dest")
.arg(&backup_dest)
.output()
.unwrap();
assert!(
!output.status.success(),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
String::from_utf8_lossy(&output.stderr).contains("backup destination is inside project")
);
assert!(!backup_dest.exists());
}