use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use crate::install::io;
use crate::install::target::MergeOutcome;
use crate::install::verify;
const SENTINEL_KEY: &str = "_pixtuoid";
const REASONIX_EVENTS: &[&str] = &[
"SessionStart",
"PreToolUse",
"PostToolUse",
"PermissionRequest",
"UserPromptSubmit",
"Stop",
"Notification",
"SessionEnd",
];
pub fn default_config_path() -> Result<PathBuf> {
reasonix_home()
.map(|h| h.join("settings.json"))
.ok_or_else(|| {
anyhow!(
"cannot resolve Reasonix's home (REASONIX_HOME/HOME unset); pass --config <path>"
)
})
}
fn reasonix_home() -> Option<PathBuf> {
resolve_reasonix_home(
io::nonempty_env("REASONIX_HOME").map(|v| io::expand_tilde(&v, None)),
cfg!(windows),
user_config_dir(),
io::user_home(),
)
}
fn resolve_reasonix_home(
reasonix_home_env: Option<PathBuf>,
windows: bool,
windows_config_dir: PathBuf,
unix_home: Option<String>,
) -> Option<PathBuf> {
if let Some(h) = reasonix_home_env {
return Some(h);
}
if windows {
return Some(windows_config_dir.join("reasonix"));
}
unix_home.map(|h| PathBuf::from(h).join(".reasonix"))
}
pub fn detect_installed() -> bool {
reasonix_home().is_some_and(|d| d.exists()) || io::home_relative(".reasonix").exists()
}
fn user_config_dir() -> PathBuf {
pixtuoid_core::platform::resolve_user_config_dir(
std::env::consts::OS,
std::env::var("APPDATA").ok(),
std::env::var("XDG_CONFIG_HOME").ok(),
&io::home_relative(""),
)
}
pub fn hook_command(resolved: &Path, _explicit: bool) -> Result<String> {
let p = resolved
.to_str()
.ok_or_else(|| anyhow!("pixtuoid-hook path is non-UTF-8: {}", resolved.display()))?;
crate::install::hook_cmd::shell_hook_command(p, "reasonix")
}
pub fn merge_install(content: &str, hook_cmd: &str) -> Result<MergeOutcome> {
let doc = verify::parse_json_or_empty(content)?;
if !doc.is_object() && !doc.is_null() {
anyhow::bail!("settings is valid JSON but not an object — refusing to overwrite");
}
let merged = verify::flat_json_merge_install(
doc.clone(),
REASONIX_EVENTS,
SENTINEL_KEY,
managed_entry,
hook_cmd,
);
let changed = merged != doc;
Ok(MergeOutcome {
content: serde_json::to_string_pretty(&merged)?,
changed,
})
}
pub fn merge_uninstall(content: &str) -> Result<MergeOutcome> {
let doc = verify::parse_json_or_empty(content)?;
let cleaned = verify::flat_json_merge_uninstall(doc.clone(), SENTINEL_KEY);
let changed = cleaned != doc;
Ok(MergeOutcome {
content: serde_json::to_string_pretty(&cleaned)?,
changed,
})
}
fn managed_entry(hook_command: &str) -> Value {
json!({
SENTINEL_KEY: true,
"command": hook_command,
"timeout": 1000,
"description": "pixtuoid visualizer"
})
}
pub fn verify_schema(content: &str) -> crate::install::verify::SchemaParse {
crate::install::verify::flat_json_verify(content, REASONIX_EVENTS, SENTINEL_KEY)
}
#[cfg(test)]
mod tests {
use super::*;
fn json_merge_install(doc: Value, hook_command: &str) -> Value {
verify::flat_json_merge_install(
doc,
REASONIX_EVENTS,
SENTINEL_KEY,
managed_entry,
hook_command,
)
}
fn json_merge_uninstall(doc: Value) -> Value {
verify::flat_json_merge_uninstall(doc, SENTINEL_KEY)
}
#[test]
fn reasonix_home_is_appdata_on_windows_but_dot_reasonix_elsewhere() {
let appdata = PathBuf::from(r"C:\Users\me\AppData\Roaming");
assert_eq!(
resolve_reasonix_home(None, true, appdata.clone(), Some(r"C:\Users\me".into())),
Some(appdata.join("reasonix"))
);
assert_eq!(
resolve_reasonix_home(None, false, appdata, Some("/home/u".into())),
Some(PathBuf::from("/home/u").join(".reasonix"))
);
assert_eq!(
resolve_reasonix_home(None, false, PathBuf::from("/ignored"), None),
None
);
}
#[test]
fn reasonix_home_env_override_wins_verbatim_on_both_platforms() {
for windows in [true, false] {
assert_eq!(
resolve_reasonix_home(
Some("/custom/rx".into()),
windows,
PathBuf::from(r"C:\AppData"),
Some("/home/u".into()),
),
Some(PathBuf::from("/custom/rx"))
);
}
}
#[test]
fn install_creates_flat_entries_for_all_events() {
let doc = json_merge_install(json!({}), "PIXTUOID_SOURCE=reasonix '/opt/pixtuoid-hook'");
let hooks = doc.get("hooks").and_then(|v| v.as_object()).unwrap();
for ev in REASONIX_EVENTS {
let arr = hooks.get(*ev).and_then(|v| v.as_array()).unwrap();
assert_eq!(arr.len(), 1, "event {ev}");
let entry = &arr[0];
assert_eq!(
entry["command"].as_str().unwrap(),
"PIXTUOID_SOURCE=reasonix '/opt/pixtuoid-hook'"
);
assert!(entry[SENTINEL_KEY].as_bool().unwrap());
assert_eq!(entry["timeout"].as_i64().unwrap(), 1000);
assert!(
entry.get("hooks").is_none() && entry.get("type").is_none(),
"must not write CC-style nested groups"
);
assert!(entry.get("match").is_none(), "must not write a match key");
}
}
#[test]
fn install_is_idempotent_and_replaces_across_paths() {
let a = json_merge_install(json!({}), "PIXTUOID_SOURCE=reasonix '/opt/a/pixtuoid-hook'");
let b = json_merge_install(a.clone(), "PIXTUOID_SOURCE=reasonix '/opt/a/pixtuoid-hook'");
assert_eq!(a, b, "same command re-install is a no-op");
let c = json_merge_install(a, "PIXTUOID_SOURCE=reasonix '/opt/b/pixtuoid-hook'");
for ev in REASONIX_EVENTS {
assert_eq!(
c["hooks"][*ev].as_array().unwrap().len(),
1,
"event {ev} duplicated on path change"
);
}
}
#[test]
fn install_preserves_user_entries() {
let initial = json!({
"hooks": {
"PreToolUse": [ { "match": "bash", "command": "my-guard.sh" } ]
},
"other": "setting"
});
let merged = json_merge_install(initial, "/x");
let arr = merged["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["command"], json!("my-guard.sh"));
assert_eq!(merged["other"], json!("setting"));
}
#[test]
fn uninstall_removes_only_managed_entries_and_empty_maps() {
let installed = json_merge_install(
json!({"hooks": {"PreToolUse": [ { "match": "bash", "command": "my-guard.sh" } ]}}),
"/x",
);
let cleaned = json_merge_uninstall(installed);
let arr = cleaned["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["command"], json!("my-guard.sh"));
for ev in REASONIX_EVENTS.iter().filter(|e| **e != "PreToolUse") {
assert!(
cleaned["hooks"].get(*ev).is_none(),
"event {ev} should be dropped once empty"
);
}
}
#[test]
fn uninstall_all_managed_drops_hooks_map() {
let installed = json_merge_install(json!({}), "/x");
let cleaned = json_merge_uninstall(installed);
assert!(cleaned.get("hooks").is_none(), "got {cleaned}");
}
#[test]
fn merge_install_idempotent_reports_unchanged() {
let first = merge_install("", "/x").unwrap();
assert!(first.changed);
let second = merge_install(&first.content, "/x").unwrap();
assert!(!second.changed, "second install is a semantic no-op");
}
#[test]
fn merge_uninstall_no_pixtuoid_hooks_reports_unchanged() {
let user = r#"{ "hooks": { "Stop": [ { "command": "notify-send done" } ] } }"#;
let out = merge_uninstall(user).unwrap();
assert!(!out.changed, "no managed entries → semantic no-op");
}
#[test]
fn merge_install_rejects_valid_json_that_is_not_an_object() {
assert!(merge_install("[1, 2, 3]", "/x").is_err());
assert!(merge_install("42", "/x").is_err());
}
#[test]
fn merge_install_rejects_invalid_json() {
assert!(merge_install("{not json", "/x").is_err());
}
#[test]
fn install_coerces_non_object_hooks_and_non_array_events() {
let doc = json_merge_install(json!({"hooks": "garbage"}), "/x");
assert!(doc["hooks"].is_object());
let doc = json_merge_install(json!({"hooks": {"Stop": 42}}), "/x");
assert_eq!(doc["hooks"]["Stop"].as_array().unwrap().len(), 1);
}
#[cfg(unix)]
#[test]
fn hook_command_stamps_source_and_quotes() {
let cmd = hook_command(Path::new("/Users/Jane Doe/bin/pixtuoid-hook"), false).unwrap();
assert_eq!(
cmd,
"PIXTUOID_SOURCE=reasonix '/Users/Jane Doe/bin/pixtuoid-hook'"
);
}
#[test]
#[cfg(windows)]
fn hook_command_emits_bare_exec_form_with_source_flag_on_windows() {
let cmd = hook_command(Path::new(r"C:\tools\pixtuoid-hook.exe"), false).unwrap();
assert_eq!(cmd, r"C:\tools\pixtuoid-hook.exe --source reasonix");
}
#[test]
#[cfg(windows)]
fn hook_command_rejects_cmd_unsafe_path_on_windows() {
assert!(hook_command(Path::new(r"C:\Program Files\pixtuoid-hook.exe"), false).is_err());
let err = hook_command(Path::new(r"C:\Users\a&b\pixtuoid-hook.exe"), false)
.unwrap_err()
.to_string();
assert!(
err.contains("cmd.exe") && err.contains("ordinary characters"),
"must explain the cmd-unsafe path + workaround: {err}"
);
}
#[cfg(windows)]
#[test]
fn user_config_dir_uses_appdata_on_windows() {
let _env = crate::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let saved = std::env::var_os("APPDATA");
std::env::set_var("APPDATA", r"C:\Users\ada\AppData\Roaming");
assert_eq!(
user_config_dir(),
PathBuf::from(r"C:\Users\ada\AppData\Roaming")
);
match saved {
Some(v) => std::env::set_var("APPDATA", v),
None => std::env::remove_var("APPDATA"),
}
}
#[test]
#[cfg(unix)]
fn hook_command_errors_on_non_utf8_path() {
use std::os::unix::ffi::OsStrExt;
let bad = Path::new(std::ffi::OsStr::from_bytes(b"/x/\xff/pixtuoid-hook"));
assert!(hook_command(bad, false).is_err());
}
#[test]
fn every_registered_reasonix_event_decodes() {
use pixtuoid_core::source::decoder::decode_hook_payload;
for ev in REASONIX_EVENTS {
let payload = serde_json::json!({
"event": ev,
"cwd": "/repo",
"_pixtuoid_source": "reasonix",
});
assert!(
decode_hook_payload(payload).is_ok(),
"registered Reasonix hook {ev:?} has no decoder arm — it would \
bail as unsupported. Add an arm in pixtuoid-core source/reasonix.rs."
);
}
}
}