use std::sync::Arc;
use std::time::Instant;
use serde_json::{Map, Value};
use super::config::{FailMode, HookConfig};
use super::decision::{HookDecision, is_pre_event};
use super::events::{EvictionEvent, HookEvent, MemoryDelta};
use super::executor::ExecutorRegistry;
use super::timeouts::{class_deadline_for_event, per_hook_budget_ms, record_timeout_violation};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AskUserPrompt {
pub prompt: String,
pub options: Vec<String>,
pub default: Option<String>,
pub origin_command: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ChainResult {
Allow,
ModifiedAllow(MemoryDelta),
Deny { reason: String, code: i32 },
AskUser { queued: Vec<AskUserPrompt> },
}
pub struct HookChain {
hooks: Vec<HookConfig>,
}
impl HookChain {
#[must_use]
pub fn for_event(all_hooks: &[HookConfig], event: HookEvent) -> Self {
let mut hooks: Vec<HookConfig> = all_hooks
.iter()
.filter(|h| h.enabled && h.event == event)
.cloned()
.collect();
hooks.sort_by(|a, b| b.priority.cmp(&a.priority));
Self { hooks }
}
#[must_use]
pub fn new(mut hooks: Vec<HookConfig>) -> Self {
hooks.sort_by(|a, b| b.priority.cmp(&a.priority));
Self { hooks }
}
#[must_use]
pub fn hooks(&self) -> &[HookConfig] {
&self.hooks
}
pub async fn fire(
&self,
event: HookEvent,
payload: Value,
registry: &mut ExecutorRegistry,
) -> ChainResult {
let mut current_payload = payload;
let mut accumulated_delta = MemoryDelta::default();
let mut modified = false;
let mut askuser_queue: Vec<AskUserPrompt> = Vec::new();
let chain_deadline = Instant::now() + class_deadline_for_event(event);
let prepared: Vec<(HookConfig, Arc<dyn super::executor::HookExecutor>)> = self
.hooks
.iter()
.map(|h| (h.clone(), registry.get(h)))
.collect();
for (cfg, executor) in prepared {
let Some(budget_ms) =
per_hook_budget_ms(chain_deadline, Instant::now(), cfg.timeout_ms)
else {
record_timeout_violation();
match cfg.fail_mode {
FailMode::Open => {
tracing::warn!(
command = %cfg.command.display(),
event = ?event,
"hooks: chain class deadline exhausted before hook fire; \
fail_mode=open, treating as Allow"
);
continue;
}
FailMode::Closed => {
tracing::warn!(
command = %cfg.command.display(),
event = ?event,
"hooks: chain class deadline exhausted before hook fire; \
fail_mode=closed, denying"
);
return ChainResult::Deny {
reason: format!(
"hook {} skipped under fail_mode=closed: chain class deadline exhausted",
cfg.command.display()
),
code: 504,
};
}
}
};
let per_hook_deadline = std::time::Duration::from_millis(u64::from(budget_ms));
let raced = tokio::time::timeout(
per_hook_deadline,
executor.fire(event, current_payload.clone()),
)
.await;
let fire_result = match raced {
Ok(inner) => inner,
Err(_elapsed) => {
Err(super::executor::ExecutorError::Timeout {
ms: u64::from(budget_ms),
})
}
};
let decision = match fire_result {
Ok(d) => d.degrade_modify_for_post_event(event),
Err(e) => {
if matches!(e, super::executor::ExecutorError::Timeout { .. }) {
record_timeout_violation();
}
match cfg.fail_mode {
FailMode::Open => {
tracing::warn!(
command = %cfg.command.display(),
event = ?event,
error = %e,
"hooks: chain hook errored; fail_mode=open, treating as Allow"
);
HookDecision::Allow
}
FailMode::Closed => {
tracing::warn!(
command = %cfg.command.display(),
event = ?event,
error = %e,
"hooks: chain hook errored; fail_mode=closed, denying"
);
return ChainResult::Deny {
reason: format!(
"hook {} errored under fail_mode=closed: {e}",
cfg.command.display()
),
code: 503,
};
}
}
}
};
match decision {
HookDecision::Allow => {
askuser_queue.clear();
}
HookDecision::Modify(modify_payload) => {
apply_delta_to_payload(&mut current_payload, &modify_payload.delta);
merge_delta_into(&mut accumulated_delta, modify_payload.delta);
modified = true;
askuser_queue.clear();
}
HookDecision::Deny { reason, code } => {
return ChainResult::Deny { reason, code };
}
HookDecision::AskUser {
prompt,
options,
default,
} => {
askuser_queue.push(AskUserPrompt {
prompt,
options,
default,
origin_command: cfg.command.display().to_string(),
});
let _ = is_pre_event(event); }
}
}
if !askuser_queue.is_empty() {
ChainResult::AskUser {
queued: askuser_queue,
}
} else if modified {
ChainResult::ModifiedAllow(accumulated_delta)
} else {
ChainResult::Allow
}
}
}
pub async fn dispatch_event_with_hooks<F>(
event: HookEvent,
payload: Value,
chain: &HookChain,
registry: &mut ExecutorRegistry,
subscription_dispatch: F,
) -> ChainResult
where
F: FnOnce(),
{
if is_pre_event(event) {
let result = chain.fire(event, payload, registry).await;
if !matches!(result, ChainResult::Deny { .. }) {
subscription_dispatch();
}
result
} else {
subscription_dispatch();
chain.fire(event, payload, registry).await
}
}
pub async fn fire_on_index_eviction(
chain: &HookChain,
registry: &mut ExecutorRegistry,
payload: EvictionEvent,
) -> ChainResult {
let value = serde_json::to_value(&payload).unwrap_or_else(|_| Value::Null);
chain
.fire(HookEvent::OnIndexEviction, value, registry)
.await
}
pub fn spawn_eviction_observer(
chain: Arc<HookChain>,
mut registry: ExecutorRegistry,
) -> std::sync::mpsc::Sender<EvictionEvent> {
let (tx, rx) = std::sync::mpsc::channel::<EvictionEvent>();
let rx = std::sync::Mutex::new(rx);
let rx = Arc::new(rx);
tokio::spawn(async move {
loop {
let rx_clone = Arc::clone(&rx);
let next = tokio::task::spawn_blocking(move || {
let guard = rx_clone.lock().expect("eviction observer rx mutex");
guard.recv()
})
.await;
match next {
Ok(Ok(payload)) => {
let _ = fire_on_index_eviction(&chain, &mut registry, payload).await;
}
Ok(Err(_)) | Err(_) => break,
}
}
});
tx
}
fn apply_delta_to_payload(payload: &mut Value, delta: &MemoryDelta) {
let delta_value = serde_json::to_value(delta).unwrap_or_else(|_| Value::Object(Map::new()));
let Value::Object(delta_obj) = delta_value else {
return;
};
if !payload.is_object() {
*payload = Value::Object(delta_obj);
return;
}
let payload_obj = payload.as_object_mut().expect("checked is_object");
for (k, v) in delta_obj {
payload_obj.insert(k, v);
}
}
fn merge_delta_into(acc: &mut MemoryDelta, incoming: MemoryDelta) {
if incoming.tier.is_some() {
acc.tier = incoming.tier;
}
if incoming.namespace.is_some() {
acc.namespace = incoming.namespace;
}
if incoming.title.is_some() {
acc.title = incoming.title;
}
if incoming.content.is_some() {
acc.content = incoming.content;
}
if incoming.tags.is_some() {
acc.tags = incoming.tags;
}
if incoming.priority.is_some() {
acc.priority = incoming.priority;
}
if incoming.confidence.is_some() {
acc.confidence = incoming.confidence;
}
if incoming.source.is_some() {
acc.source = incoming.source;
}
if incoming.expires_at.is_some() {
acc.expires_at = incoming.expires_at;
}
if incoming.metadata.is_some() {
acc.metadata = incoming.metadata;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::config::{FailMode, HookMode};
use crate::hooks::decision::ModifyPayload;
use crate::hooks::executor::{
ExecutorError, ExecutorMetrics, HookExecutor, Result as ExecutorResult,
};
use serde_json::json;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
enum Scripted {
Decision(HookDecision),
Error,
}
struct MockExecutor {
responses: Mutex<Vec<Scripted>>,
fire_count: AtomicUsize,
seen_payloads: Mutex<Vec<Value>>,
}
impl MockExecutor {
fn new(responses: Vec<Scripted>) -> Self {
Self {
responses: Mutex::new(responses),
fire_count: AtomicUsize::new(0),
seen_payloads: Mutex::new(Vec::new()),
}
}
}
impl HookExecutor for MockExecutor {
fn fire<'a>(
&'a self,
_event: HookEvent,
payload: Value,
) -> Pin<Box<dyn std::future::Future<Output = ExecutorResult<HookDecision>> + Send + 'a>>
{
self.fire_count.fetch_add(1, Ordering::SeqCst);
self.seen_payloads.lock().unwrap().push(payload);
let mut responses = self.responses.lock().unwrap();
let next = if responses.is_empty() {
Scripted::Decision(HookDecision::Allow)
} else {
responses.remove(0)
};
Box::pin(async move {
match next {
Scripted::Decision(d) => Ok(d),
Scripted::Error => Err(ExecutorError::Decode {
reason: "mock: scripted error".into(),
}),
}
})
}
fn metrics(&self) -> ExecutorMetrics {
ExecutorMetrics {
events_fired: self.fire_count.load(Ordering::SeqCst) as u64,
events_dropped: 0,
mean_latency_us: 0,
}
}
}
fn make_cfg(priority: i32, fail_mode: FailMode, command: &str) -> HookConfig {
HookConfig {
event: HookEvent::PreStore,
command: PathBuf::from(command),
priority,
timeout_ms: 1_000,
mode: HookMode::Exec,
enabled: true,
namespace: "*".into(),
fail_mode,
}
}
async fn drive_with_mocks(
event: HookEvent,
payload: Value,
steps: Vec<(HookConfig, Arc<MockExecutor>)>,
) -> ChainResult {
let mut sorted = steps;
sorted.sort_by(|a, b| b.0.priority.cmp(&a.0.priority));
let mut current_payload = payload;
let mut accumulated_delta = MemoryDelta::default();
let mut modified = false;
let mut askuser_queue: Vec<AskUserPrompt> = Vec::new();
for (cfg, executor) in sorted {
let fire_result = executor.fire(event, current_payload.clone()).await;
let decision = match fire_result {
Ok(d) => d.degrade_modify_for_post_event(event),
Err(e) => match cfg.fail_mode {
FailMode::Open => HookDecision::Allow,
FailMode::Closed => {
return ChainResult::Deny {
reason: format!(
"hook {} errored under fail_mode=closed: {e}",
cfg.command.display()
),
code: 503,
};
}
},
};
match decision {
HookDecision::Allow => {
askuser_queue.clear();
}
HookDecision::Modify(mp) => {
apply_delta_to_payload(&mut current_payload, &mp.delta);
merge_delta_into(&mut accumulated_delta, mp.delta);
modified = true;
askuser_queue.clear();
}
HookDecision::Deny { reason, code } => {
return ChainResult::Deny { reason, code };
}
HookDecision::AskUser {
prompt,
options,
default,
} => {
askuser_queue.push(AskUserPrompt {
prompt,
options,
default,
origin_command: cfg.command.display().to_string(),
});
}
}
}
if !askuser_queue.is_empty() {
ChainResult::AskUser {
queued: askuser_queue,
}
} else if modified {
ChainResult::ModifiedAllow(accumulated_delta)
} else {
ChainResult::Allow
}
}
#[test]
fn priority_desc_sort_stable_on_ties() {
let hooks = vec![
make_cfg(50, FailMode::Open, "/bin/a"),
make_cfg(100, FailMode::Open, "/bin/b"),
make_cfg(50, FailMode::Open, "/bin/c"), make_cfg(0, FailMode::Open, "/bin/d"),
];
let chain = HookChain::new(hooks);
let order: Vec<_> = chain
.hooks()
.iter()
.map(|h| h.command.display().to_string())
.collect();
assert_eq!(order, vec!["/bin/b", "/bin/a", "/bin/c", "/bin/d"]);
}
#[test]
fn for_event_filters_disabled_and_other_events() {
let mut wrong_event = make_cfg(100, FailMode::Open, "/bin/wrong");
wrong_event.event = HookEvent::PostStore;
let mut disabled = make_cfg(50, FailMode::Open, "/bin/off");
disabled.enabled = false;
let kept = make_cfg(0, FailMode::Open, "/bin/keep");
let all = vec![wrong_event, disabled, kept];
let chain = HookChain::for_event(&all, HookEvent::PreStore);
assert_eq!(chain.hooks().len(), 1);
assert_eq!(chain.hooks()[0].command, PathBuf::from("/bin/keep"));
}
#[tokio::test]
async fn three_hooks_first_denies_chain_stops() {
let high = (
make_cfg(100, FailMode::Open, "/bin/high"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::Deny {
reason: "redact required".into(),
code: 451,
},
)])),
);
let mid = (
make_cfg(50, FailMode::Open, "/bin/mid"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::Allow,
)])),
);
let low = (
make_cfg(0, FailMode::Open, "/bin/low"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::Allow,
)])),
);
let high_count = high.1.clone();
let mid_count = mid.1.clone();
let low_count = low.1.clone();
let result = drive_with_mocks(
HookEvent::PreStore,
json!({"title": "x"}),
vec![mid, low, high], )
.await;
match result {
ChainResult::Deny { reason, code } => {
assert_eq!(reason, "redact required");
assert_eq!(code, 451);
}
other => panic!("expected Deny, got {other:?}"),
}
assert_eq!(high_count.fire_count.load(Ordering::SeqCst), 1);
assert_eq!(
mid_count.fire_count.load(Ordering::SeqCst),
0,
"mid-priority hook fired despite earlier Deny"
);
assert_eq!(
low_count.fire_count.load(Ordering::SeqCst),
0,
"low-priority hook fired despite earlier Deny"
);
}
#[tokio::test]
async fn three_hooks_all_modify_compose_into_final_delta() {
let h1 = (
make_cfg(100, FailMode::Open, "/bin/h1"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::Modify(ModifyPayload {
delta: MemoryDelta {
tags: Some(vec!["redacted".into()]),
..Default::default()
},
}),
)])),
);
let h2 = (
make_cfg(50, FailMode::Open, "/bin/h2"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::Modify(ModifyPayload {
delta: MemoryDelta {
priority: Some(9),
..Default::default()
},
}),
)])),
);
let h3 = (
make_cfg(0, FailMode::Open, "/bin/h3"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::Modify(ModifyPayload {
delta: MemoryDelta {
title: Some("rewritten".into()),
tags: Some(vec!["audited".into()]),
..Default::default()
},
}),
)])),
);
let h2_seen = h2.1.clone();
let h3_seen = h3.1.clone();
let result = drive_with_mocks(
HookEvent::PreStore,
json!({"title": "original", "content": "original"}),
vec![h1, h2, h3],
)
.await;
match result {
ChainResult::ModifiedAllow(d) => {
assert_eq!(d.tags.as_deref(), Some(&["audited".to_string()][..]));
assert_eq!(d.priority, Some(9));
assert_eq!(d.title.as_deref(), Some("rewritten"));
assert!(d.content.is_none());
}
other => panic!("expected ModifiedAllow, got {other:?}"),
}
let h2_payload = h2_seen.seen_payloads.lock().unwrap()[0].clone();
assert_eq!(h2_payload["tags"], json!(["redacted"]));
let h3_payload = h3_seen.seen_payloads.lock().unwrap()[0].clone();
assert_eq!(h3_payload["priority"], json!(9));
assert_eq!(h3_payload["tags"], json!(["redacted"]));
}
#[tokio::test]
async fn hook_crash_default_fail_open_continues_as_allow() {
let crashy = (
make_cfg(100, FailMode::Open, "/bin/crashy"),
Arc::new(MockExecutor::new(vec![Scripted::Error])),
);
let calm = (
make_cfg(50, FailMode::Open, "/bin/calm"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::Allow,
)])),
);
let calm_count = calm.1.clone();
let result = drive_with_mocks(HookEvent::PreStore, json!({}), vec![crashy, calm]).await;
assert_eq!(result, ChainResult::Allow);
assert_eq!(
calm_count.fire_count.load(Ordering::SeqCst),
1,
"fail-open must let the chain continue"
);
}
#[tokio::test]
async fn hook_crash_fail_closed_yields_deny_503() {
let crashy = (
make_cfg(100, FailMode::Closed, "/bin/strict"),
Arc::new(MockExecutor::new(vec![Scripted::Error])),
);
let calm = (
make_cfg(50, FailMode::Open, "/bin/calm"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::Allow,
)])),
);
let calm_count = calm.1.clone();
let result = drive_with_mocks(HookEvent::PreStore, json!({}), vec![crashy, calm]).await;
match result {
ChainResult::Deny { reason, code } => {
assert_eq!(code, 503);
assert!(
reason.contains("/bin/strict"),
"deny reason should name the failing hook: {reason}"
);
assert!(
reason.contains("fail_mode=closed"),
"deny reason should name the posture: {reason}"
);
}
other => panic!("expected Deny, got {other:?}"),
}
assert_eq!(
calm_count.fire_count.load(Ordering::SeqCst),
0,
"fail-closed must short-circuit the chain"
);
}
#[tokio::test]
async fn two_askusers_then_allow_queue_dropped() {
let ask1 = (
make_cfg(100, FailMode::Open, "/bin/ask1"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::AskUser {
prompt: "promote?".into(),
options: vec!["yes".into(), "no".into()],
default: Some("no".into()),
},
)])),
);
let ask2 = (
make_cfg(50, FailMode::Open, "/bin/ask2"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::AskUser {
prompt: "tag PII?".into(),
options: vec!["yes".into(), "no".into()],
default: None,
},
)])),
);
let allow = (
make_cfg(0, FailMode::Open, "/bin/allow"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::Allow,
)])),
);
let result =
drive_with_mocks(HookEvent::PreStore, json!({}), vec![ask1, ask2, allow]).await;
assert_eq!(
result,
ChainResult::Allow,
"later Allow must override queued AskUsers"
);
}
#[tokio::test]
async fn askuser_queue_surfaces_when_no_clear_winner() {
let ask1 = (
make_cfg(100, FailMode::Open, "/bin/ask1"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::AskUser {
prompt: "promote?".into(),
options: vec!["yes".into(), "no".into()],
default: Some("no".into()),
},
)])),
);
let ask2 = (
make_cfg(50, FailMode::Open, "/bin/ask2"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::AskUser {
prompt: "tag PII?".into(),
options: vec!["yes".into(), "no".into()],
default: None,
},
)])),
);
let allow_filler = (
make_cfg(75, FailMode::Open, "/bin/filler"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::Allow,
)])),
);
let result = drive_with_mocks(
HookEvent::PreStore,
json!({}),
vec![ask1, allow_filler, ask2],
)
.await;
match result {
ChainResult::AskUser { queued } => {
assert_eq!(queued.len(), 1);
assert_eq!(queued[0].prompt, "tag PII?");
assert_eq!(queued[0].origin_command, "/bin/ask2");
}
other => panic!("expected AskUser, got {other:?}"),
}
}
#[tokio::test]
async fn two_askusers_only_yields_two_queued() {
let ask1 = (
make_cfg(100, FailMode::Open, "/bin/ask1"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::AskUser {
prompt: "first?".into(),
options: vec!["a".into(), "b".into()],
default: None,
},
)])),
);
let ask2 = (
make_cfg(50, FailMode::Open, "/bin/ask2"),
Arc::new(MockExecutor::new(vec![Scripted::Decision(
HookDecision::AskUser {
prompt: "second?".into(),
options: vec!["x".into(), "y".into()],
default: Some("x".into()),
},
)])),
);
let result = drive_with_mocks(HookEvent::PreStore, json!({}), vec![ask1, ask2]).await;
match result {
ChainResult::AskUser { queued } => {
assert_eq!(queued.len(), 2);
assert_eq!(queued[0].prompt, "first?");
assert_eq!(queued[1].prompt, "second?");
assert_eq!(queued[1].default.as_deref(), Some("x"));
}
other => panic!("expected AskUser, got {other:?}"),
}
}
#[tokio::test]
async fn empty_chain_returns_allow() {
let result = drive_with_mocks(HookEvent::PreStore, json!({}), vec![]).await;
assert_eq!(result, ChainResult::Allow);
}
#[test]
fn apply_delta_overwrites_top_level_object_keys() {
let mut payload = json!({"title": "old", "untouched": "keep"});
let delta = MemoryDelta {
title: Some("new".into()),
tags: Some(vec!["t".into()]),
..Default::default()
};
apply_delta_to_payload(&mut payload, &delta);
assert_eq!(payload["title"], json!("new"));
assert_eq!(payload["tags"], json!(["t"]));
assert_eq!(
payload["untouched"],
json!("keep"),
"untouched payload fields must survive merge"
);
}
#[test]
fn apply_delta_replaces_non_object_payload() {
let mut payload = json!("scalar");
let delta = MemoryDelta {
title: Some("recovered".into()),
..Default::default()
};
apply_delta_to_payload(&mut payload, &delta);
assert!(payload.is_object());
assert_eq!(payload["title"], json!("recovered"));
}
#[test]
fn merge_delta_into_overwrites_some_fields_only() {
let mut acc = MemoryDelta {
tags: Some(vec!["old".into()]),
priority: Some(1),
..Default::default()
};
let incoming = MemoryDelta {
tags: Some(vec!["new".into()]),
title: Some("t".into()),
..Default::default()
};
merge_delta_into(&mut acc, incoming);
assert_eq!(acc.tags.as_deref(), Some(&["new".to_string()][..]));
assert_eq!(acc.title.as_deref(), Some("t"));
assert_eq!(acc.priority, Some(1));
}
#[tokio::test]
async fn dispatch_event_with_hooks_post_event_runs_subs_first() {
use std::sync::atomic::{AtomicUsize, Ordering};
static CLOCK: AtomicUsize = AtomicUsize::new(0);
static SUB_TICK: AtomicUsize = AtomicUsize::new(0);
static HOOK_TICK: AtomicUsize = AtomicUsize::new(0);
CLOCK.store(0, Ordering::SeqCst);
SUB_TICK.store(0, Ordering::SeqCst);
HOOK_TICK.store(0, Ordering::SeqCst);
struct OrderingExecutor;
impl HookExecutor for OrderingExecutor {
fn fire<'a>(
&'a self,
_event: HookEvent,
_payload: Value,
) -> Pin<Box<dyn std::future::Future<Output = ExecutorResult<HookDecision>> + Send + 'a>>
{
HOOK_TICK.store(CLOCK.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
Box::pin(async { Ok(HookDecision::Allow) })
}
fn metrics(&self) -> ExecutorMetrics {
ExecutorMetrics {
events_fired: 0,
events_dropped: 0,
mean_latency_us: 0,
}
}
}
let _ = OrderingExecutor;
let mut registry = ExecutorRegistry::new();
let post_chain = HookChain::new(vec![]);
let result = dispatch_event_with_hooks(
HookEvent::PostStore,
json!({}),
&post_chain,
&mut registry,
|| {
SUB_TICK.store(CLOCK.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
},
)
.await;
assert_eq!(result, ChainResult::Allow);
assert!(
SUB_TICK.load(Ordering::SeqCst) >= 1,
"subscription closure must run for post- events"
);
}
#[tokio::test]
async fn hook_chain_fire_empty_returns_allow_directly() {
let chain = HookChain::new(vec![]);
let mut reg = ExecutorRegistry::new();
let r = chain
.fire(HookEvent::PreStore, json!({"k":"v"}), &mut reg)
.await;
assert_eq!(r, ChainResult::Allow);
}
#[tokio::test]
async fn fire_on_index_eviction_empty_chain_returns_allow() {
let chain = HookChain::new(vec![]);
let mut reg = ExecutorRegistry::new();
let ev = EvictionEvent {
memory_id: "1".into(),
namespace: "test".into(),
evicted_at: "2026-01-01T00:00:00Z".into(),
reason: "max_entries_reached".into(),
};
let r = fire_on_index_eviction(&chain, &mut reg, ev).await;
assert_eq!(r, ChainResult::Allow);
}
#[tokio::test]
async fn spawn_eviction_observer_exits_when_sender_drops() {
let chain = Arc::new(HookChain::new(vec![]));
let reg = ExecutorRegistry::new();
let tx = spawn_eviction_observer(chain, reg);
drop(tx);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
#[test]
fn chain_result_partial_eq_modified_allow_equal_deltas() {
let a = ChainResult::ModifiedAllow(MemoryDelta {
tags: Some(vec!["x".into()]),
..Default::default()
});
let b = ChainResult::ModifiedAllow(MemoryDelta {
tags: Some(vec!["x".into()]),
..Default::default()
});
assert_eq!(a, b);
}
#[test]
fn chain_result_partial_eq_distinct_variants_not_equal() {
let allow = ChainResult::Allow;
let deny = ChainResult::Deny {
reason: "x".into(),
code: 500,
};
let ask = ChainResult::AskUser {
queued: vec![AskUserPrompt {
prompt: "?".into(),
options: vec!["a".into()],
default: None,
origin_command: "/h".into(),
}],
};
let mod_allow = ChainResult::ModifiedAllow(MemoryDelta::default());
assert_ne!(allow, deny);
assert_ne!(allow, ask);
assert_ne!(allow, mod_allow);
assert_ne!(deny, ask);
assert_ne!(deny, mod_allow);
assert_ne!(ask, mod_allow);
}
#[test]
fn chain_result_partial_eq_deny_different_codes_not_equal() {
let a = ChainResult::Deny {
reason: "x".into(),
code: 403,
};
let b = ChainResult::Deny {
reason: "x".into(),
code: 503,
};
assert_ne!(a, b);
}
#[test]
fn ask_user_prompt_partial_eq_round_trip() {
let p1 = AskUserPrompt {
prompt: "p".into(),
options: vec!["a".into(), "b".into()],
default: Some("a".into()),
origin_command: "/h".into(),
};
let p2 = p1.clone();
assert_eq!(p1, p2);
}
#[test]
fn apply_delta_to_payload_does_nothing_on_empty_delta() {
let mut payload = json!({"keep": "me"});
apply_delta_to_payload(&mut payload, &MemoryDelta::default());
assert_eq!(payload["keep"], json!("me"));
}
#[test]
fn merge_delta_into_overwrites_all_fields() {
let mut acc = MemoryDelta::default();
let incoming = MemoryDelta {
tier: Some(crate::models::Tier::Short),
namespace: Some("ns".into()),
title: Some("t".into()),
content: Some("c".into()),
tags: Some(vec!["tag".into()]),
priority: Some(7),
confidence: Some(0.5),
source: Some("src".into()),
expires_at: Some("2026-01-01".into()),
metadata: Some(json!({"k": "v"})),
};
merge_delta_into(&mut acc, incoming);
assert!(acc.tier.is_some());
assert_eq!(acc.namespace.as_deref(), Some("ns"));
assert_eq!(acc.title.as_deref(), Some("t"));
assert_eq!(acc.content.as_deref(), Some("c"));
assert_eq!(acc.priority, Some(7));
assert_eq!(acc.confidence, Some(0.5));
assert_eq!(acc.source.as_deref(), Some("src"));
assert_eq!(acc.expires_at.as_deref(), Some("2026-01-01"));
assert_eq!(acc.metadata.as_ref().unwrap()["k"], json!("v"));
}
#[test]
fn merge_delta_into_none_fields_dont_overwrite() {
let mut acc = MemoryDelta {
tier: Some(crate::models::Tier::Long),
namespace: Some("orig".into()),
title: Some("orig-title".into()),
content: Some("orig-content".into()),
tags: Some(vec!["orig".into()]),
priority: Some(1),
confidence: Some(0.1),
source: Some("orig-src".into()),
expires_at: Some("orig-exp".into()),
metadata: Some(json!({"orig": true})),
};
merge_delta_into(&mut acc, MemoryDelta::default());
assert!(acc.tier.is_some());
assert_eq!(acc.namespace.as_deref(), Some("orig"));
assert_eq!(acc.title.as_deref(), Some("orig-title"));
assert_eq!(acc.content.as_deref(), Some("orig-content"));
assert_eq!(acc.priority, Some(1));
}
#[tokio::test]
async fn dispatch_event_with_hooks_pre_event_deny_skips_subscription() {
use std::sync::atomic::{AtomicBool, Ordering};
let ran = std::sync::Arc::new(AtomicBool::new(false));
let ran2 = ran.clone();
let mut registry = ExecutorRegistry::new();
let pre_chain = HookChain::new(vec![]);
let result = dispatch_event_with_hooks(
HookEvent::PreStore,
json!({}),
&pre_chain,
&mut registry,
move || {
ran2.store(true, Ordering::SeqCst);
},
)
.await;
assert_eq!(result, ChainResult::Allow);
assert!(
ran.load(Ordering::SeqCst),
"Allow on pre-event must let subscription dispatch run"
);
}
}