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 mut settings: serde_json::Value =
193 serde_json::from_str(&std::fs::read_to_string(&settings_path)?)?;
194 let Some(hooks) = settings
195 .get_mut("hooks")
196 .and_then(|hooks| hooks.as_object_mut())
197 else {
198 return Ok(());
199 };
200 let mut empty_events = Vec::new();
201 for (event, groups) in hooks.iter_mut() {
202 let Some(groups) = groups.as_array_mut() else {
203 continue;
204 };
205 let registered: Vec<&str> = managed_hooks
206 .iter()
207 .filter(|hook| hook.settings_path == settings_relpath && hook.event == *event)
208 .map(|hook| hook.command.as_str())
209 .collect();
210 let is_registered = |entry: &serde_json::Value| {
211 entry
212 .get("command")
213 .and_then(serde_json::Value::as_str)
214 .is_some_and(|command| registered.contains(&command))
215 };
216 for group in groups.iter_mut() {
217 if let Some(entries) = group
218 .get_mut("hooks")
219 .and_then(|value| value.as_array_mut())
220 {
221 entries.retain(|entry| !is_registered(entry));
222 }
223 }
224 groups.retain(
225 |group| match group.get("hooks").and_then(|value| value.as_array()) {
226 Some(entries) => !entries.is_empty(),
227 None => !is_registered(group),
228 },
229 );
230 if groups.is_empty() {
231 empty_events.push(event.clone());
232 }
233 }
234 for event in empty_events {
235 hooks.remove(&event);
236 }
237 std::fs::write(
238 &settings_path,
239 serde_json::to_string_pretty(&settings)? + "\n",
240 )?;
241 eprintln!(
242 "updated {display_name} hook settings -> {}",
243 lockfile::relative_or_absolute_fs(&settings_path, repo_root)
244 );
245 Ok(())
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 fn managed(settings_path: &str, event: &str, command: &str) -> ManagedHook {
253 ManagedHook {
254 settings_path: settings_path.to_string(),
255 event: event.to_string(),
256 canonical_event: None,
257 command: command.to_string(),
258 baseline_hash: String::new(),
259 }
260 }
261
262 #[test]
263 fn a_grouped_fragment_registers_a_typed_entry_inside_a_group() {
264 let fragment = HookSettingsShape::Grouped.command_fragment("PreToolUse", "sh run.sh");
265 assert_eq!(
266 fragment,
267 serde_json::json!({
268 "hooks": {"PreToolUse": [{"hooks": [{"type": "command", "command": "sh run.sh"}]}]}
269 })
270 );
271 }
272
273 #[test]
274 fn a_flat_fragment_registers_the_entry_directly_and_declares_a_version() {
275 let fragment = HookSettingsShape::Flat.command_fragment("preToolUse", "sh run.sh");
276 assert_eq!(
277 fragment,
278 serde_json::json!({
279 "version": 1,
280 "hooks": {"preToolUse": [{"command": "sh run.sh"}]}
281 })
282 );
283 }
284
285 #[test]
286 fn merging_the_same_fragment_twice_does_not_duplicate_the_hook() {
287 for shape in [HookSettingsShape::Grouped, HookSettingsShape::Flat] {
290 let fragment = shape.command_fragment("before_finish", "sh .agents/hooks/x/run.sh");
291 let once = shape
292 .merge_fragment("settings.json", None, &fragment)
293 .expect("first merge");
294 let twice = shape
295 .merge_fragment("settings.json", Some(&once), &fragment)
296 .expect("second merge");
297 let settings: serde_json::Value = serde_json::from_slice(&twice).unwrap();
298 assert_eq!(
299 settings["hooks"]["before_finish"].as_array().unwrap().len(),
300 1,
301 "{shape:?}: re-adding a hook must not register it twice"
302 );
303 assert_eq!(
304 once, twice,
305 "{shape:?}: a redundant merge must leave the file unchanged"
306 );
307 }
308 }
309
310 #[test]
311 fn merging_keeps_what_the_user_already_had() {
312 let existing = br#"{"permissions": {"allow": ["Bash"]}, "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "theirs"}]}]}}"#;
313 let fragment = HookSettingsShape::Grouped.command_fragment("Stop", "ours");
314 let merged = HookSettingsShape::Grouped
315 .merge_fragment(".claude/settings.json", Some(existing), &fragment)
316 .unwrap();
317 let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
318 assert_eq!(settings["permissions"]["allow"][0], "Bash");
319 let groups = settings["hooks"]["Stop"].as_array().unwrap();
320 assert_eq!(groups.len(), 2);
321 assert_eq!(groups[0]["hooks"][0]["command"], "theirs");
322 assert_eq!(groups[1]["hooks"][0]["command"], "ours");
323 }
324
325 #[test]
326 fn a_flat_file_without_a_version_gains_one() {
327 let existing = br#"{"hooks": {}}"#;
328 let fragment = HookSettingsShape::Flat.command_fragment("stop", "sh run.sh");
329 let merged = HookSettingsShape::Flat
330 .merge_fragment(".cursor/hooks.json", Some(existing), &fragment)
331 .unwrap();
332 let settings: serde_json::Value = serde_json::from_slice(&merged).unwrap();
333 assert_eq!(settings["version"], 1);
334 }
335
336 #[test]
337 fn a_whole_settings_file_is_refused_as_a_fragment() {
338 let full = serde_json::json!({"permissions": {}, "hooks": {}});
339 let error = HookSettingsShape::Grouped
340 .validate_fragment(&full)
341 .unwrap_err();
342 assert!(error.to_string().contains("hooks-only"), "{error}");
343
344 HookSettingsShape::Flat
346 .validate_fragment(&serde_json::json!({"version": 1, "hooks": {}}))
347 .unwrap();
348 let error = HookSettingsShape::Flat
349 .validate_fragment(&full)
350 .unwrap_err();
351 assert!(error.to_string().contains("optional 'version'"), "{error}");
352
353 for shape in [HookSettingsShape::Grouped, HookSettingsShape::Flat] {
354 assert!(shape.validate_fragment(&serde_json::json!([])).is_err());
355 assert!(shape.validate_fragment(&serde_json::json!({})).is_err());
356 assert!(
357 shape
358 .validate_fragment(&serde_json::json!({"hooks": []}))
359 .is_err()
360 );
361 }
362 }
363
364 #[test]
365 fn a_settings_file_that_is_not_an_object_is_reported_as_corrupt() {
366 let error = HookSettingsShape::Grouped
367 .merge_fragment(
368 ".claude/settings.json",
369 Some(b"[]"),
370 &HookSettingsShape::Grouped.command_fragment("Stop", "x"),
371 )
372 .unwrap_err();
373 assert!(
374 error
375 .to_string()
376 .contains(".claude/settings.json must be a JSON object"),
377 "{error}"
378 );
379 }
380
381 #[test]
382 fn removal_takes_out_only_tuff_registrations_in_either_shape() {
383 let temp = tempfile::tempdir().unwrap();
384 let grouped = r#"{"permissions": {}, "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "theirs"}, {"type": "command", "command": "ours"}]}], "PreToolUse": [{"hooks": [{"type": "command", "command": "ours"}]}]}}"#;
385 std::fs::create_dir_all(temp.path().join(".claude")).unwrap();
386 std::fs::write(temp.path().join(".claude/settings.json"), grouped).unwrap();
387 remove_registrations(
388 ".claude/settings.json",
389 "Claude",
390 temp.path(),
391 &[
392 managed(".claude/settings.json", "Stop", "ours"),
393 managed(".claude/settings.json", "PreToolUse", "ours"),
394 managed(".cursor/hooks.json", "stop", "theirs"),
395 ],
396 )
397 .unwrap();
398 let settings: serde_json::Value = serde_json::from_str(
399 &std::fs::read_to_string(temp.path().join(".claude/settings.json")).unwrap(),
400 )
401 .unwrap();
402 assert_eq!(
403 settings,
404 serde_json::json!({
405 "permissions": {},
406 "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "theirs"}]}]}
407 }),
408 "the event Tuff emptied is pruned; the other file's registration is ignored"
409 );
410
411 let flat = r#"{"version": 1, "hooks": {"stop": [{"command": "theirs"}, {"command": "ours"}], "preToolUse": [{"command": "ours"}]}}"#;
412 std::fs::create_dir_all(temp.path().join(".cursor")).unwrap();
413 std::fs::write(temp.path().join(".cursor/hooks.json"), flat).unwrap();
414 remove_registrations(
415 ".cursor/hooks.json",
416 "Cursor",
417 temp.path(),
418 &[
419 managed(".cursor/hooks.json", "stop", "ours"),
420 managed(".cursor/hooks.json", "preToolUse", "ours"),
421 ],
422 )
423 .unwrap();
424 let settings: serde_json::Value = serde_json::from_str(
425 &std::fs::read_to_string(temp.path().join(".cursor/hooks.json")).unwrap(),
426 )
427 .unwrap();
428 assert_eq!(
429 settings,
430 serde_json::json!({"version": 1, "hooks": {"stop": [{"command": "theirs"}]}})
431 );
432 }
433
434 #[test]
435 fn removal_with_nothing_registered_or_no_file_is_a_no_op() {
436 let temp = tempfile::tempdir().unwrap();
437 remove_registrations(".claude/settings.json", "Claude", temp.path(), &[]).unwrap();
438 remove_registrations(
439 ".claude/settings.json",
440 "Claude",
441 temp.path(),
442 &[managed(".claude/settings.json", "Stop", "ours")],
443 )
444 .unwrap();
445 assert!(!temp.path().join(".claude/settings.json").exists());
446 }
447}