use assert_cmd::Command;
use std::fs;
use std::path::Path;
const MANIFEST: &str = r#"
version = 1
canonical = "json-schema-2020-12"
authoring = "zod"
[[contracts]]
name = "user"
source = "contracts/user.zod.ts"
emit = ["zod"]
[[mappings]]
glob = "app/api/**/*.ts"
contracts = ["user"]
require = "boundary-validation"
[gates]
suppression_comments = "deny"
protected_paths = ["pushkin.toml"]
"#;
const NONCONFORMING: &str = "export async function POST(req: Request) {\n \
const body = await req.json();\n return Response.json({ name: body.name });\n}\n";
fn repo() -> Option<tempfile::TempDir> {
let dir = tempfile::tempdir().ok()?;
fs::write(dir.path().join("pushkin.toml"), MANIFEST).ok()?;
Some(dir)
}
fn hook_write(dir: &Path, envs: &[(&str, &str)]) -> Option<String> {
let payload = serde_json::json!({
"session_id": "worktree-suite",
"tool_name": "Write",
"tool_input": { "file_path": "app/api/users/route.ts", "content": NONCONFORMING }
})
.to_string();
let mut cmd = Command::cargo_bin("pushkin").ok()?;
cmd.args(["hook", "claude"]).current_dir(dir);
for (key, value) in envs {
cmd.env(key, value);
}
let output = cmd.write_stdin(payload).output().ok()?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
if stdout.trim().is_empty() {
return None;
}
let json: serde_json::Value = serde_json::from_str(&stdout).ok()?;
Some(
json["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()?
.to_owned(),
)
}
fn policy_test(dir: &Path, envs: &[(&str, &str)]) -> Option<(String, i32)> {
let mut cmd = Command::cargo_bin("pushkin").ok()?;
cmd.args(["policy", "test"]).current_dir(dir);
for (key, value) in envs {
cmd.env(key, value);
}
let output = cmd.output().ok()?;
Some((
String::from_utf8_lossy(&output.stdout).into_owned(),
output.status.code().unwrap_or(-1),
))
}
#[test]
fn marker_file_detaches_worktree() {
let dir = repo().unwrap();
assert!(
hook_write(dir.path(), &[]).is_some(),
"precondition: attached worktree must deny the violation"
);
fs::write(dir.path().join(".pushkinignore-self"), "").unwrap();
assert!(
hook_write(dir.path(), &[]).is_none(),
"marker file must detach: the gate stands down in this worktree"
);
}
#[test]
fn denylist_glob_detaches_and_hot_reloads_on_mtime() {
let dir = repo().unwrap();
let denylist = tempfile::NamedTempFile::new().unwrap();
let env: &[(&str, &str)] = &[(
"PUSHKIN_WORKTREE_DENYLIST",
denylist.path().to_str().unwrap(),
)];
assert!(
hook_write(dir.path(), env).is_some(),
"empty denylist must leave the worktree attached"
);
fs::write(denylist.path(), format!("{}/**\n", dir.path().display())).unwrap();
assert!(
hook_write(dir.path(), env).is_none(),
"denylist glob must detach the matching worktree"
);
fs::write(denylist.path(), "# nothing denied\n").unwrap();
assert!(
hook_write(dir.path(), env).is_some(),
"clearing the denylist must re-attach (hot reload on mtime)"
);
}
#[test]
fn env_kill_switch_detaches_everything() {
let dir = repo().unwrap();
assert!(
hook_write(dir.path(), &[("PUSHKIN_DISABLE", "1")]).is_none(),
"env kill-switch must detach regardless of marker/denylist"
);
}
#[test]
fn explicit_attach_beats_policy() {
let dir = repo().unwrap();
fs::write(dir.path().join(".pushkinignore-self"), "").unwrap();
let reason = hook_write(dir.path(), &[("PUSHKIN_ATTACH", "1")]);
assert!(
reason.is_some(),
"explicit attach must override the marker and gate the write"
);
let reason = hook_write(
dir.path(),
&[("PUSHKIN_ATTACH", "1"), ("PUSHKIN_DISABLE", "1")],
);
assert!(
reason.is_some(),
"explicit attach must beat the env kill-switch as well"
);
}
#[test]
fn policy_test_verb_dry_runs() {
let dir = repo().unwrap();
let (stdout, code) = policy_test(dir.path(), &[]).unwrap();
assert_eq!(code, 0);
assert!(
stdout.contains("attached"),
"clean worktree must report attached: {stdout}"
);
fs::write(dir.path().join(".pushkinignore-self"), "").unwrap();
let (stdout, _) = policy_test(dir.path(), &[]).unwrap();
assert!(
stdout.contains("detached") && stdout.contains(".pushkinignore-self"),
"policy test must report detached and name the deciding layer: {stdout}"
);
let attached = repo().unwrap();
assert!(
hook_write(attached.path(), &[]).is_some(),
"policy test must not mutate global gate state"
);
}