use std::ffi::OsString;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use super::StableHookExeError;
use crate::core::doctor_repair::{RepairMode, RepairStep, StepStatus};
pub const NOTIFICATION_EVENT: &str = "Notification";
pub const NOTIFY_INBOX_ENV: &str = "TRUSTY_MPM_NOTIFY_INBOX";
pub const INBOX_EVENTS_FILE: &str = "events.jsonl";
pub const NOTIFICATION_HOOK_CHECK: &str = "notification_hook";
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct NotificationHookConfig {
#[serde(deserialize_with = "lenient_opt_in")]
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub inbox: Option<PathBuf>,
}
fn lenient_opt_in<'de, D: Deserializer<'de>>(deserializer: D) -> Result<bool, D::Error> {
match Value::deserialize(deserializer)? {
Value::Bool(on) => Ok(on),
other => {
tracing::warn!(
"[notification_hook] enabled = {other} is not a boolean; treating the opt-in as off"
);
Ok(false)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PushTarget {
Unset,
Inbox(PathBuf),
NotAbsolute,
}
pub fn resolve_push_target(
env_value: Option<OsString>,
config: &NotificationHookConfig,
) -> PushTarget {
let chosen = env_value
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.or_else(|| config.inbox.clone());
match chosen {
None => PushTarget::Unset,
Some(dir) if dir.is_absolute() => PushTarget::Inbox(dir),
Some(_) => PushTarget::NotAbsolute,
}
}
pub fn notification_hook_additions_with_exe(
exe_override: Option<&Path>,
) -> Result<Value, StableHookExeError> {
let cmd = super::mpm_hook_command(exe_override)?;
Ok(serde_json::json!({
"hooks": {
NOTIFICATION_EVENT: [{
"matcher": "*",
"hooks": [{ "type": "command", "command": cmd, "timeout": 5 }]
}]
}
}))
}
pub fn apply_notification_hook(
settings_path: &Path,
exe_override: Option<&Path>,
enabled: bool,
) -> anyhow::Result<bool> {
let additions = if enabled {
Some(notification_hook_additions_with_exe(exe_override)?)
} else {
None
};
if !enabled && !settings_path.exists() {
return Ok(false);
}
crate::core::settings_lock::with_settings_lock(settings_path, || {
let Some(next) = plan_notification_hook(settings_path, additions.as_ref())? else {
return Ok(false);
};
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent)?;
}
super::backup::snapshot_then_prune(
settings_path,
super::backup::HOOK_SETTINGS_SNAPSHOTS_KEPT,
)
.map_err(|e| anyhow::anyhow!("snapshot {}: {e}", settings_path.display()))?;
crate::core::settings_lock::publish(settings_path, &next)
.map_err(|e| anyhow::anyhow!("write {}: {e}", settings_path.display()))?;
Ok(true)
})?
}
fn plan_notification_hook(
settings_path: &Path,
additions: Option<&Value>,
) -> anyhow::Result<Option<Value>> {
let original = read_settings_object(settings_path)?;
let mut next = original.clone();
super::strip_hook_entries_matching_for_events(
&mut next,
Some(&[NOTIFICATION_EVENT.to_string()]),
super::is_mpm_hook_command,
);
if let Some(additions) = additions {
next = trusty_common::claude_config::merge_hook_entries(&next, additions);
}
Ok((next != original).then_some(next))
}
fn read_settings_object(settings_path: &Path) -> anyhow::Result<Value> {
let text = match std::fs::read_to_string(settings_path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::json!({})),
Err(e) => anyhow::bail!("read {}: {e}", settings_path.display()),
};
if text.trim().is_empty() {
return Ok(serde_json::json!({}));
}
let refuse = || {
anyhow::anyhow!(
"{} is not a settings object tm can edit safely; left unchanged — fix it, then re-run",
settings_path.display()
)
};
let value: Value = serde_json::from_str(&text).map_err(|_| refuse())?;
let hooks = value.get("hooks");
let well_formed = value.is_object()
&& hooks.is_none_or(Value::is_object)
&& hooks
.and_then(|h| h.get(NOTIFICATION_EVENT))
.is_none_or(Value::is_array);
if well_formed {
Ok(value)
} else {
Err(refuse())
}
}
pub fn repair_notification_hook(
settings_path: &Path,
exe_override: Option<&Path>,
enabled: bool,
mode: RepairMode,
) -> Vec<RepairStep> {
let what = if enabled {
"write the tm `Notification` hook entry ([notification_hook] enabled = true)"
} else {
"remove the tm `Notification` hook entry ([notification_hook] is off)"
};
let step = |status| RepairStep {
check: NOTIFICATION_HOOK_CHECK,
path: settings_path.to_path_buf(),
what: what.to_string(),
status,
};
if !enabled && !settings_path.exists() {
return Vec::new();
}
let additions = match enabled.then(|| notification_hook_additions_with_exe(exe_override)) {
Some(Err(e)) => return vec![step(StepStatus::Refused(e.to_string()))],
Some(Ok(v)) => Some(v),
None => None,
};
match plan_notification_hook(settings_path, additions.as_ref()) {
Ok(None) => Vec::new(),
Err(e) => vec![step(StepStatus::Refused(e.to_string()))],
Ok(Some(_)) if mode == RepairMode::DryRun => vec![step(StepStatus::Planned)],
Ok(Some(_)) => match apply_notification_hook(settings_path, exe_override, enabled) {
Ok(_) => vec![step(StepStatus::Applied { backup: None })],
Err(e) => vec![step(StepStatus::Failed(e.to_string()))],
},
}
}
#[cfg(test)]
#[path = "notification_tests.rs"]
mod tests;