pub mod claude;
pub mod codex;
mod hook_cmd;
pub mod io;
pub mod reasonix;
pub mod target;
use std::io::IsTerminal;
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use crate::cli::TargetName;
use target::{Target, BACKUP_SUFFIX};
const NO_CLIS_MSG: &str = "no supported CLIs detected; pass --target claude|codex|reasonix|all";
fn present_targets(rows: &[(&'static Target, bool)]) -> Vec<&'static Target> {
rows.iter().filter(|(_, p)| *p).map(|(t, _)| *t).collect()
}
pub struct InstallArgs {
pub hook_path: Option<PathBuf>,
pub config: Option<PathBuf>,
pub target: Option<TargetName>,
pub yes: bool,
}
pub struct UninstallArgs {
pub config: Option<PathBuf>,
pub target: Option<TargetName>,
pub yes: bool,
}
pub enum Plan {
Targets(Vec<&'static Target>),
NothingDetected,
Conflict(String),
}
pub fn plan_targets(
requested: Option<TargetName>,
explicit_config: bool,
present: &[(&'static Target, bool)],
is_tty: bool,
) -> Plan {
match requested {
Some(TargetName::All) => {
if explicit_config {
return Plan::Conflict(
"--config applies to a single target; use --target claude|codex|reasonix"
.into(),
);
}
let chosen = present_targets(present);
if chosen.is_empty() {
Plan::NothingDetected
} else {
Plan::Targets(chosen)
}
}
Some(t) => match target::by_name(t.as_str()) {
Some(found) => Plan::Targets(vec![found]),
None => Plan::Conflict(format!("{} target not registered", t.as_str())),
},
None => {
if explicit_config {
return match target::by_name("claude") {
Some(t) => Plan::Targets(vec![t]),
None => Plan::Conflict("claude target not registered".into()),
};
}
let detected = present_targets(present);
match detected.len() {
0 => Plan::NothingDetected,
1 => Plan::Targets(detected), _ if is_tty => Plan::Targets(detected), _ => Plan::Conflict(
"multiple CLIs detected; pass --target claude|codex|reasonix|all".into(),
),
}
}
}
}
fn parse_confirm(answer: &str) -> bool {
let a = answer.trim().to_ascii_lowercase();
a.is_empty() || a == "y" || a == "yes"
}
fn interpret_confirm_read(read: Result<usize, ()>, line: &str) -> bool {
match read {
Ok(0) | Err(()) => false,
Ok(_) => parse_confirm(line),
}
}
fn confirm(prompt: &str) -> bool {
use std::io::Write;
print!("{prompt} [Y/n] ");
let _ = std::io::stdout().flush();
let mut line = String::new();
let read = std::io::stdin().read_line(&mut line).map_err(|_| ());
interpret_confirm_read(read, &line)
}
fn detection() -> Vec<(&'static Target, bool)> {
target::TARGETS
.iter()
.map(|t| (*t, target::is_present(t)))
.collect()
}
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 select_targets(
prompt: &str,
candidates: &[&'static Target],
) -> Result<Option<Vec<&'static Target>>> {
let options: Vec<&str> = candidates.iter().map(|t| t.display_name).collect();
let all: Vec<usize> = (0..options.len()).collect();
let chosen = inquire::MultiSelect::new(prompt, options)
.with_default(&all)
.raw_prompt_skippable()
.context("target selection prompt failed")?;
Ok(chosen.map(|sel| sel.into_iter().map(|opt| candidates[opt.index]).collect()))
}
fn interactive_terminal() -> bool {
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
}
fn interactive_pick(
target: &Option<TargetName>,
config: &Option<PathBuf>,
yes: bool,
is_tty: bool,
) -> bool {
target.is_none() && config.is_none() && !yes && is_tty
}
fn run_interactive(
candidates: Vec<&'static Target>,
empty_msg: &str,
prompt: &str,
verb: &str,
op: impl Fn(&'static Target) -> Result<()>,
) -> Result<()> {
let chosen = match candidates.len() {
0 => {
println!("{empty_msg}");
return Ok(());
}
1 => candidates,
_ => match select_targets(prompt, &candidates)? {
Some(sel) if !sel.is_empty() => sel,
Some(_) => {
println!("nothing selected");
return Ok(());
}
None => {
println!("aborted");
return Ok(());
}
},
};
run_each(&chosen, verb, op)
}
pub fn install(args: InstallArgs) -> Result<()> {
let is_tty = interactive_terminal();
if interactive_pick(&args.target, &args.config, args.yes, is_tty) {
let detected = present_targets(&detection());
return run_interactive(
detected,
NO_CLIS_MSG,
"Install pixtuoid hooks into",
"install",
|t| run_install(t, None, args.hook_path.clone()),
);
}
let plan = plan_targets(args.target, args.config.is_some(), &detection(), is_tty);
let targets = resolve_plan(plan)?;
run_each(&targets, "install", |t| {
run_install(t, args.config.clone(), args.hook_path.clone())
})
}
pub fn uninstall(args: UninstallArgs) -> Result<()> {
let is_tty = interactive_terminal();
if interactive_pick(&args.target, &args.config, args.yes, is_tty) {
let installed: Vec<&'static Target> = target::TARGETS
.iter()
.copied()
.filter(|t| has_hooks(t))
.collect();
return run_interactive(
installed,
"no pixtuoid hooks found to remove",
"Remove pixtuoid hooks from",
"uninstall",
|t| run_uninstall(t, None),
);
}
let plan = plan_targets(args.target, args.config.is_some(), &detection(), is_tty);
let targets = resolve_plan(plan)?;
if needs_confirm(targets.len(), args.yes, is_tty)
&& !confirm_targets("remove pixtuoid hooks from", &targets)
{
println!("aborted");
return Ok(());
}
run_each(&targets, "uninstall", |t| {
run_uninstall(t, args.config.clone())
})
}
fn resolve_plan(plan: Plan) -> Result<Vec<&'static Target>> {
match plan {
Plan::Targets(t) => Ok(t),
Plan::NothingDetected => {
println!("{NO_CLIS_MSG}");
Ok(vec![])
}
Plan::Conflict(msg) => bail!(msg),
}
}
fn needs_confirm(n: usize, yes: bool, is_tty: bool) -> bool {
!yes && is_tty && n > 1
}
fn confirm_targets(verb: &str, targets: &[&'static Target]) -> bool {
let names: Vec<_> = targets.iter().map(|t| t.display_name).collect();
confirm(&format!("{verb} {}?", names.join(" + ")))
}
fn run_each(
targets: &[&'static Target],
verb: &str,
op: impl Fn(&'static Target) -> Result<()>,
) -> Result<()> {
let mut failed = 0usize;
for &t in targets {
if let Err(e) = op(t) {
eprintln!("error: {verb} for {} failed: {e:#}", t.display_name);
failed += 1;
}
}
if failed > 0 {
bail!("{failed} of {} target(s) failed", targets.len());
}
Ok(())
}
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)),
}
}
fn run_install(t: &Target, config: Option<PathBuf>, hook_path: Option<PathBuf>) -> Result<()> {
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()))?;
if t.needs_path_warning && !explicit_hook && !io::hook_on_path() {
println!("warn: `pixtuoid-hook` not found on PATH (checked against this shell).");
println!(" Install it on PATH, e.g. `cargo install --path crates/pixtuoid-hook`.");
}
if !outcome.changed {
println!("[{}] already up to date — {}", t.name, path.display());
return Ok(());
}
let backup = lock.backup_once(BACKUP_SUFFIX)?;
lock.write_atomic(&outcome.content)?;
println!(
"ok: installed pixtuoid hooks into {} ({})",
path.display(),
t.display_name
);
if let Some(b) = backup {
println!(
"backup: {} (removed automatically on uninstall-hooks)",
b.display()
);
}
if let Some(note) = t.post_install_note {
println!("{note}");
}
println!(
"→ start a new {} session for this to take effect.",
t.restart_noun
);
Ok(())
}
fn run_uninstall(t: &Target, config: Option<PathBuf>) -> Result<()> {
let path = config
.map(Ok)
.unwrap_or_else(|| (t.default_config_path)())?;
if !target::config_present(&path) {
println!(
"[{}] no pixtuoid hooks found in {} — nothing to remove",
t.name,
path.display()
);
return Ok(());
}
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 {
println!(
"[{}] no pixtuoid hooks found in {} — nothing to remove",
t.name,
path.display()
);
return Ok(());
}
lock.write_atomic(&outcome.content)?;
println!(
"ok: removed pixtuoid hooks from {} ({})",
path.display(),
t.display_name
);
if let Some(b) = lock.remove_backup(BACKUP_SUFFIX)? {
println!("removed backup: {}", b.display());
}
println!(
"→ start a new {} session for this to take effect.",
t.restart_noun
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::install::target::{MergeOutcome, Target, CLAUDE, CODEX};
static FAKE: Target = Target {
name: "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",
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",
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 present(claude: bool, fake: bool) -> Vec<(&'static Target, bool)> {
vec![(&CLAUDE, claude), (&FAKE, fake)]
}
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 explicit_target_claude_ignores_detection() {
let p = plan_targets(
Some(TargetName::Claude),
false,
&present(false, false),
false,
);
assert!(matches!(p, Plan::Targets(ref t) if t.len() == 1 && t[0].name == "claude"));
}
#[test]
fn explicit_all_with_config_is_conflict() {
let p = plan_targets(Some(TargetName::All), true, &present(true, true), true);
assert!(matches!(p, Plan::Conflict(_)));
}
#[test]
fn no_target_tty_returns_detected() {
let p = plan_targets(None, false, &present(true, true), true);
assert!(matches!(p, Plan::Targets(ref t) if t.len() == 2));
}
#[test]
fn no_target_non_tty_single_claude_installs_claude() {
let p = plan_targets(None, false, &present(true, false), false);
assert!(matches!(p, Plan::Targets(ref t) if t.len() == 1 && t[0].name == "claude"));
}
#[test]
fn no_target_non_tty_multiple_present_is_conflict() {
let p = plan_targets(None, false, &present(true, true), false);
assert!(matches!(p, Plan::Conflict(_)));
}
#[test]
fn no_target_nothing_present_is_nothing_detected() {
let p = plan_targets(None, false, &present(false, false), false);
assert!(matches!(p, Plan::NothingDetected));
}
#[test]
fn confirm_answer_parses_default_yes() {
assert!(parse_confirm(""));
assert!(parse_confirm("y"));
assert!(parse_confirm("YES"));
assert!(!parse_confirm("n"));
assert!(!parse_confirm("no"));
assert!(!parse_confirm("garbage")); }
#[test]
fn interactive_pick_only_on_bare_tty() {
let none: Option<TargetName> = None;
let no_cfg: Option<PathBuf> = None;
assert!(interactive_pick(&none, &no_cfg, false, true));
assert!(!interactive_pick(&none, &no_cfg, false, false));
assert!(!interactive_pick(&none, &no_cfg, true, true));
assert!(!interactive_pick(
&Some(TargetName::Claude),
&no_cfg,
false,
true
));
assert!(!interactive_pick(
&none,
&Some(PathBuf::from("/x")),
false,
true
));
}
#[test]
fn confirm_read_eof_and_error_cancel_but_entered_line_decides() {
assert!(!interpret_confirm_read(Ok(0), ""));
assert!(!interpret_confirm_read(Err(()), ""));
assert!(interpret_confirm_read(Ok(1), "\n"));
assert!(interpret_confirm_read(Ok(2), "y\n"));
assert!(!interpret_confirm_read(Ok(2), "n\n"));
}
#[test]
fn all_with_nothing_present_is_nothing_detected() {
let p = plan_targets(Some(TargetName::All), false, &present(false, false), false);
assert!(matches!(p, Plan::NothingDetected));
}
#[test]
fn all_with_both_present_returns_both() {
let p = plan_targets(Some(TargetName::All), false, &present(true, true), false);
assert!(matches!(p, Plan::Targets(ref t) if t.len() == 2));
}
#[test]
fn explicit_target_codex_resolves_to_codex() {
let p = plan_targets(Some(TargetName::Codex), false, &present(true, true), false);
assert!(matches!(p, Plan::Targets(ref t) if t.len() == 1 && t[0].name == "codex"));
}
#[test]
fn target_name_enum_and_registry_cover_each_other() {
use clap::ValueEnum;
for v in TargetName::value_variants() {
if *v != TargetName::All {
assert!(
target::by_name(v.as_str()).is_some(),
"{v:?} has no Target row in target::TARGETS"
);
}
}
for t in target::TARGETS {
assert!(
TargetName::value_variants()
.iter()
.any(|v| v.as_str() == t.name),
"Target {:?} has no TargetName variant — `--target {}` would be unrepresentable",
t.name,
t.name
);
}
}
#[test]
fn resolve_plan_targets_passes_through() {
match resolve_plan(Plan::Targets(vec![&CLAUDE])) {
Ok(got) => {
assert_eq!(got.len(), 1);
assert_eq!(got[0].name, "claude");
}
Err(e) => panic!("expected Ok, got {e}"),
}
}
#[test]
fn resolve_plan_nothing_detected_is_ok_empty() {
match resolve_plan(Plan::NothingDetected) {
Ok(got) => assert!(got.is_empty()),
Err(e) => panic!("expected Ok(empty), got {e}"),
}
}
#[test]
fn resolve_plan_conflict_is_err() {
match resolve_plan(Plan::Conflict("boom".into())) {
Ok(_) => panic!("expected a Conflict to be an Err"),
Err(e) => assert!(e.to_string().contains("boom")),
}
}
#[test]
fn run_each_all_ok_returns_ok() {
let n = std::cell::Cell::new(0);
run_each(&[&FAKE, &FAKE2], "install", |_| {
n.set(n.get() + 1);
Ok(())
})
.unwrap();
assert_eq!(n.get(), 2, "op ran for each target");
}
#[test]
fn run_each_reports_failed_count_and_bails() {
let err = run_each(&[&FAKE, &FAKE2], "install", |_| anyhow::bail!("kaboom")).unwrap_err();
assert!(
err.to_string().contains("2 of 2 target(s) failed"),
"got: {err}"
);
}
#[test]
fn needs_confirm_only_multi_target_interactive_no_yes() {
assert!(needs_confirm(2, false, true));
assert!(!needs_confirm(1, false, true)); assert!(!needs_confirm(2, true, true)); assert!(!needs_confirm(2, false, false)); }
#[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 run_interactive_zero_candidates_prints_and_skips_op() {
let ran = std::cell::Cell::new(false);
run_interactive(vec![], "nothing here", "prompt", "install", |_| {
ran.set(true);
Ok(())
})
.unwrap();
assert!(!ran.get(), "op must NOT run when there are no candidates");
}
#[test]
fn run_interactive_single_candidate_runs_op_once() {
let count = std::cell::Cell::new(0);
run_interactive(vec![&FAKE], "nothing here", "prompt", "install", |_| {
count.set(count.get() + 1);
Ok(())
})
.unwrap();
assert_eq!(
count.get(),
1,
"single candidate acts directly, no checklist"
);
}
#[test]
fn run_install_fake_target_is_up_to_date_noop() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("fake.toml");
run_install(&FAKE, Some(cfg.clone()), None).unwrap();
assert!(!cfg.exists(), "the up-to-date branch never writes");
}
#[test]
fn run_install_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();
run_install(
&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"
);
run_install(
&CLAUDE,
Some(cfg.clone()),
Some(PathBuf::from("/fake/pixtuoid-hook")),
)
.unwrap();
}
#[test]
fn run_install_fails_fast_while_the_config_lock_is_held() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("settings.json");
run_install(
&CLAUDE,
Some(cfg.clone()),
Some(PathBuf::from("/fake/pixtuoid-hook")),
)
.unwrap();
let _guard = io::lock_config(&cfg).unwrap();
let err = run_install(
&CLAUDE,
Some(cfg.clone()),
Some(PathBuf::from("/fake/pixtuoid-hook")),
)
.unwrap_err();
assert!(err.to_string().contains("could not lock"), "got: {err:#}");
}
#[test]
fn run_uninstall_fails_fast_while_the_config_lock_is_held() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("settings.json");
run_install(
&CLAUDE,
Some(cfg.clone()),
Some(PathBuf::from("/fake/pixtuoid-hook")),
)
.unwrap();
let _guard = io::lock_config(&cfg).unwrap();
let err = run_uninstall(&CLAUDE, Some(cfg.clone())).unwrap_err();
assert!(err.to_string().contains("could not lock"), "got: {err:#}");
}
#[test]
fn run_uninstall_absent_config_creates_no_dirs_or_lock() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = tmp.path().join("missing").join("settings.json");
run_uninstall(&CLAUDE, Some(cfg.clone())).unwrap();
assert!(
!cfg.parent().unwrap().exists(),
"a no-op uninstall must leave no side effects"
);
}
#[test]
fn run_uninstall_fake2_changed_writes_and_removes_backup() {
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();
run_uninstall(&FAKE2, Some(cfg.clone())).unwrap();
assert!(
!bak.exists(),
"the backup is removed on a changing uninstall"
);
}
#[test]
fn run_uninstall_fake_unchanged_is_noop() {
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();
run_uninstall(&FAKE, Some(cfg.clone())).unwrap();
assert!(bak.exists(), "a no-op uninstall must NOT delete the backup");
}
}