1use crate::{FqlPattern, HookAction, HookContext, HookPoint};
4
5pub trait Hook: Send + Sync {
19 fn id(&self) -> &str;
21
22 fn fql_pattern(&self) -> &FqlPattern;
24
25 fn hook_point(&self) -> HookPoint;
27
28 fn priority(&self) -> i32 {
30 100
31 }
32
33 fn execute(&self, ctx: HookContext) -> HookAction;
42}
43
44#[cfg(any(test, feature = "test-utils"))]
46pub mod testing {
47 use super::*;
48 use std::sync::atomic::{AtomicUsize, Ordering};
49 use std::sync::Arc;
50
51 pub struct MockHook {
56 pub id: String,
58 pub fql: FqlPattern,
60 pub point: HookPoint,
62 pub priority: i32,
64 pub action_fn: Box<dyn Fn(HookContext) -> HookAction + Send + Sync>,
66 pub call_count: Arc<AtomicUsize>,
68 }
69
70 impl MockHook {
71 pub fn pass_through(id: &str, fql: &str, point: HookPoint) -> Self {
73 Self {
74 id: id.to_string(),
75 fql: FqlPattern::parse(fql).expect("valid FQL for MockHook"),
76 point,
77 priority: 100,
78 action_fn: Box::new(|ctx| HookAction::Continue(Box::new(ctx))),
79 call_count: Arc::new(AtomicUsize::new(0)),
80 }
81 }
82
83 pub fn modifier(
85 id: &str,
86 fql: &str,
87 point: HookPoint,
88 modifier: impl Fn(&mut HookContext) + Send + Sync + 'static,
89 ) -> Self {
90 Self {
91 id: id.to_string(),
92 fql: FqlPattern::parse(fql).expect("valid FQL for MockHook"),
93 point,
94 priority: 100,
95 action_fn: Box::new(move |mut ctx| {
96 modifier(&mut ctx);
97 HookAction::Continue(Box::new(ctx))
98 }),
99 call_count: Arc::new(AtomicUsize::new(0)),
100 }
101 }
102
103 pub fn aborter(id: &str, fql: &str, point: HookPoint, reason: &str) -> Self {
105 let reason = reason.to_string();
106 Self {
107 id: id.to_string(),
108 fql: FqlPattern::parse(fql).expect("valid FQL for MockHook"),
109 point,
110 priority: 100,
111 action_fn: Box::new(move |_ctx| HookAction::Abort {
112 reason: reason.clone(),
113 }),
114 call_count: Arc::new(AtomicUsize::new(0)),
115 }
116 }
117
118 pub fn skipper(id: &str, fql: &str, point: HookPoint, value: serde_json::Value) -> Self {
120 Self {
121 id: id.to_string(),
122 fql: FqlPattern::parse(fql).expect("valid FQL for MockHook"),
123 point,
124 priority: 100,
125 action_fn: Box::new(move |_ctx| HookAction::Skip(value.clone())),
126 call_count: Arc::new(AtomicUsize::new(0)),
127 }
128 }
129
130 pub fn replacer(id: &str, fql: &str, point: HookPoint, value: serde_json::Value) -> Self {
132 Self {
133 id: id.to_string(),
134 fql: FqlPattern::parse(fql).expect("valid FQL for MockHook"),
135 point,
136 priority: 100,
137 action_fn: Box::new(move |_ctx| HookAction::Replace(value.clone())),
138 call_count: Arc::new(AtomicUsize::new(0)),
139 }
140 }
141
142 #[must_use]
144 pub fn with_priority(mut self, priority: i32) -> Self {
145 self.priority = priority;
146 self
147 }
148
149 pub fn calls(&self) -> usize {
151 self.call_count.load(Ordering::SeqCst)
152 }
153 }
154
155 impl Hook for MockHook {
156 fn id(&self) -> &str {
157 &self.id
158 }
159
160 fn fql_pattern(&self) -> &FqlPattern {
161 &self.fql
162 }
163
164 fn hook_point(&self) -> HookPoint {
165 self.point
166 }
167
168 fn priority(&self) -> i32 {
169 self.priority
170 }
171
172 fn execute(&self, ctx: HookContext) -> HookAction {
173 self.call_count.fetch_add(1, Ordering::SeqCst);
174 (self.action_fn)(ctx)
175 }
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::testing::MockHook;
182 use super::*;
183 use orcs_types::{ChannelId, ComponentId, Principal};
184 use serde_json::json;
185
186 fn test_ctx() -> HookContext {
187 HookContext::new(
188 HookPoint::RequestPreDispatch,
189 ComponentId::builtin("llm"),
190 ChannelId::new(),
191 Principal::System,
192 0,
193 json!({"op": "test"}),
194 )
195 }
196
197 #[test]
198 fn mock_pass_through() {
199 let hook = MockHook::pass_through("test", "*::*", HookPoint::RequestPreDispatch);
200 let ctx = test_ctx();
201 let action = hook.execute(ctx.clone());
202 assert!(action.is_continue());
203 assert_eq!(hook.calls(), 1);
204 }
205
206 #[test]
207 fn mock_aborter() {
208 let hook = MockHook::aborter("test", "*::*", HookPoint::RequestPreDispatch, "blocked");
209 let action = hook.execute(test_ctx());
210 assert!(action.is_abort());
211 if let HookAction::Abort { reason } = action {
212 assert_eq!(reason, "blocked");
213 }
214 }
215
216 #[test]
217 fn mock_modifier() {
218 let hook = MockHook::modifier("test", "*::*", HookPoint::RequestPreDispatch, |ctx| {
219 ctx.payload = json!({"modified": true});
220 });
221 let action = hook.execute(test_ctx());
222 if let HookAction::Continue(ctx) = action {
223 assert_eq!(ctx.payload, json!({"modified": true}));
224 } else {
225 panic!("expected Continue");
226 }
227 }
228
229 #[test]
230 fn mock_priority() {
231 let hook =
232 MockHook::pass_through("test", "*::*", HookPoint::RequestPreDispatch).with_priority(50);
233 assert_eq!(hook.priority(), 50);
234 }
235
236 #[test]
237 fn mock_call_count_increments() {
238 let hook = MockHook::pass_through("test", "*::*", HookPoint::RequestPreDispatch);
239 hook.execute(test_ctx());
240 hook.execute(test_ctx());
241 hook.execute(test_ctx());
242 assert_eq!(hook.calls(), 3);
243 }
244
245 #[test]
246 fn hook_default_priority() {
247 let hook = MockHook::pass_through("test", "*::*", HookPoint::RequestPreDispatch);
248 assert_eq!(hook.priority(), 100);
249 }
250}