use std::path::PathBuf;
use serde_json::{json, Map, Value};
pub fn parse_json_or_empty(content: &str) -> anyhow::Result<Value> {
if content.trim().is_empty() {
return Ok(json!({}));
}
use anyhow::Context;
serde_json::from_str(content).context("not valid JSON — refusing to overwrite")
}
pub fn parse_toml_or_empty(content: &str) -> anyhow::Result<toml::Value> {
if content.trim().is_empty() {
return Ok(toml::Value::Table(toml::value::Table::new()));
}
use anyhow::Context;
toml::from_str(content).context("not valid TOML — refusing to overwrite")
}
pub fn flat_json_merge_install(
doc: Value,
events: &[&str],
sentinel: &str,
make_entry: impl Fn(&str) -> 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 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_flat_managed(entry, sentinel));
arr.push(make_entry(hook_command));
}
}
}
Value::Object(root)
}
pub fn flat_json_merge_uninstall(mut doc: Value, sentinel: &str) -> 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_flat_managed(entry, sentinel));
}
}
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
}
fn is_flat_managed(entry: &Value, sentinel: &str) -> bool {
entry.get(sentinel).and_then(|v| v.as_bool()) == Some(true)
}
#[derive(Debug, PartialEq, Eq)]
pub enum ShimRef {
Absolute(PathBuf),
BareName,
Unknown,
}
#[derive(Debug)]
pub struct SchemaParse {
pub issues: Vec<String>,
pub shim: ShimRef,
}
impl SchemaParse {
pub fn broken(issue: impl Into<String>) -> Self {
SchemaParse {
issues: vec![issue.into()],
shim: ShimRef::Unknown,
}
}
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct SchemaVerifyResult {
pub issues: Vec<String>,
pub notes: Vec<String>,
}
impl SchemaVerifyResult {
pub fn is_sound(&self) -> bool {
self.issues.is_empty()
}
}
pub fn display_safe(p: &std::path::Path) -> String {
p.display()
.to_string()
.chars()
.filter(|c| !c.is_control())
.collect()
}
pub fn assemble(
missing_events: &[&str],
any_managed: bool,
shim: ShimRef,
extra: Vec<String>,
) -> SchemaParse {
let mut issues = extra;
if !any_managed {
issues.push(
"no managed pixtuoid hook entries (the `_pixtuoid` sentinel is absent — the config \
was hand-edited or hooks were never installed)"
.into(),
);
} else if !missing_events.is_empty() {
issues.push(format!(
"missing hook entries for: {} (an older pixtuoid install, or an upstream config-schema \
change, orphaned them — reconnect via the Sources panel)",
missing_events.join(", ")
));
}
SchemaParse { issues, shim }
}
pub fn flat_json_verify(content: &str, events: &[&str], sentinel: &str) -> SchemaParse {
let Ok(doc) = serde_json::from_str::<serde_json::Value>(content) else {
return SchemaParse::broken("hooks config no longer parses as JSON");
};
let hooks = doc.get("hooks").and_then(|h| h.as_object());
let mut missing = Vec::new();
let mut any = false;
let mut shim = ShimRef::Unknown;
for ev in events {
let managed = hooks
.and_then(|h| h.get(*ev))
.and_then(|a| a.as_array())
.and_then(|arr| {
arr.iter()
.find(|e| e.get(sentinel).and_then(|v| v.as_bool()) == Some(true))
});
match managed {
Some(entry) => {
any = true;
if shim == ShimRef::Unknown {
shim = entry
.get("command")
.and_then(|c| c.as_str())
.map(shell_shim_ref)
.unwrap_or(ShimRef::Unknown);
}
}
None => missing.push(*ev),
}
}
assemble(&missing, any, shim, vec![])
}
pub fn shell_shim_ref(command: &str) -> ShimRef {
let head = match command.split_once(" --event ") {
Some((before, _)) => before,
None => command,
};
if let Some((path, _)) = head.split_once(" --source ") {
let p = path.trim();
return if p.is_empty() {
ShimRef::Unknown
} else {
ShimRef::Absolute(PathBuf::from(p))
};
}
match head.split_whitespace().next_back() {
Some(tok) => {
let p = tok.trim_matches('\'');
if p.is_empty() {
ShimRef::Unknown
} else {
ShimRef::Absolute(PathBuf::from(p))
}
}
None => ShimRef::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_shim_ref_unix_env_prefix_form() {
assert_eq!(
shell_shim_ref("PIXTUOID_SOURCE=codex '/opt/pixtuoid-hook'"),
ShimRef::Absolute(PathBuf::from("/opt/pixtuoid-hook"))
);
}
#[test]
fn shell_shim_ref_windows_bare_form() {
assert_eq!(
shell_shim_ref(r"C:\bin\pixtuoid-hook.exe --source reasonix"),
ShimRef::Absolute(PathBuf::from(r"C:\bin\pixtuoid-hook.exe"))
);
}
#[test]
fn shell_shim_ref_strips_codewhale_event_tail() {
assert_eq!(
shell_shim_ref("PIXTUOID_SOURCE=codewhale '/opt/pixtuoid-hook' --event session_start"),
ShimRef::Absolute(PathBuf::from("/opt/pixtuoid-hook"))
);
assert_eq!(
shell_shim_ref(r"C:\bin\pixtuoid-hook.exe --source codewhale --event session_start"),
ShimRef::Absolute(PathBuf::from(r"C:\bin\pixtuoid-hook.exe"))
);
}
#[test]
fn shell_shim_ref_empty_is_unknown() {
assert_eq!(shell_shim_ref(""), ShimRef::Unknown);
}
#[test]
fn display_safe_strips_control_chars_from_a_hostile_path() {
let hostile = std::path::Path::new("/x/\x1b]0;pwned\x07\x1b[31mhook");
let got = display_safe(hostile);
assert!(!got.chars().any(|c| c.is_control()), "{got:?}");
assert!(got.contains("hook") && got.contains("/x/"), "{got:?}");
}
#[test]
fn schema_verify_soundness_ignores_notes() {
let clean = SchemaVerifyResult::default();
assert!(clean.is_sound());
let soft = SchemaVerifyResult {
issues: vec![],
notes: vec!["pixtuoid-hook not on PATH".into()],
};
assert!(
soft.is_sound(),
"soft notes must not make an install 'broken'"
);
let hard = SchemaVerifyResult {
issues: vec!["shim missing".into()],
notes: vec![],
};
assert!(!hard.is_sound());
}
}