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::{Fragment, HookDef, 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 fragment files with `fragment = \"<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)]
35    fragment: 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
94fn failed(code: &str, message: impl Into<String>) -> HookRead {
95    HookRead {
96        def: None,
97        diagnostics: vec![SchemaDiagnostic::new(
98            code,
99            Severity::Error,
100            Vec::new(),
101            message,
102        )],
103        unknown_visibility: None,
104    }
105}
106
107/// Read a `hook.toml`. `dir_name` names the hook when the file does not.
108pub fn read(text: &str, dir_name: &str, origin: Origin) -> HookRead {
109    let value: toml::Value = match toml::from_str(text) {
110        Ok(value) => value,
111        Err(error) => return failed("hook.parse", format!("failed to parse: {error}")),
112    };
113    if is_removed_schema(&value) {
114        return failed(REMOVED_SCHEMA_CODE, REMOVED_SCHEMA_MESSAGE);
115    }
116    let mut diagnostics = Vec::new();
117    let warn = |diagnostics: &mut Vec<SchemaDiagnostic>, code: &str, message: String| {
118        diagnostics.push(SchemaDiagnostic::new(
119            code,
120            Severity::Warning,
121            Vec::new(),
122            message,
123        ));
124    };
125    let raw: Result<RawHookDef, _> = match origin {
126        Origin::Local => toml::from_str(text),
127        Origin::Dependency => {
128            let mut value = value;
129            if let Some(table) = value.as_table_mut() {
130                let unknown: Vec<String> = table
131                    .keys()
132                    .filter(|key| !RawHookDef::KEYS.contains(&key.as_str()))
133                    .cloned()
134                    .collect();
135                for key in unknown {
136                    table.remove(&key);
137                    warn(
138                        &mut diagnostics,
139                        "hook.unknown-key",
140                        format!("ignoring unknown key `{key}`"),
141                    );
142                }
143            }
144            value.try_into()
145        }
146    };
147    let raw = match raw {
148        Ok(raw) => raw,
149        Err(error) => return failed("hook.parse", format!("failed to parse: {error}")),
150    };
151    let name = raw.name.unwrap_or_else(|| dir_name.to_owned());
152    if let Err(message) = validate_name(&name) {
153        return failed("hook.name", format!("invalid name `{name}`: {message}"));
154    }
155
156    let mut portable = match raw.hooks.as_ref().map(PortableHooks::from_toml) {
157        None => None,
158        Some(Ok(portable)) => Some(portable),
159        Some(Err(message)) => return failed("hook.hooks", message),
160    };
161    if let Some(portable) = &mut portable {
162        if let Some(message) = portable.handler_problem() {
163            return failed("hook.handler", message);
164        }
165        let unknown: Vec<String> = portable
166            .events()
167            .map(|(event, _)| event)
168            .filter(|event| targets::claude_event(event).is_none())
169            .map(str::to_owned)
170            .collect();
171        for event in unknown {
172            if raw.unchecked {
173                continue;
174            }
175            if origin == Origin::Local {
176                return failed(
177                    "hook.unknown-event",
178                    format!(
179                        "unknown event `{event}`; valid events: {}; set top-level `unchecked = true` to render a newer event on Claude only",
180                        targets::claude_event_names()
181                    ),
182                );
183            }
184            portable.remove_event(&event);
185            warn(
186                &mut diagnostics,
187                "hook.unknown-event",
188                format!("skipping unknown event `{event}`; the hook's other events are installed"),
189            );
190        }
191    } else if raw.unchecked {
192        return failed("hook.unchecked", "top-level `unchecked` needs `[hooks]`");
193    }
194    if raw.targets.is_empty() && portable.as_ref().is_none_or(PortableHooks::is_empty) {
195        return failed(
196            "hook.no-targets",
197            "needs `[hooks]` with an event, or at least one target table",
198        );
199    }
200
201    let mut skip_targets = BTreeSet::new();
202    for root in raw.skip_targets {
203        if targets::target(&root).is_none() {
204            return failed(
205                "hook.skip-targets",
206                format!(
207                    "`skip-targets` entry `{root}` is not a managed target root such as `.codex`"
208                ),
209            );
210        }
211        skip_targets.insert(root);
212    }
213
214    let mut targets = BTreeMap::new();
215    for (root, spec) in raw.targets {
216        let is_file = targets::target(&root).is_some_and(Target::is_file);
217        if is_file && spec.fragment.is_none() {
218            return failed(
219                "hook.fragment-required",
220                format!(
221                    "target `{root}` must name its `fragment`: a file target is a TypeScript file with no default name, and `{}.json` would be installed as one",
222                    root.trim_start_matches('.')
223                ),
224            );
225        }
226        if portable.is_some() && spec.fragment.is_none() {
227            return failed(
228                "hook.fragment-required",
229                format!(
230                    "target `{root}` must name its `fragment` because the hook has `[hooks]`; an empty table would pick up a leftover `{}.json`",
231                    root.trim_start_matches('.')
232                ),
233            );
234        }
235        let mut unchecked = spec.unchecked;
236        if unchecked.is_some() && is_file {
237            let message = format!(
238                "target `{root}` uses file-mode fragments; `unchecked` is not supported because file contents and events are not validated"
239            );
240            if origin == Origin::Local {
241                return failed("hook.unchecked-file", message);
242            }
243            warn(
244                &mut diagnostics,
245                "hook.unchecked-file",
246                format!("ignoring `unchecked`: {message}"),
247            );
248            unchecked = None;
249        }
250        let file = spec
251            .fragment
252            .unwrap_or_else(|| format!("{}.json", root.trim_start_matches('.')));
253        if let Err(message) = validate_fragment_path(&file) {
254            return failed(
255                "hook.fragment-path",
256                format!("target `{root}` has invalid fragment `{file}`: {message}"),
257            );
258        }
259        targets.insert(
260            root,
261            Fragment {
262                file,
263                unchecked: unchecked.unwrap_or(false),
264            },
265        );
266    }
267    let (visibility, unknown_visibility) = match raw.visibility.as_deref() {
268        None => (Visibility::Local, None),
269        Some(text) => match Visibility::parse(text) {
270            Some(visibility) => (visibility, None),
271            None => (Visibility::Local, Some(text.to_owned())),
272        },
273    };
274    HookRead {
275        def: Some(HookDef {
276            name,
277            visibility,
278            order: raw.order,
279            targets,
280            portable,
281            skip_targets,
282        }),
283        diagnostics,
284        unknown_visibility,
285    }
286}
287
288/// A hook's name is one directory under a target; reject anything that could escape it.
289pub(super) fn validate_name(name: &str) -> Result<(), &'static str> {
290    if name.is_empty() {
291        return Err("must not be empty");
292    }
293    if name.contains(['/', '\\']) {
294        return Err("must be a single path component");
295    }
296    // The name is written into a generated file's `//` header, which U+2028 / U+2029 end.
297    if name
298        .chars()
299        .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))
300    {
301        return Err("must not contain control characters");
302    }
303    let mut components = Path::new(name).components();
304    match (components.next(), components.next()) {
305        (Some(Component::Normal(_)), None) => {}
306        _ => return Err("must be a single path component"),
307    }
308    if name.ends_with(RESERVED_NAME_SUFFIX) {
309        return Err("names ending in `.hooks` are reserved");
310    }
311    Ok(())
312}
313
314fn validate_fragment_path(path: &str) -> Result<(), &'static str> {
315    if path.is_empty() || path.contains('\0') {
316        return Err("must be a non-empty relative path");
317    }
318    if path.contains('\\') {
319        return Err("must use a relative portable path");
320    }
321    if Path::new(path)
322        .components()
323        .any(|c| !matches!(c, Component::Normal(_)))
324    {
325        return Err("must be a relative path without `.` or `..`");
326    }
327    Ok(())
328}