1use crate::HookPoint;
4use orcs_types::{ChannelId, ComponentId, Principal};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::HashMap;
8
9pub const DEFAULT_MAX_DEPTH: u8 = 4;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct HookContext {
19 pub hook_point: HookPoint,
21
22 pub component_id: ComponentId,
24
25 pub channel_id: ChannelId,
27
28 pub principal: Principal,
30
31 pub timestamp_ms: u64,
33
34 pub payload: Value,
39
40 pub metadata: HashMap<String, Value>,
43
44 pub depth: u8,
47
48 pub max_depth: u8,
50}
51
52impl HookContext {
53 #[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 #[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 #[must_use]
86 pub fn is_depth_exceeded(&self) -> bool {
87 self.depth >= self.max_depth
88 }
89
90 #[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 #[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); }
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}