Skip to main content

orcs_hook/
hook.rs

1//! Hook trait and testing utilities.
2
3use crate::{FqlPattern, HookAction, HookContext, HookPoint};
4
5/// A single hook handler.
6///
7/// Hooks are registered with the [`HookRegistry`](crate::HookRegistry) and
8/// invoked at specific lifecycle points. Each hook declares:
9///
10/// - An FQL pattern (which components it targets)
11/// - A hook point (when it fires)
12/// - A priority (execution order within the same point)
13///
14/// # Thread Safety
15///
16/// Hooks must be `Send + Sync` for concurrent access from multiple
17/// channel runners.
18pub trait Hook: Send + Sync {
19    /// Unique identifier for this hook.
20    fn id(&self) -> &str;
21
22    /// FQL pattern this hook matches.
23    fn fql_pattern(&self) -> &FqlPattern;
24
25    /// Which lifecycle point this hook fires on.
26    fn hook_point(&self) -> HookPoint;
27
28    /// Priority (lower = earlier). Default: 100.
29    fn priority(&self) -> i32 {
30        100
31    }
32
33    /// Execute the hook with the given context.
34    ///
35    /// # Returns
36    ///
37    /// - `Continue(ctx)` — pass modified context to next hook / operation
38    /// - `Skip(value)` — skip the operation (pre-hooks only)
39    /// - `Abort { reason }` — abort the operation (pre-hooks only)
40    /// - `Replace(value)` — replace result payload (post-hooks only)
41    fn execute(&self, ctx: HookContext) -> HookAction;
42}
43
44/// Test utilities for the hook system.
45#[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    /// A mock hook for testing.
52    ///
53    /// Returns a fixed `HookAction` on every `execute()` call.
54    /// Tracks invocation count via `call_count`.
55    pub struct MockHook {
56        /// Hook ID.
57        pub id: String,
58        /// FQL pattern.
59        pub fql: FqlPattern,
60        /// Hook point.
61        pub point: HookPoint,
62        /// Priority.
63        pub priority: i32,
64        /// The action to return on every execute() call.
65        pub action_fn: Box<dyn Fn(HookContext) -> HookAction + Send + Sync>,
66        /// Number of times execute() has been called.
67        pub call_count: Arc<AtomicUsize>,
68    }
69
70    impl MockHook {
71        /// Creates a pass-through mock that returns `Continue(ctx)`.
72        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        /// Creates a mock that modifies the payload via the given function.
84        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        /// Creates a mock that aborts with the given reason.
104        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        /// Creates a mock that skips with the given value.
119        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        /// Creates a mock that replaces with the given value.
131        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        /// Sets the priority.
143        #[must_use]
144        pub fn with_priority(mut self, priority: i32) -> Self {
145            self.priority = priority;
146            self
147        }
148
149        /// Returns the number of times this hook has been executed.
150        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}