nornir 0.4.54

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! Pre-commit hook installer for the docs **auto-trigger**.
//!
//! `nornir docs install-hooks <repo>` writes a *tracked* pre-commit hook to
//! `<repo>/.githooks/pre-commit` and points the repo's git at it with
//! `git config core.hooksPath .githooks`. The hook re-renders the repo's
//! managed markdown (`README.md` / `CHANGELOG.md`) **markdown-only** (never the
//! noisy SVG/PDF) and `git add`s the regenerated docs so each commit ships fresh
//! docs without a manual `docs render`.
//!
//! Two hard rules baked into the script:
//!
//! 1. **Markdown only.** It runs `nornir docs render <repo> --markdown-only`, so
//!    a commit never regenerates the non-deterministic depgraph SVG / book PDF.
//!    Those heavy artifacts are refreshed by `docs render` (full) and the release
//!    backstop ([`crate::release::gate::render_and_stage_docs`]).
//! 2. **Never block an unrelated commit.** If `nornir` isn't on `PATH`, the
//!    warehouse is unreachable, or render fails for *any* reason, the hook prints
//!    a warning and **exits 0**. Docs freshness is a convenience, not a gate —
//!    the authoritative check is the release `docs_fresh` gate.
//!
//! Why `.githooks/` + `core.hooksPath` and not `.git/hooks/`: the former is
//! tracked and shareable (every clone that runs `install-hooks`, or that we tell
//! git to honour, gets the same hook), whereas `.git/hooks/` is untracked and
//! per-clone.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

/// Directory (relative to the repo root) holding the tracked hooks.
pub const HOOKS_DIR: &str = ".githooks";
/// The pre-commit hook file name.
pub const PRE_COMMIT: &str = "pre-commit";

/// Outcome of [`install_hooks`].
#[derive(Debug)]
pub struct InstallReport {
    /// Absolute path of the written hook script.
    pub hook_path: PathBuf,
    /// `true` if the hook content changed (or was newly created); `false` if it
    /// was already byte-identical (idempotent re-run).
    pub hook_changed: bool,
    /// `true` if `core.hooksPath` was set/updated; `false` if already correct.
    pub hookspath_changed: bool,
}

/// Render the POSIX-sh pre-commit hook body for `repo_name`.
///
/// The script is deliberately conservative: it discovers `nornir` on `PATH`,
/// bails *softly* (exit 0) when anything is missing or fails, and only ever
/// `git add`s the managed markdown so it can never sweep unrelated changes into
/// the commit.
pub fn pre_commit_script(repo_name: &str) -> String {
    // `repo_name` is a config key (alnum/_/-) — safe to embed. We still
    // single-quote it in the script for defence in depth.
    format!(
        r#"#!/bin/sh
# nornir docs auto-trigger — re-render managed markdown (README.md / CHANGELOG.md)
# before each commit and stage the result, so published docs never drift.
#
# GENERATED by `nornir docs install-hooks {repo}` — safe to re-generate.
#
# HARD RULES:
#   * markdown only — never regenerates the depgraph SVG or the rendered book.
#   * never blocks a commit — any failure prints a warning and exits 0.
set -u

REPO='{repo}'

# Degrade gracefully if nornir isn't installed.
if ! command -v nornir >/dev/null 2>&1; then
    echo "nornir: docs pre-commit skipped (nornir not on PATH)" >&2
    exit 0
fi

# Re-render managed markdown only (no assets). Capture output; on ANY failure
# (warehouse unreachable, render error, …) warn and exit 0 — never block.
if ! out=$(nornir docs render "$REPO" --markdown-only 2>&1); then
    echo "nornir: docs render failed — committing without doc refresh" >&2
    echo "$out" | sed 's/^/nornir: /' >&2
    exit 0
fi

# Stage only the managed docs nornir owns, and only if they exist + changed.
for f in README.md CHANGELOG.md; do
    if [ -f "$f" ]; then
        git add -- "$f" 2>/dev/null || true
    fi
done

exit 0
"#,
        repo = repo_name
    )
}

/// Install (or refresh) the tracked pre-commit hook for `repo_root` and point
/// the repo's git at `.githooks` via `core.hooksPath`. Idempotent.
///
/// `repo_name` is the nornir config key passed through to the script's
/// `nornir docs render <repo>` invocation.
pub fn install_hooks(repo_root: &Path, repo_name: &str) -> Result<InstallReport> {
    let hooks_dir = repo_root.join(HOOKS_DIR);
    std::fs::create_dir_all(&hooks_dir)
        .with_context(|| format!("create {}", hooks_dir.display()))?;

    let hook_path = hooks_dir.join(PRE_COMMIT);
    let want = pre_commit_script(repo_name);
    let prev = std::fs::read_to_string(&hook_path).ok();
    let hook_changed = prev.as_deref() != Some(want.as_str());
    if hook_changed {
        std::fs::write(&hook_path, &want)
            .with_context(|| format!("write {}", hook_path.display()))?;
    }
    // Make it executable (POSIX). Git honours the bit when running the hook.
    set_executable(&hook_path)?;

    let hookspath_changed = set_hookspath(repo_root)?;

    Ok(InstallReport { hook_path, hook_changed, hookspath_changed })
}

/// `chmod +x` equivalent: ensure the owner/group/other execute bits are set.
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;
    let mut perm = std::fs::metadata(path)
        .with_context(|| format!("stat {}", path.display()))?
        .permissions();
    let mode = perm.mode();
    // Add execute for user/group/other where read is present.
    perm.set_mode(mode | 0o755);
    std::fs::set_permissions(path, perm)
        .with_context(|| format!("chmod +x {}", path.display()))?;
    Ok(())
}

#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<()> {
    // No-op on non-unix: git on Windows runs `sh` hooks regardless of the bit.
    Ok(())
}

/// Read the current `core.hooksPath` (trimmed), if any.
pub fn current_hookspath(repo_root: &Path) -> Option<String> {
    let out = std::process::Command::new("git")
        .arg("-C")
        .arg(repo_root)
        .args(["config", "--local", "--get", "core.hooksPath"])
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
    if s.is_empty() {
        None
    } else {
        Some(s)
    }
}

/// Set `core.hooksPath` to `.githooks` for `repo_root`. Returns `true` if the
/// value changed (was unset or different). No-op (returns `false`) if already
/// correct.
pub fn set_hookspath(repo_root: &Path) -> Result<bool> {
    if current_hookspath(repo_root).as_deref() == Some(HOOKS_DIR) {
        return Ok(false);
    }
    let status = std::process::Command::new("git")
        .arg("-C")
        .arg(repo_root)
        .args(["config", "--local", "core.hooksPath", HOOKS_DIR])
        .status()
        .with_context(|| format!("git -C {} config core.hooksPath", repo_root.display()))?;
    if !status.success() {
        anyhow::bail!(
            "git config core.hooksPath {HOOKS_DIR} failed in {}",
            repo_root.display()
        );
    }
    Ok(true)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn git_repo() -> TempDir {
        let t = TempDir::new().unwrap();
        let ok = std::process::Command::new("git")
            .arg("-C")
            .arg(t.path())
            .arg("init")
            .status()
            .map(|s| s.success())
            .unwrap_or(false);
        assert!(ok, "git init failed (is git installed?)");
        t
    }

    /// install_hooks writes the script AND sets core.hooksPath = .githooks.
    #[test]
    fn install_writes_script_and_sets_hookspath() {
        let t = git_repo();
        let report = install_hooks(t.path(), "znippy").unwrap();

        // The script exists at the tracked path and is non-empty.
        let hook = t.path().join(HOOKS_DIR).join(PRE_COMMIT);
        assert_eq!(report.hook_path, hook);
        assert!(report.hook_changed, "first install should write the hook");
        let body = std::fs::read_to_string(&hook).unwrap();
        // It is a POSIX-sh script that renders markdown-only for THIS repo and
        // never blocks (exit 0 on failure).
        assert!(body.starts_with("#!/bin/sh"), "must be POSIX sh: {body:?}");
        assert!(body.contains("nornir docs render \"$REPO\" --markdown-only"));
        assert!(body.contains("REPO='znippy'"));
        assert!(body.contains("exit 0"));
        // Never blocks: there is no `exit 1` anywhere in the script.
        assert!(!body.contains("exit 1"), "hook must never block a commit");
        // Markdown only: it must invoke render with --markdown-only and never the
        // heavy book/export verbs.
        assert!(!body.contains("docs book"), "hook must not build the book");
        assert!(!body.contains("docs export"), "hook must not run export");

        // core.hooksPath now points at .githooks.
        assert!(report.hookspath_changed);
        assert_eq!(current_hookspath(t.path()).as_deref(), Some(HOOKS_DIR));

        // Executable bit set on unix.
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(&hook).unwrap().permissions().mode();
            assert!(mode & 0o111 != 0, "hook must be executable; mode={mode:o}");
        }
    }

    /// Re-running install_hooks is a no-op: same bytes, hooksPath already set.
    #[test]
    fn install_is_idempotent() {
        let t = git_repo();
        let r1 = install_hooks(t.path(), "skade").unwrap();
        assert!(r1.hook_changed);
        assert!(r1.hookspath_changed);

        let r2 = install_hooks(t.path(), "skade").unwrap();
        assert!(!r2.hook_changed, "second run should not rewrite the hook");
        assert!(!r2.hookspath_changed, "core.hooksPath already set");
        // Bytes unchanged across runs.
        assert_eq!(
            std::fs::read_to_string(r1.hook_path).unwrap(),
            std::fs::read_to_string(r2.hook_path).unwrap()
        );
    }

    /// The script embeds the exact repo name it was generated for.
    #[test]
    fn script_targets_named_repo() {
        let s = pre_commit_script("holger");
        assert!(s.contains("REPO='holger'"));
        assert!(s.contains("install-hooks holger"));
    }

    /// GRACEFUL DEGRADE (the critical rule): when `nornir docs render` FAILS, the
    /// hook must print a warning and exit **0** — never block the commit. We run
    /// the generated script with a fake `nornir` on PATH that exits non-zero, and
    /// assert: exit status 0, the warning was printed, and the managed markdown
    /// was NOT staged (a failed render stages nothing).
    #[cfg(unix)]
    #[test]
    fn render_failure_exits_zero_and_does_not_block() {
        let t = git_repo();
        let root = t.path();
        install_hooks(root, "znippy").unwrap();

        // A README that must remain UNTOUCHED when render fails.
        std::fs::write(root.join("README.md"), "# original\n").unwrap();

        // Fake `nornir` that always FAILS — simulates warehouse-unreachable /
        // render error. Put it on a PATH-shadowing bin dir.
        let bin = root.join("fakebin");
        std::fs::create_dir_all(&bin).unwrap();
        let fake = bin.join("nornir");
        std::fs::write(
            &fake,
            "#!/bin/sh\necho 'boom: warehouse unreachable' >&2\nexit 7\n",
        )
        .unwrap();
        set_executable(&fake).unwrap();

        let hook = root.join(HOOKS_DIR).join(PRE_COMMIT);
        let path_env = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default());
        let out = std::process::Command::new("/bin/sh")
            .arg(&hook)
            .current_dir(root)
            .env("PATH", &path_env)
            .output()
            .unwrap();

        // EXIT 0 — the commit is never blocked.
        assert!(
            out.status.success(),
            "hook must exit 0 even when render fails; status={:?} stderr={}",
            out.status,
            String::from_utf8_lossy(&out.stderr)
        );
        // It warned the user.
        let stderr = String::from_utf8_lossy(&out.stderr);
        assert!(
            stderr.contains("docs render failed"),
            "hook should warn on render failure; stderr={stderr}"
        );
        // The README is left untouched (a failed render writes nothing).
        assert_eq!(
            std::fs::read_to_string(root.join("README.md")).unwrap(),
            "# original\n"
        );
    }

    /// When `nornir` is entirely absent from PATH the hook still exits 0 (it
    /// degrades to a skip) — the other half of "never block a commit".
    #[cfg(unix)]
    #[test]
    fn missing_nornir_exits_zero() {
        let t = git_repo();
        let root = t.path();
        install_hooks(root, "skade").unwrap();
        let hook = root.join(HOOKS_DIR).join(PRE_COMMIT);
        // Empty PATH ⇒ `command -v nornir` fails ⇒ skip + exit 0. Provide a
        // minimal PATH that contains neither nornir nor git tooling beyond sh.
        let out = std::process::Command::new("/bin/sh")
            .arg(&hook)
            .current_dir(root)
            .env("PATH", "/nonexistent-empty-path")
            .output()
            .unwrap();
        assert!(out.status.success(), "hook must exit 0 when nornir is missing");
        let stderr = String::from_utf8_lossy(&out.stderr);
        assert!(stderr.contains("not on PATH"), "should warn nornir missing; stderr={stderr}");
    }
}