use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tempfile::TempDir;
struct TestEnv {
home_dir: TempDir,
repo_dir: TempDir,
binary: PathBuf,
}
impl TestEnv {
fn new() -> Self {
let home_dir = TempDir::new().unwrap();
let repo_dir = TempDir::new().unwrap();
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(repo_dir.path())
.env("HOME", home_dir.path())
.output()
.unwrap()
};
git(&["init"]);
git(&["config", "user.email", "test@test.com"]);
git(&["config", "user.name", "Test"]);
std::fs::write(repo_dir.path().join(".gitkeep"), "").unwrap();
git(&["add", "."]);
git(&["commit", "-m", "init"]);
let binary = env!("CARGO_BIN_EXE_git-cloak").into();
Self {
home_dir,
repo_dir,
binary,
}
}
fn run(&self, args: &[&str]) -> Output {
Command::new(&self.binary)
.args(args)
.current_dir(self.repo_dir.path())
.env("HOME", self.home_dir.path())
.output()
.expect("failed to execute git-cloak")
}
fn repo(&self) -> &Path {
self.repo_dir.path()
}
fn store_base(&self) -> PathBuf {
self.home_dir.path().join(".git-cloak")
}
fn pid_dir(&self) -> PathBuf {
let library = self.store_base().join("library");
let entries: Vec<_> = std::fs::read_dir(&library)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().unwrap().is_dir())
.collect();
assert_eq!(entries.len(), 1, "expected exactly one PID directory");
entries[0].path()
}
}
fn stdout_str(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stderr_str(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
fn assert_success(output: &Output) {
assert!(
output.status.success(),
"command failed.\nstdout: {}\nstderr: {}",
stdout_str(output),
stderr_str(output),
);
}
fn is_symlink(path: &Path) -> bool {
path.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
#[test]
fn track_creates_store_structure() {
let env = TestEnv::new();
std::fs::write(env.repo().join("secret.txt"), "my secret").unwrap();
let out = env.run(&["track", "secret.txt"]);
assert_success(&out);
let link = env.repo().join("secret.txt");
assert!(is_symlink(&link));
let target = std::fs::read_link(&link).unwrap();
assert!(target.starts_with(env.store_base().join("library")));
assert_eq!(std::fs::read_to_string(&target).unwrap(), "my secret");
let index_path = env.store_base().join("index.json");
assert!(index_path.exists());
let index: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&index_path).unwrap()).unwrap();
assert_eq!(index["projects"].as_object().unwrap().len(), 1);
let manifest_path = env.pid_dir().join("cloak.manifest.json");
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
assert_eq!(manifest["files"][0]["target_path"], "secret.txt");
assert_eq!(manifest["files"][0]["strategy"], "symlink");
let exclude = env.repo().join(".git/info/exclude");
if exclude.exists() {
let content = std::fs::read_to_string(&exclude).unwrap();
assert!(content.contains("secret.txt"));
}
}
#[test]
fn track_with_explicit_target_path() {
let env = TestEnv::new();
std::fs::write(env.repo().join("my_agents.md"), "agent content").unwrap();
let out = env.run(&["track", "my_agents.md", "AGENTS.md"]);
assert_success(&out);
assert!(is_symlink(&env.repo().join("AGENTS.md")));
assert!(!env.repo().join("my_agents.md").exists());
assert_eq!(
std::fs::read_to_string(env.repo().join("AGENTS.md")).unwrap(),
"agent content"
);
}
#[test]
fn full_lifecycle_track_eject_inject_untrack() {
let env = TestEnv::new();
std::fs::write(env.repo().join("secret.env"), "DB_PASS=hunter2").unwrap();
let out = env.run(&["track", "secret.env"]);
assert_success(&out);
assert!(is_symlink(&env.repo().join("secret.env")));
assert_eq!(
std::fs::read_to_string(env.repo().join("secret.env")).unwrap(),
"DB_PASS=hunter2"
);
let out = env.run(&["eject"]);
assert_success(&out);
assert!(!env.repo().join("secret.env").exists());
let stored = env.pid_dir().join("files").join("secret.env");
assert!(stored.exists());
assert_eq!(
std::fs::read_to_string(&stored).unwrap(),
"DB_PASS=hunter2"
);
let exclude = env.repo().join(".git/info/exclude");
if exclude.exists() {
let content = std::fs::read_to_string(&exclude).unwrap();
assert!(!content.contains("secret.env"));
}
let out = env.run(&["inject"]);
assert_success(&out);
assert!(is_symlink(&env.repo().join("secret.env")));
assert_eq!(
std::fs::read_to_string(env.repo().join("secret.env")).unwrap(),
"DB_PASS=hunter2"
);
let content = std::fs::read_to_string(&exclude).unwrap();
assert!(content.contains("secret.env"));
let out = env.run(&["untrack", "secret.env"]);
assert_success(&out);
let meta = std::fs::symlink_metadata(env.repo().join("secret.env")).unwrap();
assert!(meta.is_file());
assert!(!meta.file_type().is_symlink());
assert_eq!(
std::fs::read_to_string(env.repo().join("secret.env")).unwrap(),
"DB_PASS=hunter2"
);
let index: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(env.store_base().join("index.json")).unwrap(),
)
.unwrap();
assert!(index["projects"].as_object().unwrap().is_empty());
}
#[test]
fn pid_is_stable_across_commands() {
let env = TestEnv::new();
std::fs::write(env.repo().join("a.txt"), "aaa").unwrap();
std::fs::write(env.repo().join("b.txt"), "bbb").unwrap();
assert_success(&env.run(&["track", "a.txt"]));
assert_success(&env.run(&["track", "b.txt"]));
let _pid_dir = env.pid_dir();
let manifest: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(env.pid_dir().join("cloak.manifest.json")).unwrap(),
)
.unwrap();
assert_eq!(manifest["files"].as_array().unwrap().len(), 2);
}
#[test]
fn pid_is_git_based() {
let env = TestEnv::new();
std::fs::write(env.repo().join("f.txt"), "").unwrap();
assert_success(&env.run(&["track", "f.txt"]));
let name = env
.pid_dir()
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
assert!(
name.starts_with("git_"),
"PID should start with git_, got: {name}"
);
assert_eq!(name.len(), 20, "git PID should be 20 chars, got: {name}");
}
#[test]
fn track_nonexistent_file_fails() {
let env = TestEnv::new();
let out = env.run(&["track", "does_not_exist.txt"]);
assert!(!out.status.success());
let err = stderr_str(&out);
assert!(
err.contains("not found"),
"expected 'not found' in stderr, got: {err}"
);
}
#[test]
fn inject_is_idempotent() {
let env = TestEnv::new();
std::fs::write(env.repo().join("x.txt"), "content").unwrap();
assert_success(&env.run(&["track", "x.txt"]));
assert_success(&env.run(&["inject"]));
assert_success(&env.run(&["inject"]));
assert_eq!(
std::fs::read_to_string(env.repo().join("x.txt")).unwrap(),
"content"
);
}
#[test]
fn eject_is_idempotent() {
let env = TestEnv::new();
std::fs::write(env.repo().join("x.txt"), "content").unwrap();
assert_success(&env.run(&["track", "x.txt"]));
assert_success(&env.run(&["eject"]));
assert_success(&env.run(&["eject"]));
}
#[test]
fn track_already_tracked_is_idempotent() {
let env = TestEnv::new();
std::fs::write(env.repo().join("x.txt"), "content").unwrap();
assert_success(&env.run(&["track", "x.txt"]));
let out = env.run(&["track", "x.txt"]);
assert_success(&out);
assert!(stdout_str(&out).contains("Already tracked"));
}
#[test]
fn inject_eject_no_tracked_files() {
let env = TestEnv::new();
let out = env.run(&["inject"]);
assert_success(&out);
assert!(stdout_str(&out).contains("No tracked files"));
let out = env.run(&["eject"]);
assert_success(&out);
assert!(stdout_str(&out).contains("No tracked files"));
}
#[test]
fn track_file_in_subdirectory() {
let env = TestEnv::new();
std::fs::create_dir_all(env.repo().join("src/config")).unwrap();
std::fs::write(env.repo().join("src/config/local.toml"), "key=val").unwrap();
let out = env.run(&["track", "src/config/local.toml"]);
assert_success(&out);
assert!(is_symlink(&env.repo().join("src/config/local.toml")));
let stored = env.pid_dir().join("files/src/config/local.toml");
assert!(stored.exists());
assert_eq!(std::fs::read_to_string(&stored).unwrap(), "key=val");
}
#[test]
fn store_is_lazily_initialized() {
let env = TestEnv::new();
assert!(!env.store_base().exists());
std::fs::write(env.repo().join("f.txt"), "").unwrap();
assert_success(&env.run(&["track", "f.txt"]));
assert!(env.store_base().exists());
assert!(env.store_base().join("library").is_dir());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::metadata(env.store_base().join("library"))
.unwrap()
.permissions();
assert_eq!(perms.mode() & 0o777, 0o700);
}
}
#[test]
fn inject_after_eject_target_occupied_fails() {
let env = TestEnv::new();
std::fs::write(env.repo().join("f.txt"), "original").unwrap();
assert_success(&env.run(&["track", "f.txt"]));
assert_success(&env.run(&["eject"]));
std::fs::write(env.repo().join("f.txt"), "imposter").unwrap();
let out = env.run(&["inject"]);
assert!(!out.status.success());
let err = stderr_str(&out);
assert!(
err.contains("occupied"),
"expected 'occupied' in stderr, got: {err}"
);
}
#[test]
fn multiple_files_batch_eject_inject() {
let env = TestEnv::new();
std::fs::write(env.repo().join("a.txt"), "aaa").unwrap();
std::fs::write(env.repo().join("b.txt"), "bbb").unwrap();
assert_success(&env.run(&["track", "a.txt"]));
assert_success(&env.run(&["track", "b.txt"]));
assert_success(&env.run(&["eject"]));
assert!(!env.repo().join("a.txt").exists());
assert!(!env.repo().join("b.txt").exists());
assert_success(&env.run(&["inject"]));
assert!(is_symlink(&env.repo().join("a.txt")));
assert!(is_symlink(&env.repo().join("b.txt")));
assert_eq!(
std::fs::read_to_string(env.repo().join("a.txt")).unwrap(),
"aaa"
);
assert_eq!(
std::fs::read_to_string(env.repo().join("b.txt")).unwrap(),
"bbb"
);
}
#[test]
fn status_shows_tracked_files() {
let env = TestEnv::new();
std::fs::write(env.repo().join("secret.txt"), "my secret").unwrap();
assert_success(&env.run(&["track", "secret.txt"]));
let out = env.run(&["status"]);
assert_success(&out);
let stdout = stdout_str(&out);
assert!(stdout.contains("secret.txt"), "expected 'secret.txt' in status output, got: {stdout}");
assert!(stdout.contains("Linked"), "expected 'Linked' in status output, got: {stdout}");
assert_success(&env.run(&["eject"]));
let out = env.run(&["status"]);
assert_success(&out);
let stdout = stdout_str(&out);
assert!(stdout.contains("secret.txt"), "expected 'secret.txt' in status output, got: {stdout}");
assert!(stdout.contains("Ejected"), "expected 'Ejected' in status output, got: {stdout}");
}
#[test]
fn projects_shows_managed_projects() {
let env = TestEnv::new();
std::fs::write(env.repo().join("f.txt"), "content").unwrap();
assert_success(&env.run(&["track", "f.txt"]));
let out = env.run(&["projects"]);
assert_success(&out);
let stdout = stdout_str(&out);
let repo_name = env.repo().file_name().unwrap().to_string_lossy();
assert!(
stdout.contains(&*repo_name),
"expected repo name '{repo_name}' in projects output, got: {stdout}"
);
assert!(stdout.contains("git"), "expected 'git' type in projects output, got: {stdout}");
}
#[test]
fn status_no_tracked_files() {
let env = TestEnv::new();
let out = env.run(&["status"]);
assert_success(&out);
let stdout = stdout_str(&out);
assert!(
stdout.contains("No tracked files"),
"expected 'No tracked files' in output, got: {stdout}"
);
}
#[test]
fn projects_no_managed_projects() {
let env = TestEnv::new();
let out = env.run(&["projects"]);
assert_success(&out);
let stdout = stdout_str(&out);
assert!(
stdout.contains("No managed projects"),
"expected 'No managed projects' in output, got: {stdout}"
);
}