use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use serde_json::{json, Map, Value};
use crate::install::io;
use crate::install::target::MergeOutcome;
const SENTINEL_KEY: &str = "_pixtuoid";
const REASONIX_EVENTS: &[&str] = &[
"SessionStart",
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Stop",
"Notification",
"SessionEnd",
];
pub fn default_config_path() -> Result<PathBuf> {
io::home_relative_checked(".reasonix/settings.json")
}
pub fn detect_installed() -> bool {
user_config_dir().join("reasonix").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")
}
fn parse_or_empty(content: &str) -> Result<Value> {
if content.trim().is_empty() {
return Ok(json!({}));
}
serde_json::from_str(content).context("not valid JSON — refusing to overwrite")
}
pub fn merge_install(content: &str, hook_cmd: &str) -> Result<MergeOutcome> {
let doc = parse_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 = json_merge_install(doc.clone(), 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 = parse_or_empty(content)?;
let cleaned = json_merge_uninstall(doc.clone());
let changed = cleaned != doc;
Ok(MergeOutcome {
content: serde_json::to_string_pretty(&cleaned)?,
changed,
})
}
fn is_managed_entry(entry: &Value) -> bool {
entry.get(SENTINEL_KEY).and_then(|v| v.as_bool()) == Some(true)
}
fn managed_entry(hook_command: &str) -> Value {
json!({
SENTINEL_KEY: true,
"command": hook_command,
"timeout": 1000,
"description": "pixtuoid visualizer"
})
}
fn json_merge_install(doc: Value, hook_command: &str) -> Value {
let mut root: Map<String, Value> = doc.as_object().cloned().unwrap_or_default();
let hooks = root
.entry("hooks".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !hooks.is_object() {
*hooks = Value::Object(Map::new());
}
if let Value::Object(hooks_obj) = hooks {
for ev in REASONIX_EVENTS {
let list = hooks_obj
.entry((*ev).to_string())
.or_insert_with(|| Value::Array(vec![]));
if !list.is_array() {
*list = Value::Array(vec![]);
}
if let Value::Array(arr) = list {
arr.retain(|entry| !is_managed_entry(entry));
arr.push(managed_entry(hook_command));
}
}
}
Value::Object(root)
}
fn json_merge_uninstall(mut doc: Value) -> Value {
let Some(root) = doc.as_object_mut() else {
return doc;
};
let Some(Value::Object(hooks_obj)) = root.get_mut("hooks") else {
return doc;
};
for (_ev, list) in hooks_obj.iter_mut() {
if let Some(arr) = list.as_array_mut() {
arr.retain(|entry| !is_managed_entry(entry));
}
}
let to_remove: Vec<String> = hooks_obj
.iter()
.filter_map(|(k, v)| match v.as_array() {
Some(a) if a.is_empty() => Some(k.clone()),
_ => None,
})
.collect();
for k in to_remove {
hooks_obj.remove(&k);
}
if hooks_obj.is_empty() {
root.remove("hooks");
}
doc
}
#[cfg(test)]
mod tests {
use super::*;
#[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."
);
}
}
}