Skip to main content

forge_primitives/hooks/
outputs.rs

1//! `hookSpecificOutput` typed wrappers for the two hook kinds forge
2//! answers with an input override.
3//!
4//! Each carries a zero-sized `event_name` field that serde always emits
5//! as the correct `hookEventName` string, so the discriminator is present
6//! however the wrapper is serialised.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use super::HookKind;
12
13/// Tag ZST helpers that serialise as a fixed `hookEventName` string and
14/// ignore the actual value on the way back in. One ZST per event kind keeps
15/// the wrapper structs `Default`, `Clone`, and roundtrip-safe without
16/// requiring nightly-only const-generic string parameters.
17macro_rules! declare_event_name_tag {
18    ($name:ident, $tag:literal) => {
19        #[doc = concat!("Zero-sized tag that always serialises as `\"", $tag, "\"`.")]
20        #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21        pub struct $name;
22
23        impl Serialize for $name {
24            fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
25                ser.serialize_str($tag)
26            }
27        }
28
29        impl<'de> Deserialize<'de> for $name {
30            fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
31                let _ = String::deserialize(de)?;
32                Ok(Self)
33            }
34        }
35    };
36}
37
38declare_event_name_tag!(PreToolUseTag, "PreToolUse");
39declare_event_name_tag!(UserPromptSubmitTag, "UserPromptSubmit");
40
41/// `hookSpecificOutput` shape for `PreToolUse` hook responses.
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct PreToolUseHookSpecificOutput {
44    /// Fixed `"PreToolUse"` discriminator on the wire.
45    #[serde(rename = "hookEventName", default)]
46    pub event_name: PreToolUseTag,
47    /// Substitute input the tool should run with instead of the proposed one.
48    #[serde(default, rename = "updatedInput", skip_serializing_if = "Option::is_none")]
49    pub updated_input: Option<Value>,
50}
51
52/// `hookSpecificOutput` shape for `UserPromptSubmit`.
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54pub struct UserPromptSubmitHookSpecificOutput {
55    /// Fixed `"UserPromptSubmit"` discriminator.
56    #[serde(rename = "hookEventName", default)]
57    pub event_name: UserPromptSubmitTag,
58    /// Out-of-band context to inject alongside the submitted prompt.
59    #[serde(default, rename = "additionalContext", skip_serializing_if = "Option::is_none")]
60    pub additional_context: Option<String>,
61}
62
63/// Encode a `replace_input` `forge_sdk::HookDecision` into a
64/// `hookSpecificOutput` wrapper for the given hook kind.
65///
66/// Returns `None` when the hook kind has no wire field for an input
67/// override (anything other than `PreToolUse` / `UserPromptSubmit`) or
68/// when the supplied value doesn't match the wrapper's expected shape
69/// (e.g. a non-string payload for `UserPromptSubmit`, whose wire struct
70/// only carries `additionalContext: str`). Emits a `tracing::warn!` in
71/// every drop path so misuse is visible rather than silent.
72pub fn encode_updated_input_wrapper(kind: HookKind, updated: &Value) -> Option<Value> {
73    match kind {
74        HookKind::PreToolUse => {
75            let typed = PreToolUseHookSpecificOutput {
76                updated_input: Some(updated.clone()),
77                ..Default::default()
78            };
79            serde_json::to_value(typed)
80                .map_err(|e| {
81                    tracing::warn!(
82                        ?kind,
83                        error = %e,
84                        "PreToolUse hookSpecificOutput serialise failed; \
85                         dropping updated_input"
86                    );
87                })
88                .ok()
89        }
90        HookKind::UserPromptSubmit => {
91            // Upstream `UserPromptSubmitHookSpecificOutput` only carries
92            // `additionalContext: str`. If the caller hands us a JSON
93            // string, forward it there; otherwise drop the payload and
94            // warn - there is no wire field to land a structured
95            // replacement.
96            updated.as_str().map_or_else(
97                || {
98                    tracing::warn!(
99                        ?kind,
100                        "UserPromptSubmit replace_input expects a JSON string \
101                         (used as additionalContext); dropping non-string payload"
102                    );
103                    None
104                },
105                |s| {
106                    let typed = UserPromptSubmitHookSpecificOutput {
107                        additional_context: Some(s.to_string()),
108                        ..Default::default()
109                    };
110                    serde_json::to_value(typed)
111                        .map_err(|e| {
112                            tracing::warn!(
113                                ?kind,
114                                error = %e,
115                                "UserPromptSubmit hookSpecificOutput serialise failed; \
116                                 dropping updated_input"
117                            );
118                        })
119                        .ok()
120                },
121            )
122        }
123        _ => {
124            tracing::warn!(
125                ?kind,
126                "hook returned updated_input but hook kind doesn't support it; ignoring"
127            );
128            None
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests_hooks_specific_output {
135    // Test-mod `use super::*;` brings the parent's full surface in; not every test consumes every item.
136    #[allow(unused_imports)]
137    use super::*;
138
139    use crate::{PreToolUseHookSpecificOutput, UserPromptSubmitHookSpecificOutput};
140    use serde_json::json;
141
142    #[test]
143    fn pre_tool_use_hook_specific_output_serialises_with_updated_input() {
144        let out = PreToolUseHookSpecificOutput {
145            updated_input: Some(json!({"command": "echo safe"})),
146            ..Default::default()
147        };
148        let v = serde_json::to_value(&out).expect("serialise");
149        assert_eq!(v["hookEventName"], "PreToolUse");
150        assert_eq!(v["updatedInput"], json!({"command": "echo safe"}));
151    }
152
153    #[test]
154    fn pre_tool_use_hook_specific_output_omits_unset_updated_input() {
155        let v = serde_json::to_value(PreToolUseHookSpecificOutput::default()).expect("serialise");
156        assert_eq!(v["hookEventName"], "PreToolUse");
157        assert!(v.get("updatedInput").is_none(), "None-valued fields must not serialise");
158    }
159
160    #[test]
161    fn user_prompt_submit_hook_specific_output_round_trips() {
162        let out = UserPromptSubmitHookSpecificOutput {
163            additional_context: Some("context to inject".into()),
164            ..Default::default()
165        };
166        let v = serde_json::to_value(&out).expect("serialise");
167        assert_eq!(v["hookEventName"], "UserPromptSubmit");
168        assert_eq!(v["additionalContext"], "context to inject");
169    }
170}