rdar 0.6.7

radar - the repository cartographer for AI agents: compiles a repo into tiny committed MAP.md routers, with measured token benchmarks
Documentation
//! Semantic slots: radar computes facts, the host agent fills
//! the ~25-word semantics. `slots` lists pending work with tiny context
//! packets; `fill` validates and stamps.

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

use serde::Serialize;

use crate::check;
use crate::frontmatter;
use crate::mapfile;

/// Purpose sentences are capped here (map budget discipline).
pub const PURPOSE_MAX_CHARS: usize = 160;

#[derive(Debug, Serialize)]
pub struct SlotPacket {
    /// Map path relative to the repo root.
    pub map: String,
    pub scope: String,
    pub slot: &'static str,
    pub reason: &'static str,
    pub constraints: Constraints,
    pub context: Context,
}

#[derive(Debug, Serialize)]
pub struct Constraints {
    pub max_chars: usize,
    pub style: &'static str,
}

#[derive(Debug, Serialize)]
pub struct Context {
    pub top_signatures: Vec<String>,
    pub children: Vec<String>,
    pub tests: Vec<String>,
}

const STYLE: &str =
    "one present-tense sentence saying what this unit does/owns; no 'Contains', lists, markdown";

/// Discover pending purpose slots (fallback text still present).
pub fn pending(root: &Path) -> Vec<SlotPacket> {
    let maps = check::load_maps(root);
    let mut out = Vec::new();
    for (scope, map) in &maps {
        if map.slot_filled {
            continue;
        }
        let path = mapfile::map_path(root, scope);
        let Ok(doc) = std::fs::read_to_string(&path) else {
            continue;
        };
        let Some((fm, body)) = frontmatter::parse(&doc) else {
            continue;
        };
        // Top signatures straight from the rendered API section ONLY
        // (already ranked + budget-packed - the packet stays ≤ ~600 tokens;
        // review fix: an unscoped line filter also swept up Tests rows).
        let top_signatures: Vec<String> = body
            .lines()
            .skip_while(|l| *l != "## API")
            .skip(1)
            .take_while(|l| !l.starts_with("## "))
            .filter_map(|line| {
                let signature = line.trim_start().strip_prefix("- ")?;
                let signature = signature.trim_matches('`');
                if signature.starts_with("also:") || signature.starts_with('+') {
                    None
                } else {
                    Some(signature.to_string())
                }
            })
            .take(10)
            .collect();
        // GraphRAG-style rollup context: carry the child PURPOSES, not just
        // links, so a parent sentence can summarize its children.
        let children: Vec<String> = fm
            .get_list("children")
            .unwrap_or_default()
            .iter()
            .map(|link| {
                let child_scope = crate::check::resolve_link(scope, link).unwrap_or_default();
                let child_path = mapfile::map_path(root, &child_scope);
                let purpose = std::fs::read_to_string(&child_path)
                    .ok()
                    .and_then(|d| frontmatter::parse(&d).map(|(_, b)| b.to_string()))
                    .and_then(|b| mapfile::slot_text(&b, "purpose"));
                match purpose {
                    Some(p) => format!("{link} - {p}"),
                    None => link.clone(),
                }
            })
            .collect();
        let tests: Vec<String> = body
            .lines()
            .skip_while(|l| *l != "## Tests")
            .skip(1)
            .take_while(|l| !l.starts_with("## "))
            .filter(|l| l.starts_with("- "))
            .map(|l| l.trim_start_matches("- ").trim_matches('`').to_string())
            .collect();
        let map_rel = if scope.is_empty() {
            "MAP.md".to_string()
        } else {
            format!("{scope}/MAP.md")
        };
        out.push(SlotPacket {
            map: map_rel,
            scope: if scope.is_empty() {
                ".".into()
            } else {
                scope.clone()
            },
            slot: "purpose",
            reason: "new",
            constraints: Constraints {
                max_chars: PURPOSE_MAX_CHARS,
                style: STYLE,
            },
            context: Context {
                top_signatures,
                children,
                tests,
            },
        });
    }
    out
}

/// Render one packet as a self-contained micro-prompt for any LLM.
pub fn render_prompt(p: &SlotPacket) -> String {
    let mut s = format!(
        "Purpose for `{}` MAP.\n\
         Max {} chars; {}.\n",
        p.scope, p.constraints.max_chars, p.constraints.style
    );
    if !p.context.top_signatures.is_empty() {
        s.push_str("API:\n");
        for sig in &p.context.top_signatures {
            s.push_str(&format!("- {sig}\n"));
        }
    }
    if !p.context.children.is_empty() {
        s.push_str(&format!("Children: {}\n", p.context.children.join(", ")));
    }
    if !p.context.tests.is_empty() {
        s.push_str(&format!("Tests: {}\n", p.context.tests.join(", ")));
    }
    s.push_str("Return only the sentence.\n");
    s
}

/// Validate a proposed slot value. Returns the normalized sentence.
pub fn validate(text: &str) -> Result<String, String> {
    let collapsed: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
    if collapsed.is_empty() {
        return Err("empty slot text".into());
    }
    if collapsed.chars().count() > PURPOSE_MAX_CHARS {
        return Err(format!(
            "slot text is {} chars (max {PURPOSE_MAX_CHARS})",
            collapsed.chars().count()
        ));
    }
    if collapsed.contains('#') || collapsed.contains("```") {
        return Err("no markdown in slot text".into());
    }
    if collapsed.to_lowercase().contains("todo") {
        return Err("no placeholders in slot text".into());
    }
    Ok(collapsed)
}

/// Write a validated sentence into a map's purpose slot, refreshing
/// tokens/stamped in the frontmatter.
pub fn fill(root: &Path, map_rel: &str, text: &str) -> io::Result<()> {
    let sentence = validate(text).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
    let path = root.join(map_rel);
    let doc = std::fs::read_to_string(&path)?;
    let Some((mut fm, body)) = frontmatter::parse(&doc) else {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("{map_rel} has no radar frontmatter"),
        ));
    };
    let open_tag = "<!-- radar:slot purpose";
    let Some(start) = body.find(open_tag) else {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("{map_rel} has no purpose slot"),
        ));
    };
    let after_open = body[start..]
        .find("-->")
        .map(|i| start + i + 3)
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "malformed slot marker"))?;
    let close = body[after_open..]
        .find("<!-- /radar:slot -->")
        .map(|i| after_open + i)
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "unterminated slot"))?;

    let new_body = format!("{}\n{}\n{}", &body[..after_open], sentence, &body[close..]);
    fm.set(
        "tokens",
        format!("~{}", mapfile::approx_tokens(new_body.len())),
    );
    fm.set("stamped", mapfile::now_iso());
    std::fs::write(&path, fm.render() + &new_body)?;
    crate::initcmd::sync_embedded_root_map(root);
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validate_normalizes_and_rejects() {
        assert_eq!(
            validate("  Verifies   tokens.  ").unwrap(),
            "Verifies tokens."
        );
        assert!(validate("").is_err());
        assert!(validate(&"x".repeat(200)).is_err());
        assert!(validate("# heading").is_err());
        assert!(validate("TODO write me").is_err());
    }

    #[test]
    fn prompt_keeps_constraints_and_context_compactly() {
        let packet = SlotPacket {
            map: "MAP.md".into(),
            scope: ".".into(),
            slot: "purpose",
            reason: "new",
            constraints: Constraints {
                max_chars: PURPOSE_MAX_CHARS,
                style: STYLE,
            },
            context: Context {
                top_signatures: vec!["pub fn run()".into()],
                children: vec!["src/MAP.md - Implements source routing.".into()],
                tests: vec!["tests/cli_smoke.rs".into()],
            },
        };
        let prompt = render_prompt(&packet);
        assert!(prompt.contains("Purpose for `.` MAP."));
        assert!(prompt.contains("Max 160 chars; one present-tense sentence"));
        assert!(prompt.contains("API:\n- pub fn run()"));
        assert!(prompt.contains("Children: src/MAP.md"));
        assert!(prompt.contains("Tests: tests/cli_smoke.rs"));
        assert!(prompt.contains("Return only the sentence."));
    }
}