Skip to main content

orcs_hook/
context.rs

1//! Hook context — data passed to hook handlers.
2
3use crate::HookPoint;
4use orcs_types::{ChannelId, ComponentId, Principal};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::HashMap;
8
9/// Default maximum hook chain recursion depth.
10pub const DEFAULT_MAX_DEPTH: u8 = 4;
11
12/// Context passed to hook handlers.
13///
14/// Pre-hooks can modify `payload` to alter the downstream operation.
15/// Post-hooks receive the final result in `payload`.
16/// `metadata` carries cross-hook state from pre → post for the same operation.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct HookContext {
19    /// Which hook point triggered this.
20    pub hook_point: HookPoint,
21
22    /// The component targeted by this hook (from FQL match).
23    pub component_id: ComponentId,
24
25    /// The channel where this is happening.
26    pub channel_id: ChannelId,
27
28    /// Who initiated the action.
29    pub principal: Principal,
30
31    /// Monotonic timestamp (ms since engine start).
32    pub timestamp_ms: u64,
33
34    /// Hook-specific payload (varies by HookPoint).
35    ///
36    /// Pre hooks: the input data (request, config, args, etc.)
37    /// Post hooks: the result data (response, child_result, etc.)
38    pub payload: Value,
39
40    /// Mutable metadata bag — hooks can store cross-hook state here.
41    /// Carried from pre → post for the same operation.
42    pub metadata: HashMap<String, Value>,
43
44    /// Recursion depth counter. Incremented each time hooks re-enter
45    /// (e.g., hook calls orcs.exec() which triggers ToolPreExecute hooks).
46    pub depth: u8,
47
48    /// Maximum recursion depth (default: 4).
49    pub max_depth: u8,
50}
51
52impl HookContext {
53    /// Creates a new HookContext with all fields specified.
54    #[must_use]
55    pub fn new(
56        hook_point: HookPoint,
57        component_id: ComponentId,
58        channel_id: ChannelId,
59        principal: Principal,
60        timestamp_ms: u64,
61        payload: Value,
62    ) -> Self {
63        Self {
64            hook_point,
65            component_id,
66            channel_id,
67            principal,
68            timestamp_ms,
69            payload,
70            metadata: HashMap::new(),
71            depth: 0,
72            max_depth: DEFAULT_MAX_DEPTH,
73        }
74    }
75
76    /// Returns a new context with `depth` incremented by 1.
77    #[must_use]
78    pub fn with_incremented_depth(&self) -> Self {
79        let mut ctx = self.clone();
80        ctx.depth = ctx.depth.saturating_add(1);
81        ctx
82    }
83
84    /// Returns `true` if the current depth has reached or exceeded `max_depth`.
85    #[must_use]
86    pub fn is_depth_exceeded(&self) -> bool {
87        self.depth >= self.max_depth
88    }
89
90    /// Sets the maximum recursion depth.
91    #[must_use]
92    pub fn with_max_depth(mut self, max_depth: u8) -> Self {
93        self.max_depth = max_depth;
94        self
95    }
96
97    /// Adds a metadata entry.
98    #[must_use]
99    pub fn with_metadata(mut self, key: impl Into<String>, value: Value) -> Self {
100        self.metadata.insert(key.into(), value);
101        self
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use orcs_types::PrincipalId;
109    use serde_json::json;
110
111    fn test_ctx() -> HookContext {
112        HookContext::new(
113            HookPoint::RequestPreDispatch,
114            ComponentId::builtin("llm"),
115            ChannelId::new(),
116            Principal::User(PrincipalId::new()),
117            12345,
118            json!({"operation": "chat"}),
119        )
120    }
121
122    #[test]
123    fn new_has_correct_defaults() {
124        let ctx = test_ctx();
125        assert_eq!(ctx.depth, 0);
126        assert_eq!(ctx.max_depth, DEFAULT_MAX_DEPTH);
127        assert!(ctx.metadata.is_empty());
128    }
129
130    #[test]
131    fn depth_increment() {
132        let ctx = test_ctx();
133        let incremented = ctx.with_incremented_depth();
134        assert_eq!(incremented.depth, 1);
135        assert_eq!(ctx.depth, 0); // original unchanged
136    }
137
138    #[test]
139    fn depth_saturation() {
140        let mut ctx = test_ctx();
141        ctx.depth = u8::MAX;
142        let incremented = ctx.with_incremented_depth();
143        assert_eq!(incremented.depth, u8::MAX);
144    }
145
146    #[test]
147    fn depth_exceeded() {
148        let mut ctx = test_ctx();
149        ctx.max_depth = 3;
150        ctx.depth = 2;
151        assert!(!ctx.is_depth_exceeded());
152        ctx.depth = 3;
153        assert!(ctx.is_depth_exceeded());
154        ctx.depth = 4;
155        assert!(ctx.is_depth_exceeded());
156    }
157
158    #[test]
159    fn with_max_depth() {
160        let ctx = test_ctx().with_max_depth(8);
161        assert_eq!(ctx.max_depth, 8);
162    }
163
164    #[test]
165    fn with_metadata() {
166        let ctx = test_ctx().with_metadata("audit_id", json!("abc-123"));
167        assert_eq!(ctx.metadata.get("audit_id"), Some(&json!("abc-123")));
168    }
169
170    #[test]
171    fn serde_roundtrip() {
172        let ctx = test_ctx().with_metadata("key", json!(42)).with_max_depth(8);
173        let json = serde_json::to_string(&ctx).expect("HookContext should serialize to JSON");
174        let restored: HookContext =
175            serde_json::from_str(&json).expect("HookContext should deserialize from JSON");
176        assert_eq!(restored.hook_point, ctx.hook_point);
177        assert_eq!(restored.depth, ctx.depth);
178        assert_eq!(restored.max_depth, ctx.max_depth);
179        assert_eq!(restored.payload, ctx.payload);
180        assert_eq!(restored.metadata, ctx.metadata);
181    }
182
183    #[test]
184    fn clone_is_independent() {
185        let mut ctx = test_ctx();
186        let cloned = ctx.clone();
187        ctx.payload = json!({"modified": true});
188        assert_ne!(ctx.payload, cloned.payload);
189    }
190}