use std::path::{Path, PathBuf};
use crate::error::{OlError, ERR_HOOK_BINARY_UNRESOLVABLE};
pub fn hook_bin_name() -> &'static str {
if cfg!(windows) {
"openlatch-hook.exe"
} else {
"openlatch-hook"
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StageOutcome {
AlreadyStaged {
target: PathBuf,
},
Staged {
target: PathBuf,
source: PathBuf,
},
}
impl StageOutcome {
pub fn target(&self) -> &Path {
match self {
StageOutcome::AlreadyStaged { target } | StageOutcome::Staged { target, .. } => target,
}
}
}
pub fn stage_hook_binary(ol_dir: &Path) -> Result<StageOutcome, OlError> {
let bin_name = hook_bin_name();
let target_dir = ol_dir.join("bin");
let target = target_dir.join(bin_name);
if target.exists() {
return Ok(StageOutcome::AlreadyStaged { target });
}
let Some(source) = locate_hook_source(bin_name) else {
return Err(OlError::new(
ERR_HOOK_BINARY_UNRESOLVABLE,
format!(
"Cannot locate an '{bin_name}' binary to install into {}",
target_dir.display()
),
)
.with_suggestion(format!(
"Reinstall OpenLatch so '{bin_name}' sits next to the 'openlatch' binary, \
or point OPENLATCH_HOOK_BIN at an existing one."
)));
};
std::fs::create_dir_all(&target_dir).map_err(|e| {
OlError::new(
ERR_HOOK_BINARY_UNRESOLVABLE,
format!("Cannot create '{}': {e}", target_dir.display()),
)
.with_suggestion("Check that you have write permission to the openlatch directory.")
})?;
std::fs::copy(&source, &target).map_err(|e| {
OlError::new(
ERR_HOOK_BINARY_UNRESOLVABLE,
format!(
"Cannot copy '{}' to '{}': {e}",
source.display(),
target.display()
),
)
.with_suggestion("Check that you have write permission to the openlatch directory.")
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).map_err(|e| {
OlError::new(
ERR_HOOK_BINARY_UNRESOLVABLE,
format!("Cannot make '{}' executable: {e}", target.display()),
)
})?;
}
Ok(StageOutcome::Staged { target, source })
}
fn locate_hook_source(bin_name: &str) -> Option<PathBuf> {
if let Ok(override_path) = std::env::var("OPENLATCH_HOOK_BIN") {
if !override_path.is_empty() {
let p = PathBuf::from(override_path);
if p.is_file() {
return Some(p);
}
}
}
let current_exe = std::env::current_exe().ok()?;
let candidate = current_exe.parent()?.join(bin_name);
candidate.is_file().then_some(candidate)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_hook_bin_env<T>(value: &str, f: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var("OPENLATCH_HOOK_BIN").ok();
std::env::set_var("OPENLATCH_HOOK_BIN", value);
let out = f();
match prev {
Some(p) => std::env::set_var("OPENLATCH_HOOK_BIN", p),
None => std::env::remove_var("OPENLATCH_HOOK_BIN"),
}
out
}
fn sibling_hook_exists() -> bool {
std::env::current_exe()
.ok()
.and_then(|e| e.parent().map(|d| d.join(hook_bin_name())))
.is_some_and(|p| p.is_file())
}
#[test]
fn already_staged_is_a_noop() {
let tmp = TempDir::new().unwrap();
let bin_dir = tmp.path().join("bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let target = bin_dir.join(hook_bin_name());
std::fs::write(&target, b"existing").unwrap();
let outcome = stage_hook_binary(tmp.path()).unwrap();
assert_eq!(outcome, StageOutcome::AlreadyStaged { target });
assert_eq!(std::fs::read(outcome.target()).unwrap(), b"existing");
}
#[test]
fn missing_source_is_an_error_not_a_silent_skip() {
let tmp = TempDir::new().unwrap();
let missing = tmp.path().join("nowhere").join(hook_bin_name());
let result = with_hook_bin_env(&missing.to_string_lossy(), || {
(stage_hook_binary(tmp.path()), sibling_hook_exists())
});
match result {
(Err(e), false) => {
assert_eq!(e.code, ERR_HOOK_BINARY_UNRESOLVABLE);
assert!(!tmp.path().join("bin").join(hook_bin_name()).exists());
}
(Ok(outcome), true) => {
assert!(outcome.target().starts_with(tmp.path()));
}
(other, sibling) => {
panic!("unexpected staging result {other:?} with sibling_hook_exists={sibling}")
}
}
}
#[test]
fn stages_from_the_env_override() {
let tmp = TempDir::new().unwrap();
let source = tmp.path().join("source-hook");
std::fs::write(&source, b"#!/bin/sh\nexit 0\n").unwrap();
let outcome = with_hook_bin_env(&source.to_string_lossy(), || {
stage_hook_binary(tmp.path()).unwrap()
});
let target = tmp.path().join("bin").join(hook_bin_name());
assert_eq!(outcome, StageOutcome::Staged { target, source });
assert!(outcome.target().is_file());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(outcome.target())
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o111, 0o111, "staged copy must be executable");
}
}
}