1use std::path::Path;
12
13use serde::Serialize;
14
15use crate::error::{Result, TuffError};
16use crate::lockfile::{self, ManagedHook};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "lowercase")]
21pub enum HookSettingsShape {
22 Grouped,
29 Flat,
36}
37
38const FLAT_SETTINGS_VERSION: u64 = 1;
40
41impl HookSettingsShape {
42 fn empty_settings(self) -> serde_json::Value {
44 match self {
45 Self::Grouped => serde_json::json!({}),
46 Self::Flat => serde_json::json!({"version": FLAT_SETTINGS_VERSION}),
47 }
48 }
49
50 fn extra_fragment_keys(self) -> &'static [&'static str] {
52 match self {
53 Self::Grouped => &[],
54 Self::Flat => &["version"],
55 }
56 }
57
58 pub fn command_fragment(self, native_event: &str, command: &str) -> serde_json::Value {
60 match self {
61 Self::Grouped => serde_json::json!({
62 "hooks": {
63 native_event: [{
64 "hooks": [{"type": "command", "command": command}]
65 }]
66 }
67 }),
68 Self::Flat => serde_json::json!({
69 "version": FLAT_SETTINGS_VERSION,
70 "hooks": {native_event: [{"command": command}]}
71 }),
72 }
73 }
74
75 pub fn validate_fragment(self, fragment: &serde_json::Value) -> Result<()> {
79 let object = fragment
80 .as_object()
81 .ok_or_else(|| TuffError::usage("--hook-file fragment must be a JSON object"))?;
82 if !object.contains_key("hooks") {
83 return Err(TuffError::usage(
84 "--hook-file fragment must contain a top-level 'hooks' object",
85 ));
86 }
87 let allowed = self.extra_fragment_keys();
88 if object
89 .keys()
90 .any(|key| key != "hooks" && !allowed.contains(&key.as_str()))
91 {
92 return Err(TuffError::usage(match self {
93 Self::Grouped => {
94 "--hook-file must be a hooks-only fragment, not a full settings file"
95 }
96 Self::Flat => "--hook-file must contain only 'hooks' and optional 'version'",
97 }));
98 }
99 if !fragment["hooks"].is_object() {
100 return Err(TuffError::usage(
101 "--hook-file field 'hooks' must be an object",
102 ));
103 }
104 Ok(())
105 }
106
107 pub fn merge_fragment(
115 self,
116 settings_relpath: &str,
117 existing: Option<&[u8]>,
118 fragment: &serde_json::Value,
119 ) -> Result<Vec<u8>> {
120 self.validate_fragment(fragment)?;
121 let mut settings = match existing {
122 Some(bytes) if !bytes.is_empty() => serde_json::from_slice(bytes)?,
123 _ => self.empty_settings(),
124 };
125 let settings_object = settings.as_object_mut().ok_or_else(|| {
126 TuffError::corrupt(format!("{settings_relpath} must be a JSON object"))
127 })?;
128 if self == Self::Flat {
129 settings_object
130 .entry("version")
131 .or_insert_with(|| serde_json::json!(FLAT_SETTINGS_VERSION));
132 }
133 let fragment_hooks = fragment["hooks"]
134 .as_object()
135 .expect("validated hooks object");
136 let settings_hooks = settings_object
137 .entry("hooks")
138 .or_insert_with(|| serde_json::json!({}))
139 .as_object_mut()
140 .ok_or_else(|| {
141 TuffError::corrupt(format!(
142 "{settings_relpath} field 'hooks' must be an object"
143 ))
144 })?;
145 for (event, additions) in fragment_hooks {
146 let additions = additions.as_array().ok_or_else(|| {
147 TuffError::usage(format!("--hook-file hooks.{event} must be an array"))
148 })?;
149 let groups = settings_hooks
150 .entry(event.clone())
151 .or_insert_with(|| serde_json::json!([]))
152 .as_array_mut()
153 .ok_or_else(|| {
154 TuffError::corrupt(format!("{settings_relpath} hooks.{event} must be an array"))
155 })?;
156 extend_hook_groups(groups, additions);
157 }
158 Ok(serde_json::to_string_pretty(&settings)?.into_bytes())
159 }
160}
161
162pub fn extend_hook_groups(existing: &mut Vec<serde_json::Value>, additions: &[serde_json::Value]) {
164 for addition in additions {
165 if !existing.iter().any(|group| group == addition) {
166 existing.push(addition.clone());
167 }
168 }
169}
170
171pub fn remove_registrations(
180 settings_relpath: &str,
181 display_name: &str,
182 repo_root: &Path,
183 managed_hooks: &[ManagedHook],
184) -> Result<()> {
185 if managed_hooks.is_empty() {
186 return Ok(());
187 }
188 let settings_path = repo_root.join(settings_relpath);
189 if !settings_path.is_file() {
190 return Ok(());
191 }
192 let existing = std::fs::read(&settings_path)?;
193 let Some(updated) = without_registrations(settings_relpath, &existing, managed_hooks)? else {
194 return Ok(());
195 };
196 std::fs::write(&settings_path, updated)?;
197 eprintln!(
198 "updated {display_name} hook settings -> {}",
199 lockfile::relative_or_absolute_fs(&settings_path, repo_root)
200 );
201 Ok(())
202}
203
204pub fn without_registrations(
208 settings_relpath: &str,
209 existing: &[u8],
210 managed_hooks: &[ManagedHook],
211) -> Result<Option<Vec<u8>>> {
212 let mut settings: serde_json::Value = serde_json::from_slice(existing)?;
213 let Some(hooks) = settings
214 .get_mut("hooks")
215 .and_then(|hooks| hooks.as_object_mut())
216 else {
217 return Ok(None);
218 };
219 let mut empty_events = Vec::new();
220 for (event, groups) in hooks.iter_mut() {
221 let Some(groups) = groups.as_array_mut() else {
222 continue;
223 };
224 let registered: Vec<&str> = managed_hooks
225 .iter()
226 .filter(|hook| hook.settings_path == settings_relpath && hook.event == *event)
227 .map(|hook| hook.command.as_str())
228 .collect();
229 let is_registered = |entry: &serde_json::Value| {
230 entry
231 .get("command")
232 .and_then(serde_json::Value::as_str)
233 .is_some_and(|command| registered.contains(&command))
234 };
235 for group in groups.iter_mut() {
236 if let Some(entries) = group
237 .get_mut("hooks")
238 .and_then(|value| value.as_array_mut())
239 {
240 entries.retain(|entry| !is_registered(entry));
241 }
242 }
243 groups.retain(
244 |group| match group.get("hooks").and_then(|value| value.as_array()) {
245 Some(entries) => !entries.is_empty(),
246 None => !is_registered(group),
247 },
248 );
249 if groups.is_empty() {
250 empty_events.push(event.clone());
251 }
252 }
253 for event in empty_events {
254 hooks.remove(&event);
255 }
256 Ok(Some(
257 (serde_json::to_string_pretty(&settings)? + "\n").into_bytes(),
258 ))
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 fn managed(settings_path: &str, event: &str, command: &str) -> ManagedHook {
266 ManagedHook {
267 settings_path: settings_path.to_string(),
268 event: event.to_string(),
269 canonical_event: None,
270 command: command.to_string(),
271 baseline_hash: String::new(),
272 }
273 }
274
275 #[test]
276 fn a_grouped_fragment_registers_a_typed_entry_inside_a_group() {
277 let fragment = HookSettingsShape::Grouped.command_fragment("PreToolUse", "sh run.sh");
278 assert_eq!(
279 fragment,
280 serde_json::json!({
281 "hooks": {"PreToolUse": [{"hooks": [{"type": "command", "command": "sh run.sh"}]}]}
282 })
283 );
284 }
285
286 #[test]
287 fn a_flat_fragment_registers_the_entry_directly_and_declares_a_version() {
288 let fragment = HookSettingsShape::Flat.command_fragment("preToolUse", "sh run.sh");
289 assert_eq!(
290 fragment,
291 serde_json::json!({
292 "version": 1,
293 "hooks": {"preToolUse": [{"command": "sh run.sh"}]}
294 })
295 );
296 }
297
298 #[test]
299 fn merging_the_same_fragment_twice_does_not_duplicate_the_hook() {
300 for shape in [HookSettingsShape::Grouped, HookSettingsShape::Flat] {
303 let fragment = shape.command_fragment("before_finish", "sh .agents/hooks/x/run.sh");
304 let once = shape
305 .merge_fragment("settings.json", None, &fragment)
306 .expect("first merge");
307 let twice = shape
308 .merge_fragment("settings.json", Some(&once), &fragment)
309 .expect("second merge");
310 let settings: serde_json::Value = serde_json::from_slice(&twice).unwrap();
311 assert_eq!(
312 settings["hooks"]["before_finish"].as_array().unwrap().len(),
313 1,
314 "{shape:?}: re-adding a hook must not register it twice"
315 );
316 assert_eq!(
317 once, twice,
318 "{shape:?}: a redundant merge must leave the file unchanged"
319 );
320 }
321 }
322
323 #[test]
324 fn merging_keeps_what_the_user_already_had() {
325 let existing = br#"{"permissions": {"allow": ["Bash"]}, "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "theirs"}]}]}}"#;
326 let fragment = HookSettingsShape::Grouped.command_fragment("Stop", "ours");
327 let merged = HookSettingsShape::Grouped
328 .merge_fragment(".claude/settings.json", Some(existing), &fragment)
329 .unwrap();
330 let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
331 assert_eq!(settings["permissions"]["allow"][0], "Bash");
332 let groups = settings["hooks"]["Stop"].as_array().unwrap();
333 assert_eq!(groups.len(), 2);
334 assert_eq!(groups[0]["hooks"][0]["command"], "theirs");
335 assert_eq!(groups[1]["hooks"][0]["command"], "ours");
336 }
337
338 #[test]
339 fn a_flat_file_without_a_version_gains_one() {
340 let existing = br#"{"hooks": {}}"#;
341 let fragment = HookSettingsShape::Flat.command_fragment("stop", "sh run.sh");
342 let merged = HookSettingsShape::Flat
343 .merge_fragment(".cursor/hooks.json", Some(existing), &fragment)
344 .unwrap();
345 let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
346 assert_eq!(settings["version"], 1);
347 }
348
349 #[test]
350 fn a_whole_settings_file_is_refused_as_a_fragment() {
351 let full = serde_json::json!({"permissions": {}, "hooks": {}});
352 let error = HookSettingsShape::Grouped
353 .validate_fragment(&full)
354 .unwrap_err();
355 assert!(error.to_string().contains("hooks-only"), "{error}");
356
357 HookSettingsShape::Flat
359 .validate_fragment(&serde_json::json!({"version": 1, "hooks": {}}))
360 .unwrap();
361 let error = HookSettingsShape::Flat
362 .validate_fragment(&full)
363 .unwrap_err();
364 assert!(error.to_string().contains("optional 'version'"), "{error}");
365
366 for shape in [HookSettingsShape::Grouped, HookSettingsShape::Flat] {
367 assert!(shape.validate_fragment(&serde_json::json!([])).is_err());
368 assert!(shape.validate_fragment(&serde_json::json!({})).is_err());
369 assert!(
370 shape
371 .validate_fragment(&serde_json::json!({"hooks": []}))
372 .is_err()
373 );
374 }
375 }
376
377 #[test]
378 fn a_settings_file_that_is_not_an_object_is_reported_as_corrupt() {
379 let error = HookSettingsShape::Grouped
380 .merge_fragment(
381 ".claude/settings.json",
382 Some(b"[]"),
383 &HookSettingsShape::Grouped.command_fragment("Stop", "x"),
384 )
385 .unwrap_err();
386 assert!(
387 error
388 .to_string()
389 .contains(".claude/settings.json must be a JSON object"),
390 "{error}"
391 );
392 }
393
394 #[test]
395 fn removal_takes_out_only_tuff_registrations_in_either_shape() {
396 let temp = tempfile::tempdir().unwrap();
397 let grouped = r#"{"permissions": {}, "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "theirs"}, {"type": "command", "command": "ours"}]}], "PreToolUse": [{"hooks": [{"type": "command", "command": "ours"}]}]}}"#;
398 std::fs::create_dir_all(temp.path().join(".claude")).unwrap();
399 std::fs::write(temp.path().join(".claude/settings.json"), grouped).unwrap();
400 remove_registrations(
401 ".claude/settings.json",
402 "Claude",
403 temp.path(),
404 &[
405 managed(".claude/settings.json", "Stop", "ours"),
406 managed(".claude/settings.json", "PreToolUse", "ours"),
407 managed(".cursor/hooks.json", "stop", "theirs"),
408 ],
409 )
410 .unwrap();
411 let settings: serde_json::Value = serde_json::from_str(
412 &std::fs::read_to_string(temp.path().join(".claude/settings.json")).unwrap(),
413 )
414 .unwrap();
415 assert_eq!(
416 settings,
417 serde_json::json!({
418 "permissions": {},
419 "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "theirs"}]}]}
420 }),
421 "the event Tuff emptied is pruned; the other file's registration is ignored"
422 );
423
424 let flat = r#"{"version": 1, "hooks": {"stop": [{"command": "theirs"}, {"command": "ours"}], "preToolUse": [{"command": "ours"}]}}"#;
425 std::fs::create_dir_all(temp.path().join(".cursor")).unwrap();
426 std::fs::write(temp.path().join(".cursor/hooks.json"), flat).unwrap();
427 remove_registrations(
428 ".cursor/hooks.json",
429 "Cursor",
430 temp.path(),
431 &[
432 managed(".cursor/hooks.json", "stop", "ours"),
433 managed(".cursor/hooks.json", "preToolUse", "ours"),
434 ],
435 )
436 .unwrap();
437 let settings: serde_json::Value = serde_json::from_str(
438 &std::fs::read_to_string(temp.path().join(".cursor/hooks.json")).unwrap(),
439 )
440 .unwrap();
441 assert_eq!(
442 settings,
443 serde_json::json!({"version": 1, "hooks": {"stop": [{"command": "theirs"}]}})
444 );
445 }
446
447 #[test]
448 fn removal_with_nothing_registered_or_no_file_is_a_no_op() {
449 let temp = tempfile::tempdir().unwrap();
450 remove_registrations(".claude/settings.json", "Claude", temp.path(), &[]).unwrap();
451 remove_registrations(
452 ".claude/settings.json",
453 "Claude",
454 temp.path(),
455 &[managed(".claude/settings.json", "Stop", "ours")],
456 )
457 .unwrap();
458 assert!(!temp.path().join(".claude/settings.json").exists());
459 }
460}