Skip to main content

rac_engine/
hook.rs

1//! Bundled git hooks — `decided hook` (PORT-CONTRACT.d/15).
2//!
3//! Port of `src/rac/core/hooks.py` (registry + resource loading) and
4//! `src/asdecided/services/hook.py` (`install_hook`). The packaged `<style>.sh`
5//! scripts are embedded verbatim from `rust/decided-engine/assets/hooks/`,
6//! vendored byte-identical copies of the Python package files (unit test
7//! below); the INSTALLED file is named `<style>` with no extension, under
8//! `<dir>/.git/hooks/`, and is made executable (hook brief, landmines 1-2).
9
10use std::path::Path;
11
12use crate::walk::py_join;
13
14/// One bundled git hook: its style (the git hook filename) and description.
15pub struct HookSpec {
16    pub style: &'static str,
17    pub description: &'static str,
18}
19
20/// Bundled hooks, registry order. `install` defaults to the first.
21pub const BUNDLED_HOOKS: [HookSpec; 2] = [
22    HookSpec {
23        style: "post-commit",
24        description: "Advisory write-cadence nudge after each commit (never blocks).",
25    },
26    HookSpec {
27        style: "pre-commit",
28        description: "Validate staged Markdown artifacts before each commit (blocks on errors).",
29    },
30];
31
32/// `DEFAULT_STYLE` — the first bundled hook.
33pub const DEFAULT_STYLE: &str = "post-commit";
34
35/// The embedded hook scripts, index-aligned with [`BUNDLED_HOOKS`].
36pub(crate) const HOOK_BYTES: [&[u8]; 2] = [
37    include_bytes!("../assets/hooks/post-commit.sh"),
38    include_bytes!("../assets/hooks/pre-commit.sh"),
39];
40
41/// `available_hooks()` — bundled hook styles, registry order.
42pub fn available_hooks() -> Vec<&'static str> {
43    BUNDLED_HOOKS.iter().map(|h| h.style).collect()
44}
45
46fn hook_bytes(style: &str) -> Option<&'static [u8]> {
47    BUNDLED_HOOKS
48        .iter()
49        .position(|h| h.style == style)
50        .map(|i| HOOK_BYTES[i])
51}
52
53/// Result of a `decided hook install` run (`InstalledHook`; `bytes_written` is
54/// in the oracle's model but absent from its JSON).
55pub struct InstalledHook {
56    pub style: String,
57    pub path: String,
58}
59
60/// The failure contract of `install_hook`, message-shaped like the oracle.
61/// `HookNotFound` is unreachable via the CLI (argparse `--style` choices
62/// fire first) and is folded into the usage-error path by the caller.
63pub enum HookInstallError {
64    /// `NotAGitWorkTree` — no `.git` DIRECTORY (a `.git` file — a worktree
65    /// or submodule pointer — fails too). Usage error, exit 2.
66    NotAGitWorkTree(String),
67    /// `HookFileExists` — refused; the existing hook is untouched (exit 1).
68    FileExists(String),
69    /// Filesystem write failure.
70    Io(String),
71}
72
73/// `install_hook(target_dir, style)` — write the bundled `style` script to
74/// `<dir>/.git/hooks/<style>` and make it executable (git requires the exec
75/// bit; the oracle ORs `S_IXUSR|S_IXGRP|S_IXOTH` onto the fresh file's mode,
76/// yielding 0755 under the usual umask).
77pub fn install_hook(target_dir: &str, style: &str) -> Result<InstalledHook, HookInstallError> {
78    let content = hook_bytes(style).expect("argparse-validated style");
79
80    let git_dir = Path::new(target_dir).join(".git");
81    if !git_dir.is_dir() {
82        return Err(HookInstallError::NotAGitWorkTree(format!(
83            "no .git directory in {target_dir}; run `decided hook install` from a git repository root"
84        )));
85    }
86
87    let dest_display = py_join(target_dir, &[".git", "hooks", style]);
88    let dest = Path::new(&dest_display);
89    if dest.exists() {
90        return Err(HookInstallError::FileExists(format!(
91            "{dest_display} already exists; decided hook install never overwrites"
92        )));
93    }
94
95    let hooks_dir = git_dir.join("hooks");
96    std::fs::create_dir_all(&hooks_dir)
97        .map_err(|e| HookInstallError::Io(format!("{e}: {}", hooks_dir.display())))?;
98    std::fs::write(dest, content)
99        .map_err(|e| HookInstallError::Io(format!("{e}: {dest_display}")))?;
100    // dest.chmod(dest.stat().st_mode | S_IXUSR | S_IXGRP | S_IXOTH)
101    #[cfg(unix)]
102    {
103        use std::os::unix::fs::PermissionsExt;
104        let mode = std::fs::metadata(dest)
105            .map_err(|e| HookInstallError::Io(format!("{e}: {dest_display}")))?
106            .permissions();
107        let new_mode = mode.mode() | 0o111;
108        std::fs::set_permissions(dest, std::fs::Permissions::from_mode(new_mode))
109            .map_err(|e| HookInstallError::Io(format!("{e}: {dest_display}")))?;
110    }
111    Ok(InstalledHook {
112        style: style.to_string(),
113        path: dest_display,
114    })
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn registry_order_and_default() {
123        assert_eq!(available_hooks(), vec!["post-commit", "pre-commit"]);
124        assert_eq!(DEFAULT_STYLE, "post-commit");
125    }
126}