pub mod claude;
pub mod codewhale;
pub mod codex;
mod hook_cmd;
pub mod io;
pub mod opencode;
pub mod reasonix;
pub mod target;
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use target::{Target, BACKUP_SUFFIX};
pub fn has_hooks(t: &'static Target) -> bool {
let Ok(path) = (t.default_config_path)() else {
return false;
};
match io::read_config(&path) {
Ok(c) if c.trim().is_empty() => false,
Ok(c) => (t.merge_uninstall)(&c).map(|o| o.changed).unwrap_or(true),
Err(_) => true,
}
}
fn is_drive_relative(p: &std::path::Path) -> bool {
!p.has_root() && matches!(p.components().next(), Some(std::path::Component::Prefix(_)))
}
fn resolve_hook_binary_from(
t: &Target,
hook_path: Option<PathBuf>,
env_hook: Option<PathBuf>,
locate: impl FnOnce() -> Result<PathBuf>,
) -> Result<(PathBuf, bool)> {
let explicit = hook_path
.map(|p| (p, "--hook-path"))
.or(env_hook.map(|p| (p, "PIXTUOID_HOOK")));
if let Some((p, origin)) = explicit {
if is_drive_relative(&p) {
bail!(
"{origin} {} is drive-relative (a drive prefix with no root, like C:foo.exe) \
and would resolve against a per-drive cwd at hook time; pass an absolute path",
p.display()
);
}
let p = if p.is_relative() {
let cwd = std::env::current_dir().with_context(|| {
format!("{origin} is relative and the current directory is unreadable; pass an absolute path")
})?;
cwd.join(&p)
} else {
p
};
if !p.exists() {
println!(
"warning: {origin} {} does not exist yet; the hook will fail until it does",
p.display()
);
}
return Ok((p, true));
}
match locate() {
Ok(p) => Ok((p, false)),
Err(e) if t.needs_resolved_binary => Err(e),
Err(_) => Ok((PathBuf::from("pixtuoid-hook"), false)),
}
}
#[derive(Debug)]
pub enum InstallOutcome {
Installed,
AlreadyUpToDate,
}
#[derive(Debug)]
pub struct InstallReport {
pub outcome: InstallOutcome,
pub config_path: PathBuf,
pub backup: Option<PathBuf>,
pub restart_noun: &'static str,
pub post_note: Option<&'static str>,
pub path_warning: bool,
}
pub fn install_target(
t: &Target,
config: Option<PathBuf>,
hook_path: Option<PathBuf>,
) -> Result<InstallReport> {
let path = config
.map(Ok)
.unwrap_or_else(|| (t.default_config_path)())?;
let env_hook = io::nonempty_env("PIXTUOID_HOOK").map(PathBuf::from);
let (binary, explicit_hook) =
resolve_hook_binary_from(t, hook_path, env_hook, io::default_hook_binary)?;
let hook_cmd = (t.hook_command)(&binary, explicit_hook)?;
let lock = io::lock_config(&path)?;
let content = lock.read()?;
let outcome = (t.merge_install)(&content, &hook_cmd)
.with_context(|| format!("processing {}", path.display()))?;
let path_warning = t.needs_path_warning && !explicit_hook && !io::hook_on_path();
if !outcome.changed {
return Ok(InstallReport {
outcome: InstallOutcome::AlreadyUpToDate,
config_path: path,
backup: None,
restart_noun: t.restart_noun,
post_note: t.post_install_note,
path_warning,
});
}
let backup = lock.backup_once(BACKUP_SUFFIX)?;
lock.write_atomic(&outcome.content)?;
Ok(InstallReport {
outcome: InstallOutcome::Installed,
config_path: path,
backup,
restart_noun: t.restart_noun,
post_note: t.post_install_note,
path_warning,
})
}
#[derive(Debug)]
pub enum UninstallOutcome {
Removed,
NothingToRemove,
}
#[derive(Debug)]
pub struct UninstallReport {
pub outcome: UninstallOutcome,
pub config_path: PathBuf,
pub removed_backup: Option<PathBuf>,
pub restart_noun: &'static str,
}
pub fn uninstall_target(t: &Target, config: Option<PathBuf>) -> Result<UninstallReport> {
let path = config
.map(Ok)
.unwrap_or_else(|| (t.default_config_path)())?;
if !target::config_present(&path) {
return Ok(UninstallReport {
outcome: UninstallOutcome::NothingToRemove,
config_path: path,
removed_backup: None,
restart_noun: t.restart_noun,
});
}
let lock = io::lock_config(&path)?;
let content = lock.read()?;
let outcome =
(t.merge_uninstall)(&content).with_context(|| format!("processing {}", path.display()))?;
if !outcome.changed {
return Ok(UninstallReport {
outcome: UninstallOutcome::NothingToRemove,
config_path: path,
removed_backup: None,
restart_noun: t.restart_noun,
});
}
lock.write_atomic(&outcome.content)?;
let removed_backup = lock.remove_backup(BACKUP_SUFFIX)?;
Ok(UninstallReport {
outcome: UninstallOutcome::Removed,
config_path: path,
removed_backup,
restart_noun: t.restart_noun,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::install::target::{MergeOutcome, Target, CLAUDE, CODEX};
static FAKE: Target = Target {
name: "fake",
core_source: "fake",
display_name: "Fake",
restart_noun: "Fake",
default_config_path: || Ok(std::path::PathBuf::from("/nonexistent/fake")),
hook_command: |_, _| Ok("x".into()),
merge_install: |c, _| {
Ok(MergeOutcome {
content: c.to_string(),
changed: false,
})
},
merge_uninstall: |c| {
Ok(MergeOutcome {
content: c.to_string(),
changed: false,
})
},
needs_path_warning: false,
needs_resolved_binary: false,
post_install_note: None,
presence_probe: None,
};
fn fake2_config_path() -> std::path::PathBuf {
std::env::temp_dir().join(format!("pixtuoid-test-fake2-{}.toml", std::process::id()))
}
fn fake_dir_config_path() -> std::path::PathBuf {
std::env::temp_dir().join(format!("pixtuoid-test-fake-dir-{}", std::process::id()))
}
static FAKE2: Target = Target {
name: "fake2",
core_source: "fake2",
display_name: "Fake2",
restart_noun: "Fake2",
default_config_path: || Ok(fake2_config_path()),
hook_command: |_, _| Ok("x".into()),
merge_install: |c, _| {
Ok(MergeOutcome {
content: c.to_string(),
changed: false,
})
},
merge_uninstall: |c| {
Ok(MergeOutcome {
content: c.to_string(),
changed: !c.trim().is_empty(),
})
},
needs_path_warning: false,
needs_resolved_binary: false,
post_install_note: None,
presence_probe: None,
};
static FAKE_DIR: Target = Target {
name: "fakedir",
core_source: "fakedir",
display_name: "FakeDir",
restart_noun: "FakeDir",
default_config_path: || Ok(fake_dir_config_path()),
hook_command: |_, _| Ok("x".into()),
merge_install: |c, _| {
Ok(MergeOutcome {
content: c.to_string(),
changed: false,
})
},
merge_uninstall: |c| {
Ok(MergeOutcome {
content: c.to_string(),
changed: false,
})
},
needs_path_warning: false,
needs_resolved_binary: false,
post_install_note: None,
presence_probe: None,
};
fn abs_fixture(unix: &str, windows: &str) -> PathBuf {
if cfg!(windows) {
PathBuf::from(windows)
} else {
PathBuf::from(unix)
}
}
#[test]
fn resolve_hook_binary_explicit_path_wins() {
let p = abs_fixture("/x/hook", r"C:\x\hook");
let got = resolve_hook_binary_from(&CLAUDE, Some(p.clone()), None, || {
panic!("locate must not be called when --hook-path is given")
});
assert_eq!(got.unwrap(), (p, true));
}
#[test]
fn resolve_hook_binary_absolutizes_a_relative_explicit_path() {
let (got, explicit) = resolve_hook_binary_from(
&CLAUDE,
Some(PathBuf::from("target/debug/pixtuoid-hook")),
None,
|| unreachable!("explicit path must win"),
)
.unwrap();
assert!(explicit);
assert!(got.is_absolute(), "expected absolutized path, got {got:?}");
assert!(got.ends_with("target/debug/pixtuoid-hook"));
}
#[cfg(unix)]
#[test]
fn resolve_hook_binary_claude_falls_back_to_bare_name_when_unresolvable() {
let got = resolve_hook_binary_from(&CLAUDE, None, None, || {
Err(anyhow::anyhow!("could not locate"))
});
assert_eq!(got.unwrap(), (PathBuf::from("pixtuoid-hook"), false));
}
#[cfg(windows)]
#[test]
fn resolve_hook_binary_claude_errors_when_unresolvable_on_windows() {
let got = resolve_hook_binary_from(&CLAUDE, None, None, || {
Err(anyhow::anyhow!("could not locate"))
});
assert!(got.is_err(), "exec form requires a real resolved .exe");
}
#[test]
fn resolve_hook_binary_codex_errors_when_unresolvable() {
let got = resolve_hook_binary_from(&CODEX, None, None, || {
Err(anyhow::anyhow!("could not locate"))
});
assert!(got.is_err());
}
#[test]
fn resolve_hook_binary_env_override_routes_through_the_explicit_arm() {
let (got, explicit) = resolve_hook_binary_from(
&CODEX,
None,
Some(PathBuf::from("target/debug/pixtuoid-hook")),
|| unreachable!("the env override must win over locate"),
)
.unwrap();
assert!(
got.is_absolute(),
"expected absolutized env path, got {got:?}"
);
assert!(got.ends_with("target/debug/pixtuoid-hook"));
assert!(explicit);
}
#[test]
fn resolve_hook_binary_cli_flag_outranks_env_override() {
let cli = abs_fixture("/cli/hook", r"C:\cli\hook");
let env = abs_fixture("/env/hook", r"C:\env\hook");
let got = resolve_hook_binary_from(&CLAUDE, Some(cli.clone()), Some(env), || {
unreachable!("an explicit path must win over locate")
});
assert_eq!(got.unwrap(), (cli, true));
}
#[test]
fn resolve_hook_binary_no_overrides_uses_locate() {
let located = abs_fixture("/located/hook", r"C:\located\hook");
let expect = located.clone();
let got = resolve_hook_binary_from(&CLAUDE, None, None, || Ok(located));
assert_eq!(got.unwrap(), (expect, false));
}
#[test]
fn empty_env_override_counts_as_unset_at_the_live_read() {
let _env = crate::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let saved = std::env::var_os("PIXTUOID_HOOK");
std::env::set_var("PIXTUOID_HOOK", "");
let empty = io::nonempty_env("PIXTUOID_HOOK");
std::env::set_var("PIXTUOID_HOOK", " ");
let blank = io::nonempty_env("PIXTUOID_HOOK");
std::env::set_var("PIXTUOID_HOOK", "/real/hook");
let real = io::nonempty_env("PIXTUOID_HOOK");
match saved {
Some(v) => std::env::set_var("PIXTUOID_HOOK", v),
None => std::env::remove_var("PIXTUOID_HOOK"),
}
assert_eq!(empty, None);
assert_eq!(blank, None);
assert_eq!(real, Some("/real/hook".into()));
}
#[test]
fn is_drive_relative_only_matches_prefix_without_root() {
use std::path::Path;
#[cfg(windows)]
{
assert!(is_drive_relative(Path::new(r"C:rel\hook.exe")));
assert!(!is_drive_relative(Path::new(r"C:\abs\hook.exe")));
assert!(!is_drive_relative(Path::new(r"rel\hook.exe")));
assert!(!is_drive_relative(Path::new(r"\rooted\hook.exe")));
}
#[cfg(unix)]
assert!(!is_drive_relative(Path::new("C:foo.exe")));
}
#[cfg(windows)]
#[test]
fn resolve_hook_binary_rejects_a_drive_relative_explicit_path() {
let err = resolve_hook_binary_from(
&CLAUDE,
Some(PathBuf::from(r"C:rel\hook.exe")),
None,
|| unreachable!("the explicit path must win"),
)
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("drive-relative") && msg.contains("absolute path"),
"got: {msg}"
);
}
#[cfg(windows)]
#[test]
fn resolve_hook_binary_rejects_a_drive_relative_env_override() {
let err =
resolve_hook_binary_from(&CODEX, None, Some(PathBuf::from(r"C:rel\hook.exe")), || {
unreachable!("the env override must win")
})
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("PIXTUOID_HOOK") && msg.contains("drive-relative"),
"the error must name the seam that supplied the bad path: {msg}"
);
}
#[test]
fn has_hooks_empty_config_is_false() {
assert!(!has_hooks(&FAKE));
}
#[test]
fn has_hooks_unreadable_config_is_true() {
let dir = fake_dir_config_path();
let _ = std::fs::remove_file(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert!(has_hooks(&FAKE_DIR));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn has_hooks_changed_vs_unchanged_arms() {
let path = fake2_config_path();
std::fs::write(&path, "model = \"x\"\n").unwrap();
assert!(has_hooks(&FAKE2));
std::fs::write(&path, " \n").unwrap();
assert!(!has_hooks(&FAKE2));
let _ = std::fs::remove_file(&path);
}
#[test]
fn install_target_claude_writes_sentinel_and_backs_up() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("settings.json");
std::fs::write(&cfg, "{}\n").unwrap();
install_target(
&CLAUDE,
Some(cfg.clone()),
Some(PathBuf::from("/fake/pixtuoid-hook")),
)
.unwrap();
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap();
assert!(v["hooks"]["PreToolUse"][0]["_pixtuoid"].as_bool().unwrap());
assert!(
tmp.path().join("settings.json.pixtuoid.bak").exists(),
"a backup of the prior content was written"
);
install_target(
&CLAUDE,
Some(cfg.clone()),
Some(PathBuf::from("/fake/pixtuoid-hook")),
)
.unwrap();
}
#[test]
fn install_target_fails_fast_while_the_config_lock_is_held() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("settings.json");
install_target(
&CLAUDE,
Some(cfg.clone()),
Some(PathBuf::from("/fake/pixtuoid-hook")),
)
.unwrap();
let _guard = io::lock_config(&cfg).unwrap();
let err = install_target(
&CLAUDE,
Some(cfg.clone()),
Some(PathBuf::from("/fake/pixtuoid-hook")),
)
.unwrap_err();
assert!(err.to_string().contains("could not lock"), "got: {err:#}");
}
#[test]
fn uninstall_target_fails_fast_while_the_config_lock_is_held() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("settings.json");
install_target(
&CLAUDE,
Some(cfg.clone()),
Some(PathBuf::from("/fake/pixtuoid-hook")),
)
.unwrap();
let _guard = io::lock_config(&cfg).unwrap();
let err = uninstall_target(&CLAUDE, Some(cfg.clone())).unwrap_err();
assert!(err.to_string().contains("could not lock"), "got: {err:#}");
}
#[test]
fn uninstall_target_unchanged_preserves_backup() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("config.toml");
std::fs::write(&cfg, "anything\n").unwrap();
let bak = tmp.path().join("config.toml.pixtuoid.bak");
std::fs::write(&bak, "backup").unwrap();
uninstall_target(&FAKE, Some(cfg.clone())).unwrap();
assert!(bak.exists(), "a no-op uninstall must NOT delete the backup");
}
#[test]
fn install_target_reports_installed_then_up_to_date() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("settings.json");
std::fs::write(&cfg, "{}\n").unwrap();
let r = install_target(
&CLAUDE,
Some(cfg.clone()),
Some(PathBuf::from("/fake/pixtuoid-hook")),
)
.unwrap();
assert!(matches!(r.outcome, InstallOutcome::Installed));
assert!(
r.backup.is_some(),
"first install of an existing file takes a backup"
);
assert_eq!(r.config_path, cfg);
let r2 = install_target(
&CLAUDE,
Some(cfg.clone()),
Some(PathBuf::from("/fake/pixtuoid-hook")),
)
.unwrap();
assert!(matches!(r2.outcome, InstallOutcome::AlreadyUpToDate));
assert!(r2.backup.is_none(), "a no-op install reports no backup");
}
#[test]
fn install_target_explicit_hook_suppresses_path_warning() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("settings.json");
let r = install_target(
&CLAUDE,
Some(cfg),
Some(PathBuf::from("/fake/pixtuoid-hook")),
)
.unwrap();
assert!(!r.path_warning);
}
#[test]
fn uninstall_target_reports_removed_then_nothing() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("config.toml");
std::fs::write(&cfg, "model = \"x\"\n").unwrap();
let bak = tmp.path().join("config.toml.pixtuoid.bak");
std::fs::write(&bak, "backup").unwrap();
let r = uninstall_target(&FAKE2, Some(cfg.clone())).unwrap();
assert!(matches!(r.outcome, UninstallOutcome::Removed));
assert_eq!(r.removed_backup.as_deref(), Some(bak.as_path()));
assert!(!bak.exists());
let missing = tmp.path().join("missing").join("settings.json");
let r2 = uninstall_target(&CLAUDE, Some(missing.clone())).unwrap();
assert!(matches!(r2.outcome, UninstallOutcome::NothingToRemove));
assert!(r2.removed_backup.is_none());
assert!(
!missing.parent().unwrap().exists(),
"a no-op uninstall leaves no dirs"
);
}
#[test]
fn install_target_round_trips_every_registered_target() {
for t in target::TARGETS {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("cfg");
let hook = || Some(PathBuf::from("/fake/pixtuoid-hook"));
let r = install_target(t, Some(cfg.clone()), hook()).unwrap();
assert!(
matches!(r.outcome, InstallOutcome::Installed),
"{}: first install must write hooks",
t.name
);
assert!(cfg.exists(), "{}: install wrote a config", t.name);
let r2 = install_target(t, Some(cfg.clone()), hook()).unwrap();
assert!(
matches!(r2.outcome, InstallOutcome::AlreadyUpToDate),
"{}: re-install must be a no-op (sentinel idempotency)",
t.name
);
let u = uninstall_target(t, Some(cfg.clone())).unwrap();
assert!(
matches!(u.outcome, UninstallOutcome::Removed),
"{}: uninstall must remove the managed entries",
t.name
);
let u2 = uninstall_target(t, Some(cfg.clone())).unwrap();
assert!(
matches!(u2.outcome, UninstallOutcome::NothingToRemove),
"{}: re-uninstall must find nothing to remove",
t.name
);
}
}
}