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, okf: 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" {
return 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"
);
}
let mut body = if fetch {
FETCH_REFRESH.to_owned()
} else {
"# 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"
.to_owned()
};
if okf {
body.push_str(&okf_refresh());
}
format!("{header}{body}")
}
fn okf_refresh() -> String {
format!(
"# Regenerate the local OKF bundle (a build-output; gitignore it) to match.\n\
if command -v roteiro >/dev/null 2>&1; then\n\
\troteiro render okf >/dev/null || echo 'roteiro: could not refresh the \
OKF bundle in {dir}/ — run `roteiro render okf` to see why' >&2\n\
fi\n",
dir = crate::BUNDLE_DIR
)
}
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",
"# A flag records success rather than `exit`ing, so any step appended below\n",
"# (e.g. --okf's render) still runs.\n",
"loaded=0\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\tloaded=1\n",
"\tfi\n",
"\t[ -n \"$tmp\" ] && rm -f \"$tmp\"\n",
"fi\n",
"# Rebuild locally only if the fast path didn't load a matching artifact.\n",
"[ \"$loaded\" = 1 ] || 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,
okf: 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, okf))?;
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, authored\n\
intent (ADRs, blueprints) and their links in one provenance-tagged store\n\
(every fact labelled `derived` | `authored` | `inferred`). Prefer querying\n\
it over grepping when orienting.\n\
\n\
**Find, then explain:**\n\
\n\
- `roteiro search \"<text>\"` — ranked text search (names, keys, paths and\n\
captured prose); curated ADRs/blueprints rank first. The offline entry point\n\
for \"what/why\" questions — then `query` a returned key. (`roteiro serve\n\
--models` exposes the same as a `search` tool + an OpenAI `/v1` endpoint;\n\
MCP agents get `search`/`explain`/`path`/`debt` directly.)\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>` — list nodes of a kind (`fn`, `adr`, …).\n\
- `roteiro context <key>` — a node's callers, callees and governing ADRs.\n\
- `roteiro path <a> <b>` · `roteiro debt` — connections and intent-debt.\n\
\n\
**Plan a change:** `roteiro spec context <topic>` → `spec scaffold … --kind adr`\n\
→ `spec draft <file>` (the first two need no model), then `roteiro check`.\n\
\n\
**Before finishing a change:** run `roteiro review [--json]` (a graph-grounded\n\
review of your change — callers/callees, governing ADRs, drift and blast\n\
radius) and `roteiro check` (fails on ADR/annotation drift; a managed\n\
`pre-commit` hook enforces it too, `git commit --no-verify` skips). `roteiro\n\
sync` refreshes the graph (git hooks do this automatically).\n\
\n\
For the full operational guide, see the installed skill at\n\
`.agents/skills/roteiro/SKILL.md`.\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()))
}
const SKILL_SUBPATH: [&str; 3] = ["skills", "roteiro", "SKILL.md"];
#[must_use]
pub fn skill_markdown() -> &'static str {
include_str!("../assets/skill/SKILL.md")
}
#[must_use]
pub fn is_managed_skill(content: &str) -> bool {
content.contains(HOOK_MARKER)
}
#[must_use]
pub fn skill_path(base_dir: &Path) -> std::path::PathBuf {
SKILL_SUBPATH
.iter()
.fold(base_dir.to_path_buf(), |p, seg| p.join(seg))
}
pub fn install_skill(base_dir: &Path) -> io::Result<HookOutcome> {
let path = skill_path(base_dir);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let outcome = match fs::read_to_string(&path) {
Ok(existing) if is_managed_skill(&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, skill_markdown())?;
Ok(outcome)
}
#[cfg(test)]
mod tests {
use super::{
HookOutcome, agents_section, ensure_agents, hook_script, install_hook, install_skill,
is_managed_hook, is_managed_skill, skill_markdown, skill_path,
};
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, 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!(!s.contains("render okf"), "bundle render is opt-in");
assert!(!is_managed_hook("#!/bin/sh\necho other\n"));
}
#[test]
fn okf_flag_appends_bundle_render_to_freshness_hooks_only() {
let fresh = hook_script("post-merge", false, true);
assert!(
fresh.contains("\troteiro render okf >"),
"freshness hook regenerates the bundle with --okf: {fresh}"
);
assert!(
fresh.contains("roteiro sync --committed"),
"still syncs first"
);
assert!(
!fresh.contains("render okf >/dev/null 2>&1"),
"the render's stderr must reach the user: {fresh}"
);
assert!(
fresh.contains("could not refresh the OKF bundle in okf/ —"),
"a failing render must name itself: {fresh}"
);
let fetch_okf = hook_script("post-checkout", true, true);
assert!(
fetch_okf.contains("\troteiro render okf >"),
"bundle render is present with --fetch --okf: {fetch_okf}"
);
assert!(
fetch_okf.contains("loaded=1"),
"fetch success records a flag, not an early exit"
);
assert!(
!fetch_okf.contains("; exit 0"),
"no early `exit 0` that would short-circuit the appended bundle render"
);
assert!(!hook_script("pre-commit", false, true).contains("render okf"));
}
#[test]
fn every_roteiro_command_a_hook_runs_is_one_the_cli_accepts() {
use clap::Parser as _;
let mut checked = 0usize;
for name in super::MANAGED_HOOKS {
for fetch in [false, true] {
for okf in [false, true] {
let script = hook_script(name, fetch, okf);
for argv in roteiro_invocations(&script) {
let shown = argv.join(" ");
let parsed = crate::Cli::try_parse_from(
std::iter::once("roteiro".to_owned()).chain(argv.iter().cloned()),
);
assert!(
parsed.is_ok(),
"hook `{name}` (fetch={fetch}, okf={okf}) runs `roteiro {shown}`, \
which this CLI does not accept: {:?}",
parsed.err().map(|e| e.to_string())
);
if argv.first().map(String::as_str) == Some("render") {
let target = argv.get(1).map(String::as_str).unwrap_or_default();
assert!(
rto_render::Target::parse(target).is_some(),
"hook `{name}` renders target `{target}`, which is not a \
render target this build has"
);
}
checked += 1;
}
}
}
}
assert!(
checked >= 8,
"the scripts must contain roteiro invocations to check; found {checked}"
);
}
fn roteiro_invocations(script: &str) -> Vec<Vec<String>> {
let mut out = Vec::new();
for line in script.lines() {
let line = line.trim_start_matches(['\t', ' ']);
if line.starts_with('#') {
continue;
}
for part in line
.split("&&")
.flat_map(|p| p.split("||"))
.flat_map(|p| p.split(';'))
.flat_map(|p| p.split('|'))
{
let part = part.trim();
let part = part
.strip_prefix("if ")
.or_else(|| part.strip_prefix("then "))
.unwrap_or(part)
.trim();
let Some(rest) = part.strip_prefix("roteiro ") else {
continue;
};
let argv: Vec<String> = rest
.split_whitespace()
.take_while(|t| !t.starts_with('>') && !t.starts_with("2>"))
.map(|t| t.trim_matches('"').to_owned())
.collect();
if !argv.is_empty() {
out.push(argv);
}
}
}
out
}
#[test]
fn every_generated_hook_is_valid_posix_shell() {
let dir = tmp("shell-syntax");
for name in super::MANAGED_HOOKS {
for fetch in [false, true] {
for okf in [false, true] {
let script = hook_script(name, fetch, okf);
let path = dir.join(format!("{name}-{fetch}-{okf}.sh"));
std::fs::write(&path, &script).expect("write");
let out = std::process::Command::new("sh")
.arg("-n")
.arg(&path)
.output()
.expect("run sh -n");
assert!(
out.status.success(),
"hook `{name}` (fetch={fetch}, okf={okf}) is not valid shell: {}\n{script}",
String::from_utf8_lossy(&out.stderr)
);
}
}
}
}
#[test]
fn the_default_bundle_directory_is_ignored_here() {
let ignore = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(".gitignore"),
)
.expect("the repository has a .gitignore");
let entry = format!("/{}/", crate::BUNDLE_DIR);
assert!(
ignore.lines().any(|l| l.trim() == entry),
"`.gitignore` must carry `{entry}` — the directory `render okf` writes \
without `--out`"
);
}
#[test]
fn fetch_hook_tries_artifact_then_falls_back_to_sync() {
let s = hook_script("post-merge", true, false);
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, true),
hook_script("pre-commit", false, false)
);
}
#[test]
fn pre_commit_hook_gates_on_check_and_is_skippable() {
let s = hook_script("pre-commit", false, 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, 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, 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, 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();
}
#[test]
fn agents_section_points_at_search_and_the_skill() {
let s = agents_section();
assert!(
s.contains("`search`"),
"search is the find-then-explain entry"
);
assert!(s.contains("roteiro review"), "review is still called out");
assert!(
s.contains(".agents/skills/roteiro/SKILL.md"),
"points at the installed skill for depth"
);
}
const IGNORE_FILE_DIRECTIVE: &str = concat!("roteiro", ":ignore-file");
fn frontmatter(md: &str) -> Option<&str> {
let rest = md.strip_prefix("---\n")?;
let end = rest.find("\n---\n")?;
Some(&rest[..end])
}
#[test]
fn skill_is_managed_and_a_valid_skill_document() {
let md = skill_markdown();
assert!(is_managed_skill(md), "skill carries the managed marker");
assert!(!is_managed_skill("---\nname: other\n---\n"));
let fm = frontmatter(md).unwrap_or_else(|| {
panic!(
"SKILL.md must *begin* with its YAML frontmatter: byte 0 must be the \
opening `---` line, and the block must close on a later `---` line \
before any other content. Nothing may precede the opening delimiter \
— not an HTML comment, not a blank line. A loader anchors its match \
at the start of the file and reports the frontmatter *missing* even \
when it is present and valid further down. Move the prose below the \
closing `---`; both markers this file carries are found by \
whole-document search, so neither needs to be first. The document \
currently begins:\n{}",
md.lines().take(3).collect::<Vec<_>>().join("\n"),
)
});
assert!(
fm.contains("name: roteiro"),
"has a skill name, inside the frontmatter block"
);
assert!(
fm.contains("description:"),
"has a description for relevance, inside the frontmatter block"
);
assert!(
md.contains(IGNORE_FILE_DIRECTIVE),
"the skill enumerates the intent-debt vocabulary, so it must keep the \
whole-file opt-out or it registers as debt itself"
);
assert!(md.contains("search"), "covers the search entry point");
assert!(md.contains("provenance"), "covers the provenance model");
assert!(md.contains("roteiro spec"), "covers the plan workflow");
assert!(
md.contains("## Proving a negative"),
"the template must teach that `grep` cannot establish absence"
);
assert!(
md.contains("Never assert absence from `grep` alone"),
"the companion rule-of-thumb bullet points at that section"
);
}
#[test]
fn frontmatter_is_recognised_only_when_it_is_first() {
let body = "name: roteiro\ndescription: d";
let good = format!("---\n{body}\n---\n\n# Heading\n");
assert_eq!(frontmatter(&good), Some(body), "block at byte 0 is read");
let shipped = format!("<!-- roteiro-managed -->\n<!-- x -->\n---\n{body}\n---\n");
assert!(
shipped.contains("name: roteiro") && shipped.contains("description:"),
"the shipped shape satisfies every needle the old guard checked"
);
assert_eq!(
frontmatter(&shipped),
None,
"two comments above the block mean the document has no frontmatter"
);
assert_eq!(frontmatter(&format!("\n---\n{body}\n---\n")), None);
assert_eq!(frontmatter("---\nname: roteiro\n"), None);
}
fn repo_root() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("repo root is two levels above crates/roteiro")
.to_path_buf()
}
#[test]
fn committed_skill_artifacts_match_the_template() {
const COPIES: &[&str] = &[
".agents/skills/roteiro/SKILL.md",
".github/skills/roteiro/SKILL.md",
];
let root = repo_root();
for rel in COPIES {
let Ok(committed) = std::fs::read_to_string(root.join(rel)) else {
continue;
};
let template = skill_markdown();
if committed == template {
continue;
}
let (got, want): (Vec<_>, Vec<_>) = (
committed.split('\n').collect(),
template.split('\n').collect(),
);
let n = got
.iter()
.zip(&want)
.position(|(a, b)| a != b)
.unwrap_or_else(|| got.len().min(want.len()));
panic!(
"{rel} has diverged from crates/roteiro/assets/skill/SKILL.md at line {}:\n \
committed: {:?}\n template: {:?}\n\
The template is written verbatim over this file by `roteiro init`, so an \
edit made here is deleted on the next run — silently, with no conflict. \
Move the change into crates/roteiro/assets/skill/SKILL.md and re-run \
`roteiro init` to regenerate both copies. If the two sides above read \
identically, compare them as printed: a trailing `\\r`, or a trailing \
empty segment, is a line-ending divergence rather than a content edit — \
there is nothing to move, and normalising this file to LF is the fix.",
n + 1,
got.get(n),
want.get(n),
);
}
}
#[test]
fn install_skill_creates_updates_and_skips_foreign() {
let dir = tmp("skill");
let base = dir.join(".agents");
let path = skill_path(&base);
assert_eq!(
install_skill(&base).expect("install"),
HookOutcome::Installed
);
assert!(path.exists());
assert!(path.ends_with("skills/roteiro/SKILL.md"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), skill_markdown());
assert_eq!(
install_skill(&base).expect("reinstall"),
HookOutcome::Updated
);
std::fs::write(&path, "# my own skill\n").unwrap();
assert_eq!(
install_skill(&base).expect("skip"),
HookOutcome::SkippedForeign
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "# my own skill\n");
std::fs::remove_dir_all(&dir).ok();
}
}