use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use super::{InitError, InstallOutcome, InstallPath, InstallStatus};
const MANAGED_MARKER: &str = "ptuf-managed: cline PreToolUse";
pub const DEFAULT_MATCHER: &str = "PreToolUse";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetPaths {
pub hook_path: PathBuf,
pub global: bool,
}
pub fn detect_binary() -> String {
super::detect_binary_impl()
}
fn cline_hook_file_name() -> &'static str {
if cfg!(windows) {
"PreToolUse.ps1"
} else {
"PreToolUse"
}
}
pub fn resolve_paths(start: Option<&Path>) -> Result<TargetPaths, InitError> {
let file_name = cline_hook_file_name();
if let Some(root) = start.and_then(crate::config::repo::discover) {
return Ok(TargetPaths {
hook_path: root.join(".clinerules/hooks").join(file_name),
global: false,
});
}
let home = std::env::var_os("HOME").ok_or(InitError::HomeNotSet)?;
Ok(TargetPaths {
hook_path: PathBuf::from(home)
.join("Documents/Cline/Hooks")
.join(file_name),
global: true,
})
}
pub fn install(
targets: &TargetPaths,
ptuf_binary: &str,
dry_run: bool,
) -> Result<InstallOutcome, InitError> {
let command = format!("{ptuf_binary} hook cline");
let desired = render_wrapper(ptuf_binary);
let status = match fs::read(&targets.hook_path) {
Ok(existing) if !is_ptuf_managed(&existing) => {
return Err(InitError::HookFileConflict {
path: targets.hook_path.clone(),
});
},
Ok(existing) if existing == desired => InstallStatus::AlreadyPresent,
Ok(_) => apply(&targets.hook_path, &desired, dry_run)?,
Err(e) if e.kind() == ErrorKind::NotFound => apply(&targets.hook_path, &desired, dry_run)?,
Err(e) => {
return Err(InitError::Io {
path: targets.hook_path.clone(),
source: e,
});
},
};
let label = if targets.global {
"hook (global)"
} else {
"hook"
};
Ok(InstallOutcome {
status,
agent: "cline",
paths: vec![InstallPath {
label,
path: targets.hook_path.clone(),
}],
matcher: DEFAULT_MATCHER.to_string(),
command,
})
}
fn apply(path: &Path, desired: &[u8], dry_run: bool) -> Result<InstallStatus, InitError> {
if dry_run {
return Ok(InstallStatus::WouldInstall);
}
write_executable_atomically(path, desired)?;
Ok(InstallStatus::Installed)
}
fn render_wrapper(ptuf_binary: &str) -> Vec<u8> {
if cfg!(windows) {
format!(
"# {MANAGED_MARKER}\n& {} hook cline\nexit $LASTEXITCODE\n",
quote_powershell(ptuf_binary),
)
.into_bytes()
} else {
format!(
"#!/usr/bin/env sh\n# {MANAGED_MARKER}\nexec {} hook cline\n",
quote_sh(ptuf_binary),
)
.into_bytes()
}
}
fn quote_sh(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn quote_powershell(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn is_ptuf_managed(bytes: &[u8]) -> bool {
let text = String::from_utf8_lossy(bytes);
text.contains(MANAGED_MARKER) || text.contains("ptuf hook cline")
}
fn write_executable_atomically(path: &Path, bytes: &[u8]) -> Result<(), InitError> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).map_err(|e| InitError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
let tmp = sibling_temp_path(path);
crate::init::write_executable(&tmp, bytes).map_err(|e| InitError::Io {
path: tmp.clone(),
source: e,
})?;
fs::rename(&tmp, path).map_err(|e| InitError::Io {
path: path.to_path_buf(),
source: e,
})
}
fn sibling_temp_path(path: &Path) -> PathBuf {
super::sibling_install_tmp_path(path, "PreToolUse")
}
#[cfg(test)]
mod tests {
use super::*;
fn workdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ptuf-init-cline-{}-{}-{}",
tag,
std::process::id(),
line!()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap()
}
fn repo_targets(dir: &Path) -> TargetPaths {
TargetPaths {
hook_path: dir.join(".clinerules/hooks").join(cline_hook_file_name()),
global: false,
}
}
#[test]
fn resolve_paths_targets_repo_local_clinerules_hooks() {
let dir = workdir("resolve-repo");
fs::create_dir_all(dir.join(".git")).unwrap();
let targets = resolve_paths(Some(dir.as_path())).unwrap();
assert!(!targets.global);
assert!(
targets.hook_path.ends_with(".clinerules/hooks/PreToolUse")
|| targets
.hook_path
.ends_with(".clinerules/hooks/PreToolUse.ps1"),
"got: {:?}",
targets.hook_path,
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn resolve_paths_falls_back_to_global_cline_hooks_dir() {
match resolve_paths(None) {
Ok(targets) => {
assert!(targets.global);
assert!(
targets.hook_path.starts_with(
PathBuf::from(std::env::var_os("HOME").unwrap())
.join("Documents/Cline/Hooks"),
),
"got: {:?}",
targets.hook_path,
);
},
Err(InitError::HomeNotSet) => {},
Err(other) => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn cline_hook_file_name_matches_platform() {
let name = cline_hook_file_name();
if cfg!(windows) {
assert_eq!(name, "PreToolUse.ps1");
} else {
assert_eq!(name, "PreToolUse");
}
}
#[test]
fn install_writes_wrapper_with_marker_and_hook_command() {
let dir = workdir("install-marker");
let targets = repo_targets(&dir);
let outcome = install(&targets, "/usr/local/bin/ptuf", false).unwrap();
assert_eq!(outcome.status, InstallStatus::Installed);
assert_eq!(outcome.agent, "cline");
assert_eq!(outcome.matcher, "PreToolUse");
let body = read(&targets.hook_path);
assert!(
body.contains("ptuf-managed: cline PreToolUse"),
"body: {body}"
);
assert!(body.contains("hook cline"), "body: {body}");
assert!(body.contains("/usr/local/bin/ptuf"), "body: {body}");
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn install_marks_wrapper_executable() {
use std::os::unix::fs::PermissionsExt;
let dir = workdir("install-exec");
let targets = repo_targets(&dir);
install(&targets, "/usr/local/bin/ptuf", false).unwrap();
let mode = fs::metadata(&targets.hook_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o700, "wrapper must be owner-only executable");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_dry_run_reports_changes_without_writing() {
let dir = workdir("dry-run");
let targets = repo_targets(&dir);
let outcome = install(&targets, "/usr/local/bin/ptuf", true).unwrap();
assert_eq!(outcome.status, InstallStatus::WouldInstall);
assert!(!targets.hook_path.exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_is_idempotent_when_wrapper_is_byte_identical() {
let dir = workdir("idempotent");
let targets = repo_targets(&dir);
install(&targets, "/x/ptuf", false).unwrap();
let before = read(&targets.hook_path);
let outcome = install(&targets, "/x/ptuf", false).unwrap();
assert_eq!(outcome.status, InstallStatus::AlreadyPresent);
assert_eq!(before, read(&targets.hook_path));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_rewrites_managed_wrapper_when_binary_path_changes() {
let dir = workdir("update-binary");
let targets = repo_targets(&dir);
install(&targets, "/old/ptuf", false).unwrap();
let outcome = install(&targets, "/new/ptuf", false).unwrap();
assert_eq!(outcome.status, InstallStatus::Installed);
let body = read(&targets.hook_path);
assert!(body.contains("/new/ptuf"), "body: {body}");
assert!(!body.contains("/old/ptuf"), "body: {body}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_dry_run_on_managed_wrapper_change_does_not_write() {
let dir = workdir("dry-run-update");
let targets = repo_targets(&dir);
install(&targets, "/old/ptuf", false).unwrap();
let before = read(&targets.hook_path);
let outcome = install(&targets, "/new/ptuf", true).unwrap();
assert_eq!(outcome.status, InstallStatus::WouldInstall);
assert_eq!(before, read(&targets.hook_path), "dry-run must not rewrite");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_refuses_to_overwrite_unmanaged_hook() {
let dir = workdir("conflict");
let targets = repo_targets(&dir);
fs::create_dir_all(targets.hook_path.parent().unwrap()).unwrap();
fs::write(&targets.hook_path, "#!/bin/sh\necho hand-written\n").unwrap();
let err = install(&targets, "/x/ptuf", false).unwrap_err();
match err {
InitError::HookFileConflict { path } => assert_eq!(path, targets.hook_path),
other => panic!("expected HookFileConflict, got {other:?}"),
}
assert_eq!(read(&targets.hook_path), "#!/bin/sh\necho hand-written\n");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_reports_io_error_when_hook_path_is_a_directory() {
let dir = workdir("hook-is-dir");
let hook_path = dir.join("PreToolUse");
fs::create_dir_all(&hook_path).unwrap();
let targets = TargetPaths {
hook_path,
global: false,
};
let err = install(&targets, "/x/ptuf", false).unwrap_err();
assert!(matches!(err, InitError::Io { .. }), "got: {err:?}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn install_global_outcome_labels_path_as_global() {
let dir = workdir("global-label");
let targets = TargetPaths {
hook_path: dir.join("Documents/Cline/Hooks/PreToolUse"),
global: true,
};
let outcome = install(&targets, "/x/ptuf", false).unwrap();
assert_eq!(outcome.paths[0].label, "hook (global)");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn quote_sh_escapes_embedded_single_quotes() {
assert_eq!(quote_sh("/p/ptuf"), "'/p/ptuf'");
assert_eq!(quote_sh("/a'b/ptuf"), "'/a'\\''b/ptuf'");
}
#[test]
fn quote_powershell_doubles_embedded_single_quotes() {
assert_eq!(quote_powershell(r"C:\p\ptuf.exe"), r"'C:\p\ptuf.exe'");
assert_eq!(quote_powershell("a'b"), "'a''b'");
}
#[test]
fn is_ptuf_managed_recognises_marker_and_invocation() {
assert!(is_ptuf_managed(b"# ptuf-managed: cline PreToolUse\n"));
assert!(is_ptuf_managed(b"do ptuf hook cline now"));
assert!(!is_ptuf_managed(b"#!/bin/sh\necho hello\n"));
assert!(!is_ptuf_managed(&[0xff, 0xfe]));
}
#[test]
fn detect_binary_delegates_to_shared_impl() {
assert!(!detect_binary().is_empty());
}
}