Skip to main content

mars_schema/hooks/
output.rs

1//! What a hook installs on one target: `[hooks]` rendered, the fragment laid over it, and
2//! the fragment's own contract checks.
3
4use 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/// What a hook installs on one target.
15#[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    /// What `[hooks]` leaves out on the target, minus what a fragment replaces.
24    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/// A file target's files for a hook: the authored fragment as written, and the file
33/// generated from `[hooks]`.
34#[derive(Debug, Clone, PartialEq, Default, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct FileOutput {
37    pub files: Vec<OutputFile>,
38    /// What is wrong with the fragment, if anything; it is installed regardless.
39    pub fragment_problem: Option<&'static str>,
40    pub skips: Vec<Skip>,
41    /// Rendered events whose portable answers do not all carry over to the target.
42    pub answer_limits: Vec<Skip>,
43}
44
45impl FileOutput {
46    /// Whether a fragment installs next to a file generated from `[hooks]`.
47    pub fn has_fragment_beside_generated(&self) -> bool {
48        self.files.len() > 1
49    }
50}
51
52/// A file a hook installs on a file target.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54pub struct OutputFile {
55    /// Relative to the target root.
56    pub path: String,
57    pub content: String,
58}
59
60/// One merge target's events for a hook: `[hooks]` rendered, with the fragment's events
61/// replacing the generated ones they name.
62#[derive(Debug, Clone, PartialEq, Default, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct MergeOutput {
65    /// Finished entries per native event. An event whose array is empty is turned off.
66    pub events: BTreeMap<String, Vec<OrderedValue>>,
67    /// Unknown events a dependency's fragment lists, left out of `events`.
68    pub skipped_events: Vec<String>,
69    /// Events the target does not list that `unchecked = true` let through.
70    pub unchecked_events: Vec<String>,
71    /// Events the fragment declares that `[hooks]` also declares: the fragment's replace
72    /// the generated entries.
73    pub overrides: Vec<String>,
74    /// What `[hooks]` leaves out here, minus the events a fragment replaces.
75    pub skips: Vec<Skip>,
76}
77
78impl MergeOutput {
79    /// Hold every event Claude lists to Codex's handler rules, reporting what that leaves
80    /// out. Events outside Claude's table (`unchecked`) and events turned off stay as they
81    /// are. Idempotent, so entries `[hooks]` already rendered pass unchanged.
82    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            // `hooks.<Event>[..]…`: the second segment is the event.
103            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/// Why a fragment's text is refused. The caller adds the file it came from.
120#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
121#[error("{0}")]
122pub struct FragmentError(String);
123
124impl HookDef {
125    /// What this hook installs on `target`: `[hooks]` rendered (unless the target is in
126    /// `skip-targets`) and the fragment laid over it. `fragment_text` is the fragment's file,
127    /// read by the caller when the hook declares one for the target.
128    ///
129    /// On a merge target the fragment is an event-keyed JSON object, optionally wrapped as
130    /// `{"hooks": …, "version"?, "description"?}`, whose events the target lists (unless
131    /// its table is `unchecked`) and whose values are arrays. It replaces only the events it
132    /// declares, so one with an empty array turns that event off. On a file target it is
133    /// opaque text.
134    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    /// What this hook puts in a plugin build's tree for `target`. A tree whose hook file
187    /// another harness also reads passes `shared` (the Claude tree: [`targets::PLUGIN_CLAUDE_TREE`]):
188    /// Codex rejects the whole `hooks/hooks.json` over one handler it cannot parse, so
189    /// everything that reaches the file, `[hooks]` and fragment alike, is rendered under
190    /// `shared` and conformed to Codex's rules while keeping its events (D21). Any other
191    /// tree passes `None` and takes what [`Self::output`] gives.
192    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    /// Whether the hook's fragment for `target` loads, as [`Self::output`] would lay it. A
209    /// file target's fragment is opaque, and `[hooks]` never fails to render.
210    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    /// Lay the hook's fragment over the events `[hooks]` rendered.
256    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        // Sorted, so the first fault reported does not depend on authored order.
278        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}