1use 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
17pub const REMOVED_SCHEMA_CODE: &str = "hook.removed-schema";
19
20#[derive(Debug, Clone)]
22pub struct HookRead {
23 pub def: Option<HookDef>,
24 pub diagnostics: Vec<SchemaDiagnostic>,
26 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)]
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 const KEYS: [&'static str; 7] = [
64 "name",
65 "visibility",
66 "targets",
67 "order",
68 "hooks",
69 "skip-targets",
70 "unchecked",
71 ];
72}
73
74pub 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
110pub 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
310pub(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 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}