use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Map, Value, json};
pub fn home_dir() -> Result<PathBuf> {
std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| anyhow!("HOME environment variable is not set"))
}
pub(crate) const SENTINEL_ID: &str = "git-prism-bash-redirect-v1";
pub(crate) const REDIRECT_SCRIPT_NAME: &str = "git-prism-redirect.sh";
pub(crate) const REDIRECT_PY_NAME: &str = "bash_redirect_hook.py";
const REDIRECT_SH_CONTENT: &str = include_str!("../hooks/git-prism-redirect.sh");
const REDIRECT_PY_CONTENT: &str = include_str!("../hooks/bash_redirect_hook.py");
#[cfg(unix)]
const SCRIPT_MODE: u32 = 0o755;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
User,
Project,
Local,
}
impl Scope {
pub fn parse(value: &str) -> Result<Self> {
match value {
"user" => Ok(Self::User),
"project" => Ok(Self::Project),
"local" => Ok(Self::Local),
other => bail!("unknown scope {other:?} (expected user|project|local)"),
}
}
fn as_str(self) -> &'static str {
match self {
Scope::User => "user",
Scope::Project => "project",
Scope::Local => "local",
}
}
}
#[derive(Debug, Clone)]
pub struct ScopePaths {
pub settings_file: PathBuf,
pub hooks_dir: PathBuf,
}
impl ScopePaths {
pub fn resolve(scope: Scope, home: &Path, cwd: &Path) -> Self {
match scope {
Scope::User => {
let claude = home.join(".claude");
Self {
settings_file: claude.join("settings.json"),
hooks_dir: claude.join("hooks"),
}
}
Scope::Project => {
let claude = cwd.join(".claude");
Self {
settings_file: claude.join("settings.json"),
hooks_dir: claude.join("hooks"),
}
}
Scope::Local => {
let claude = cwd.join(".claude");
Self {
settings_file: claude.join("settings.local.json"),
hooks_dir: claude.join("hooks"),
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallOutcome {
Installed,
AlreadyInstalled,
Updated,
Skipped,
UpdatedForced,
DryRun(Value),
}
#[derive(Debug, Clone)]
pub struct InstallOptions {
pub scope: Scope,
pub dry_run: bool,
pub force: bool,
}
fn canonical_entry(hooks_dir: &Path) -> Result<Value> {
let command = hooks_dir
.join(REDIRECT_SCRIPT_NAME)
.to_str()
.ok_or_else(|| anyhow!("hooks directory path contains non-UTF-8 bytes"))?
.to_string();
Ok(json!({ "id": SENTINEL_ID, "matcher": "Bash", "command": command }))
}
fn read_settings(path: &Path) -> Result<Value> {
if !path.exists() {
return Ok(Value::Object(Map::new()));
}
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read settings at {}", path.display()))?;
if raw.trim().is_empty() {
return Ok(Value::Object(Map::new()));
}
serde_json::from_str(&raw)
.with_context(|| format!("settings file at {} is not valid JSON", path.display()))
}
fn find_entry_index(entries: &[Value], id: &str) -> Option<usize> {
entries
.iter()
.position(|v| v.get("id").and_then(|s| s.as_str()) == Some(id))
}
fn newer_sentinel_id(entries: &[Value]) -> Option<String> {
for entry in entries {
let Some(id) = entry.get("id").and_then(|v| v.as_str()) else {
continue;
};
if let Some(rest) = id.strip_prefix("git-prism-bash-redirect-v")
&& let Ok(version) = rest.parse::<u32>()
&& version > 1
{
return Some(id.to_string());
}
}
None
}
fn pretool_use_array_mut(settings: &mut Value) -> &mut Vec<Value> {
if !settings.is_object() {
*settings = Value::Object(Map::new());
}
let root = settings.as_object_mut().expect("ensured object above");
let hooks = root
.entry("hooks".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !hooks.is_object() {
*hooks = Value::Object(Map::new());
}
let hooks_obj = hooks.as_object_mut().expect("ensured object above");
let pretool_use = hooks_obj
.entry("PreToolUse".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if !pretool_use.is_array() {
*pretool_use = Value::Array(Vec::new());
}
pretool_use.as_array_mut().expect("ensured array above")
}
fn write_settings(path: &Path, settings: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let mut serialized =
serde_json::to_string_pretty(settings).context("failed to serialize settings JSON")?;
serialized.push('\n');
std::fs::write(path, serialized)
.with_context(|| format!("failed to write settings to {}", path.display()))?;
Ok(())
}
fn copy_bundled_scripts(hooks_dir: &Path) -> Result<()> {
std::fs::create_dir_all(hooks_dir)
.with_context(|| format!("failed to create {}", hooks_dir.display()))?;
let sh_path = hooks_dir.join(REDIRECT_SCRIPT_NAME);
std::fs::write(&sh_path, REDIRECT_SH_CONTENT)
.with_context(|| format!("failed to write {}", sh_path.display()))?;
set_executable(&sh_path)?;
let py_path = hooks_dir.join(REDIRECT_PY_NAME);
std::fs::write(&py_path, REDIRECT_PY_CONTENT)
.with_context(|| format!("failed to write {}", py_path.display()))?;
set_executable(&py_path)?;
Ok(())
}
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(SCRIPT_MODE);
std::fs::set_permissions(path, perms)
.with_context(|| format!("failed to chmod {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<()> {
Ok(())
}
fn is_stale_path(existing_command: &str, canonical_command: &str) -> bool {
if existing_command == canonical_command {
return false;
}
existing_command.ends_with(REDIRECT_SCRIPT_NAME)
}
pub fn other_scopes_with_sentinel(scope: Scope, home: &Path, cwd: &Path) -> Vec<Scope> {
let mut hits = Vec::new();
for candidate in [Scope::User, Scope::Project, Scope::Local] {
if candidate == scope {
continue;
}
let paths = ScopePaths::resolve(candidate, home, cwd);
let Ok(settings) = read_settings(&paths.settings_file) else {
continue;
};
let entries = settings
.get("hooks")
.and_then(|h| h.get("PreToolUse"))
.and_then(|p| p.as_array());
if let Some(entries) = entries
&& find_entry_index(entries, SENTINEL_ID).is_some()
{
hits.push(candidate);
}
}
hits
}
pub fn execute_install(
settings_path: &Path,
hooks_dir: &Path,
options: &InstallOptions,
) -> Result<InstallOutcome> {
let mut settings = read_settings(settings_path)?;
{
let entries = pretool_use_array_mut(&mut settings);
if let Some(newer) = newer_sentinel_id(entries) {
bail!(
"{newer} already installed; this binary writes v1 — run `git-prism hooks uninstall` first"
);
}
}
let canonical = canonical_entry(hooks_dir)?;
let canonical_command = canonical
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("canonical entry missing command field"))?
.to_string();
if options.dry_run {
let entries = pretool_use_array_mut(&mut settings);
match find_entry_index(entries, SENTINEL_ID) {
Some(idx) => entries[idx] = canonical.clone(),
None => entries.push(canonical.clone()),
}
return Ok(InstallOutcome::DryRun(settings));
}
let outcome = {
let entries = pretool_use_array_mut(&mut settings);
match find_entry_index(entries, SENTINEL_ID) {
None => {
entries.push(canonical);
InstallOutcome::Installed
}
Some(idx) => {
let existing_command = entries[idx]
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if existing_command == canonical_command {
return Ok(InstallOutcome::AlreadyInstalled);
}
if is_stale_path(&existing_command, &canonical_command) {
entries[idx] = canonical;
InstallOutcome::Updated
} else if options.force {
entries[idx] = canonical;
InstallOutcome::UpdatedForced
} else {
return Ok(InstallOutcome::Skipped);
}
}
}
};
copy_bundled_scripts(hooks_dir)?;
write_settings(settings_path, &settings)?;
Ok(outcome)
}
pub fn install_redirect_hook(
options: &InstallOptions,
home: &Path,
cwd: &Path,
stdin: &mut dyn std::io::Read,
stdout: &mut dyn std::io::Write,
stderr: &mut dyn std::io::Write,
) -> Result<i32> {
let paths = ScopePaths::resolve(options.scope, home, cwd);
if !options.dry_run
&& !options.force
&& let Some(other) = other_scopes_with_sentinel(options.scope, home, cwd).first()
{
writeln!(
stderr,
"Warning: redirect hook already installed at {} scope — duplicate redirects will fire on every Bash call. Continue? [y/N]",
other.as_str()
)?;
let mut buf = [0u8; 1];
let user_confirmation_char = match stdin.read(&mut buf)? {
0 => 'n',
_ => buf[0] as char,
};
if user_confirmation_char != 'y' && user_confirmation_char != 'Y' {
return Ok(0);
}
}
match execute_install(&paths.settings_file, &paths.hooks_dir, options) {
Ok(InstallOutcome::Installed) => {
writeln!(
stdout,
"Installed git-prism redirect hook at {} scope",
options.scope.as_str()
)?;
Ok(0)
}
Ok(InstallOutcome::AlreadyInstalled) => Ok(0),
Ok(InstallOutcome::Updated) => {
writeln!(
stdout,
"git-prism redirect hook at {} scope: updated (stale path replaced)",
options.scope.as_str()
)?;
Ok(0)
}
Ok(InstallOutcome::Skipped) => {
writeln!(
stdout,
"skipped: user-customized entry preserved; use --force to overwrite"
)?;
Ok(0)
}
Ok(InstallOutcome::UpdatedForced) => {
writeln!(
stdout,
"git-prism redirect hook at {} scope: updated (--force overwrote user-edited entry)",
options.scope.as_str()
)?;
Ok(0)
}
Ok(InstallOutcome::DryRun(preview)) => {
writeln!(stdout, "{}", serde_json::to_string_pretty(&preview)?)?;
Ok(0)
}
Err(err) => {
writeln!(stderr, "{err:#}")?;
Ok(1)
}
}
}
pub fn uninstall_redirect_hook(scope: Scope, home: &Path, cwd: &Path) -> Result<()> {
let paths = ScopePaths::resolve(scope, home, cwd);
if !paths.settings_file.exists() {
return Ok(());
}
let mut settings = read_settings(&paths.settings_file)?;
let entry_count_before_removal = {
let entries = pretool_use_array_mut(&mut settings);
let len = entries.len();
entries.retain(|entry| {
let id = entry.get("id").and_then(|v| v.as_str()).unwrap_or("");
!id.starts_with("git-prism-bash-redirect-")
});
len
};
let entry_count_after_removal = pretool_use_array_mut(&mut settings).len();
if entry_count_after_removal < entry_count_before_removal {
write_settings(&paths.settings_file, &settings)?;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusReport {
pub lines: Vec<String>,
}
pub fn status_report(home: &Path, cwd: &Path, cwd_is_repo: bool) -> Result<StatusReport> {
let mut lines = Vec::new();
for scope in [Scope::User, Scope::Project, Scope::Local] {
if !cwd_is_repo && matches!(scope, Scope::Project | Scope::Local) {
continue;
}
let paths = ScopePaths::resolve(scope, home, cwd);
let settings = read_settings(&paths.settings_file)?;
let entries = settings
.get("hooks")
.and_then(|h| h.get("PreToolUse"))
.and_then(|p| p.as_array());
let Some(entries) = entries else { continue };
for entry in entries {
let id = entry.get("id").and_then(|v| v.as_str()).unwrap_or("");
if id.starts_with("git-prism-bash-redirect-") {
lines.push(format!("{}: {}", scope.as_str(), id));
break;
}
}
}
if lines.is_empty() {
lines.push("not installed".to_string());
}
Ok(StatusReport { lines })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
fn install_options(scope: Scope) -> InstallOptions {
InstallOptions {
scope,
dry_run: false,
force: false,
}
}
fn read_settings_json(path: &Path) -> Value {
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
}
#[test]
fn fresh_install_writes_pretool_use_entry_with_sentinel_id() {
let dir = TempDir::new().unwrap();
let settings = dir.path().join("settings.json");
let hooks = dir.path().join("hooks");
let outcome = execute_install(&settings, &hooks, &install_options(Scope::User)).unwrap();
assert!(matches!(outcome, InstallOutcome::Installed));
let data = read_settings_json(&settings);
let entries = data["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["id"], json!(SENTINEL_ID));
assert_eq!(entries[0]["matcher"], json!("Bash"));
let cmd = entries[0]["command"].as_str().unwrap();
assert!(cmd.ends_with(REDIRECT_SCRIPT_NAME), "command was {cmd:?}");
}
#[test]
fn fresh_install_copies_bundled_scripts_and_marks_them_executable() {
let dir = TempDir::new().unwrap();
let settings = dir.path().join("settings.json");
let hooks = dir.path().join("hooks");
execute_install(&settings, &hooks, &install_options(Scope::User)).unwrap();
let sh = hooks.join(REDIRECT_SCRIPT_NAME);
let py = hooks.join(REDIRECT_PY_NAME);
assert!(sh.is_file(), "missing {}", sh.display());
assert!(py.is_file(), "missing {}", py.display());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(sh.metadata().unwrap().permissions().mode() & 0o777, 0o755);
assert_eq!(py.metadata().unwrap().permissions().mode() & 0o777, 0o755);
}
}
#[test]
fn second_install_with_canonical_command_is_a_no_op() {
let dir = TempDir::new().unwrap();
let settings = dir.path().join("settings.json");
let hooks = dir.path().join("hooks");
execute_install(&settings, &hooks, &install_options(Scope::User)).unwrap();
let sha_before = sha256_of_file(&settings);
let outcome = execute_install(&settings, &hooks, &install_options(Scope::User)).unwrap();
assert!(matches!(outcome, InstallOutcome::AlreadyInstalled));
let sha_after = sha256_of_file(&settings);
assert_eq!(sha_before, sha_after);
let data = read_settings_json(&settings);
let entries = data["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(entries.len(), 1, "duplicate entry was appended on no-op");
}
#[test]
fn install_with_stale_path_is_overwritten_in_place() {
let dir = TempDir::new().unwrap();
let settings = dir.path().join("settings.json");
let hooks = dir.path().join("hooks");
let stale = json!({
"hooks": {
"PreToolUse": [
{
"id": SENTINEL_ID,
"matcher": "Bash",
"command": "/old/stale/path/git-prism-redirect.sh"
}
]
}
});
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
std::fs::write(&settings, serde_json::to_string_pretty(&stale).unwrap()).unwrap();
let outcome = execute_install(&settings, &hooks, &install_options(Scope::User)).unwrap();
assert!(matches!(outcome, InstallOutcome::Updated));
let data = read_settings_json(&settings);
let entries = data["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(entries.len(), 1);
let cmd = entries[0]["command"].as_str().unwrap();
assert!(
!cmd.contains("/old/stale/path"),
"command still stale: {cmd}"
);
}
#[test]
fn user_edited_entry_is_preserved_without_force() {
let dir = TempDir::new().unwrap();
let settings = dir.path().join("settings.json");
let hooks = dir.path().join("hooks");
let edited = json!({
"hooks": {
"PreToolUse": [
{"id": SENTINEL_ID, "matcher": "Bash", "command": "echo HAND-EDITED"}
]
}
});
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
std::fs::write(&settings, serde_json::to_string_pretty(&edited).unwrap()).unwrap();
let outcome = execute_install(&settings, &hooks, &install_options(Scope::User)).unwrap();
assert!(matches!(outcome, InstallOutcome::Skipped));
let data = read_settings_json(&settings);
let entries = data["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(entries[0]["command"], json!("echo HAND-EDITED"));
}
#[test]
fn user_edited_entry_is_overwritten_with_force() {
let dir = TempDir::new().unwrap();
let settings = dir.path().join("settings.json");
let hooks = dir.path().join("hooks");
let edited = json!({
"hooks": {
"PreToolUse": [
{"id": SENTINEL_ID, "matcher": "Bash", "command": "echo HAND-EDITED"}
]
}
});
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
std::fs::write(&settings, serde_json::to_string_pretty(&edited).unwrap()).unwrap();
let mut options = install_options(Scope::User);
options.force = true;
let outcome = execute_install(&settings, &hooks, &options).unwrap();
assert!(matches!(outcome, InstallOutcome::UpdatedForced));
let data = read_settings_json(&settings);
let entries = data["hooks"]["PreToolUse"].as_array().unwrap();
let cmd = entries[0]["command"].as_str().unwrap();
assert_ne!(cmd, "echo HAND-EDITED");
assert!(cmd.contains(REDIRECT_SCRIPT_NAME));
}
#[test]
fn install_refuses_to_downgrade_v2_to_v1() {
let dir = TempDir::new().unwrap();
let settings = dir.path().join("settings.json");
let hooks = dir.path().join("hooks");
let v2 = json!({
"hooks": {
"PreToolUse": [
{"id": "git-prism-bash-redirect-v2", "matcher": "Bash", "command": "echo v2"}
]
}
});
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
std::fs::write(&settings, serde_json::to_string_pretty(&v2).unwrap()).unwrap();
let err = execute_install(&settings, &hooks, &install_options(Scope::User))
.expect_err("downgrade must be refused");
let msg = err.to_string();
assert!(msg.contains("v2"), "{msg}");
assert!(msg.contains("uninstall"), "{msg}");
let data = read_settings_json(&settings);
let entries = data["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["id"], json!("git-prism-bash-redirect-v2"));
}
#[test]
fn uninstall_removes_only_our_entries() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let settings = home.join(".claude").join("settings.json");
let unrelated = json!({
"hooks": {
"PreToolUse": [
{"id": "user-custom-hook", "matcher": "Bash", "command": "echo unrelated"},
{"id": SENTINEL_ID, "matcher": "Bash", "command": "/abs/git-prism-redirect.sh"}
]
}
});
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
std::fs::write(&settings, serde_json::to_string_pretty(&unrelated).unwrap()).unwrap();
uninstall_redirect_hook(Scope::User, home, home).unwrap();
let data = read_settings_json(&settings);
let entries = data["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["id"], json!("user-custom-hook"));
}
#[test]
fn dry_run_does_not_write_settings_or_copy_scripts() {
let dir = TempDir::new().unwrap();
let settings = dir.path().join("settings.json");
let hooks = dir.path().join("hooks");
let mut options = install_options(Scope::User);
options.dry_run = true;
let outcome = execute_install(&settings, &hooks, &options).unwrap();
match outcome {
InstallOutcome::DryRun(preview) => {
let entries = preview["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(entries[0]["id"], json!(SENTINEL_ID));
}
other => panic!("expected DryRun, got {other:?}"),
}
assert!(
!settings.exists(),
"settings.json was written during dry-run"
);
assert!(!hooks.exists(), "hooks dir was created during dry-run");
}
#[test]
fn local_scope_resolves_to_settings_local_json() {
let home = Path::new("/home/u");
let cwd = Path::new("/proj");
let user = ScopePaths::resolve(Scope::User, home, cwd);
let project = ScopePaths::resolve(Scope::Project, home, cwd);
let local = ScopePaths::resolve(Scope::Local, home, cwd);
assert_eq!(
user.settings_file,
Path::new("/home/u/.claude/settings.json")
);
assert_eq!(
project.settings_file,
Path::new("/proj/.claude/settings.json")
);
assert_eq!(
local.settings_file,
Path::new("/proj/.claude/settings.local.json")
);
assert_eq!(local.hooks_dir, Path::new("/proj/.claude/hooks"));
}
#[test]
fn status_reports_not_installed_when_no_settings_files_exist() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let cwd = home; let report = status_report(home, cwd, false).unwrap();
assert_eq!(report.lines, vec!["not installed".to_string()]);
}
#[test]
fn status_reports_user_scope_when_only_user_is_installed() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let settings = home.join(".claude").join("settings.json");
let hooks = home.join(".claude").join("hooks");
execute_install(&settings, &hooks, &install_options(Scope::User)).unwrap();
let report = status_report(home, home, false).unwrap();
assert_eq!(report.lines, vec![format!("user: {SENTINEL_ID}")]);
}
#[test]
fn status_reports_both_scopes_when_user_and_project_installed() {
let home_dir = TempDir::new().unwrap();
let proj_dir = TempDir::new().unwrap();
let home = home_dir.path();
let cwd = proj_dir.path();
execute_install(
&home.join(".claude/settings.json"),
&home.join(".claude/hooks"),
&install_options(Scope::User),
)
.unwrap();
execute_install(
&cwd.join(".claude/settings.json"),
&cwd.join(".claude/hooks"),
&install_options(Scope::Project),
)
.unwrap();
let report = status_report(home, cwd, true).unwrap();
assert_eq!(
report.lines.len(),
2,
"expected exactly 2 status lines, got: {:?}",
report.lines
);
assert!(
report
.lines
.iter()
.any(|l| l == &format!("user: {SENTINEL_ID}")),
"user scope line missing from: {:?}",
report.lines
);
assert!(
report
.lines
.iter()
.any(|l| l == &format!("project: {SENTINEL_ID}")),
"project scope line missing from: {:?}",
report.lines
);
}
#[test]
fn newer_sentinel_id_returns_v2_when_present() {
let entries = vec![json!({"id": "git-prism-bash-redirect-v2", "command": "x"})];
assert_eq!(
newer_sentinel_id(&entries),
Some("git-prism-bash-redirect-v2".to_string())
);
}
#[test]
fn newer_sentinel_id_returns_v3_when_present() {
let entries = vec![json!({"id": "git-prism-bash-redirect-v3", "command": "x"})];
assert_eq!(
newer_sentinel_id(&entries),
Some("git-prism-bash-redirect-v3".to_string())
);
}
#[test]
fn newer_sentinel_id_returns_v10_when_present() {
let entries = vec![json!({"id": "git-prism-bash-redirect-v10", "command": "x"})];
assert_eq!(
newer_sentinel_id(&entries),
Some("git-prism-bash-redirect-v10".to_string())
);
}
#[test]
fn newer_sentinel_id_returns_none_for_v1_only() {
let entries = vec![json!({"id": SENTINEL_ID, "command": "x"})];
assert_eq!(newer_sentinel_id(&entries), None);
}
#[test]
fn install_into_empty_settings_file_succeeds() {
let dir = TempDir::new().unwrap();
let settings = dir.path().join("settings.json");
let hooks = dir.path().join("hooks");
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
std::fs::write(&settings, "").unwrap();
let outcome = execute_install(&settings, &hooks, &install_options(Scope::User)).unwrap();
assert!(matches!(outcome, InstallOutcome::Installed));
}
#[test]
fn install_preserves_existing_unrelated_pretool_use_entries() {
let dir = TempDir::new().unwrap();
let settings = dir.path().join("settings.json");
let hooks = dir.path().join("hooks");
let existing = json!({
"hooks": {
"PreToolUse": [
{"id": "user-custom-hook", "matcher": "Bash", "command": "echo unrelated"}
]
}
});
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
execute_install(&settings, &hooks, &install_options(Scope::User)).unwrap();
let data = read_settings_json(&settings);
let entries = data["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(entries.len(), 2);
let ids: Vec<_> = entries.iter().map(|e| e["id"].as_str().unwrap()).collect();
assert!(ids.contains(&"user-custom-hook"));
assert!(ids.contains(&SENTINEL_ID));
}
#[test]
fn uninstall_is_a_no_op_when_settings_file_does_not_exist() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let result = uninstall_redirect_hook(Scope::User, home, home);
assert!(result.is_ok(), "uninstall on absent settings must succeed");
assert!(
!home.join(".claude").join("settings.json").exists(),
"uninstall must not create settings.json when it did not exist"
);
}
#[test]
fn other_scopes_with_sentinel_returns_empty_when_only_current_scope_has_it() {
let home_dir = TempDir::new().unwrap();
let proj_dir = TempDir::new().unwrap();
let home = home_dir.path();
let cwd = proj_dir.path();
execute_install(
&home.join(".claude/settings.json"),
&home.join(".claude/hooks"),
&install_options(Scope::User),
)
.unwrap();
let hits = other_scopes_with_sentinel(Scope::User, home, cwd);
assert!(hits.is_empty(), "expected no other scopes, got: {hits:?}");
}
#[test]
fn other_scopes_with_sentinel_returns_user_when_project_also_installed() {
let home_dir = TempDir::new().unwrap();
let proj_dir = TempDir::new().unwrap();
let home = home_dir.path();
let cwd = proj_dir.path();
execute_install(
&home.join(".claude/settings.json"),
&home.join(".claude/hooks"),
&install_options(Scope::User),
)
.unwrap();
execute_install(
&cwd.join(".claude/settings.json"),
&cwd.join(".claude/hooks"),
&install_options(Scope::Project),
)
.unwrap();
let hits = other_scopes_with_sentinel(Scope::Project, home, cwd);
assert_eq!(hits, vec![Scope::User]);
}
#[test]
fn install_redirect_hook_prints_installed_message_on_fresh_install() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let options = InstallOptions {
scope: Scope::User,
dry_run: false,
force: false,
};
let mut stdin = std::io::empty();
let mut stdout_buf = Vec::<u8>::new();
let mut stderr_buf = Vec::<u8>::new();
let code = install_redirect_hook(
&options,
home,
home,
&mut stdin,
&mut stdout_buf,
&mut stderr_buf,
)
.unwrap();
assert_eq!(code, 0);
let out = String::from_utf8(stdout_buf).unwrap();
assert!(
out.contains("Installed"),
"expected 'Installed' in stdout, got: {out:?}"
);
assert!(
out.contains("user"),
"expected scope 'user' in stdout, got: {out:?}"
);
assert!(
String::from_utf8(stderr_buf).unwrap().is_empty(),
"expected empty stderr on fresh install"
);
}
#[test]
fn install_redirect_hook_is_silent_on_already_installed() {
let home_dir = TempDir::new().unwrap();
let cwd_dir = TempDir::new().unwrap(); let home = home_dir.path();
let cwd = cwd_dir.path();
let options = InstallOptions {
scope: Scope::User,
dry_run: false,
force: false,
};
let (mut setup_stdin, mut setup_stdout, mut setup_stderr) =
(std::io::empty(), Vec::<u8>::new(), Vec::<u8>::new());
install_redirect_hook(
&options,
home,
cwd,
&mut setup_stdin,
&mut setup_stdout,
&mut setup_stderr,
)
.unwrap();
let mut stdin2 = std::io::empty();
let mut stdout_buf = Vec::<u8>::new();
let mut stderr_buf = Vec::<u8>::new();
let code = install_redirect_hook(
&options,
home,
cwd,
&mut stdin2,
&mut stdout_buf,
&mut stderr_buf,
)
.unwrap();
assert_eq!(code, 0);
assert!(
String::from_utf8(stdout_buf).unwrap().is_empty(),
"no-op install must produce no stdout"
);
assert!(
String::from_utf8(stderr_buf).unwrap().is_empty(),
"no-op install must produce no stderr"
);
}
#[test]
fn install_redirect_hook_prints_skipped_when_entry_is_user_edited() {
let home_dir = TempDir::new().unwrap();
let cwd_dir = TempDir::new().unwrap(); let home = home_dir.path();
let cwd = cwd_dir.path();
let settings = home.join(".claude").join("settings.json");
let edited = json!({
"hooks": { "PreToolUse": [
{"id": SENTINEL_ID, "matcher": "Bash", "command": "echo HAND-EDITED"}
]}
});
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
std::fs::write(&settings, serde_json::to_string_pretty(&edited).unwrap()).unwrap();
let options = InstallOptions {
scope: Scope::User,
dry_run: false,
force: false,
};
let mut stdin = std::io::empty();
let mut stdout_buf = Vec::<u8>::new();
let mut stderr_buf = Vec::<u8>::new();
let code = install_redirect_hook(
&options,
home,
cwd,
&mut stdin,
&mut stdout_buf,
&mut stderr_buf,
)
.unwrap();
assert_eq!(code, 0);
let out = String::from_utf8(stdout_buf).unwrap();
assert!(
out.contains("skipped"),
"expected 'skipped' in stdout, got: {out:?}"
);
}
#[test]
fn install_redirect_hook_returns_exit_1_on_downgrade_attempt() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let settings = home.join(".claude").join("settings.json");
let v2 = json!({
"hooks": { "PreToolUse": [
{"id": "git-prism-bash-redirect-v2", "matcher": "Bash", "command": "echo v2"}
]}
});
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
std::fs::write(&settings, serde_json::to_string_pretty(&v2).unwrap()).unwrap();
let options = InstallOptions {
scope: Scope::User,
dry_run: false,
force: false,
};
let mut stdin = std::io::empty();
let mut stdout_buf = Vec::<u8>::new();
let mut stderr_buf = Vec::<u8>::new();
let code = install_redirect_hook(
&options,
home,
home,
&mut stdin,
&mut stdout_buf,
&mut stderr_buf,
)
.unwrap();
assert_eq!(code, 1, "downgrade must return exit code 1");
let err = String::from_utf8(stderr_buf).unwrap();
assert!(
err.contains("v2"),
"stderr must name the blocking version, got: {err:?}"
);
assert!(
err.contains("uninstall"),
"stderr must mention 'uninstall', got: {err:?}"
);
}
fn sha256_of_file(path: &Path) -> String {
use crate::treesitter::finalize_hex;
use sha2::{Digest, Sha256};
let bytes = std::fs::read(path).unwrap();
let mut h = Sha256::new();
h.update(&bytes);
finalize_hex(h)
}
}