use std::io;
use std::path::Path;
const BEGIN: &str = "<!-- radar:begin navigation -->";
const END: &str = "<!-- radar:end navigation -->";
pub fn compact_embedded_root_map(body: &str) -> String {
let mut out = Vec::new();
let mut in_purpose = false;
let mut in_routes = false;
let mut saw_routes = false;
for raw in body.lines() {
let line = raw.trim_end();
let trimmed = line.trim();
if line.starts_with("# ") {
out.push(line.to_string());
continue;
}
if trimmed.starts_with("<!-- radar:slot purpose") {
in_purpose = true;
continue;
}
if trimmed.starts_with("<!-- /radar:slot") {
in_purpose = false;
continue;
}
if in_purpose {
if !trimmed.is_empty() {
out.push(line.to_string());
}
continue;
}
if line == "## Routes" {
if out.last().is_some_and(|last| !last.is_empty()) {
out.push(String::new());
}
out.push(line.to_string());
in_routes = true;
saw_routes = true;
continue;
}
if in_routes {
if line.starts_with("## ") {
break;
}
out.push(line.to_string());
}
}
if saw_routes {
out.join("\n").trim().to_string()
} else {
body.trim().to_string()
}
}
fn contract_block(root_map_body: Option<&str>) -> String {
let embedded = match root_map_body {
Some(body) => {
let body = compact_embedded_root_map(body);
format!(
"\n### Embedded root map (fresh; do not read ./MAP.md)\n\n{}\n",
body.trim()
)
}
None => String::new(),
};
format!(
"{BEGIN}\n\
## Radar navigation\n\
\n\
0. Before grep/rg/find/globs/tree, run `radar query \"<task>\"`; add `--source` only\n\
for behavior questions starting \"In NAME,\". `FINAL SOURCE ANCHOR`/\n\
`FINAL REPOSITORY OVERVIEW` is current; copy/stop. Zero model calls.\n\
1. Root miss: `radar route find \"<task>\"` (`ok`/`auto` verify; `stale`\n\
hints); solved: `radar route add \"<task>\" <file#symbol>`.\n\
2. Exact name: one `.radar/SYMBOLS.md` tab row.\n\
3. Else use embedded root routes/glue anchors.\n\
4. Batch reads; peek MAP frontmatter, limit 16.\n\
5. Before API scans, expect signature; verify fallback anchors;\n\
stale/missing maps -> text search, refresh.\n\
6. MAP conflict: take either side, run `radar map`, commit.\n\
{embedded}{END}\n"
)
}
pub fn sync_embedded_root_map(root: &Path) {
let body = std::fs::read_to_string(root.join("MAP.md"))
.ok()
.and_then(|doc| crate::frontmatter::parse(&doc).map(|(_, b)| b.trim().to_string()));
for name in ["AGENTS.md", "CLAUDE.md", "README.md"] {
let path = root.join(name);
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
if !text.contains(BEGIN) {
continue;
}
let updated = upsert_block_with(&text, body.as_deref());
if updated != text {
let _ = std::fs::write(&path, updated);
}
}
}
const SKILL: &str = r#"---
name: radar-navigation
description: Query Radar before search; stop on finals; verify fallbacks.
---
1. Before grep/rg/find/globs/tree, run `radar query "<task>" --path .`; add `--source` only for behavior questions starting "In NAME,".
2. Stop on `FINAL SOURCE ANCHOR`/`FINAL REPOSITORY OVERVIEW`.
3. Root miss: `radar route find "<task>"` (`ok`/`auto` verify; `stale` hints) -> exact `.radar/SYMBOLS.md` tab row -> `./MAP.md`. Batch reads; peek frontmatter. Expect signatures; verify anchors; refresh stale maps; record via `radar route add "<task>" <file#symbol>`.
"#;
#[derive(Debug, Default)]
pub struct InitReport {
pub touched: Vec<String>,
}
fn upsert_block_with(text: &str, root_map_body: Option<&str>) -> String {
if let (Some(start), Some(end)) = (text.find(BEGIN), text.find(END))
&& end >= start
{
let mut out = String::new();
out.push_str(&text[..start]);
out.push_str(&contract_block(root_map_body));
out.push_str(text[end + END.len()..].trim_start_matches('\n'));
out
} else {
let mut out = text.to_string();
if !out.is_empty() && !out.ends_with("\n\n") {
out.push_str(if out.ends_with('\n') { "\n" } else { "\n\n" });
}
out.push_str(&contract_block(root_map_body));
out
}
}
pub fn init(root: &Path) -> io::Result<InitReport> {
let mut report = InitReport::default();
let mut targets: Vec<std::path::PathBuf> = ["AGENTS.md", "CLAUDE.md"]
.iter()
.map(|f| root.join(f))
.filter(|p| p.exists())
.collect();
if targets.is_empty() {
targets.push(root.join("README.md"));
}
let root_body = std::fs::read_to_string(root.join("MAP.md"))
.ok()
.and_then(|doc| crate::frontmatter::parse(&doc).map(|(_, b)| b.trim().to_string()));
for target in targets {
let existing = std::fs::read_to_string(&target).unwrap_or_default();
let updated = upsert_block_with(&existing, root_body.as_deref());
if updated != existing {
std::fs::write(&target, updated)?;
}
report.touched.push(
target
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
);
}
for skill_root in [".claude/skills", ".agents/skills"] {
let skill_dir = root.join(skill_root).join("radar-navigation");
std::fs::create_dir_all(&skill_dir)?;
let skill_path = skill_dir.join("SKILL.md");
if std::fs::read_to_string(&skill_path).ok().as_deref() != Some(SKILL) {
std::fs::write(&skill_path, SKILL)?;
}
report
.touched
.push(format!("{skill_root}/radar-navigation/SKILL.md"));
}
let toml = root.join("radar.toml");
if !toml.exists() {
std::fs::write(
&toml,
"# radar config: honored keys only.\n\n[agent]\n# cmd = \"claude\" # optional radar agent command\n\n[routes]\n# enabled = true # route cache\n# auto = true # deterministic route seeding\n",
)?;
report.touched.push("radar.toml".into());
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("radar-init-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
dir
}
#[test]
fn bare_repo_gets_readme_fallback() {
let dir = tmp("bare");
let report = init(&dir).expect("init");
assert!(report.touched.contains(&"README.md".to_string()));
let readme = std::fs::read_to_string(dir.join("README.md")).expect("read");
assert!(readme.contains(BEGIN) && readme.contains("## Radar navigation"));
for required in [
"Before grep/rg/find/globs/tree",
"add `--source` only",
"behavior questions starting",
"FINAL SOURCE ANCHOR",
"FINAL REPOSITORY OVERVIEW",
"radar route find",
".radar/SYMBOLS.md",
"embedded root routes",
"Batch reads",
"limit 16",
"expect signature",
"verify fallback anchors",
"text search, refresh",
"radar route add",
"radar map",
] {
assert!(readme.contains(required), "missing {required}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn embedded_root_map_keeps_routes_not_api_jump() {
let block = contract_block(Some(
"# .\n\n<!-- radar:slot purpose max=160 -->\nPurpose text.\n<!-- /radar:slot -->\n\n## Routes\n- src/MAP.md\n\n## API\n`src/lib.rs`\n- `pub fn noisy()`\n\n## Jump\n- `noisy` used by src/main.rs\n",
));
assert!(block.contains("Purpose text."));
assert!(block.contains("## Routes\n- src/MAP.md"));
assert!(!block.contains("## API"));
assert!(!block.contains("## Jump"));
assert!(!block.contains("radar:slot purpose"));
}
#[test]
fn agents_and_claude_both_patched_and_idempotent() {
let dir = tmp("both");
std::fs::write(dir.join("AGENTS.md"), "# Dev guide\n\nExisting rules.\n").unwrap();
std::fs::write(dir.join("CLAUDE.md"), "# Claude notes\n").unwrap();
init(&dir).expect("init");
init(&dir).expect("init twice");
for f in ["AGENTS.md", "CLAUDE.md"] {
let text = std::fs::read_to_string(dir.join(f)).expect("read");
assert_eq!(text.matches(BEGIN).count(), 1, "{f}: exactly one block");
assert!(text.contains("one `.radar/SYMBOLS.md` tab row"), "{f}");
assert!(
text.contains("Existing rules.") || f == "CLAUDE.md",
"{f} preserved"
);
}
for skill in [
".claude/skills/radar-navigation/SKILL.md",
".agents/skills/radar-navigation/SKILL.md",
] {
let text = std::fs::read_to_string(dir.join(skill)).expect("read skill");
for required in [
"Query Radar before search",
"Before grep/rg/find/globs/tree",
"FINAL SOURCE ANCHOR",
"FINAL REPOSITORY OVERVIEW",
"radar route find",
"`ok`/`auto` verify; `stale` hints",
".radar/SYMBOLS.md",
"`./MAP.md`",
"Batch reads",
"peek frontmatter",
"Expect signatures",
"verify anchors",
"refresh stale maps",
"radar route add",
"exact `.radar/SYMBOLS.md` tab row",
] {
assert!(text.contains(required), "missing {required}");
}
assert!(!text.contains(".radar/ROUTES.md"));
assert_eq!(text, SKILL);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn block_updates_in_place() {
let old = format!("intro\n\n{BEGIN}\nOLD CONTENT\n{END}\noutro\n");
let updated = upsert_block_with(&old, Some("# ROOT BODY"));
assert!(!updated.contains("OLD CONTENT"));
assert!(updated.starts_with("intro"));
assert!(updated.trim_end().ends_with("outro"));
assert_eq!(updated.matches(BEGIN).count(), 1);
}
#[test]
fn radar_toml_created_once_never_clobbered() {
let dir = tmp("toml");
init(&dir).expect("init");
std::fs::write(dir.join("radar.toml"), "[agent]\ncmd = \"my-agent\"\n").unwrap();
init(&dir).expect("init again");
let text = std::fs::read_to_string(dir.join("radar.toml")).expect("read");
assert!(text.contains("my-agent"), "user config preserved");
let _ = std::fs::remove_dir_all(&dir);
}
}