#[cfg(feature = "otel")]
pub mod otel;
#[cfg(feature = "otel")]
pub use otel::OtelHook;
use harness_core::{Event, Hook, HookOutcome, World, iter_macro_hooks};
use std::sync::Arc;
#[derive(Default)]
pub struct HookBus {
hooks: Vec<Arc<dyn Hook>>,
}
impl HookBus {
pub fn new() -> Self {
Self::default()
}
pub fn with_macro_hooks(mut self) -> Self {
for h in iter_macro_hooks() {
self.hooks.push(h);
}
self
}
pub fn with_macro_hooks_take(self) -> Self {
self.with_macro_hooks()
}
pub fn register(&mut self, h: Arc<dyn Hook>) {
self.hooks.push(h);
}
pub fn len(&self) -> usize {
self.hooks.len()
}
pub fn is_empty(&self) -> bool {
self.hooks.is_empty()
}
pub fn fire(&self, ev: &Event<'_>, world: &mut World) -> HookOutcome {
let mut injects = Vec::<String>::new();
for h in self.hooks.iter().filter(|h| h.matches(ev)) {
match h.fire(ev, world) {
HookOutcome::Deny { reason } => {
tracing::warn!(hook = h.name(), event = ev.name(), %reason, "hook denied");
return HookOutcome::Deny { reason };
}
HookOutcome::Inject(s) => injects.push(s),
HookOutcome::Mutate(_) => {
tracing::debug!(
hook = h.name(),
event = ev.name(),
"hook mutation ignored (not yet wired)"
);
}
HookOutcome::Allow => {}
_ => {
tracing::warn!(
hook = h.name(),
"unrecognised HookOutcome variant — treating as Allow"
);
}
}
}
if injects.is_empty() {
HookOutcome::Allow
} else {
HookOutcome::Inject(injects.join("\n"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use harness_core::{Event, World};
struct AlwaysDeny;
impl Hook for AlwaysDeny {
fn name(&self) -> &str {
"always-deny"
}
fn matches(&self, _: &Event<'_>) -> bool {
true
}
fn fire(&self, _: &Event<'_>, _: &mut World) -> HookOutcome {
HookOutcome::Deny {
reason: "nope".into(),
}
}
}
struct Counter(std::sync::atomic::AtomicU32);
impl Hook for Counter {
fn name(&self) -> &str {
"counter"
}
fn matches(&self, _: &Event<'_>) -> bool {
true
}
fn fire(&self, _: &Event<'_>, _: &mut World) -> HookOutcome {
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
HookOutcome::Allow
}
}
fn mock_world() -> World {
use harness_core::{Clock, KvStore, ProcessOutput, ProcessRunner, RepoView};
use std::path::Path;
struct NoopClock;
impl Clock for NoopClock {
fn now_ms(&self) -> i64 {
0
}
}
struct NoopRunner;
#[async_trait::async_trait]
impl ProcessRunner for NoopRunner {
async fn exec(
&self,
_: &str,
_: &[&str],
_: Option<&Path>,
) -> std::io::Result<ProcessOutput> {
Ok(ProcessOutput {
status: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
struct NoopKv;
#[async_trait::async_trait]
impl KvStore for NoopKv {
async fn get(&self, _: &str) -> Option<Vec<u8>> {
None
}
async fn set(&self, _: &str, _: Vec<u8>) {}
async fn delete(&self, _: &str) {}
}
World {
repo: RepoView { root: ".".into() },
runner: Arc::new(NoopRunner),
clock: Arc::new(NoopClock),
kv: Arc::new(NoopKv),
profile: harness_core::UserProfile::default(),
}
}
#[test]
fn deny_short_circuits() {
let counter = Arc::new(Counter(0.into()));
let mut bus = HookBus::new();
bus.register(Arc::new(AlwaysDeny));
bus.register(counter.clone());
let mut world = mock_world();
let outcome = bus.fire(&Event::Stop, &mut world);
assert!(matches!(outcome, HookOutcome::Deny { .. }));
assert_eq!(counter.0.load(std::sync::atomic::Ordering::SeqCst), 0);
}
#[test]
fn all_match_fire_in_order() {
let counter = Arc::new(Counter(0.into()));
let mut bus = HookBus::new();
bus.register(counter.clone());
bus.register(counter.clone());
bus.register(counter.clone());
let mut world = mock_world();
let outcome = bus.fire(&Event::Stop, &mut world);
assert!(matches!(outcome, HookOutcome::Allow));
assert_eq!(counter.0.load(std::sync::atomic::Ordering::SeqCst), 3);
}
}