forge_sdk/hooks/
callback.rs1use std::marker::PhantomData;
4
5use serde_json::Value;
6
7use forge_primitives::HookContext;
8
9#[derive(Debug, Clone)]
15pub struct HookDecision {
16 inner: HookDecisionKind,
17}
18
19#[derive(Debug, Clone)]
20enum HookDecisionKind {
21 Allow {
22 updated_input: Option<Value>,
23 },
24 Deny {
25 reason: String,
26 },
27 Passthrough,
29 Defer {
36 timeout_ms: Option<u64>,
37 },
38}
39
40impl HookDecision {
41 fn with_inner(inner: HookDecisionKind) -> Self {
42 Self { inner }
43 }
44
45 pub fn allow() -> Self {
47 Self::with_inner(HookDecisionKind::Allow { updated_input: None })
48 }
49
50 pub fn replace_input(new_input: Value) -> Self {
53 Self::with_inner(HookDecisionKind::Allow { updated_input: Some(new_input) })
54 }
55
56 pub fn deny(reason: impl Into<String>) -> Self {
58 Self::with_inner(HookDecisionKind::Deny { reason: reason.into() })
59 }
60
61 pub fn passthrough() -> Self {
64 Self::with_inner(HookDecisionKind::Passthrough)
65 }
66
67 pub fn defer(timeout_ms: Option<u64>) -> Self {
74 Self::with_inner(HookDecisionKind::Defer { timeout_ms })
75 }
76
77 pub fn is_deferred(&self) -> bool {
79 matches!(self.inner, HookDecisionKind::Defer { .. })
80 }
81
82 pub fn defer_timeout_ms(&self) -> Option<u64> {
85 match &self.inner {
86 HookDecisionKind::Defer { timeout_ms } => *timeout_ms,
87 _ => None,
88 }
89 }
90
91 pub fn is_allow(&self) -> bool {
93 !matches!(self.inner, HookDecisionKind::Deny { .. })
94 }
95
96 pub fn updated_input(&self) -> Option<&Value> {
98 match &self.inner {
99 HookDecisionKind::Allow { updated_input } => updated_input.as_ref(),
100 _ => None,
101 }
102 }
103
104 pub fn reason(&self) -> Option<&str> {
106 match &self.inner {
107 HookDecisionKind::Deny { reason } => Some(reason),
108 _ => None,
109 }
110 }
111}
112
113pub trait HookCallback<I>: Send + Sync
116where
117 I: Send + 'static,
118{
119 fn call<'a>(
121 &'a self,
122 input: I,
123 context: HookContext,
124 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = HookDecision> + Send + 'a>>;
125}
126
127impl<F, Fut, I> HookCallback<I> for F
128where
129 F: Fn(I, HookContext) -> Fut + Send + Sync,
130 Fut: std::future::Future<Output = HookDecision> + Send + 'static,
131 I: Send + 'static,
132{
133 fn call<'a>(
134 &'a self,
135 input: I,
136 context: HookContext,
137 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = HookDecision> + Send + 'a>> {
138 Box::pin(self(input, context))
139 }
140}
141
142#[async_trait::async_trait]
145pub trait ErasedHookCallback: Send + Sync {
146 async fn call_erased(&self, input: Value, context: HookContext) -> HookDecision;
149}
150
151pub(crate) struct ErasedCallback<I, C>
154where
155 I: serde::de::DeserializeOwned + Send + 'static,
156 C: HookCallback<I>,
157{
158 pub(crate) inner: C,
159 pub(crate) _marker: PhantomData<fn() -> I>,
160}
161
162#[async_trait::async_trait]
163impl<I, C> ErasedHookCallback for ErasedCallback<I, C>
164where
165 I: serde::de::DeserializeOwned + Send + 'static,
166 C: HookCallback<I>,
167{
168 async fn call_erased(&self, input: Value, context: HookContext) -> HookDecision {
169 match serde_json::from_value::<I>(input) {
170 Ok(typed) => self.inner.call(typed, context).await,
171 Err(e) => {
172 tracing::warn!(
176 error = %e,
177 hook_kind = ?context.kind,
178 "hook input deserialise failed; passthrough (hook not consulted). CLI schema drift?"
179 );
180 HookDecision::passthrough()
181 }
182 }
183 }
184}