Skip to main content

tuff_core/
hook_settings.rs

1//! Hook registrations inside a harness's settings file.
2//!
3//! This is the one piece of adapter logic that is genuinely per-harness,
4//! and until it lived here every adapter crate carried its own copy of it.
5//! The copies drifted: a dedupe bug had to be fixed four times at once
6//! (tuff#83), and each crate spelled the same validation error a little
7//! differently. What actually differs between harnesses is the *shape* of
8//! the file, captured by [`HookSettingsShape`]; the merge and the removal
9//! are the same algorithm over either shape.
10
11use std::path::Path;
12
13use serde::Serialize;
14
15use crate::error::{Result, TuffError};
16use crate::lockfile::{self, ManagedHook};
17
18/// How a harness lays out the hook registrations in its settings file.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "lowercase")]
21pub enum HookSettingsShape {
22    /// Claude Code's shape, shared by Open Agents and Codex: every event
23    /// holds groups, and every group holds typed hook entries.
24    ///
25    /// ```json
26    /// {"hooks": {"PreToolUse": [{"hooks": [{"type": "command", "command": "sh …"}]}]}}
27    /// ```
28    Grouped,
29    /// Cursor's shape: every event holds the entries directly, and the
30    /// file carries a `version` beside `hooks`.
31    ///
32    /// ```json
33    /// {"version": 1, "hooks": {"preToolUse": [{"command": "sh …"}]}}
34    /// ```
35    Flat,
36}
37
38/// The `version` a flat settings file declares.
39const FLAT_SETTINGS_VERSION: u64 = 1;
40
41impl HookSettingsShape {
42    /// The settings a harness starts from when the file does not exist.
43    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    /// The keys a hooks-only fragment may carry beside `hooks`.
51    fn extra_fragment_keys(self) -> &'static [&'static str] {
52        match self {
53            Self::Grouped => &[],
54            Self::Flat => &["version"],
55        }
56    }
57
58    /// The fragment that registers one command under one native event.
59    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    /// Refuse anything that is not a hooks-only fragment. A whole settings
76    /// file pasted in by mistake would otherwise be merged key by key into
77    /// the harness's real one.
78    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    /// Merge a hooks-only fragment into the harness's settings file, given
108    /// the bytes it holds now, and return the bytes it should hold next.
109    ///
110    /// Everything the user already has is kept. `tuff add` is re-runnable
111    /// and a pack may be installed over an existing install, so a group the
112    /// event already registers is not appended again; the harness would
113    /// otherwise run that hook once per copy.
114    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
162/// Append hook groups that this event does not already register.
163pub 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
171/// Take Tuff's registrations back out of the harness's settings file,
172/// leaving everything else in it alone.
173///
174/// The registrations are matched by command, the way the lockfile records
175/// them, so this needs no shape: a grouped entry is found inside its
176/// group's `hooks`, a flat entry is the group itself, and a group or an
177/// event left empty is pruned so the file reads as if Tuff had never
178/// written to it.
179pub 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
204/// `remove_registrations` on a settings file's bytes: the bytes it should
205/// hold without the registrations, or `None` when it has no `hooks` object
206/// to take them from.
207pub 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        // tuff#83: the harness ran a hook once per copy. Pinned for both
301        // shapes, since the fix once had to be applied in four places.
302        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        // The flat shape carries its version beside the hooks, and only that.
358        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}