1#[cfg(feature = "otel")]
8pub mod otel;
9
10#[cfg(feature = "otel")]
11pub use otel::OtelHook;
12
13use harness_core::{Event, Hook, HookOutcome, World, iter_macro_hooks};
14use std::sync::Arc;
15
16#[derive(Default)]
18pub struct HookBus {
19 hooks: Vec<Arc<dyn Hook>>,
20}
21
22impl HookBus {
23 pub fn new() -> Self {
24 Self::default()
25 }
26
27 pub fn with_macro_hooks(mut self) -> Self {
29 for h in iter_macro_hooks() {
30 self.hooks.push(h);
31 }
32 self
33 }
34
35 pub fn with_macro_hooks_take(self) -> Self {
37 self.with_macro_hooks()
38 }
39
40 pub fn register(&mut self, h: Arc<dyn Hook>) {
41 self.hooks.push(h);
42 }
43
44 pub fn len(&self) -> usize {
45 self.hooks.len()
46 }
47 pub fn is_empty(&self) -> bool {
48 self.hooks.is_empty()
49 }
50
51 pub fn fire(&self, ev: &Event<'_>, world: &mut World) -> HookOutcome {
58 let mut injects = Vec::<String>::new();
59 for h in self.hooks.iter().filter(|h| h.matches(ev)) {
60 match h.fire(ev, world) {
61 HookOutcome::Deny { reason } => {
62 tracing::warn!(hook = h.name(), event = ev.name(), %reason, "hook denied");
63 return HookOutcome::Deny { reason };
64 }
65 HookOutcome::Inject(s) => injects.push(s),
66 HookOutcome::Mutate(_) => {
67 tracing::debug!(
69 hook = h.name(),
70 event = ev.name(),
71 "hook mutation ignored (not yet wired)"
72 );
73 }
74 HookOutcome::Allow => {}
75 _ => {
77 tracing::warn!(
78 hook = h.name(),
79 "unrecognised HookOutcome variant — treating as Allow"
80 );
81 }
82 }
83 }
84 if injects.is_empty() {
85 HookOutcome::Allow
86 } else {
87 HookOutcome::Inject(injects.join("\n"))
88 }
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use harness_core::{Event, World};
96
97 struct AlwaysDeny;
98 impl Hook for AlwaysDeny {
99 fn name(&self) -> &str {
100 "always-deny"
101 }
102 fn matches(&self, _: &Event<'_>) -> bool {
103 true
104 }
105 fn fire(&self, _: &Event<'_>, _: &mut World) -> HookOutcome {
106 HookOutcome::Deny {
107 reason: "nope".into(),
108 }
109 }
110 }
111
112 struct Counter(std::sync::atomic::AtomicU32);
113 impl Hook for Counter {
114 fn name(&self) -> &str {
115 "counter"
116 }
117 fn matches(&self, _: &Event<'_>) -> bool {
118 true
119 }
120 fn fire(&self, _: &Event<'_>, _: &mut World) -> HookOutcome {
121 self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
122 HookOutcome::Allow
123 }
124 }
125
126 fn mock_world() -> World {
127 use harness_core::{Clock, KvStore, ProcessOutput, ProcessRunner, RepoView};
128 use std::path::Path;
129
130 struct NoopClock;
131 impl Clock for NoopClock {
132 fn now_ms(&self) -> i64 {
133 0
134 }
135 }
136 struct NoopRunner;
137 #[async_trait::async_trait]
138 impl ProcessRunner for NoopRunner {
139 async fn exec(
140 &self,
141 _: &str,
142 _: &[&str],
143 _: Option<&Path>,
144 ) -> std::io::Result<ProcessOutput> {
145 Ok(ProcessOutput {
146 status: 0,
147 stdout: String::new(),
148 stderr: String::new(),
149 })
150 }
151 }
152 struct NoopKv;
153 #[async_trait::async_trait]
154 impl KvStore for NoopKv {
155 async fn get(&self, _: &str) -> Option<Vec<u8>> {
156 None
157 }
158 async fn set(&self, _: &str, _: Vec<u8>) {}
159 async fn delete(&self, _: &str) {}
160 }
161
162 World {
163 repo: RepoView { root: ".".into() },
164 runner: Arc::new(NoopRunner),
165 clock: Arc::new(NoopClock),
166 kv: Arc::new(NoopKv),
167 profile: harness_core::UserProfile::default(),
168 }
169 }
170
171 #[test]
172 fn deny_short_circuits() {
173 let counter = Arc::new(Counter(0.into()));
174 let mut bus = HookBus::new();
175 bus.register(Arc::new(AlwaysDeny));
176 bus.register(counter.clone());
177 let mut world = mock_world();
178 let outcome = bus.fire(&Event::Stop, &mut world);
179 assert!(matches!(outcome, HookOutcome::Deny { .. }));
180 assert_eq!(counter.0.load(std::sync::atomic::Ordering::SeqCst), 0);
181 }
182
183 #[test]
184 fn all_match_fire_in_order() {
185 let counter = Arc::new(Counter(0.into()));
186 let mut bus = HookBus::new();
187 bus.register(counter.clone());
188 bus.register(counter.clone());
189 bus.register(counter.clone());
190 let mut world = mock_world();
191 let outcome = bus.fire(&Event::Stop, &mut world);
192 assert!(matches!(outcome, HookOutcome::Allow));
193 assert_eq!(counter.0.load(std::sync::atomic::Ordering::SeqCst), 3);
194 }
195}