1use std::collections::BTreeMap;
5
6use serde::Serialize;
7
8use super::portable::{self, PortableHooks};
9use super::render::{self, Skip};
10use super::targets::{self, Target};
11use super::{HookDef, Origin, Tokens};
12use crate::ordered::OrderedValue;
13
14#[derive(Debug, Clone, PartialEq, Serialize)]
16#[serde(tag = "kind", rename_all = "lowercase")]
17pub enum HookOutput {
18 Merge(MergeOutput),
19 File(FileOutput),
20}
21
22impl HookOutput {
23 pub fn skips(&self) -> &[Skip] {
25 match self {
26 Self::Merge(output) => &output.skips,
27 Self::File(output) => &output.skips,
28 }
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Default, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct FileOutput {
37 pub files: Vec<OutputFile>,
38 pub native_hook_problem: Option<&'static str>,
40 pub skips: Vec<Skip>,
41 pub answer_limits: Vec<Skip>,
43}
44
45impl FileOutput {
46 pub fn has_native_hook_beside_generated(&self) -> bool {
48 self.files.len() > 1
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54pub struct OutputFile {
55 pub path: String,
57 pub content: String,
58}
59
60#[derive(Debug, Clone, PartialEq, Default, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct MergeOutput {
65 pub events: BTreeMap<String, Vec<OrderedValue>>,
67 pub skipped_events: Vec<String>,
69 pub unchecked_events: Vec<String>,
71 pub overrides: Vec<String>,
74 pub skips: Vec<Skip>,
76}
77
78impl MergeOutput {
79 fn conform_to_codex(&mut self, profile: &targets::Profile) -> Result<(), NativeHookError> {
83 let checked: BTreeMap<_, _> = self
84 .events
85 .iter()
86 .filter(|(event, entries)| {
87 !entries.is_empty() && targets::claude_event(event).is_some()
88 })
89 .map(|(event, entries)| (event.clone(), OrderedValue::Array(entries.clone())))
90 .collect();
91 if checked.is_empty() {
92 return Ok(());
93 }
94 let (table, nulls) = PortableHooks::from_ordered(OrderedValue::Object(
95 checked
96 .iter()
97 .map(|(k, v)| (k.clone(), v.clone()))
98 .collect(),
99 ))
100 .map_err(|error| NativeHookError(format!("native hook is not a hook table: {error}")))?;
101 for path in nulls {
102 let event = path.split(['.', '[']).nth(1).map(str::to_owned);
104 self.skips.push(render::Skip {
105 event,
106 reason: format!("`null` at `{path}` dropped: Codex cannot parse it"),
107 });
108 }
109 let rendered = table.render(profile, &Tokens::default());
110 self.skips.extend(rendered.skips.iter().cloned());
111 for event in checked.keys() {
112 self.events.remove(event);
113 }
114 self.events.extend(rendered.events().clone());
115 Ok(())
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
121#[error("{0}")]
122pub struct NativeHookError(String);
123
124impl HookDef {
125 pub fn output(
135 &self,
136 target: &Target,
137 native_hook_text: Option<&str>,
138 origin: Origin,
139 tokens: &Tokens<'_>,
140 ) -> Result<HookOutput, NativeHookError> {
141 if target.is_file() {
142 Ok(HookOutput::File(self.file_output(
143 target,
144 native_hook_text,
145 tokens,
146 )))
147 } else {
148 self.merge_output(target, &target.profile, native_hook_text, origin, tokens)
149 .map(HookOutput::Merge)
150 }
151 }
152
153 fn file_output(
154 &self,
155 target: &Target,
156 native_hook_text: Option<&str>,
157 tokens: &Tokens<'_>,
158 ) -> FileOutput {
159 let tokens = &Tokens {
160 hook_name: &self.name,
161 ..*tokens
162 };
163 let mut output = FileOutput::default();
164 if let (Some(text), Some(path)) = (native_hook_text, target.native_hook_path(&self.name)) {
165 let content = tokens.substitute(text);
166 output.native_hook_problem = target.native_hook_problem(&content);
167 output.files.push(OutputFile { path, content });
168 }
169 if let (Some(portable), true) = (&self.portable, self.generates_for(target.root)) {
170 let rendered = portable.render(&target.profile, tokens);
171 if let (
172 render::Output::File {
173 text: Some(content),
174 },
175 Some(path),
176 ) = (rendered.output, target.generated_path(&self.name))
177 {
178 output.files.push(OutputFile { path, content });
179 }
180 output.skips = rendered.skips;
181 output.answer_limits = rendered.answer_limits;
182 }
183 output
184 }
185
186 pub fn plugin_output(
193 &self,
194 target: &Target,
195 shared: Option<&targets::Profile>,
196 native_hook_text: Option<&str>,
197 origin: Origin,
198 tokens: &Tokens<'_>,
199 ) -> Result<HookOutput, NativeHookError> {
200 let Some(profile) = shared else {
201 return self.output(target, native_hook_text, origin, tokens);
202 };
203 let mut output = self.merge_output(target, profile, native_hook_text, origin, tokens)?;
204 output.conform_to_codex(profile)?;
205 Ok(HookOutput::Merge(output))
206 }
207
208 pub fn check_native_hook(
211 &self,
212 target: &Target,
213 native_hook_text: Option<&str>,
214 origin: Origin,
215 ) -> Result<(), NativeHookError> {
216 if target.is_file() {
217 return Ok(());
218 }
219 let tokens = Tokens::default();
220 self.lay_native_hook(
221 MergeOutput::default(),
222 target,
223 &target.profile,
224 native_hook_text,
225 origin,
226 &tokens,
227 )
228 .map(drop)
229 }
230
231 fn merge_output(
232 &self,
233 target: &Target,
234 profile: &targets::Profile,
235 native_hook_text: Option<&str>,
236 origin: Origin,
237 tokens: &Tokens<'_>,
238 ) -> Result<MergeOutput, NativeHookError> {
239 let mut output = MergeOutput::default();
240 if let (Some(portable), true) = (&self.portable, self.generates_for(target.root)) {
241 let rendered = portable.render(profile, tokens);
242 if let render::Output::Merge {
243 events,
244 unchecked_events,
245 } = rendered.output
246 {
247 output.events = events;
248 output.unchecked_events = unchecked_events;
249 }
250 output.skips = rendered.skips;
251 }
252 self.lay_native_hook(output, target, profile, native_hook_text, origin, tokens)
253 }
254
255 fn lay_native_hook(
257 &self,
258 mut output: MergeOutput,
259 target: &Target,
260 profile: &targets::Profile,
261 native_hook_text: Option<&str>,
262 origin: Origin,
263 tokens: &Tokens<'_>,
264 ) -> Result<MergeOutput, NativeHookError> {
265 let (Some(text), Some(native_hook)) = (native_hook_text, self.native_hook(target.root))
266 else {
267 return Ok(output);
268 };
269 let err = |message: String| NativeHookError(message);
270 let mut value: OrderedValue = serde_json::from_str(text)
271 .map_err(|error| err(format!("native hook is not valid JSON: {error}")))?;
272 value = portable::unwrap_hooks_object(value);
273 let OrderedValue::Object(object) = value else {
274 return Err(err(
275 "native hook top level must be an event-keyed JSON object".into(),
276 ));
277 };
278 let sorted: BTreeMap<_, _> = object.into_iter().collect();
280 for (event, entries) in sorted {
281 if profile.event(&event).is_none() {
282 if !native_hook.is_unchecked() && origin == Origin::Dependency {
283 output.skipped_events.push(event);
284 continue;
285 }
286 if !native_hook.is_unchecked() {
287 let checked = profile
288 .verified_against
289 .map_or(String::new(), |v| format!(" (checked against {v})"));
290 return Err(err(format!(
291 "unknown event `{event}` for target `{}`; valid events: {}{checked}; use `unchecked = true` to pass a newer native event through verbatim",
292 target.root,
293 profile
294 .events
295 .iter()
296 .map(|e| e.name)
297 .collect::<Vec<_>>()
298 .join(", ")
299 )));
300 }
301 output.unchecked_events.push(event.clone());
302 }
303 let OrderedValue::Array(mut array) = entries else {
304 return Err(err(format!("event `{event}` value must be an array")));
305 };
306 array.iter_mut().for_each(|e| tokens.substitute_value(e));
307 if self.portable.as_ref().is_some_and(|p| p.declares(&event)) {
308 output.overrides.push(event.clone());
309 }
310 output
311 .skips
312 .retain(|skip| skip.event.as_deref() != Some(event.as_str()));
313 output.events.insert(event, array);
314 }
315 Ok(output)
316 }
317}