Skip to main content

oneharness_core/io/
hooks.rs

1//! Installing a normalized hook into a harness's config — the I/O half of
2//! cross-harness hook management. Works at either [`Scope`]: a project directory
3//! or the harness's user-global location (`$HOME`/`$XDG_CONFIG_HOME`), with the
4//! same write strategy and only the anchor moving. The shape rendering is pure
5//! (`src/domain/hooks.rs`); this layer resolves the target file(s), seeds a new
6//! file's scaffolding, deep-merges (or writes a JS shim), and writes atomically.
7//!
8//! Every write is non-destructive and idempotent, the same contract `oneharness
9//! sync` already honours for permission rules: unrelated keys are preserved,
10//! hook lists union, an unparseable target is refused and left intact, and the
11//! write goes through a temp file + rename. The four [`HookBinding`] variants
12//! cover all eight harnesses — three deep-merge JSON, OpenCode writes a shim.
13
14use std::path::{Path, PathBuf};
15
16use serde_json::{Map, Value};
17
18use crate::domain::harness::{HarnessSpec, HookBase, HookBinding};
19use crate::domain::hooks::{render, HookSpec};
20use crate::domain::sync::deep_merge;
21use crate::errors::OneharnessError;
22use crate::io::sync::{write_atomically, FileStatus};
23
24/// The plugin/file identity used when a [`HookSpec`] names none.
25const DEFAULT_PLUGIN_NAME: &str = "oneharness";
26
27/// Which scope a hook install targets: a project directory, or the user-global
28/// location resolved from the environment.
29pub enum Scope<'a> {
30    /// Install under a project directory (the harness's project-relative paths).
31    Project(&'a Path),
32    /// Install at the harness's user-global location, anchored under these dirs.
33    Global(&'a GlobalDirs),
34}
35
36/// The user-level base directories a [`Scope::Global`] install resolves against.
37/// Injected so installs stay hermetic and testable; populate from the process
38/// with [`GlobalDirs::from_env`].
39#[derive(Debug, Clone, Default)]
40pub struct GlobalDirs {
41    /// `$HOME`.
42    pub home: Option<PathBuf>,
43    /// `$XDG_CONFIG_HOME`.
44    pub config_home: Option<PathBuf>,
45}
46
47impl GlobalDirs {
48    /// Read `HOME` and `XDG_CONFIG_HOME` from the process environment (and, on
49    /// Windows, `USERPROFILE`).
50    pub fn from_env() -> Self {
51        Self::from_vars(
52            std::env::var_os("HOME"),
53            std::env::var_os("XDG_CONFIG_HOME"),
54            std::env::var_os("USERPROFILE"),
55        )
56    }
57
58    /// Pure resolution of the directories from raw env values, so the
59    /// platform-specific home selection stays testable. On Windows the user home
60    /// is `%USERPROFILE%` — what Node-based CLIs (`os.homedir()`) and most tools
61    /// anchor `~` to — whereas `$HOME` is often unset or an MSYS path (e.g. Git
62    /// Bash's `/c/Users/...`), which a native harness can't resolve. Preferring
63    /// `USERPROFILE` there keeps a `--global` hook install landing where the
64    /// harness actually reads it. Unix is unchanged: `HOME` only.
65    fn from_vars(
66        home: Option<std::ffi::OsString>,
67        config_home: Option<std::ffi::OsString>,
68        userprofile: Option<std::ffi::OsString>,
69    ) -> Self {
70        let home = if cfg!(windows) {
71            userprofile.or(home)
72        } else {
73            let _ = userprofile;
74            home
75        };
76        Self {
77            home: home.map(PathBuf::from),
78            config_home: config_home.map(PathBuf::from),
79        }
80    }
81
82    /// Resolve a [`HookBase`] to a concrete directory, applying the
83    /// `$XDG_CONFIG_HOME` → `$HOME/.config` fallback for `ConfigHome`.
84    fn resolve(&self, base: HookBase) -> Option<PathBuf> {
85        match base {
86            HookBase::Home => self.home.clone(),
87            HookBase::ConfigHome => self
88                .config_home
89                .clone()
90                .or_else(|| self.home.as_ref().map(|h| h.join(".config"))),
91        }
92    }
93}
94
95/// One file `install` created, updated, or found already current.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct HookWrite {
98    pub path: PathBuf,
99    pub status: FileStatus,
100}
101
102/// Install `hook` into `spec`'s config at `scope`, returning a write per file
103/// touched (a Goose install touches two). `check` plans without writing. The
104/// strategy is identical across scopes — only the anchor moves — so a project
105/// and a global install of the same hook produce the same file shapes. A harness
106/// with no hook mapping (or no global location, for [`Scope::Global`]) is a loud
107/// [`OneharnessError`], never a silent no-op.
108pub fn install(
109    scope: Scope,
110    spec: &HarnessSpec,
111    hook: &HookSpec,
112    check: bool,
113) -> Result<Vec<HookWrite>, OneharnessError> {
114    let Some(binding) = &spec.hooks else {
115        return Err(OneharnessError::HookUnsupported { id: spec.id.into() });
116    };
117    let name = hook.plugin_name.as_deref().unwrap_or(DEFAULT_PLUGIN_NAME);
118    let anchor = anchor_for(&scope, spec, binding, name)?;
119
120    match binding {
121        HookBinding::SameFile { shape, path } => {
122            let fragment = wrap(path, render(hook, *shape));
123            Ok(vec![merge_json(&anchor, &fragment, None, check)?])
124        }
125        HookBinding::File {
126            shape, path, seed, ..
127        } => {
128            let fragment = wrap(path, render(hook, *shape));
129            Ok(vec![merge_json(&anchor, &fragment, *seed, check)?])
130        }
131        HookBinding::GoosePlugin {
132            shape,
133            manifest,
134            path,
135            ..
136        } => {
137            // `anchor` is the plugin directory; the manifest and hooks file sit
138            // beneath it. A caller-supplied description overrides the manifest's
139            // default text (set on the parsed value to avoid JSON-escaping the
140            // raw template).
141            let mut manifest_json = parse_seed(&manifest.replace("{name}", name));
142            if let (Some(desc), Value::Object(map)) = (&hook.description, &mut manifest_json) {
143                map.insert("description".into(), Value::String(desc.clone()));
144            }
145            let manifest_write =
146                merge_json(&anchor.join("plugin.json"), &manifest_json, None, check)?;
147            let fragment = wrap(path, render(hook, *shape));
148            let hooks_write = merge_json(
149                &anchor.join("hooks").join("hooks.json"),
150                &fragment,
151                None,
152                check,
153            )?;
154            Ok(vec![manifest_write, hooks_write])
155        }
156        HookBinding::JsPlugin { template, .. } => {
157            let content = render_shim(template, name, &hook.command);
158            Ok(vec![write_text(&anchor, &content, check)?])
159        }
160    }
161}
162
163/// A byte-level snapshot of config files about to be touched by an ephemeral
164/// hook install (`run --mock-rules`), so the workspace can be put back exactly
165/// as it was after the run — including in an "original" workspace whose
166/// existing config the install merged into. A file absent at snapshot time is
167/// restored by deletion, along with any directories the install had to create
168/// for it (removed only while empty, so nothing else is ever swept up).
169#[derive(Debug, Default)]
170pub struct HookSnapshot {
171    /// Each path with its pre-install bytes (`None` = did not exist).
172    entries: Vec<(PathBuf, Option<Vec<u8>>)>,
173    /// Ancestor directories that did not exist at snapshot time, deepest last.
174    created_dirs: Vec<PathBuf>,
175}
176
177impl HookSnapshot {
178    /// Record the current state of `paths` (typically the planned writes a
179    /// check-mode [`install`] returned) before the real install runs.
180    pub fn capture(paths: &[PathBuf]) -> Self {
181        let mut snapshot = HookSnapshot::default();
182        for path in paths {
183            snapshot
184                .entries
185                .push((path.clone(), std::fs::read(path).ok()));
186            // Note which ancestors the install will have to create, so restore
187            // can remove them again (nearest-missing first here; restore walks
188            // them deepest-first).
189            let mut missing = Vec::new();
190            let mut cursor = path.parent();
191            while let Some(dir) = cursor {
192                if dir.as_os_str().is_empty() || dir.exists() {
193                    break;
194                }
195                missing.push(dir.to_path_buf());
196                cursor = dir.parent();
197            }
198            for dir in missing.into_iter().rev() {
199                if !snapshot.created_dirs.contains(&dir) {
200                    snapshot.created_dirs.push(dir);
201                }
202            }
203        }
204        snapshot
205    }
206
207    /// Fold another snapshot's entries into this one (per-harness installs are
208    /// captured separately so an earlier install's writes are never re-captured
209    /// as if they were pre-existing state).
210    pub fn extend(&mut self, other: HookSnapshot) {
211        self.entries.extend(other.entries);
212        for dir in other.created_dirs {
213            if !self.created_dirs.contains(&dir) {
214                self.created_dirs.push(dir);
215            }
216        }
217    }
218
219    /// Put every captured path back: rewrite prior bytes, delete files that did
220    /// not exist, and prune the directories the install created (best-effort,
221    /// only while empty). Returns the failures (path + error) instead of
222    /// aborting, so a caller can warn per file — a restore must never take the
223    /// run's results down with it.
224    pub fn restore(&self) -> Vec<(PathBuf, std::io::Error)> {
225        let mut failures = Vec::new();
226        for (path, prior) in &self.entries {
227            let result = match prior {
228                Some(bytes) => std::fs::write(path, bytes),
229                None => match std::fs::remove_file(path) {
230                    // Already absent — nothing to undo.
231                    Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
232                    other => other,
233                },
234            };
235            if let Err(err) = result {
236                failures.push((path.clone(), err));
237            }
238        }
239        // Deepest-first so nested created dirs empty out before their parents;
240        // `remove_dir` refuses non-empty dirs, which is exactly the safety we
241        // want (never remove anything the user put there meanwhile).
242        for dir in self.created_dirs.iter().rev() {
243            let _ = std::fs::remove_dir(dir);
244        }
245        failures
246    }
247}
248
249/// The path a binding anchors at for the given scope: a file for the JSON-merge
250/// and JS-shim strategies, the plugin directory for Goose. The project paths come
251/// from the [`HookBinding`]; the global ones from the harness's `global_hook`.
252fn anchor_for(
253    scope: &Scope,
254    spec: &HarnessSpec,
255    binding: &HookBinding,
256    name: &str,
257) -> Result<PathBuf, OneharnessError> {
258    match scope {
259        Scope::Project(dir) => Ok(project_anchor(dir, spec, binding, name)),
260        Scope::Global(dirs) => {
261            let global = spec
262                .global_hook
263                .ok_or_else(|| OneharnessError::HookGlobalUnsupported { id: spec.id.into() })?;
264            let base =
265                dirs.resolve(global.base)
266                    .ok_or_else(|| OneharnessError::HookGlobalDirMissing {
267                        id: spec.id.into(),
268                        var: match global.base {
269                            HookBase::Home => "HOME",
270                            HookBase::ConfigHome => "XDG_CONFIG_HOME or HOME",
271                        },
272                    })?;
273            Ok(base.join(global.anchor.replace("{name}", name)))
274        }
275    }
276}
277
278/// Project-scope anchor for a binding under `project_dir`.
279fn project_anchor(
280    project_dir: &Path,
281    spec: &HarnessSpec,
282    binding: &HookBinding,
283    name: &str,
284) -> PathBuf {
285    match binding {
286        HookBinding::SameFile { .. } => {
287            // Hooks share the permissions file, so honour the same alt_files
288            // precedence `sync` does (e.g. crush's `.crush.json`).
289            let sync = spec
290                .sync
291                .as_ref()
292                .expect("SameFile hooks require a sync config file");
293            sync.alt_files
294                .iter()
295                .map(|f| project_dir.join(f))
296                .find(|p| p.is_file())
297                .unwrap_or_else(|| project_dir.join(sync.file))
298        }
299        HookBinding::File { file, .. } => project_dir.join(file.replace("{name}", name)),
300        HookBinding::GoosePlugin { plugins_dir, .. } => project_dir.join(plugins_dir).join(name),
301        HookBinding::JsPlugin { plugin_dir, .. } => {
302            project_dir.join(plugin_dir).join(format!("{name}.js"))
303        }
304    }
305}
306
307/// Nest `value` under `path` (e.g. `["hooks"]` -> `{"hooks": value}`), the
308/// object the harness reads at its top level.
309fn wrap(path: &[&str], value: Value) -> Value {
310    let mut node = value;
311    for key in path.iter().rev() {
312        let mut map = Map::new();
313        map.insert((*key).to_string(), node);
314        node = Value::Object(map);
315    }
316    node
317}
318
319/// Render the OpenCode shim: substitute the JS-safe export identifier, the
320/// command as a JSON argv array, and the display name.
321fn render_shim(template: &str, name: &str, command: &str) -> String {
322    let argv: Vec<&str> = command.split_whitespace().collect();
323    let argv_json = serde_json::to_string(&argv).expect("argv of strings serializes");
324    template
325        .replace("{export}", &js_identifier(name))
326        .replace("{argv}", &argv_json)
327        .replace("{name}", name)
328}
329
330/// A safe JS identifier from a plugin name: non-alphanumeric runs become `_`,
331/// and a leading digit is prefixed, so `my-tool` -> `my_tool`.
332fn js_identifier(name: &str) -> String {
333    let mut out: String = name
334        .chars()
335        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
336        .collect();
337    if out.chars().next().is_none_or(|c| c.is_ascii_digit()) {
338        out.insert(0, '_');
339    }
340    out
341}
342
343/// Parse a registry seed/manifest, which is test-pinned valid JSON.
344fn parse_seed(text: &str) -> Value {
345    serde_json::from_str(text).expect("registry seed/manifest is valid JSON (test-pinned)")
346}
347
348/// Deep-merge `fragment` into `target`'s JSON. A missing file is created from
349/// `seed` (or an empty object) then merged; an existing one is merged in place.
350/// An unparseable target is refused and left untouched.
351fn merge_json(
352    target: &Path,
353    fragment: &Value,
354    seed: Option<&str>,
355    check: bool,
356) -> Result<HookWrite, OneharnessError> {
357    let existing: Option<Value> = match std::fs::read_to_string(target) {
358        Ok(text) => Some(serde_json::from_str(&text).map_err(|e| {
359            OneharnessError::HarnessConfigUnmergeable {
360                path: target.display().to_string(),
361                message: format!("not valid JSON ({e}); fix or remove it and re-run"),
362            }
363        })?),
364        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
365        Err(source) => {
366            return Err(OneharnessError::HarnessConfigRead {
367                path: target.display().to_string(),
368                source,
369            })
370        }
371    };
372
373    let (merged, status) = match &existing {
374        Some(existing) => {
375            let merged = deep_merge(existing, fragment);
376            let status = if &merged == existing {
377                FileStatus::Unchanged
378            } else {
379                FileStatus::Updated
380            };
381            (merged, status)
382        }
383        None => {
384            let base = seed.map(parse_seed).unwrap_or(Value::Object(Map::new()));
385            (deep_merge(&base, fragment), FileStatus::Created)
386        }
387    };
388
389    if !check && status != FileStatus::Unchanged {
390        write_atomically(target, &merged)?;
391    }
392    Ok(HookWrite {
393        path: target.to_path_buf(),
394        status,
395    })
396}
397
398/// Write a text file (the JS shim) idempotently and atomically: unchanged when
399/// the bytes already match, else created/updated via a temp file + rename.
400fn write_text(target: &Path, content: &str, check: bool) -> Result<HookWrite, OneharnessError> {
401    let existing = match std::fs::read_to_string(target) {
402        Ok(text) => Some(text),
403        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
404        Err(source) => {
405            return Err(OneharnessError::HarnessConfigRead {
406                path: target.display().to_string(),
407                source,
408            })
409        }
410    };
411    let status = match &existing {
412        Some(text) if text == content => FileStatus::Unchanged,
413        Some(_) => FileStatus::Updated,
414        None => FileStatus::Created,
415    };
416
417    if !check && status != FileStatus::Unchanged {
418        let write_err = |source: std::io::Error| OneharnessError::HarnessConfigWrite {
419            path: target.display().to_string(),
420            source,
421        };
422        if let Some(parent) = target.parent() {
423            std::fs::create_dir_all(parent).map_err(write_err)?;
424        }
425        let tmp = target.with_extension("oneharness.tmp");
426        std::fs::write(&tmp, content).map_err(write_err)?;
427        std::fs::rename(&tmp, target).map_err(write_err)?;
428    }
429    Ok(HookWrite {
430        path: target.to_path_buf(),
431        status,
432    })
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::domain::harness;
439    use serde_json::json;
440
441    #[test]
442    fn global_home_prefers_userprofile_on_windows_only() {
443        // A `--global` hook must land where the (often Node-based) harness reads
444        // `~`: %USERPROFILE% on Windows, $HOME elsewhere.
445        let dirs = GlobalDirs::from_vars(Some("/home/u".into()), None, Some(r"C:\Users\u".into()));
446        if cfg!(windows) {
447            assert_eq!(dirs.home, Some(PathBuf::from(r"C:\Users\u")));
448        } else {
449            assert_eq!(dirs.home, Some(PathBuf::from("/home/u")));
450        }
451    }
452
453    fn temp_project(tag: &str) -> PathBuf {
454        let dir = std::env::temp_dir().join(format!(
455            "oneharness-hooks-{tag}-{}-{:?}",
456            std::process::id(),
457            std::thread::current().id()
458        ));
459        let _ = std::fs::remove_dir_all(&dir);
460        std::fs::create_dir_all(&dir).unwrap();
461        dir
462    }
463
464    fn install_one(dir: &Path, id: &str, hook: &HookSpec) -> Vec<HookWrite> {
465        install(
466            Scope::Project(dir),
467            harness::by_id(id).unwrap(),
468            hook,
469            false,
470        )
471        .expect("install should succeed")
472    }
473
474    fn read_json(path: &Path) -> Value {
475        serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
476    }
477
478    /// Claude shares its permissions file: the hook lands under `hooks` in
479    /// `.claude/settings.json` without disturbing existing keys.
480    #[test]
481    fn same_file_merges_into_claude_settings_without_clobbering() {
482        let dir = temp_project("claude");
483        std::fs::create_dir_all(dir.join(".claude")).unwrap();
484        std::fs::write(
485            dir.join(".claude/settings.json"),
486            r#"{"permissions":{"allow":["Read"]}}"#,
487        )
488        .unwrap();
489        let hook = HookSpec {
490            command: "guard hook claude-code".into(),
491            matcher: Some("Bash".into()),
492            timeout: None,
493            plugin_name: None,
494            description: None,
495        };
496        let writes = install_one(&dir, "claude-code", &hook);
497        assert_eq!(writes.len(), 1);
498        assert_eq!(writes[0].status, FileStatus::Updated);
499        assert_eq!(
500            read_json(&dir.join(".claude/settings.json")),
501            json!({
502                "permissions": { "allow": ["Read"] },
503                "hooks": {
504                    "PreToolUse": [
505                        { "matcher": "Bash", "hooks": [{ "type": "command", "command": "guard hook claude-code" }] }
506                    ]
507                }
508            }),
509        );
510        let _ = std::fs::remove_dir_all(&dir);
511    }
512
513    /// Codex hooks live in a dedicated file that is created on first install.
514    #[test]
515    fn file_strategy_creates_codex_hooks_file() {
516        let dir = temp_project("codex");
517        let writes = install_one(&dir, "codex", &HookSpec::command("guard hook codex"));
518        assert_eq!(writes[0].status, FileStatus::Created);
519        assert_eq!(writes[0].path, dir.join(".codex/hooks.json"));
520        assert_eq!(
521            read_json(&dir.join(".codex/hooks.json")),
522            json!({
523                "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command", "command": "guard hook codex" }] }] }
524            }),
525        );
526        let _ = std::fs::remove_dir_all(&dir);
527    }
528
529    /// Cursor's dedicated file is seeded with its required `version` and fans
530    /// the command across the three `before*` events plus `preToolUse` (the
531    /// rewrite-capable event `oneharness mock` rides).
532    #[test]
533    fn file_strategy_seeds_cursor_version() {
534        let dir = temp_project("cursor");
535        install_one(&dir, "cursor", &HookSpec::command("guard hook cursor"));
536        assert_eq!(
537            read_json(&dir.join(".cursor/hooks.json")),
538            json!({
539                "version": 1,
540                "hooks": {
541                    "beforeShellExecution": [{ "command": "guard hook cursor" }],
542                    "beforeReadFile": [{ "command": "guard hook cursor" }],
543                    "beforeMCPExecution": [{ "command": "guard hook cursor" }],
544                    "preToolUse": [{ "command": "guard hook cursor" }],
545                }
546            }),
547        );
548        let _ = std::fs::remove_dir_all(&dir);
549    }
550
551    /// Copilot's per-owner file name comes from the plugin identity.
552    #[test]
553    fn file_strategy_names_copilot_file_after_plugin() {
554        let dir = temp_project("copilot");
555        let hook = HookSpec {
556            plugin_name: Some("guard".into()),
557            ..HookSpec::command("guard hook copilot")
558        };
559        let writes = install_one(&dir, "copilot", &hook);
560        assert_eq!(writes[0].path, dir.join(".github/hooks/guard.json"));
561        assert_eq!(
562            read_json(&dir.join(".github/hooks/guard.json")),
563            json!({
564                "version": 1,
565                "hooks": {
566                    "preToolUse": [
567                        { "type": "command", "bash": "guard hook copilot", "powershell": "guard hook copilot" }
568                    ]
569                }
570            }),
571        );
572        let _ = std::fs::remove_dir_all(&dir);
573    }
574
575    /// Goose installs two files: a one-time manifest and the hooks json, both
576    /// under a plugin dir named for the plugin identity.
577    #[test]
578    fn goose_plugin_writes_manifest_and_hooks() {
579        let dir = temp_project("goose");
580        let hook = HookSpec {
581            command: "guard hook goose".into(),
582            matcher: Some("^(shell|read)$".into()),
583            timeout: Some(10),
584            plugin_name: None,
585            description: None,
586        };
587        let writes = install_one(&dir, "goose", &hook);
588        assert_eq!(writes.len(), 2);
589        let plugin = dir.join(".agents/plugins/oneharness");
590        assert_eq!(
591            read_json(&plugin.join("plugin.json")),
592            json!({
593                "name": "oneharness",
594                "version": "0.1.0",
595                "description": "Pre-tool hook installed by oneharness.",
596            }),
597        );
598        assert_eq!(
599            read_json(&plugin.join("hooks/hooks.json")),
600            json!({
601                "hooks": {
602                    "PreToolUse": [
603                        {
604                            "matcher": "^(shell|read)$",
605                            "hooks": [{ "type": "command", "command": "guard hook goose", "timeout": 10 }]
606                        }
607                    ]
608                }
609            }),
610        );
611        let _ = std::fs::remove_dir_all(&dir);
612    }
613
614    /// A caller-supplied description brands the Goose manifest, overriding the
615    /// default text — so a consumer (e.g. allowlister) keeps its own plugin
616    /// description instead of inheriting oneharness's.
617    #[test]
618    fn goose_manifest_honors_a_caller_description() {
619        let dir = temp_project("goose-desc");
620        let hook = HookSpec {
621            description: Some("Gate AI-agent shell commands through allowlister.".into()),
622            ..HookSpec::command("allowlister hook goose")
623        };
624        install_one(&dir, "goose", &hook);
625        let manifest = read_json(&dir.join(".agents/plugins/oneharness/plugin.json"));
626        assert_eq!(
627            manifest["description"],
628            "Gate AI-agent shell commands through allowlister."
629        );
630        let _ = std::fs::remove_dir_all(&dir);
631    }
632
633    /// OpenCode gets a JS shim that spawns the command as an argv array under
634    /// its plugin name; the command is wired in, never hardcoded.
635    #[test]
636    fn js_plugin_writes_shim_with_command_argv() {
637        let dir = temp_project("opencode");
638        let hook = HookSpec {
639            plugin_name: Some("guard".into()),
640            ..HookSpec::command("guard hook opencode")
641        };
642        let writes = install_one(&dir, "opencode", &hook);
643        assert_eq!(writes[0].path, dir.join(".opencode/plugin/guard.js"));
644        let shim = std::fs::read_to_string(writes[0].path.clone()).unwrap();
645        assert!(
646            shim.contains(r#"Bun.spawn(["guard","hook","opencode"]"#),
647            "command must be wired into the shim as an argv array:\n{shim}"
648        );
649        assert!(
650            shim.contains("export const guard ="),
651            "export uses the plugin identity:\n{shim}"
652        );
653        // The shim forwards OpenCode's camelCase `input.sessionID`, normalized to
654        // the snake_case `session_id` every adapter feeds the engine.
655        assert!(
656            shim.contains("const session_id = (input && input.sessionID) || undefined;"),
657            "session id must be read from input.sessionID:\n{shim}"
658        );
659        assert!(
660            shim.contains("JSON.stringify({ tool_name, tool_input: args, cwd, session_id })"),
661            "session_id must be on the stdin payload:\n{shim}"
662        );
663        let _ = std::fs::remove_dir_all(&dir);
664    }
665
666    /// A snapshot taken before an ephemeral install restores an existing file
667    /// byte-identically, deletes a file that did not exist, and prunes the
668    /// directories the install created — but only while they are empty.
669    #[test]
670    fn snapshot_restores_existing_files_and_removes_created_ones() {
671        let dir = temp_project("snapshot");
672        let existing = dir.join("crush.json");
673        std::fs::write(&existing, r#"{"keep":"me"}"#).unwrap();
674        let created = dir.join(".codex").join("hooks.json");
675
676        let snapshot = HookSnapshot::capture(&[existing.clone(), created.clone()]);
677        // Simulate the installs: mutate the existing file, create the new one.
678        std::fs::write(&existing, r#"{"keep":"me","hooks":{}}"#).unwrap();
679        std::fs::create_dir_all(created.parent().unwrap()).unwrap();
680        std::fs::write(&created, r#"{"hooks":{}}"#).unwrap();
681
682        let failures = snapshot.restore();
683        assert!(failures.is_empty(), "{failures:?}");
684        assert_eq!(
685            std::fs::read_to_string(&existing).unwrap(),
686            r#"{"keep":"me"}"#
687        );
688        assert!(!created.exists(), "created file must be deleted");
689        assert!(
690            !dir.join(".codex").exists(),
691            "the directory the install created must be pruned"
692        );
693        let _ = std::fs::remove_dir_all(&dir);
694    }
695
696    /// A created directory that gained OTHER content meanwhile is left alone —
697    /// restore only ever prunes empty dirs, never sweeps up user files. And
698    /// restoring an already-absent created file is a no-op, not a failure.
699    #[test]
700    fn snapshot_restore_never_removes_nonempty_dirs_and_tolerates_absence() {
701        let dir = temp_project("snapshot-keep");
702        let created = dir.join(".codex").join("hooks.json");
703        let snapshot = HookSnapshot::capture(std::slice::from_ref(&created));
704        std::fs::create_dir_all(created.parent().unwrap()).unwrap();
705        // The install never happened (or the file vanished) — and the user put
706        // something else into the created dir meanwhile.
707        std::fs::write(dir.join(".codex").join("user.txt"), "mine").unwrap();
708
709        let failures = snapshot.restore();
710        assert!(failures.is_empty(), "{failures:?}");
711        assert!(
712            dir.join(".codex").join("user.txt").exists(),
713            "restore must not touch user content"
714        );
715        // Snapshots merge without double-counting created dirs.
716        let mut a = HookSnapshot::capture(std::slice::from_ref(&created));
717        let b = HookSnapshot::capture(&[created]);
718        a.extend(b);
719        let _ = a.restore();
720        let _ = std::fs::remove_dir_all(&dir);
721    }
722
723    /// Re-installing the same hook is a no-op everywhere — across plain files,
724    /// the seeded Cursor file, the Goose plugin pair, and the JS shim.
725    #[test]
726    fn reinstall_is_idempotent_across_strategies() {
727        for id in [
728            "claude-code",
729            "codex",
730            "cursor",
731            "copilot",
732            "goose",
733            "opencode",
734        ] {
735            let dir = temp_project(&format!("idem-{id}"));
736            let hook = HookSpec {
737                command: format!("guard hook {id}"),
738                matcher: Some("Bash".into()),
739                timeout: Some(10),
740                plugin_name: None,
741                description: None,
742            };
743            install_one(&dir, id, &hook);
744            let second = install(
745                Scope::Project(dir.as_path()),
746                harness::by_id(id).unwrap(),
747                &hook,
748                false,
749            )
750            .unwrap();
751            assert!(
752                second.iter().all(|w| w.status == FileStatus::Unchanged),
753                "second install of `{id}` must be all-unchanged, got {second:?}"
754            );
755            let _ = std::fs::remove_dir_all(&dir);
756        }
757    }
758
759    /// `check` plans the writes without creating any file.
760    #[test]
761    fn check_mode_writes_nothing() {
762        let dir = temp_project("check");
763        let writes = install(
764            Scope::Project(&dir),
765            harness::by_id("codex").unwrap(),
766            &HookSpec::command("x"),
767            true,
768        )
769        .unwrap();
770        assert_eq!(writes[0].status, FileStatus::Created);
771        assert!(!dir.join(".codex/hooks.json").exists());
772        let _ = std::fs::remove_dir_all(&dir);
773    }
774
775    /// An unparseable target is refused and left exactly as it was.
776    #[test]
777    fn unparseable_target_is_refused_and_untouched() {
778        let dir = temp_project("bad");
779        std::fs::create_dir_all(dir.join(".codex")).unwrap();
780        let path = dir.join(".codex/hooks.json");
781        std::fs::write(&path, "{ not json").unwrap();
782        let err = install(
783            Scope::Project(&dir),
784            harness::by_id("codex").unwrap(),
785            &HookSpec::command("x"),
786            false,
787        )
788        .unwrap_err();
789        assert!(err.to_string().contains("not valid JSON"), "{err}");
790        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ not json");
791        let _ = std::fs::remove_dir_all(&dir);
792    }
793
794    /// Every harness in the registry can take a hook — the cross-harness
795    /// promise — and each install produces at least one write.
796    #[test]
797    fn every_harness_supports_hook_install() {
798        for spec in harness::all() {
799            let dir = temp_project(&format!("all-{}", spec.id));
800            let writes = install(
801                Scope::Project(&dir),
802                spec,
803                &HookSpec::command("guard hook x"),
804                false,
805            )
806            .unwrap_or_else(|e| panic!("{}: {e}", spec.id));
807            assert!(!writes.is_empty(), "{}: no writes", spec.id);
808            let _ = std::fs::remove_dir_all(&dir);
809        }
810    }
811
812    /// A global install anchors under the injected HOME / XDG dirs at each
813    /// harness's user-global location — which for several harnesses is a
814    /// *different* relative path than the project one (Copilot's `.github/hooks`
815    /// becomes `.copilot/hooks`; Crush and OpenCode move under the config dir).
816    #[test]
817    fn global_scope_anchors_at_each_harness_user_location() {
818        let root = temp_project("global");
819        let home = root.join("home");
820        let xdg = root.join("xdg");
821        let dirs = GlobalDirs {
822            home: Some(home.clone()),
823            config_home: Some(xdg.clone()),
824        };
825        let hook = HookSpec {
826            plugin_name: Some("guard".into()),
827            ..HookSpec::command("guard hook x")
828        };
829        let cases: &[(&str, PathBuf)] = &[
830            ("claude-code", home.join(".claude/settings.json")),
831            ("codex", home.join(".codex/hooks.json")),
832            ("qwen", home.join(".qwen/settings.json")),
833            ("cursor", home.join(".cursor/hooks.json")),
834            ("copilot", home.join(".copilot/hooks/guard.json")),
835            ("crush", xdg.join("crush/crush.json")),
836            ("opencode", xdg.join("opencode/plugin/guard.js")),
837        ];
838        for (id, expected) in cases {
839            let writes = install(
840                Scope::Global(&dirs),
841                harness::by_id(id).unwrap(),
842                &hook,
843                false,
844            )
845            .unwrap();
846            assert_eq!(writes[0].path, *expected, "{id} global anchor");
847            assert!(expected.is_file(), "{id}: file not written at {expected:?}");
848        }
849        // Goose installs its plugin pair under the global plugins dir.
850        let goose = install(
851            Scope::Global(&dirs),
852            harness::by_id("goose").unwrap(),
853            &hook,
854            false,
855        )
856        .unwrap();
857        let plugin = home.join(".agents/plugins/guard");
858        assert_eq!(goose[0].path, plugin.join("plugin.json"));
859        assert_eq!(goose[1].path, plugin.join("hooks/hooks.json"));
860        let _ = std::fs::remove_dir_all(&root);
861    }
862
863    /// The OpenCode global shim is byte-identical to the project one — same
864    /// protocol, just a different location — so the gate command works either way.
865    #[test]
866    fn global_and_project_opencode_shims_match() {
867        let root = temp_project("opencode-scope");
868        let project = root.join("proj");
869        std::fs::create_dir_all(&project).unwrap();
870        let dirs = GlobalDirs {
871            home: Some(root.join("home")),
872            config_home: Some(root.join("xdg")),
873        };
874        let hook = HookSpec::command("allowlister hook opencode");
875        let spec = harness::by_id("opencode").unwrap();
876        let proj = install(Scope::Project(&project), spec, &hook, false).unwrap();
877        let glob = install(Scope::Global(&dirs), spec, &hook, false).unwrap();
878        let proj_js = std::fs::read_to_string(&proj[0].path).unwrap();
879        let glob_js = std::fs::read_to_string(&glob[0].path).unwrap();
880        assert_eq!(
881            proj_js, glob_js,
882            "global and project shims must be identical"
883        );
884        let _ = std::fs::remove_dir_all(&root);
885    }
886
887    /// A `ConfigHome` harness falls back to `$HOME/.config` when XDG is unset.
888    #[test]
889    fn config_home_falls_back_to_dot_config() {
890        let root = temp_project("xdg-fallback");
891        let home = root.join("home");
892        let dirs = GlobalDirs {
893            home: Some(home.clone()),
894            config_home: None,
895        };
896        let writes = install(
897            Scope::Global(&dirs),
898            harness::by_id("crush").unwrap(),
899            &HookSpec::command("x"),
900            false,
901        )
902        .unwrap();
903        assert_eq!(writes[0].path, home.join(".config/crush/crush.json"));
904        let _ = std::fs::remove_dir_all(&root);
905    }
906
907    /// A global install with no resolvable base directory is a loud error, never
908    /// a write to a guessed path.
909    #[test]
910    fn global_without_base_dir_is_a_loud_error() {
911        let dirs = GlobalDirs::default(); // neither HOME nor XDG set
912        let err = install(
913            Scope::Global(&dirs),
914            harness::by_id("claude-code").unwrap(),
915            &HookSpec::command("x"),
916            false,
917        )
918        .unwrap_err();
919        assert!(err.to_string().contains("HOME is not set"), "{err}");
920    }
921}