forge_primitives/hooks/
outputs.rs1use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use super::HookKind;
12
13macro_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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct PreToolUseHookSpecificOutput {
44 #[serde(rename = "hookEventName", default)]
46 pub event_name: PreToolUseTag,
47 #[serde(default, rename = "updatedInput", skip_serializing_if = "Option::is_none")]
49 pub updated_input: Option<Value>,
50}
51
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54pub struct UserPromptSubmitHookSpecificOutput {
55 #[serde(rename = "hookEventName", default)]
57 pub event_name: UserPromptSubmitTag,
58 #[serde(default, rename = "additionalContext", skip_serializing_if = "Option::is_none")]
60 pub additional_context: Option<String>,
61}
62
63pub 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 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 #[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}