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"];
#[must_use]
pub fn hook_script() -> String {
format!(
"#!/bin/sh\n\
# {HOOK_MARKER}: keep the Roteiro knowledge graph fresh after HEAD changes.\n\
# Delete this file to disable. Re-run `roteiro init` to reinstall.\n\
command -v roteiro >/dev/null 2>&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) -> 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())?;
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.\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();
assert!(is_managed_hook(&s));
assert!(s.starts_with("#!/bin/sh"));
assert!(s.contains("command -v roteiro"));
assert!(!is_managed_hook("#!/bin/sh\necho other\n"));
}
#[test]
fn install_creates_updates_and_skips_foreign() {
let dir = tmp("hooks");
let hooks = dir.join("hooks");
assert_eq!(
install_hook(&hooks, "post-checkout").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").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").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();
}
}