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::{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
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)]
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 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 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
107pub 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
288pub(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 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}