rdar 0.6.4

radar - the repository cartographer for AI agents: compiles a repo into tiny committed MAP.md routers, with measured token benchmarks
Documentation
//! `radar init` installs the consumption kit: the
//! MAPs→source navigation contract into AGENTS.md (and CLAUDE.md when
//! present; README.md as the fallback so the contract always lands), plus a
//! repo-scoped Claude and Codex skills, and a radar.toml stub of honored
//! keys only.

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

const BEGIN: &str = "<!-- radar:begin navigation -->";
const END: &str = "<!-- radar:end navigation -->";

/// The navigation contract block (idempotent: replaced between markers).
/// Wording is research-backed - parallel batching, cheap peeks, the escape
/// hatch, and source-verification each exist because a measured failure or
/// published ablation demanded them (see BENCHMARKS.md history).
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### Root map (embedded - no need to read ./MAP.md; radar keeps this fresh)\n\n{}\n",
                body.trim()
            )
        }
        None => String::new(),
    };
    format!(
        "{BEGIN}\n\
## Navigation: MAPs route, source answers\n\
\n\
Radar maps guide code, symbol, implementation, and orientation search:\n\
\n\
0. Before grep/rg/find/globs/tree, run `radar query \"<task>\"`. `FINAL SOURCE\n\
   ANCHOR`/`FINAL REPOSITORY OVERVIEW` is current; copy/stop unless source detail\n\
   is needed. Zero model calls.\n\
1. Miss: `.radar/ROUTES.md` (`[ok]` verified, `[stale]` hint); record solved\n\
   misses with `radar route add \"<task>\" <file#symbol>`.\n\
2. Exact name: search its `.radar/SYMBOLS.md` tab row, not the whole file.\n\
3. Else use embedded root routes; open matching glue anchors.\n\
4. Batch map/source reads; peek MAP frontmatter with limit 16.\n\
5. Before API scans, state expected signature; verify fallback anchors;\n\
   stale/missing maps -> text search, then refresh.\n\
6. MAP conflict: take either side, run `radar map`, commit.\n\
{embedded}{END}\n"
    )
}

/// Re-embed the current root-map body into every contract file carrying our
/// markers (called after map/refresh so the zero-hop copy stays fresh).
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 code search; stop on finals; verify fallbacks.
---

1. Before grep/rg/find/globs/tree, run `radar query "<task>" --path .`.
2. `FINAL SOURCE ANCHOR`/`FINAL REPOSITORY OVERVIEW` stops unless source is needed.
3. Miss: `.radar/ROUTES.md` -> exact `.radar/SYMBOLS.md` tab row (not whole file) -> root `MAP.md`. Batch reads; peek MAP frontmatter only. State expected signatures, verify anchors, refresh stale maps, and record with `radar route add "<task>" <file#symbol>`.
"#;

/// Result: which files were touched.
#[derive(Debug, Default)]
pub struct InitReport {
    pub touched: Vec<String>,
}

/// Insert or replace the marked block in `text`.
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();

    // 1. The contract: AGENTS.md (+ CLAUDE.md if present), else README.md.
    // Owner directive (§10): AGENTS.md and CLAUDE.md are patched when they
    // exist; when NEITHER exists the contract lands in README.md (created
    // on truly bare repos). radar never invents an AGENTS.md.
    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(),
        );
    }

    // 2. Repo-scoped skills in the native Claude and Codex discovery paths.
    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"));
    }

    // 3. radar.toml - honored keys only, created once, never overwritten.
    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("MAPs route, source answers"));
        assert!(readme.contains("radar query \"<task>\""));
        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("not the whole file"), "{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 code search",
                "Before grep/rg/find/globs/tree",
                "FINAL SOURCE ANCHOR",
                "FINAL REPOSITORY OVERVIEW",
                ".radar/ROUTES.md",
                ".radar/SYMBOLS.md",
                "root `MAP.md`",
                "Batch reads",
                "peek MAP frontmatter only",
                "expected signatures",
                "verify anchors",
                "refresh stale maps",
                "radar route add",
                "tab row (not whole file)",
            ] {
                assert!(text.contains(required), "missing {required}");
            }
            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);
    }
}