use thiserror::Error;
pub const MANAGED_START: &str = "# >>> klasp managed start <<<";
pub const MANAGED_END: &str = "# >>> klasp managed end <<<";
pub const SHEBANG: &str = "#!/usr/bin/env sh";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookKind {
Commit,
Push,
}
impl HookKind {
pub const fn filename(self) -> &'static str {
match self {
HookKind::Commit => "pre-commit",
HookKind::Push => "pre-push",
}
}
pub const fn trigger_arg(self) -> &'static str {
match self {
HookKind::Commit => "commit",
HookKind::Push => "push",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookConflict {
Husky,
Lefthook,
PreCommit,
}
impl HookConflict {
pub const fn tool(self) -> &'static str {
match self {
HookConflict::Husky => "husky",
HookConflict::Lefthook => "lefthook",
HookConflict::PreCommit => "pre-commit",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookWarning {
Skipped {
path: std::path::PathBuf,
kind: HookKind,
conflict: HookConflict,
},
}
#[derive(Debug, Error)]
pub enum HookError {
#[error(
"git hook: managed-block markers are malformed \
(expected exactly one `{MANAGED_START}` followed by one `{MANAGED_END}`). \
Fix the file by hand or remove both markers and re-run install."
)]
MalformedMarkers,
}
pub fn render_managed_body(kind: HookKind, schema_version: u32) -> String {
format!(
"# Managed by klasp install. Re-run `klasp install` to regenerate.\n\
KLASP_GATE_SCHEMA={ver} exec klasp gate --agent codex --trigger {trigger} \"$@\"\n",
ver = schema_version,
trigger = kind.trigger_arg(),
)
}
pub fn render_managed_block(kind: HookKind, schema_version: u32) -> String {
let body = render_managed_body(kind, schema_version);
let trimmed = body.trim_end_matches('\n');
format!("{MANAGED_START}\n{trimmed}\n{MANAGED_END}\n")
}
pub fn install_block(
existing: &str,
kind: HookKind,
schema_version: u32,
) -> Result<String, HookError> {
let block = render_managed_block(kind, schema_version);
if let Some(span) = find_block(existing)? {
let mut out = String::with_capacity(existing.len() + block.len());
out.push_str(&existing[..span.start]);
out.push_str(&block);
out.push_str(&existing[span.end..]);
return Ok(out);
}
let trimmed = existing.trim();
if trimmed.is_empty() {
let mut out = String::with_capacity(SHEBANG.len() + block.len() + 2);
out.push_str(SHEBANG);
out.push_str("\n\n");
out.push_str(&block);
return Ok(out);
}
let mut out = String::with_capacity(existing.len() + SHEBANG.len() + block.len() + 4);
if has_shebang(existing) {
out.push_str(existing.trim_end_matches('\n'));
out.push_str("\n\n");
out.push_str(&block);
} else {
out.push_str(SHEBANG);
out.push_str("\n\n");
out.push_str(existing.trim_end_matches('\n'));
out.push_str("\n\n");
out.push_str(&block);
}
Ok(out)
}
pub fn uninstall_block(existing: &str) -> Result<String, HookError> {
let Some(span) = find_block(existing)? else {
return Ok(existing.to_string());
};
let before = &existing[..span.start];
let after = &existing[span.end..];
let mut out = String::with_capacity(before.len() + after.len() + 1);
if before.is_empty() {
out.push_str(after);
} else if after.is_empty() && is_only_shebang_or_whitespace(before) {
} else if after.is_empty() {
out.push_str(before.trim_end_matches('\n'));
out.push('\n');
} else {
out.push_str(before);
out.push_str(after);
}
Ok(out)
}
pub fn contains_block(existing: &str) -> Result<bool, HookError> {
Ok(find_block(existing)?.is_some())
}
pub fn detect_conflict(existing: &str) -> Option<HookConflict> {
if existing.contains("/_/husky.sh\"") || existing.contains("/_/h\"") {
return Some(HookConflict::Husky);
}
if existing.contains("DON'T REMOVE THIS LINE (lefthook)") && existing.contains("lefthook") {
return Some(HookConflict::Lefthook);
}
if existing.contains("File generated by pre-commit: https://pre-commit.com") {
return Some(HookConflict::PreCommit);
}
None
}
struct Span {
start: usize,
end: usize,
}
fn find_block(existing: &str) -> Result<Option<Span>, HookError> {
let (Some(start), Some(end_marker_start)) =
(existing.find(MANAGED_START), existing.find(MANAGED_END))
else {
return if existing.contains(MANAGED_START) || existing.contains(MANAGED_END) {
Err(HookError::MalformedMarkers)
} else {
Ok(None)
};
};
if existing.rfind(MANAGED_START) != Some(start)
|| existing.rfind(MANAGED_END) != Some(end_marker_start)
|| end_marker_start < start
{
return Err(HookError::MalformedMarkers);
}
let after_marker = end_marker_start + MANAGED_END.len();
let end = if existing.as_bytes().get(after_marker) == Some(&b'\n') {
after_marker + 1
} else {
after_marker
};
Ok(Some(Span { start, end }))
}
fn has_shebang(s: &str) -> bool {
s.starts_with("#!")
}
fn is_only_shebang_or_whitespace(s: &str) -> bool {
let trimmed = s.trim();
trimmed.is_empty() || (trimmed.starts_with("#!") && !trimmed.contains('\n'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_block_wraps_body_in_markers() {
let s = render_managed_block(HookKind::Commit, 2);
assert!(s.starts_with(MANAGED_START));
assert!(s.contains("KLASP_GATE_SCHEMA=2"));
assert!(
!s.contains("KLASP_GATE_SCHEMA=1"),
"block must not contain stale schema v1"
);
assert!(
!s.contains("KLASP_GATE_SCHEMA=0"),
"block must not contain stale schema v0"
);
assert!(s.contains("--trigger commit"));
assert!(s.contains("--agent codex"));
assert!(s.trim_end().ends_with(MANAGED_END));
}
#[test]
fn render_block_uses_push_trigger_for_pre_push() {
let s = render_managed_block(HookKind::Push, 1);
assert!(s.contains("--trigger push"));
assert!(!s.contains("--trigger commit"));
}
#[test]
fn render_block_parameterises_schema_version() {
let s = render_managed_block(HookKind::Commit, 7);
assert!(s.contains("KLASP_GATE_SCHEMA=7"));
}
#[test]
fn install_into_empty_emits_shebang_and_block() {
let out = install_block("", HookKind::Commit, 1).unwrap();
assert!(out.starts_with(SHEBANG));
assert!(out.contains(MANAGED_START));
assert!(out.trim_end().ends_with(MANAGED_END));
}
#[test]
fn install_into_user_hook_with_shebang_appends() {
let pre = "#!/bin/bash\n\necho 'user lint'\n";
let out = install_block(pre, HookKind::Commit, 1).unwrap();
assert!(out.starts_with(pre));
assert!(out.contains("echo 'user lint'"));
let after_pre = &out[pre.len()..];
assert!(after_pre.starts_with('\n'));
assert!(after_pre[1..].starts_with(MANAGED_START));
}
#[test]
fn install_into_user_hook_without_shebang_prepends_one() {
let pre = "echo lint\n";
let out = install_block(pre, HookKind::Commit, 1).unwrap();
assert!(out.starts_with(SHEBANG));
assert!(out.contains("echo lint"));
assert!(out.contains(MANAGED_START));
}
#[test]
fn install_replaces_existing_block_in_place() {
let stale = render_managed_block(HookKind::Commit, 0);
let pre = format!("#!/bin/bash\n\n{stale}\nset -e\n");
let out = install_block(&pre, HookKind::Commit, 2).unwrap();
assert!(out.contains("KLASP_GATE_SCHEMA=2"));
assert!(
!out.contains("KLASP_GATE_SCHEMA=1"),
"block must not contain stale schema v1"
);
assert!(
!out.contains("KLASP_GATE_SCHEMA=0"),
"block must not contain stale schema v0"
);
assert!(out.starts_with("#!/bin/bash\n\n"));
assert!(out.ends_with("set -e\n"));
}
#[test]
fn install_is_idempotent() {
let pre = "#!/bin/bash\n\necho 'user lint'\n";
let once = install_block(pre, HookKind::Commit, 1).unwrap();
let twice = install_block(&once, HookKind::Commit, 1).unwrap();
assert_eq!(once, twice);
}
#[test]
fn install_uninstall_round_trip_on_user_hook_restores_input() {
let pre = "#!/bin/bash\n\necho 'user lint'\n";
let installed = install_block(pre, HookKind::Commit, 1).unwrap();
let restored = uninstall_block(&installed).unwrap();
assert_eq!(restored, pre);
}
#[test]
fn install_uninstall_round_trip_on_empty_collapses_to_empty() {
let installed = install_block("", HookKind::Commit, 1).unwrap();
let restored = uninstall_block(&installed).unwrap();
assert_eq!(restored, "", "fresh-create round-trip must empty out");
}
#[test]
fn uninstall_is_noop_when_no_block_present() {
let pre = "#!/bin/bash\n\necho lint\n";
assert_eq!(uninstall_block(pre).unwrap(), pre);
}
#[test]
fn malformed_markers_rejected() {
let pre = format!("#!/bin/bash\n{MANAGED_START}\nbody\n");
let err = install_block(&pre, HookKind::Commit, 1).expect_err("must fail");
assert!(matches!(err, HookError::MalformedMarkers));
}
#[test]
fn duplicate_start_marker_rejected() {
let pre = format!(
"#!/bin/bash\n{MANAGED_START}\nbody\n{MANAGED_END}\n{MANAGED_START}\nbody2\n{MANAGED_END}\n"
);
let err = install_block(&pre, HookKind::Commit, 1).expect_err("must fail");
assert!(matches!(err, HookError::MalformedMarkers));
}
#[test]
fn detect_conflict_avoids_false_positive_on_lone_lefthook_word() {
let pre = "#!/bin/sh\n# we used to use lefthook, removed it\necho lint\n";
assert_eq!(detect_conflict(pre), None);
assert_eq!(detect_conflict(""), None);
}
#[test]
fn hook_kind_constants_are_canonical() {
assert_eq!(HookKind::Commit.filename(), "pre-commit");
assert_eq!(HookKind::Push.filename(), "pre-push");
assert_eq!(HookKind::Commit.trigger_arg(), "commit");
assert_eq!(HookKind::Push.trigger_arg(), "push");
assert_eq!(HookConflict::Husky.tool(), "husky");
assert_eq!(HookConflict::Lefthook.tool(), "lefthook");
assert_eq!(HookConflict::PreCommit.tool(), "pre-commit");
}
}