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 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        // tuff#83: the harness ran a hook once per copy. Pinned for both
288        // shapes, since the fix once had to be applied in four places.
289        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        // The flat shape carries its version beside the hooks, and only that.
345        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}