roteiro 0.0.15

Roteiro: a provenance-tagged knowledge graph for your codebase — structure, intent, and context in one queryable store
//! `roteiro init`: scaffold the graph and install the automation around it — the
//! `post-checkout` / `post-merge` / `post-commit` freshness hooks, a `pre-commit`
//! drift gate (Stage 16), and an `AGENTS.md` snippet.
//!
//! Everything written here is *managed*: each artifact carries a marker so a
//! re-run updates Roteiro's own content in place and never clobbers a user's
//! existing (foreign) hook or notes.

use std::fs;
use std::io;
use std::path::Path;

/// Marker identifying a Roteiro-managed git hook.
const HOOK_MARKER: &str = "roteiro-managed";
/// Marker identifying the Roteiro block in `AGENTS.md`.
const AGENTS_MARKER: &str = "<!-- roteiro-managed -->";

/// The git hooks Roteiro installs. `post-checkout`/`post-merge`/`post-commit`
/// keep the graph fresh after `HEAD` moves; `pre-commit` gates a commit that
/// introduces authored-vs-code drift (Stage 16).
pub const MANAGED_HOOKS: &[&str] = &["post-checkout", "post-merge", "post-commit", "pre-commit"];

/// The content of a managed hook `name`. Every hook guards on `roteiro` being
/// installed, so it is safe on machines without the tool.
///
/// `pre-commit` runs the **worktree-aware** `check` and blocks the commit on
/// drift (non-zero exit); `git commit --no-verify` skips it. The freshness hooks
/// run `sync --committed` and never fail the git operation.
///
/// When `fetch` is set (`roteiro init --fetch`), the freshness hooks first try to
/// download the CI-published graph artifact and `load` it — a fast path that
/// skips local extraction — falling back to a rebuild if `gh` is absent, the
/// download fails, or the artifact's tree does not match `HEAD` (`load` refuses a
/// stale artifact). `pre-commit` is unaffected. Network only happens with this
/// opt-in.
#[must_use]
pub fn hook_script(name: &str, fetch: bool) -> String {
    let header = format!(
        "#!/bin/sh\n\
         # {HOOK_MARKER}: Roteiro knowledge-graph automation.\n\
         # Delete this file to disable. Re-run `roteiro init` to reinstall.\n"
    );
    if name == "pre-commit" {
        format!(
            "{header}\
             # Block a commit that introduces ADR/annotation drift. Validates the\n\
             # staged index — exactly what this commit will record. Skip once with\n\
             # `git commit --no-verify`.\n\
             command -v roteiro >/dev/null 2>&1 || exit 0\n\
             roteiro check --staged || {{\n\
             \techo 'roteiro: commit blocked by knowledge-graph drift (see above); \
             use `git commit --no-verify` to override.' >&2\n\
             \texit 1\n\
             }}\n"
        )
    } else if fetch {
        format!("{header}{FETCH_REFRESH}")
    } else {
        format!(
            "{header}\
             # Keep the Roteiro knowledge graph fresh after HEAD changes.\n\
             command -v roteiro >/dev/null 2>&1 && roteiro sync --committed >/dev/null 2>&1 || true\n"
        )
    }
}

/// The freshness-hook body used with `--fetch`: try the CI artifact, else rebuild.
/// Kept as a literal (no interpolation) so the shell `$tmp`/braces stay verbatim.
const FETCH_REFRESH: &str = concat!(
    "# Keep the Roteiro knowledge graph fresh after HEAD changes.\n",
    "command -v roteiro >/dev/null 2>&1 || exit 0\n",
    "# Opt-in fast path (`roteiro init --fetch`): try the CI-published graph\n",
    "# artifact before rebuilding. `roteiro load` refuses an artifact whose tree\n",
    "# does not match HEAD, so a stale asset falls through to a local rebuild.\n",
    "if command -v gh >/dev/null 2>&1; then\n",
    "\t# Portable temp file: GNU `mktemp` needs no args; BSD/macOS needs a\n",
    "\t# template, so fall back to `-t`.\n",
    "\ttmp=$(mktemp 2>/dev/null || mktemp -t roteiro-graph 2>/dev/null) || tmp=\"\"\n",
    "\tif [ -n \"$tmp\" ] && \\\n",
    "\t\tgh release download graph-latest --pattern roteiro-graph.json \\\n",
    "\t\t\t--output \"$tmp\" --clobber >/dev/null 2>&1 && \\\n",
    "\t\troteiro load \"$tmp\" >/dev/null 2>&1; then\n",
    "\t\trm -f \"$tmp\"; exit 0\n",
    "\tfi\n",
    "\t[ -n \"$tmp\" ] && rm -f \"$tmp\"\n",
    "fi\n",
    "roteiro sync --committed >/dev/null 2>&1 || true\n",
);

/// Whether `content` is a Roteiro-managed hook (safe to overwrite).
#[must_use]
pub fn is_managed_hook(content: &str) -> bool {
    content.contains(HOOK_MARKER)
}

/// The outcome of installing one hook.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookOutcome {
    /// The hook was newly created.
    Installed,
    /// An existing Roteiro-managed hook was refreshed.
    Updated,
    /// A pre-existing foreign hook was left untouched.
    SkippedForeign,
}

/// Install (or refresh) one managed hook in `hooks_dir`, without clobbering a
/// foreign hook of the same name. `fetch` selects the artifact-fast-path freshness
/// hooks (see [`hook_script`]).
///
/// # Errors
/// Returns [`io::Error`] on filesystem failure.
pub fn install_hook(hooks_dir: &Path, name: &str, fetch: bool) -> io::Result<HookOutcome> {
    fs::create_dir_all(hooks_dir)?;
    let path = hooks_dir.join(name);
    let outcome = match fs::read_to_string(&path) {
        Ok(existing) if is_managed_hook(&existing) => HookOutcome::Updated,
        Ok(_) => return Ok(HookOutcome::SkippedForeign),
        Err(e) if e.kind() == io::ErrorKind::NotFound => HookOutcome::Installed,
        Err(e) => return Err(e),
    };
    fs::write(&path, hook_script(name, fetch))?;
    set_executable(&path)?;
    Ok(outcome)
}

#[cfg(unix)]
fn set_executable(path: &Path) -> io::Result<()> {
    use std::os::unix::fs::PermissionsExt;
    let mut perms = fs::metadata(path)?.permissions();
    perms.set_mode(0o755);
    fs::set_permissions(path, perms)
}

#[cfg(not(unix))]
fn set_executable(_path: &Path) -> io::Result<()> {
    Ok(())
}

/// The Roteiro section for `AGENTS.md`, delimited by the managed marker so it
/// can be replaced in place on re-run.
#[must_use]
pub fn agents_section() -> String {
    format!(
        "{AGENTS_MARKER}\n\
         ## Roteiro knowledge graph\n\
         \n\
         This repository has a Roteiro knowledge graph — code structure, ADR intent,\n\
         and their links in one provenance-tagged store. Prefer querying it over\n\
         grepping when orienting:\n\
         \n\
         - `roteiro query <key> --json` — a node and its provenance-labelled edges.\n\
         Keys: `sym:<lang>:<path>#<Name>`, `file:<path>`, `adr:<id>`.\n\
         - `roteiro query --kind <kind> --json` — list nodes of a kind (`fn`, `adr`, …).\n\
         - `roteiro sync` — refresh the graph (git hooks do this automatically).\n\
         - `roteiro check` — validate ADR/annotation drift in the working tree.\n\
         Run it before finishing a change; a managed `pre-commit` hook also runs\n\
         it and blocks a drift-introducing commit (`git commit --no-verify` skips).\n\
         - `roteiro review [--json]` — a graph-grounded review of your current\n\
         change: each touched symbol's callers/callees and governing ADRs, the\n\
         drift and intent-debt it adds, and the dependents to re-check. Run it\n\
         before finishing to review against the graph, not just the diff.\n\
         {AGENTS_MARKER}\n"
    )
}

/// Ensure `AGENTS.md` at `path` contains the managed Roteiro section, replacing
/// an existing managed block or appending a new one. Returns `true` if the file
/// was created or changed.
///
/// # Errors
/// Returns [`io::Error`] on filesystem failure.
pub fn ensure_agents(path: &Path) -> io::Result<bool> {
    let section = agents_section();
    let existing = match fs::read_to_string(path) {
        Ok(s) => s,
        Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
        Err(e) => return Err(e),
    };

    let updated = match managed_block_range(&existing) {
        Some((start, end)) => {
            let mut s = String::with_capacity(existing.len());
            s.push_str(&existing[..start]);
            s.push_str(section.trim_end());
            s.push_str(&existing[end..]);
            s
        }
        None if existing.is_empty() => section,
        None => {
            let mut s = existing.clone();
            if !s.ends_with('\n') {
                s.push('\n');
            }
            s.push('\n');
            s.push_str(&section);
            s
        }
    };

    if updated == existing {
        return Ok(false);
    }
    fs::write(path, updated)?;
    Ok(true)
}

/// Byte range of the managed block (marker … marker, inclusive), if present.
fn managed_block_range(content: &str) -> Option<(usize, usize)> {
    let start = content.find(AGENTS_MARKER)?;
    let after = start + AGENTS_MARKER.len();
    let second = content[after..].find(AGENTS_MARKER)? + after;
    Some((start, second + AGENTS_MARKER.len()))
}

#[cfg(test)]
mod tests {
    use super::{
        HookOutcome, agents_section, ensure_agents, hook_script, install_hook, is_managed_hook,
    };

    fn tmp(name: &str) -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!("roteiro-init-{}-{name}", std::process::id()));
        std::fs::remove_dir_all(&dir).ok();
        std::fs::create_dir_all(&dir).expect("mkdir");
        dir
    }

    #[test]
    fn hook_is_recognisable_and_self_guarding() {
        let s = hook_script("post-checkout", false);
        assert!(is_managed_hook(&s));
        assert!(s.starts_with("#!/bin/sh"));
        assert!(s.contains("command -v roteiro"));
        assert!(
            s.contains("roteiro sync --committed"),
            "freshness hook syncs"
        );
        // The default freshness hook does not touch the network.
        assert!(!s.contains("gh release download"));
        assert!(!is_managed_hook("#!/bin/sh\necho other\n"));
    }

    #[test]
    fn fetch_hook_tries_artifact_then_falls_back_to_sync() {
        let s = hook_script("post-merge", true);
        assert!(is_managed_hook(&s));
        assert!(s.contains("command -v gh"), "guards on gh being installed");
        assert!(
            s.contains("gh release download graph-latest"),
            "fetches the CI artifact"
        );
        assert!(s.contains("roteiro load"), "loads the fetched artifact");
        assert!(
            s.contains("roteiro sync --committed"),
            "falls back to a local rebuild"
        );
        // `--fetch` only affects freshness hooks; pre-commit is unchanged.
        assert_eq!(
            hook_script("pre-commit", true),
            hook_script("pre-commit", false)
        );
    }

    #[test]
    fn pre_commit_hook_gates_on_check_and_is_skippable() {
        let s = hook_script("pre-commit", false);
        assert!(is_managed_hook(&s));
        assert!(s.contains("command -v roteiro"), "guards on install");
        assert!(s.contains("roteiro check"), "runs the worktree-aware check");
        assert!(s.contains("exit 1"), "blocks the commit on drift");
        assert!(s.contains("--no-verify"), "documents the escape hatch");
        // Distinct from the freshness hooks — it must not just sync.
        assert!(!s.contains("roteiro sync"));
    }

    #[test]
    fn install_creates_updates_and_skips_foreign() {
        let dir = tmp("hooks");
        let hooks = dir.join("hooks");

        // First install → created.
        assert_eq!(
            install_hook(&hooks, "post-checkout", false).expect("install"),
            HookOutcome::Installed
        );
        let path = hooks.join("post-checkout");
        assert!(path.exists());
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
            assert_eq!(mode & 0o111, 0o111, "hook should be executable");
        }

        // Second install → updated (managed).
        assert_eq!(
            install_hook(&hooks, "post-checkout", false).expect("reinstall"),
            HookOutcome::Updated
        );

        // A foreign hook is left untouched.
        let foreign = hooks.join("post-merge");
        std::fs::write(&foreign, "#!/bin/sh\necho mine\n").unwrap();
        assert_eq!(
            install_hook(&hooks, "post-merge", false).expect("skip"),
            HookOutcome::SkippedForeign
        );
        assert_eq!(
            std::fs::read_to_string(&foreign).unwrap(),
            "#!/bin/sh\necho mine\n"
        );

        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn agents_created_updated_and_idempotent() {
        let dir = tmp("agents");
        let path = dir.join("AGENTS.md");

        // Created.
        assert!(ensure_agents(&path).expect("create"));
        let first = std::fs::read_to_string(&path).unwrap();
        assert!(first.contains("Roteiro knowledge graph"));

        // Idempotent: no change on re-run.
        assert!(!ensure_agents(&path).expect("noop"));
        assert_eq!(std::fs::read_to_string(&path).unwrap(), first);

        // Appends after pre-existing content, and replaces only the managed block.
        std::fs::write(&path, "# My agents\n\nHello.\n").unwrap();
        assert!(ensure_agents(&path).expect("append"));
        let merged = std::fs::read_to_string(&path).unwrap();
        assert!(merged.starts_with("# My agents"));
        assert!(merged.contains("Roteiro knowledge graph"));
        // Exactly one managed block (two markers).
        assert_eq!(merged.matches("<!-- roteiro-managed -->").count(), 2);

        // Re-running after manual edits still yields a single managed block.
        assert!(!ensure_agents(&path).expect("noop2"));
        assert_eq!(
            agents_section().matches("<!-- roteiro-managed -->").count(),
            2
        );

        std::fs::remove_dir_all(&dir).ok();
    }
}