use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use crate::cli::HookAgent;
use crate::config::scope::{EnvLookup, SystemEnv};
pub mod claude_code;
pub mod cline;
pub mod codex;
pub mod copilot;
pub mod cursor;
pub mod kiro;
pub mod opencode;
pub mod pi;
pub mod verify;
pub(crate) fn command_executable(cmd: &str) -> Option<&str> {
cmd.split_whitespace().next()
}
pub fn detect_agents(cwd: Option<&Path>, home: Option<&Path>) -> Vec<HookAgent> {
detect_agents_with_env(cwd, home, &SystemEnv)
}
pub fn detect_agents_with_env(
cwd: Option<&Path>,
home: Option<&Path>,
env: &dyn EnvLookup,
) -> Vec<HookAgent> {
let repo = cwd.and_then(crate::config::repo::discover);
let mut found = Vec::new();
if home.is_some_and(|h| h.join(".claude").is_dir()) {
found.push(HookAgent::ClaudeCode);
}
if repo.as_deref().is_some_and(|r| r.join(".codex").is_dir())
|| home.is_some_and(|h| h.join(".codex").is_dir())
{
found.push(HookAgent::Codex);
}
if repo.as_deref().is_some_and(|r| r.join(".github").is_dir()) {
found.push(HookAgent::Copilot);
}
if repo.as_deref().is_some_and(|r| r.join(".kiro").is_dir())
|| home.is_some_and(|h| h.join(".kiro").is_dir())
{
found.push(HookAgent::Kiro);
}
if repo
.as_deref()
.is_some_and(|r| r.join(".clinerules").is_dir() || r.join(".cline").is_dir())
|| home.is_some_and(|h| h.join("Documents/Cline").is_dir() || h.join(".cline").is_dir())
{
found.push(HookAgent::Cline);
}
if repo.as_deref().is_some_and(|r| r.join(".cursor").is_dir())
|| home.is_some_and(|h| h.join(".cursor").is_dir())
{
found.push(HookAgent::Cursor);
}
if home.is_some_and(|h| h.join(".pi/agent").is_dir())
|| repo.as_deref().is_some_and(|r| r.join(".pi").is_dir())
{
found.push(HookAgent::Pi);
}
if home.is_some_and(|h| h.join(".config/opencode").is_dir())
|| env_opencode_config_dir(home, env).is_some_and(|p| p.is_dir())
|| repo
.as_deref()
.is_some_and(|r| r.join(".opencode").is_dir())
|| repo
.as_deref()
.is_some_and(|r| r.join("opencode.json").is_file())
{
found.push(HookAgent::Opencode);
}
found
}
fn env_opencode_config_dir(home: Option<&Path>, env: &dyn EnvLookup) -> Option<PathBuf> {
env.var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.map(|p| p.join("opencode"))
.or_else(|| home.map(|h| h.join(".config/opencode")))
}
#[derive(Debug)]
pub enum InitError {
UnknownAgent(String),
Io {
path: PathBuf,
source: std::io::Error,
},
Json { path: PathBuf, message: String },
Toml { path: PathBuf, message: String },
Schema { path: PathBuf, message: String },
HomeNotSet,
RepoRootNotFound,
HookFileConflict { path: PathBuf },
}
impl std::fmt::Display for InitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownAgent(a) => write!(f, "unknown agent: {a}"),
Self::Io { path, source } => {
write!(f, "io error at {}: {source}", path.display())
},
Self::Json { path, message } => {
write!(f, "invalid JSON in {}: {message}", path.display())
},
Self::Toml { path, message } => {
write!(f, "invalid TOML in {}: {message}", path.display())
},
Self::Schema { path, message } => {
write!(
f,
"unexpected settings shape in {}: {message}",
path.display()
)
},
Self::HomeNotSet => write!(
f,
"$HOME is not set; ptuf init needs HOME to locate the agent's settings file"
),
Self::RepoRootNotFound => write!(
f,
"could not discover a repository root; run ptuf init from inside a git working tree"
),
Self::HookFileConflict { path } => write!(
f,
"existing Cline PreToolUse hook is not managed by ptuf: {}; move or remove it, then re-run ptuf init cline",
path.display()
),
}
}
}
impl std::error::Error for InitError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct InstallOutcome {
pub status: InstallStatus,
pub agent: &'static str,
pub paths: Vec<InstallPath>,
pub matcher: String,
pub command: String,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct AdapterRunReport {
pub outcome: InstallOutcome,
pub kiro: Option<kiro::KiroInstallExtras>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct InstallPath {
pub label: &'static str,
pub path: PathBuf,
}
#[derive(Debug, PartialEq, Eq)]
pub enum InstallStatus {
AlreadyPresent,
Installed,
WouldInstall,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct PathSnapshot {
pub path: PathBuf,
pub previous: Option<Vec<u8>>,
}
pub(crate) fn capture(paths: &[&Path]) -> Result<Vec<PathSnapshot>, InitError> {
let mut out = Vec::with_capacity(paths.len());
for path in paths {
let previous = match fs::read(path) {
Ok(bytes) => Some(bytes),
Err(e) if e.kind() == ErrorKind::NotFound => None,
Err(e) => {
return Err(InitError::Io {
path: path.to_path_buf(),
source: e,
});
},
};
out.push(PathSnapshot {
path: path.to_path_buf(),
previous,
});
}
Ok(out)
}
pub(crate) fn restore(snapshots: &[PathSnapshot]) -> Result<(), InitError> {
let mut first_err: Option<InitError> = None;
for snap in snapshots {
if let Err(e) = restore_one(snap)
&& first_err.is_none()
{
first_err = Some(e);
}
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
fn restore_one(snap: &PathSnapshot) -> Result<(), InitError> {
match &snap.previous {
None => match fs::remove_file(&snap.path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
Err(e) => Err(InitError::Io {
path: snap.path.clone(),
source: e,
}),
},
Some(bytes) => write_atomically(&snap.path, bytes),
}
}
fn write_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);
write_secure(&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,
})
}
#[cfg(unix)]
pub(crate) fn write_secure(tmp: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let open = || {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(tmp)
};
let mut file = match open() {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
std::fs::remove_file(tmp)?;
open()?
},
Err(e) => return Err(e),
};
file.write_all(bytes)?;
file.sync_all()
}
#[cfg(not(unix))]
pub(crate) fn write_secure(tmp: &Path, bytes: &[u8]) -> std::io::Result<()> {
std::fs::write(tmp, bytes)
}
#[cfg(unix)]
pub(crate) fn write_executable(tmp: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let open = || {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o700)
.open(tmp)
};
let mut file = match open() {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
std::fs::remove_file(tmp)?;
open()?
},
Err(e) => return Err(e),
};
file.write_all(bytes)?;
file.sync_all()
}
#[cfg(not(unix))]
pub(crate) fn write_executable(tmp: &Path, bytes: &[u8]) -> std::io::Result<()> {
std::fs::write(tmp, bytes)
}
pub(crate) fn command_invokes_ptuf_hook(cmd: &str, tail: &[&str]) -> bool {
let tokens: Vec<&str> = cmd.split_whitespace().collect();
let n = tokens.len();
if n < tail.len() {
return false;
}
tokens[n - tail.len()..] == *tail
}
pub(crate) fn detect_binary_impl() -> String {
std::env::current_exe()
.ok()
.and_then(|p| p.into_os_string().into_string().ok())
.unwrap_or_else(|| "ptuf".to_string())
}
pub(crate) fn sibling_install_tmp_path(path: &Path, default_name: &str) -> PathBuf {
let mut name = path.file_name().map_or_else(
|| std::ffi::OsString::from(default_name),
std::ffi::OsStr::to_os_string,
);
name.push(format!(".ptuf.{}.tmp", std::process::id()));
match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.join(name),
_ => PathBuf::from(name),
}
}
fn sibling_temp_path(path: &Path) -> PathBuf {
let mut name = path.file_name().map_or_else(
|| std::ffi::OsString::from("snapshot.tmp"),
std::ffi::OsStr::to_os_string,
);
name.push(format!(".ptuf-snap.{}.tmp", std::process::id()));
match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.join(name),
_ => PathBuf::from(name),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn init_error_display_covers_all_variants() {
assert!(format!("{}", InitError::UnknownAgent("x".into())).contains("unknown agent"));
assert!(
format!(
"{}",
InitError::Io {
path: PathBuf::from("/p"),
source: std::io::Error::other("boom")
}
)
.contains("io error")
);
assert!(
format!(
"{}",
InitError::Json {
path: PathBuf::from("/p"),
message: "bad".into()
}
)
.contains("invalid JSON")
);
assert!(
format!(
"{}",
InitError::Toml {
path: PathBuf::from("/p"),
message: "bad".into()
}
)
.contains("invalid TOML")
);
assert!(
format!(
"{}",
InitError::Schema {
path: PathBuf::from("/p"),
message: "wrong type".into()
}
)
.contains("unexpected settings shape")
);
assert!(format!("{}", InitError::HomeNotSet).contains("HOME"));
assert!(format!("{}", InitError::RepoRootNotFound).contains("repository root"));
assert!(
format!(
"{}",
InitError::HookFileConflict {
path: PathBuf::from("/p/PreToolUse")
}
)
.contains("not managed by ptuf")
);
let io_err = InitError::Io {
path: PathBuf::from("/p"),
source: std::io::Error::other("x"),
};
assert!(std::error::Error::source(&io_err).is_some());
assert!(std::error::Error::source(&InitError::HomeNotSet).is_none());
}
fn workdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ptuf-init-snap-{}-{}-{}",
tag,
std::process::id(),
line!()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("mkdir");
dir
}
#[test]
fn capture_records_existing_and_missing_files() {
let dir = workdir("capture");
let exists = dir.join("a.json");
let missing = dir.join("b.json");
fs::write(&exists, b"hello").expect("write a");
let snaps = capture(&[exists.as_path(), missing.as_path()]).expect("capture");
assert_eq!(snaps.len(), 2);
assert_eq!(snaps[0].path, exists);
assert_eq!(snaps[0].previous.as_deref(), Some(b"hello".as_slice()));
assert_eq!(snaps[1].path, missing);
assert!(snaps[1].previous.is_none());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn restore_recreates_original_contents() {
let dir = workdir("restore-existing");
let path = dir.join("a.json");
fs::write(&path, b"original").expect("write");
let snaps = capture(&[path.as_path()]).expect("capture");
fs::write(&path, b"polluted").expect("overwrite");
restore(&snaps).expect("restore");
assert_eq!(fs::read(&path).unwrap(), b"original");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn restore_removes_files_that_did_not_exist_before() {
let dir = workdir("restore-missing");
let path = dir.join("a.json");
let snaps = capture(&[path.as_path()]).expect("capture");
assert!(snaps[0].previous.is_none());
fs::write(&path, b"new content").expect("write new");
restore(&snaps).expect("restore");
assert!(!path.exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn restore_is_idempotent_when_target_already_absent() {
let dir = workdir("restore-noop");
let path = dir.join("never-existed.json");
let snaps = capture(&[path.as_path()]).expect("capture");
restore(&snaps).expect("restore noop");
assert!(!path.exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_atomically_returns_io_error_when_parent_is_a_regular_file() {
let dir = workdir("atomic-parent-is-file");
let blocker = dir.join("blocker");
fs::write(&blocker, b"i am a file").expect("write blocker");
let target = blocker.join("nested").join("file.json");
let err = write_atomically(&target, b"hello").expect_err("must fail");
match err {
InitError::Io { source, .. } => {
let kind = source.kind();
assert!(
kind == ErrorKind::NotADirectory
|| kind == ErrorKind::AlreadyExists
|| kind == ErrorKind::NotFound
|| kind == ErrorKind::Other,
"unexpected error kind: {kind:?}",
);
},
other => panic!("expected Io error, got {other:?}"),
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn restore_propagates_first_error_when_atomic_write_fails() {
let dir = workdir("restore-error");
let blocker = dir.join("blocker");
fs::write(&blocker, b"blocker").expect("write blocker");
let bad_path = blocker.join("nested").join("settings.json");
let good_path = dir.join("good.json");
fs::write(&good_path, b"polluted").expect("write good");
let snaps = vec![
PathSnapshot {
path: bad_path.clone(),
previous: Some(b"original".to_vec()),
},
PathSnapshot {
path: good_path.clone(),
previous: Some(b"good-original".to_vec()),
},
];
let err = restore(&snaps).expect_err("must surface error");
assert!(matches!(err, InitError::Io { .. }));
assert_eq!(fs::read(&good_path).unwrap(), b"good-original");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn restore_one_propagates_remove_file_error_when_path_is_a_directory() {
let dir = workdir("restore-remove-dir");
let blocker = dir.join("not-a-file");
fs::create_dir_all(&blocker).expect("mkdir blocker");
let snaps = vec![PathSnapshot {
path: blocker.clone(),
previous: None,
}];
let err = restore(&snaps).expect_err("remove_file on a dir must fail");
assert!(matches!(err, InitError::Io { .. }), "got {err:?}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_atomically_propagates_rename_error_when_target_is_a_directory() {
let dir = workdir("atomic-rename-dir");
let target = dir.join("target");
fs::create_dir_all(&target).expect("mkdir target");
let err = write_atomically(&target, b"hello").expect_err("rename onto dir must fail");
assert!(matches!(err, InitError::Io { .. }), "got {err:?}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_atomically_propagates_write_error_when_temp_path_is_a_directory() {
let dir = workdir("atomic-write-collision");
let target = dir.join("target.json");
let collision = dir.join(format!("target.json.ptuf-snap.{}.tmp", std::process::id()));
fs::create_dir_all(&collision).expect("mkdir collision");
let err = write_atomically(&target, b"hello").expect_err("write onto dir must fail");
assert!(matches!(err, InitError::Io { .. }), "got {err:?}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn command_invokes_ptuf_hook_matches_trailing_tokens_against_arbitrary_tail() {
let tail: &[&str] = &["hook", "demo"];
assert!(command_invokes_ptuf_hook("ptuf hook demo", tail));
assert!(command_invokes_ptuf_hook("/usr/bin/ptuf hook demo", tail));
assert!(!command_invokes_ptuf_hook("ptuf hook other", tail));
assert!(!command_invokes_ptuf_hook("hook", tail));
assert!(!command_invokes_ptuf_hook("", tail));
}
#[test]
fn command_executable_returns_first_token_or_none() {
assert_eq!(command_executable("/x/ptuf hook"), Some("/x/ptuf"));
assert_eq!(command_executable("/x/ptuf hook codex"), Some("/x/ptuf"));
assert_eq!(command_executable(""), None);
}
#[test]
fn detect_binary_impl_returns_a_non_empty_string() {
assert!(!detect_binary_impl().is_empty());
}
#[test]
fn sibling_install_tmp_path_falls_back_to_bare_filename_when_no_parent() {
let tmp = sibling_install_tmp_path(Path::new("hooks.json"), "fallback");
assert!(
tmp.parent()
.map(Path::as_os_str)
.unwrap_or_default()
.is_empty(),
"no-parent input must yield no-parent temp path: {tmp:?}",
);
let s = tmp.to_string_lossy();
assert!(s.starts_with("hooks.json.ptuf."), "got {s}");
assert!(
!s.contains(".ptuf-snap."),
"must not collide with snap suffix: {s}"
);
}
#[test]
fn sibling_temp_path_falls_back_when_path_has_no_parent() {
let p = Path::new("just-a-name.json");
let tmp = sibling_temp_path(p);
let s = tmp.to_string_lossy();
assert!(s.starts_with("just-a-name.json"), "got {s}");
assert!(s.contains(".ptuf-snap."), "got {s}");
assert!(
tmp.parent()
.map(|p| p.as_os_str().is_empty())
.unwrap_or(true)
);
}
#[test]
fn sibling_temp_path_uses_snapshot_tmp_when_path_has_no_file_name() {
let p = Path::new("..");
let tmp = sibling_temp_path(p);
let s = tmp.to_string_lossy();
assert!(s.contains("snapshot.tmp"), "got {s}");
}
#[cfg(unix)]
#[test]
fn write_secure_surfaces_non_already_exists_open_errors() {
use std::io::ErrorKind;
let dir = workdir("write-secure-open-fail");
let blocker = dir.join("blocker");
fs::write(&blocker, b"x").unwrap();
let tmp = blocker.join("child.tmp");
let err = write_secure(&tmp, b"x").expect_err("open on file parent must fail");
assert_ne!(err.kind(), ErrorKind::AlreadyExists);
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn write_executable_surfaces_non_already_exists_open_errors() {
use std::io::ErrorKind;
let dir = workdir("write-exec-open-fail");
let blocker = dir.join("blocker");
fs::write(&blocker, b"x").unwrap();
let tmp = blocker.join("child.tmp");
let err = write_executable(&tmp, b"x").expect_err("open on file parent must fail");
assert_ne!(err.kind(), ErrorKind::AlreadyExists);
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn write_secure_retries_when_temp_path_already_exists_as_file() {
let dir = workdir("write-secure-retry");
let tmp = dir.join("payload.tmp");
fs::write(&tmp, b"stale").expect("seed stale tmp");
write_secure(&tmp, b"fresh").expect("retry must succeed");
assert_eq!(fs::read(&tmp).unwrap(), b"fresh");
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn write_executable_retries_when_temp_path_already_exists_as_file() {
let dir = workdir("write-exec-retry");
let tmp = dir.join("payload.tmp");
fs::write(&tmp, b"stale").expect("seed stale tmp");
write_executable(&tmp, b"fresh").expect("retry must succeed");
assert_eq!(fs::read(&tmp).unwrap(), b"fresh");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn capture_propagates_io_error_when_path_is_a_directory() {
let dir = workdir("capture-dir-as-file");
let blocker = dir.join("blocker");
fs::create_dir_all(&blocker).expect("mkdir blocker");
let err = capture(&[blocker.as_path()]).expect_err("reading a dir must fail");
assert!(matches!(err, InitError::Io { .. }), "got {err:?}");
let _ = fs::remove_dir_all(&dir);
}
fn detect_hermetic(cwd: Option<&Path>, home: Option<&Path>) -> Vec<HookAgent> {
struct EmptyEnv;
impl EnvLookup for EmptyEnv {
fn var_os(&self, _key: &str) -> Option<std::ffi::OsString> {
None
}
}
detect_agents_with_env(cwd, home, &EmptyEnv)
}
#[test]
fn detect_agents_returns_empty_when_no_paths_exist() {
let dir = workdir("detect-empty");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
let home = dir.join("home");
fs::create_dir_all(&home).expect("mkdir home");
assert!(detect_hermetic(Some(dir.as_path()), Some(home.as_path())).is_empty());
assert!(detect_hermetic(Some(dir.as_path()), None).is_empty());
assert!(detect_hermetic(None, Some(home.as_path())).is_empty());
}
#[test]
fn detect_agents_production_wrapper_smokes_with_system_env() {
let dir = workdir("detect-system-env");
let home = dir.join("home");
fs::create_dir_all(&home).expect("mkdir home");
let _ = detect_agents(Some(dir.as_path()), Some(home.as_path()));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_agents_finds_claude_when_only_home_dotclaude_exists() {
let dir = workdir("detect-claude");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
let home = dir.join("home");
fs::create_dir_all(home.join(".claude")).expect("mkdir home/.claude");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(found, vec![HookAgent::ClaudeCode]);
}
#[test]
fn detect_agents_finds_codex_via_repo_only() {
let dir = workdir("detect-codex-repo");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
fs::create_dir_all(dir.join(".codex")).expect("mkdir .codex");
let home = dir.join("home");
fs::create_dir_all(&home).expect("mkdir home");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(found, vec![HookAgent::Codex]);
}
#[test]
fn detect_agents_finds_codex_via_home_only() {
let dir = workdir("detect-codex-home");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
let home = dir.join("home");
fs::create_dir_all(home.join(".codex")).expect("mkdir home/.codex");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(found, vec![HookAgent::Codex]);
}
#[test]
fn detect_agents_finds_copilot_via_repo_dotgithub() {
let dir = workdir("detect-copilot");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
fs::create_dir_all(dir.join(".github")).expect("mkdir .github");
let home = dir.join("home");
fs::create_dir_all(&home).expect("mkdir home");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(found, vec![HookAgent::Copilot]);
}
#[test]
fn detect_agents_finds_kiro_via_repo_only() {
let dir = workdir("detect-kiro-repo");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
fs::create_dir_all(dir.join(".kiro")).expect("mkdir .kiro");
let home = dir.join("home");
fs::create_dir_all(&home).expect("mkdir home");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(found, vec![HookAgent::Kiro]);
}
#[test]
fn detect_agents_finds_kiro_via_home_only() {
let dir = workdir("detect-kiro-home");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
let home = dir.join("home");
fs::create_dir_all(home.join(".kiro")).expect("mkdir home/.kiro");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(found, vec![HookAgent::Kiro]);
}
#[test]
fn detect_agents_finds_cline_via_repo_clinerules() {
let dir = workdir("detect-cline-repo");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
fs::create_dir_all(dir.join(".clinerules")).expect("mkdir .clinerules");
let home = dir.join("home");
fs::create_dir_all(&home).expect("mkdir home");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(found, vec![HookAgent::Cline]);
}
#[test]
fn detect_agents_finds_cline_via_home_documents_cline() {
let dir = workdir("detect-cline-home");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
let home = dir.join("home");
fs::create_dir_all(home.join("Documents/Cline")).expect("mkdir home/Documents/Cline");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(found, vec![HookAgent::Cline]);
}
#[test]
fn detect_agents_finds_cursor_via_repo_only() {
let dir = workdir("detect-cursor-repo");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
fs::create_dir_all(dir.join(".cursor")).expect("mkdir .cursor");
let home = dir.join("home");
fs::create_dir_all(&home).expect("mkdir home");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(found, vec![HookAgent::Cursor]);
}
#[test]
fn detect_agents_finds_cursor_via_home_only() {
let dir = workdir("detect-cursor-home");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
let home = dir.join("home");
fs::create_dir_all(home.join(".cursor")).expect("mkdir home/.cursor");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(found, vec![HookAgent::Cursor]);
}
#[test]
fn detect_agents_returns_all_eight_in_stable_order() {
let dir = workdir("detect-all");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
fs::create_dir_all(dir.join(".codex")).expect("mkdir .codex");
fs::create_dir_all(dir.join(".github")).expect("mkdir .github");
fs::create_dir_all(dir.join(".kiro")).expect("mkdir .kiro");
fs::create_dir_all(dir.join(".clinerules")).expect("mkdir .clinerules");
fs::create_dir_all(dir.join(".cursor")).expect("mkdir .cursor");
fs::create_dir_all(dir.join(".pi")).expect("mkdir .pi");
fs::create_dir_all(dir.join(".opencode")).expect("mkdir .opencode");
let home = dir.join("home");
fs::create_dir_all(home.join(".claude")).expect("mkdir home/.claude");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(
found,
vec![
HookAgent::ClaudeCode,
HookAgent::Codex,
HookAgent::Copilot,
HookAgent::Kiro,
HookAgent::Cline,
HookAgent::Cursor,
HookAgent::Pi,
HookAgent::Opencode,
],
);
}
#[test]
fn detect_agents_finds_pi_via_home_dot_pi_agent() {
let dir = workdir("detect-pi-home");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
let home = dir.join("home");
fs::create_dir_all(home.join(".pi/agent")).expect("mkdir home/.pi/agent");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(found, vec![HookAgent::Pi]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_agents_finds_pi_via_repo_dot_pi() {
let dir = workdir("detect-pi-repo");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
fs::create_dir_all(dir.join(".pi")).expect("mkdir .pi");
let home = dir.join("home");
fs::create_dir_all(&home).expect("mkdir home");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(found, vec![HookAgent::Pi]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_agents_finds_opencode_via_repo_dot_opencode() {
let dir = workdir("detect-opencode-repo");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
fs::create_dir_all(dir.join(".opencode")).expect("mkdir .opencode");
let home = dir.join("home");
fs::create_dir_all(&home).expect("mkdir home");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(found, vec![HookAgent::Opencode]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_agents_finds_opencode_via_home_xdg_config() {
let dir = workdir("detect-opencode-home");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
let home = dir.join("home");
fs::create_dir_all(home.join(".config/opencode")).expect("mkdir config/opencode");
let found = detect_hermetic(Some(dir.as_path()), Some(home.as_path()));
assert_eq!(found, vec![HookAgent::Opencode]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_agents_finds_opencode_via_injected_xdg_config_home() {
struct XdgEnv(PathBuf);
impl EnvLookup for XdgEnv {
fn var_os(&self, key: &str) -> Option<std::ffi::OsString> {
(key == "XDG_CONFIG_HOME").then(|| self.0.clone().into_os_string())
}
}
let dir = workdir("detect-opencode-xdg");
fs::create_dir_all(dir.join(".git")).expect("mkdir .git");
let xdg = dir.join("xdg");
fs::create_dir_all(xdg.join("opencode")).expect("mkdir xdg/opencode");
let home = dir.join("home");
fs::create_dir_all(&home).expect("mkdir home");
let found = detect_agents_with_env(
Some(dir.as_path()),
Some(home.as_path()),
&XdgEnv(xdg.clone()),
);
assert_eq!(found, vec![HookAgent::Opencode]);
let empty_xdg = dir.join("xdg-empty");
fs::create_dir_all(&empty_xdg).expect("mkdir xdg-empty");
let found = detect_agents_with_env(
Some(dir.as_path()),
Some(home.as_path()),
&XdgEnv(empty_xdg),
);
assert!(found.is_empty());
let _ = fs::remove_dir_all(&dir);
}
}