Skip to main content

mars_schema/hooks/
output.rs

1//! What a hook installs on one target: `[hooks]` rendered, the native hook laid over it, and
2//! the native hook'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 native hook 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 native hook 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 native hook, if anything; it is installed regardless.
39    pub native_hook_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 native hook installs next to a file generated from `[hooks]`.
47    pub fn has_native_hook_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 native hook'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 native hook 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 native hook declares that `[hooks]` also declares: the native hook's replace
72    /// the generated entries.
73    pub overrides: Vec<String>,
74    /// What `[hooks]` leaves out here, minus the events a native hook 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<(), 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            // `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 native hook'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 NativeHookError(String);
123
124impl HookDef {
125    /// What this hook installs on `target`: `[hooks]` rendered (unless the target is in
126    /// `skip-targets`) and the native hook laid over it. `native_hook_text` is the native hook's file,
127    /// read by the caller when the hook declares one for the target.
128    ///
129    /// On a merge target the native hook 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        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    /// 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 native hook 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        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    /// Whether the hook's native hook for `target` loads, as [`Self::output`] would lay it. A
209    /// file target's native hook is opaque, and `[hooks]` never fails to render.
210    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    /// Lay the hook's native hook over the events `[hooks]` rendered.
256    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        // Sorted, so the first fault reported does not depend on authored order.
279        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}