Skip to main content

mars_schema/hooks/
read.rs

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