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 fragment_problem: Option<&'static str>,
40 pub skips: Vec<Skip>,
41 pub answer_limits: Vec<Skip>,
43}
44
45impl FileOutput {
46 pub fn has_fragment_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<(), FragmentError> {
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| FragmentError(format!("fragment 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 FragmentError(String);
123
124impl HookDef {
125 pub fn output(
135 &self,
136 target: &Target,
137 fragment_text: Option<&str>,
138 origin: Origin,
139 tokens: &Tokens<'_>,
140 ) -> Result<HookOutput, FragmentError> {
141 if target.is_file() {
142 Ok(HookOutput::File(self.file_output(
143 target,
144 fragment_text,
145 tokens,
146 )))
147 } else {
148 self.merge_output(target, &target.profile, fragment_text, origin, tokens)
149 .map(HookOutput::Merge)
150 }
151 }
152
153 fn file_output(
154 &self,
155 target: &Target,
156 fragment_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)) = (fragment_text, target.fragment_path(&self.name)) {
165 let content = tokens.substitute(text);
166 output.fragment_problem = target.fragment_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 fragment_text: Option<&str>,
197 origin: Origin,
198 tokens: &Tokens<'_>,
199 ) -> Result<HookOutput, FragmentError> {
200 let Some(profile) = shared else {
201 return self.output(target, fragment_text, origin, tokens);
202 };
203 let mut output = self.merge_output(target, profile, fragment_text, origin, tokens)?;
204 output.conform_to_codex(profile)?;
205 Ok(HookOutput::Merge(output))
206 }
207
208 pub fn check_fragment(
211 &self,
212 target: &Target,
213 fragment_text: Option<&str>,
214 origin: Origin,
215 ) -> Result<(), FragmentError> {
216 if target.is_file() {
217 return Ok(());
218 }
219 let tokens = Tokens::default();
220 self.lay_fragment(
221 MergeOutput::default(),
222 target,
223 &target.profile,
224 fragment_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 fragment_text: Option<&str>,
236 origin: Origin,
237 tokens: &Tokens<'_>,
238 ) -> Result<MergeOutput, FragmentError> {
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_fragment(output, target, profile, fragment_text, origin, tokens)
253 }
254
255 fn lay_fragment(
257 &self,
258 mut output: MergeOutput,
259 target: &Target,
260 profile: &targets::Profile,
261 fragment_text: Option<&str>,
262 origin: Origin,
263 tokens: &Tokens<'_>,
264 ) -> Result<MergeOutput, FragmentError> {
265 let (Some(text), Some(fragment)) = (fragment_text, self.fragment(target.root)) else {
266 return Ok(output);
267 };
268 let err = |message: String| FragmentError(message);
269 let mut value: OrderedValue = serde_json::from_str(text)
270 .map_err(|error| err(format!("fragment is not valid JSON: {error}")))?;
271 value = portable::unwrap_hooks_object(value);
272 let OrderedValue::Object(object) = value else {
273 return Err(err(
274 "fragment top level must be an event-keyed JSON object".into()
275 ));
276 };
277 let sorted: BTreeMap<_, _> = object.into_iter().collect();
279 for (event, entries) in sorted {
280 if profile.event(&event).is_none() {
281 if !fragment.is_unchecked() && origin == Origin::Dependency {
282 output.skipped_events.push(event);
283 continue;
284 }
285 if !fragment.is_unchecked() {
286 let checked = profile
287 .verified_against
288 .map_or(String::new(), |v| format!(" (checked against {v})"));
289 return Err(err(format!(
290 "unknown event `{event}` for target `{}`; valid events: {}{checked}; use `unchecked = true` to pass a newer native event through verbatim",
291 target.root,
292 profile
293 .events
294 .iter()
295 .map(|e| e.name)
296 .collect::<Vec<_>>()
297 .join(", ")
298 )));
299 }
300 output.unchecked_events.push(event.clone());
301 }
302 let OrderedValue::Array(mut array) = entries else {
303 return Err(err(format!("event `{event}` value must be an array")));
304 };
305 array.iter_mut().for_each(|e| tokens.substitute_value(e));
306 if self.portable.as_ref().is_some_and(|p| p.declares(&event)) {
307 output.overrides.push(event.clone());
308 }
309 output
310 .skips
311 .retain(|skip| skip.event.as_deref() != Some(event.as_str()));
312 output.events.insert(event, array);
313 }
314 Ok(output)
315 }
316}