use crate::paths::Paths;
use anyhow::{Result, bail};
use std::path::PathBuf;
const MARKER: &str = "# >>> keel pre-commit >>>";
const END: &str = "# <<< keel pre-commit <<<";
fn body(keel_bin: &str) -> String {
format!(
"{MARKER}\n\
keel_bin=\"{keel_bin}\"\n\
if [ ! -x \"$keel_bin\" ]; then\n\
\x20 keel_bin=\"$(command -v keel 2>/dev/null)\"\n\
fi\n\
if [ -z \"$keel_bin\" ] || [ ! -x \"$keel_bin\" ]; then\n\
\x20 echo \"keel: cannot find the keel binary, so the store check did not run.\" >&2\n\
\x20 echo \" Put keel on PATH, re-run \\`keel hook install\\`, or commit with --no-verify.\" >&2\n\
\x20 exit 1\n\
fi\n\
if ! \"$keel_bin\" store check; then\n\
\x20 echo >&2\n\
\x20 echo \"keel: projections are not current. Fix them, or commit with --no-verify.\" >&2\n\
\x20 exit 1\n\
fi\n\
{END}\n"
)
}
fn current_binary() -> String {
std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "keel".to_string())
}
fn hook_path(paths: &Paths) -> Result<PathBuf> {
let git = paths.repo.join(".git");
if !git.exists() {
bail!("no .git directory at {} — nothing to hook", paths.repo.display());
}
let dir = if git.is_file() {
let content = std::fs::read_to_string(&git)?;
let rest = content.trim().strip_prefix("gitdir:").unwrap_or("").trim();
let p = PathBuf::from(rest);
if p.is_absolute() { p } else { paths.repo.join(p) }
} else {
git
};
Ok(dir.join("hooks").join("pre-commit"))
}
pub fn install() -> Result<()> {
let paths = Paths::require_init()?;
let path = hook_path(&paths)?;
std::fs::create_dir_all(path.parent().unwrap())?;
let existing = std::fs::read_to_string(&path).unwrap_or_default();
if existing.contains(MARKER) {
println!(" already installed: {}", path.display());
return Ok(());
}
let bin = current_binary();
let content = if existing.trim().is_empty() {
format!("#!/bin/sh\n\n{}", body(&bin))
} else {
let mut s = existing;
if !s.ends_with('\n') { s.push('\n'); }
s.push('\n');
s.push_str(&body(&bin));
s
};
std::fs::write(&path, content)?;
make_executable(&path)?;
println!(" installed {}", path.display());
println!(" calls {bin}");
println!(" a drifted, stale or over-budget projection now blocks the commit");
Ok(())
}
pub fn uninstall() -> Result<()> {
let paths = Paths::require_init()?;
let path = hook_path(&paths)?;
let Ok(existing) = std::fs::read_to_string(&path) else {
println!(" no pre-commit hook installed");
return Ok(());
};
let Some(start) = existing.find(MARKER) else {
println!(" keel block not found in {}", path.display());
return Ok(());
};
let end = existing[start..].find(END).map(|i| start + i + END.len()).unwrap_or(existing.len());
let mut out = String::new();
out.push_str(&existing[..start]);
out.push_str(existing[end..].trim_start_matches('\n'));
let cleaned = out.trim_end().to_string();
if cleaned.trim() == "#!/bin/sh" {
std::fs::remove_file(&path)?;
println!(" removed {}", path.display());
} else {
std::fs::write(&path, format!("{cleaned}\n"))?;
println!(" removed keel block from {}", path.display());
}
Ok(())
}
#[cfg(unix)]
fn make_executable(path: &std::path::Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(perms.mode() | 0o111);
std::fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn make_executable(_path: &std::path::Path) -> Result<()> { Ok(()) }