Skip to main content

mars_schema/hooks/
read.rs

1//! Reading a `hook.toml`: parse, validate by origin, and build the [`HookDef`].
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Component, Path};
5
6use serde::Deserialize;
7
8use super::portable::PortableHooks;
9use super::targets::{self, Target};
10use super::{HookDef, NativeHook, Origin, RESERVED_NAME_SUFFIX};
11use crate::diagnostic::{SchemaDiagnostic, Severity};
12use crate::visibility::Visibility;
13
14pub const REMOVED_SCHEMA_MESSAGE: &str = "uses the removed v0.11.0 hook schema (`events`/`matcher`/`[action]`/`path`); \
15     migrate to per-target native hook files with `native-hook = \"<target>.json\"`";
16
17/// `Diagnostic` code of a `hook.toml` in the removed v0.11.0 schema.
18pub const REMOVED_SCHEMA_CODE: &str = "hook.removed-schema";
19
20/// What reading a `hook.toml` found. `def` is `None` when a diagnostic is an error.
21#[derive(Debug, Clone)]
22pub struct HookRead {
23    pub def: Option<HookDef>,
24    /// Path-free: the caller adds the location.
25    pub diagnostics: Vec<SchemaDiagnostic>,
26    /// A `visibility` that is neither `local` nor `exported`, read as `local`. The caller
27    /// says so.
28    pub unknown_visibility: Option<String>,
29}
30
31#[derive(Deserialize)]
32#[serde(deny_unknown_fields)]
33struct RawTarget {
34    #[serde(default, rename = "native-hook")]
35    native_hook: Option<String>,
36    #[serde(default)]
37    unchecked: Option<bool>,
38}
39
40#[derive(Deserialize)]
41#[serde(deny_unknown_fields)]
42struct RawHookDef {
43    #[serde(default)]
44    name: Option<String>,
45    visibility: Option<String>,
46    #[serde(default)]
47    targets: BTreeMap<String, RawTarget>,
48    #[serde(default)]
49    order: i32,
50    #[serde(default)]
51    hooks: Option<toml::Value>,
52    #[serde(default, rename = "skip-targets")]
53    skip_targets: Vec<String>,
54    #[serde(default)]
55    unchecked: bool,
56}
57
58impl RawHookDef {
59    /// The top-level keys above, for a dependency's reading to tell an unknown one.
60    const KEYS: [&'static str; 7] = [
61        "name",
62        "visibility",
63        "targets",
64        "order",
65        "hooks",
66        "skip-targets",
67        "unchecked",
68    ];
69}
70
71/// Whether `hook.toml` text is in the schema Mars 0.11 removed. Text that is not TOML is
72/// not.
73pub fn uses_removed_schema(text: &str) -> bool {
74    toml::from_str::<toml::Value>(text).is_ok_and(|value| is_removed_schema(&value))
75}
76
77fn is_removed_schema(value: &toml::Value) -> bool {
78    let removed = ["events", "matcher", "action", "path"];
79    let target_has_removed = value
80        .get("targets")
81        .and_then(toml::Value::as_table)
82        .is_some_and(|targets| {
83            targets.values().any(|value| {
84                value
85                    .as_table()
86                    .is_some_and(|target| removed.iter().any(|key| target.contains_key(*key)))
87            })
88        });
89    removed.iter().any(|key| value.get(*key).is_some())
90        || target_has_removed
91        || value.get("targets").is_some_and(toml::Value::is_array)
92}
93
94/// The first target table that still sets `fragment`, the alias of `native-hook` removed in
95/// 0.28, and whether that table also sets `native-hook`. Checked on the parsed value for both
96/// origins: a dependency's unknown keys are otherwise dropped with a warning, and serde's own
97/// error would not say what to write.
98fn removed_fragment_target(value: &toml::Value) -> Option<(&str, bool)> {
99    value
100        .get("targets")?
101        .as_table()?
102        .iter()
103        .find(|(_, target)| target.get("fragment").is_some())
104        .map(|(root, target)| (root.as_str(), target.get("native-hook").is_some()))
105}
106
107fn failed(code: &str, message: impl Into<String>) -> HookRead {
108    HookRead {
109        def: None,
110        diagnostics: vec![SchemaDiagnostic::new(
111            code,
112            Severity::Error,
113            Vec::new(),
114            message,
115        )],
116        unknown_visibility: None,
117    }
118}
119
120/// Read a `hook.toml`. `dir_name` names the hook when the file does not.
121pub fn read(text: &str, dir_name: &str, origin: Origin) -> HookRead {
122    let value: toml::Value = match toml::from_str(text) {
123        Ok(value) => value,
124        Err(error) => return failed("hook.parse", format!("failed to parse: {error}")),
125    };
126    if is_removed_schema(&value) {
127        return failed(REMOVED_SCHEMA_CODE, REMOVED_SCHEMA_MESSAGE);
128    }
129    if let Some((root, has_native_hook)) = removed_fragment_target(&value) {
130        let fix = if has_native_hook {
131            "delete it, `native-hook` is already set"
132        } else {
133            "use `native-hook`"
134        };
135        return failed(
136            "field.removed",
137            format!("target `{root}`: `fragment` was removed in 0.28; {fix}"),
138        );
139    }
140    let mut diagnostics = Vec::new();
141    let warn = |diagnostics: &mut Vec<SchemaDiagnostic>, code: &str, message: String| {
142        diagnostics.push(SchemaDiagnostic::new(
143            code,
144            Severity::Warning,
145            Vec::new(),
146            message,
147        ));
148    };
149    let raw: Result<RawHookDef, _> = match origin {
150        Origin::Local => toml::from_str(text),
151        Origin::Dependency => {
152            let mut value = value;
153            if let Some(table) = value.as_table_mut() {
154                let unknown: Vec<String> = table
155                    .keys()
156                    .filter(|key| !RawHookDef::KEYS.contains(&key.as_str()))
157                    .cloned()
158                    .collect();
159                for key in unknown {
160                    table.remove(&key);
161                    warn(
162                        &mut diagnostics,
163                        "hook.unknown-key",
164                        format!("ignoring unknown key `{key}`"),
165                    );
166                }
167            }
168            value.try_into()
169        }
170    };
171    let raw = match raw {
172        Ok(raw) => raw,
173        Err(error) => return failed("hook.parse", format!("failed to parse: {error}")),
174    };
175    let name = raw.name.unwrap_or_else(|| dir_name.to_owned());
176    if let Err(message) = validate_name(&name) {
177        return failed("hook.name", format!("invalid name `{name}`: {message}"));
178    }
179
180    let mut portable = match raw.hooks.as_ref().map(PortableHooks::from_toml) {
181        None => None,
182        Some(Ok(portable)) => Some(portable),
183        Some(Err(message)) => return failed("hook.hooks", message),
184    };
185    if let Some(portable) = &mut portable {
186        if let Some(message) = portable.handler_problem() {
187            return failed("hook.handler", message);
188        }
189        let unknown: Vec<String> = portable
190            .events()
191            .map(|(event, _)| event)
192            .filter(|event| targets::claude_event(event).is_none())
193            .map(str::to_owned)
194            .collect();
195        for event in unknown {
196            if raw.unchecked {
197                continue;
198            }
199            if origin == Origin::Local {
200                return failed(
201                    "hook.unknown-event",
202                    format!(
203                        "unknown event `{event}`; valid events: {}; set top-level `unchecked = true` to render a newer event on Claude only",
204                        targets::claude_event_names()
205                    ),
206                );
207            }
208            portable.remove_event(&event);
209            warn(
210                &mut diagnostics,
211                "hook.unknown-event",
212                format!("skipping unknown event `{event}`; the hook's other events are installed"),
213            );
214        }
215    } else if raw.unchecked {
216        return failed("hook.unchecked", "top-level `unchecked` needs `[hooks]`");
217    }
218    if raw.targets.is_empty() && portable.as_ref().is_none_or(PortableHooks::is_empty) {
219        return failed(
220            "hook.no-targets",
221            "needs `[hooks]` with an event, or at least one target table",
222        );
223    }
224
225    let mut skip_targets = BTreeSet::new();
226    for root in raw.skip_targets {
227        if targets::target(&root).is_none() {
228            return failed(
229                "hook.skip-targets",
230                format!(
231                    "`skip-targets` entry `{root}` is not a managed target root such as `.codex`"
232                ),
233            );
234        }
235        skip_targets.insert(root);
236    }
237
238    let mut targets = BTreeMap::new();
239    for (root, spec) in raw.targets {
240        let is_file = targets::target(&root).is_some_and(Target::is_file);
241        let named = spec.native_hook;
242        if is_file && named.is_none() {
243            return failed(
244                "hook.native-hook-required",
245                format!(
246                    "target `{root}` must name its `native-hook`: a file target is a TypeScript file with no default name, and `{}.json` would be installed as one",
247                    root.trim_start_matches('.')
248                ),
249            );
250        }
251        if portable.is_some() && named.is_none() {
252            return failed(
253                "hook.native-hook-required",
254                format!(
255                    "target `{root}` must name its `native-hook` because the hook has `[hooks]`; an empty table would pick up a leftover `{}.json`",
256                    root.trim_start_matches('.')
257                ),
258            );
259        }
260        let mut unchecked = spec.unchecked;
261        if unchecked.is_some() && is_file {
262            let message = format!(
263                "target `{root}` uses file-mode native hooks; `unchecked` is not supported because file contents and events are not validated"
264            );
265            if origin == Origin::Local {
266                return failed("hook.unchecked-file", message);
267            }
268            warn(
269                &mut diagnostics,
270                "hook.unchecked-file",
271                format!("ignoring `unchecked`: {message}"),
272            );
273            unchecked = None;
274        }
275        let file = named.unwrap_or_else(|| format!("{}.json", root.trim_start_matches('.')));
276        if let Err(message) = validate_native_hook_path(&file) {
277            return failed(
278                "hook.native-hook-path",
279                format!("target `{root}` has invalid native hook `{file}`: {message}"),
280            );
281        }
282        targets.insert(
283            root,
284            NativeHook {
285                file,
286                unchecked: unchecked.unwrap_or(false),
287            },
288        );
289    }
290    let (visibility, unknown_visibility) = match raw.visibility.as_deref() {
291        None => (Visibility::Local, None),
292        Some(text) => match Visibility::parse(text) {
293            Some(visibility) => (visibility, None),
294            None => (Visibility::Local, Some(text.to_owned())),
295        },
296    };
297    HookRead {
298        def: Some(HookDef {
299            name,
300            visibility,
301            order: raw.order,
302            targets,
303            portable,
304            skip_targets,
305        }),
306        diagnostics,
307        unknown_visibility,
308    }
309}
310
311/// A hook's name is one directory under a target; reject anything that could escape it.
312pub(super) fn validate_name(name: &str) -> Result<(), &'static str> {
313    if name.is_empty() {
314        return Err("must not be empty");
315    }
316    if name.contains(['/', '\\']) {
317        return Err("must be a single path component");
318    }
319    // The name is written into a generated file's `//` header, which U+2028 / U+2029 end.
320    if name
321        .chars()
322        .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))
323    {
324        return Err("must not contain control characters");
325    }
326    let mut components = Path::new(name).components();
327    match (components.next(), components.next()) {
328        (Some(Component::Normal(_)), None) => {}
329        _ => return Err("must be a single path component"),
330    }
331    if name.ends_with(RESERVED_NAME_SUFFIX) {
332        return Err("names ending in `.hooks` are reserved");
333    }
334    Ok(())
335}
336
337fn validate_native_hook_path(path: &str) -> Result<(), &'static str> {
338    if path.is_empty() || path.contains('\0') {
339        return Err("must be a non-empty relative path");
340    }
341    if path.contains('\\') {
342        return Err("must use a relative portable path");
343    }
344    if Path::new(path)
345        .components()
346        .any(|c| !matches!(c, Component::Normal(_)))
347    {
348        return Err("must be a relative path without `.` or `..`");
349    }
350    Ok(())
351}