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)]
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 const KEYS: [&'static str; 7] = [
61 "name",
62 "visibility",
63 "targets",
64 "order",
65 "hooks",
66 "skip-targets",
67 "unchecked",
68 ];
69}
70
71pub 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 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
120pub 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
311pub(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 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}