use std::fs;
use std::io;
use std::path::Path;
const HOOK_MARKER: &str = "roteiro-managed";
const AGENTS_MARKER: &str = "<!-- roteiro-managed -->";
pub const MANAGED_HOOKS: &[&str] = &["post-checkout", "post-merge", "post-commit", "pre-commit"];
#[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"
)
}
}
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",
);
#[must_use]
pub fn is_managed_hook(content: &str) -> bool {
content.contains(HOOK_MARKER)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookOutcome {
Installed,
Updated,
SkippedForeign,
}
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(())
}
#[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"
)
}
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(§ion);
s
}
};
if updated == existing {
return Ok(false);
}
fs::write(path, updated)?;
Ok(true)
}
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"
);
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"
);
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");
assert!(!s.contains("roteiro sync"));
}
#[test]
fn install_creates_updates_and_skips_foreign() {
let dir = tmp("hooks");
let hooks = dir.join("hooks");
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");
}
assert_eq!(
install_hook(&hooks, "post-checkout", false).expect("reinstall"),
HookOutcome::Updated
);
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");
assert!(ensure_agents(&path).expect("create"));
let first = std::fs::read_to_string(&path).unwrap();
assert!(first.contains("Roteiro knowledge graph"));
assert!(!ensure_agents(&path).expect("noop"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), first);
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"));
assert_eq!(merged.matches("<!-- roteiro-managed -->").count(), 2);
assert!(!ensure_agents(&path).expect("noop2"));
assert_eq!(
agents_section().matches("<!-- roteiro-managed -->").count(),
2
);
std::fs::remove_dir_all(&dir).ok();
}
}