rdar 0.6.5

radar - the repository cartographer for AI agents: compiles a repo into tiny committed MAP.md routers, with measured token benchmarks
Documentation
//! `radar export --html`: the whole knowledge base as one
//! self-contained human-readable page - GitHub-ish embedded CSS (light/dark),
//! stable anchors per scope, resolved cross-links, a static SVG graph of the
//! map topology, client-side filter - no CDN, no framework, no network.
//!
//! Maps are a radar-emitted markdown subset, so the renderer handles exactly
//! that subset and HTML-escapes everything else (XSS-safe by construction).

use std::fmt::Write as _;
use std::path::Path;

use crate::check::{self, CheckReport};

fn esc(s: &str) -> String {
    s.replace('&', "&")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

fn anchor(scope: &str) -> String {
    if scope.is_empty() {
        "root".to_string()
    } else {
        scope.replace('/', "-")
    }
}

/// Inline markdown: `code` spans + [label](link) with MAP.md links rewritten
/// to in-page anchors. Everything else escaped.
fn inline(text: &str, scope: &str) -> String {
    let mut out = String::new();
    let mut rest = text;
    while let Some(start) = rest.find('[') {
        let (head, tail) = rest.split_at(start);
        out.push_str(&code_spans(head));
        if let Some(close) = tail.find(']')
            && tail[close + 1..].starts_with('(')
            && let Some(end) = tail[close + 2..].find(')')
        {
            let label = &tail[1..close];
            let link = &tail[close + 2..close + 2 + end];
            let href = if link.ends_with("MAP.md") {
                check::resolve_link(scope, link)
                    .map(|s| format!("#{}", anchor(&s)))
                    .unwrap_or_else(|| esc(link))
            } else {
                esc(link)
            };
            let _ = write!(out, "<a href=\"{href}\">{}</a>", code_spans(label));
            rest = &tail[close + 3 + end..];
        } else {
            out.push('[');
            rest = &tail[1..];
        }
    }
    out.push_str(&code_spans(rest));
    out
}

/// Convert backtick code spans into escaped code elements.
fn code_spans(text: &str) -> String {
    let mut out = String::new();
    for (i, part) in text.split('`').enumerate() {
        if i % 2 == 1 {
            let _ = write!(out, "<code>{}</code>", esc(part));
        } else {
            out.push_str(&esc(part));
        }
    }
    out
}

/// Render a map body (radar's markdown subset: H1/H2, bullets, slot block).
fn body_html(body: &str, scope: &str) -> String {
    let mut out = String::new();
    let mut in_list = false;
    let mut in_slot = false;
    for line in body.lines() {
        if line.starts_with("<!-- radar:slot") {
            in_slot = true;
            continue;
        }
        if line.starts_with("<!-- /radar:slot") {
            in_slot = false;
            continue;
        }
        if in_slot {
            let _ = writeln!(out, "<p class=\"purpose\">{}</p>", inline(line, scope));
            continue;
        }
        if let Some(h) = line.strip_prefix("## ") {
            if in_list {
                out.push_str("</ul>\n");
                in_list = false;
            }
            let _ = writeln!(out, "<h3>{}</h3>", esc(h));
        } else if line.starts_with("# ") {
            // The H1 is replaced by the section header - skip.
        } else if let Some(item) = line.strip_prefix("- ") {
            if !in_list {
                out.push_str("<ul>\n");
                in_list = true;
            }
            let _ = writeln!(out, "<li>{}</li>", inline(item, scope));
        } else if line.trim().is_empty() {
            if in_list {
                out.push_str("</ul>\n");
                in_list = false;
            }
        } else {
            let _ = writeln!(out, "<p>{}</p>", inline(line, scope));
        }
    }
    if in_list {
        out.push_str("</ul>\n");
    }
    out
}

/// Static SVG of the ownership tree (layered tidy layout: depth → x,
/// leaf order → y) with straight edges.
fn graph_svg(report: &CheckReport) -> String {
    let maps = &report.maps;
    // parent per scope (nearest mapped ancestor).
    let parent = |scope: &str| -> Option<String> {
        let mut cur = scope.to_string();
        loop {
            let p = match cur.rfind('/') {
                Some(i) => cur[..i].to_string(),
                None if !cur.is_empty() => String::new(),
                None => return None,
            };
            if maps.contains_key(&p) {
                return Some(p);
            }
            if p.is_empty() {
                return None;
            }
            cur = p;
        }
    };
    // Leaf-order y positions via DFS.
    let mut kids: std::collections::BTreeMap<String, Vec<String>> = Default::default();
    for scope in maps.keys() {
        if !scope.is_empty()
            && let Some(p) = parent(scope)
        {
            kids.entry(p).or_default().push(scope.clone());
        }
    }
    let mut pos: std::collections::BTreeMap<String, (f64, f64)> = Default::default();
    let mut next_y = 0.0f64;
    fn place(
        scope: &str,
        depth: usize,
        kids: &std::collections::BTreeMap<String, Vec<String>>,
        pos: &mut std::collections::BTreeMap<String, (f64, f64)>,
        next_y: &mut f64,
    ) -> f64 {
        let children = kids.get(scope).cloned().unwrap_or_default();
        let y = if children.is_empty() {
            let y = *next_y;
            *next_y += 34.0;
            y
        } else {
            let ys: Vec<f64> = children
                .iter()
                .map(|c| place(c, depth + 1, kids, pos, next_y))
                .collect();
            ys.iter().sum::<f64>() / ys.len() as f64
        };
        pos.insert(scope.to_string(), (depth as f64 * 220.0 + 20.0, y + 20.0));
        y
    }
    if maps.contains_key("") {
        place("", 0, &kids, &mut pos, &mut next_y);
    }
    let height = (next_y + 60.0).max(80.0);
    let width = pos.values().map(|(x, _)| x + 220.0).fold(320.0, f64::max);
    let mut svg = format!(
        "<svg viewBox=\"0 0 {width:.0} {height:.0}\" xmlns=\"http://www.w3.org/2000/svg\" role=\"img\" aria-label=\"map topology\">"
    );
    for (scope, (x, y)) in &pos {
        if let Some(p) = parent(scope)
            && let Some((px, py)) = pos.get(&p)
        {
            let _ = write!(
                svg,
                "<path d=\"M{:.0},{:.0} C{:.0},{:.0} {:.0},{:.0} {:.0},{:.0}\" class=\"edge\"/>",
                px + 150.0,
                py + 12.0,
                px + 185.0,
                py + 12.0,
                x - 35.0,
                y + 12.0,
                *x,
                y + 12.0
            );
        }
    }
    for (scope, (x, y)) in &pos {
        let label = if scope.is_empty() { "." } else { scope };
        let stale = report.stale.get(scope).copied().unwrap_or(false);
        let _ = write!(
            svg,
            "<a href=\"#{}\"><g class=\"node{}\"><rect x=\"{x:.0}\" y=\"{y:.0}\" width=\"150\" height=\"24\" rx=\"6\"/><text x=\"{:.0}\" y=\"{:.0}\">{}</text></g></a>",
            anchor(scope),
            if stale { " stale" } else { "" },
            x + 8.0,
            y + 16.0,
            esc(label)
        );
    }
    svg.push_str("</svg>");
    svg
}

const CSS: &str = r#"
:root { --bg:#ffffff; --fg:#1f2328; --muted:#59636e; --border:#d1d9e0; --accent:#0969da; --code:#f6f8fa; --stale:#d1242f; }
@media (prefers-color-scheme: dark) {
  :root { --bg:#0d1117; --fg:#f0f6fc; --muted:#9198a1; --border:#3d444d; --accent:#4493f8; --code:#151b23; --stale:#ff7b72; }
}
* { box-sizing: border-box; }
body { margin:0 auto; max-width:920px; padding:2rem 1.5rem 4rem; background:var(--bg); color:var(--fg);
       font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; }
h1 { font-size:1.7rem; border-bottom:1px solid var(--border); padding-bottom:.4rem; }
h2 { font-size:1.25rem; margin-top:2.4rem; border-bottom:1px solid var(--border); padding-bottom:.3rem; }
h3 { font-size:1rem; margin:1.2rem 0 .3rem; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; font-size:.8rem; }
code { background:var(--code); border:1px solid var(--border); border-radius:6px; padding:.1em .35em; font:.85em ui-monospace,SFMono-Regular,Menlo,monospace; }
a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
ul { padding-left:1.4rem; margin:.3rem 0 .8rem; } li { margin:.15rem 0; }
.purpose { font-style:italic; color:var(--muted); margin:.4rem 0 1rem; }
.meta { font-size:.8rem; color:var(--muted); margin:-.2rem 0 .6rem; }
.meta .stale { color:var(--stale); font-weight:600; }
.graph { overflow-x:auto; border:1px solid var(--border); border-radius:8px; padding:.8rem; margin:1.2rem 0; }
svg { display:block; min-width:100%; }
svg rect { fill:var(--code); stroke:var(--border); }
svg .stale rect { stroke:var(--stale); }
svg text { fill:var(--fg); font:12px ui-monospace,Menlo,monospace; }
svg .edge { fill:none; stroke:var(--border); stroke-width:1.5; }
#filter { width:100%; padding:.5rem .8rem; font-size:.95rem; border:1px solid var(--border);
          border-radius:8px; background:var(--bg); color:var(--fg); margin:1rem 0; }
footer { margin-top:3rem; padding-top:1rem; border-top:1px solid var(--border); font-size:.8rem; color:var(--muted); }
"#;

const JS: &str = r#"
const input = document.getElementById('filter');
input.addEventListener('input', () => {
  const q = input.value.toLowerCase();
  document.querySelectorAll('section.map').forEach(s => {
    s.style.display = !q || s.textContent.toLowerCase().includes(q) ? '' : 'none';
  });
});
"#;

/// Render the whole knowledge base as one self-contained HTML document.
pub fn render(root: &Path, report: &CheckReport, repo_name: &str) -> String {
    let mut sections = String::new();
    for (scope, map) in &report.maps {
        let path = crate::mapfile::map_path(root, scope);
        let Ok(doc) = std::fs::read_to_string(&path) else {
            continue;
        };
        let Some((fm, body)) = crate::frontmatter::parse(&doc) else {
            continue;
        };
        let title = if scope.is_empty() { "." } else { scope };
        let stale = report.stale.get(scope).copied().unwrap_or(false);
        let _ = write!(
            sections,
            "<section class=\"map\" id=\"{}\"><h2><code>{}</code></h2>\
             <p class=\"meta\">fidelity: {} · {} · stamped {}{}</p>\n{}</section>\n",
            anchor(scope),
            esc(title),
            esc(&map.fidelity),
            esc(fm.get("tokens").unwrap_or("~?")),
            esc(fm.get("stamped").unwrap_or("?")),
            if stale {
                " · <span class=\"stale\">STALE</span>"
            } else {
                ""
            },
            body_html(body, scope)
        );
    }

    let generated = crate::mapfile::now_iso();
    format!(
        "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\
         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
         <meta name=\"generator\" content=\"radar {} - https://github.com/sanix-darker/radar\">\
         <title>{} - radar knowledge base</title><style>{CSS}</style></head><body>\n\
         <h1>{} - knowledge base</h1>\n\
         <p class=\"meta\">{} map(s) · generated {generated} by radar {}</p>\n\
         <div class=\"graph\">{}</div>\n\
         <input id=\"filter\" type=\"search\" placeholder=\"filter maps…\" aria-label=\"filter\">\n\
         {sections}\
         <footer>Generated by <strong>radar {}</strong> on {generated} - maps are routers, not truth; verify at the destination.</footer>\n\
         <script>{JS}</script></body></html>\n",
        crate::VERSION,
        esc(repo_name),
        esc(repo_name),
        report.maps.len(),
        crate::VERSION,
        graph_svg(report),
        crate::VERSION,
    )
}

/// `radar serve --web`: a GET-only localhost server for the same artifact,
/// re-rendered per request so it always reflects the current maps
/// The web app is the export, with no server-side state.
pub fn serve_web(root: &std::path::Path, addr: &str, repo_name: &str) -> std::io::Result<()> {
    use std::io::{Read, Write};
    let listener = std::net::TcpListener::bind(addr)?;
    let local = listener.local_addr()?;
    println!("radar serve --web → http://{local}/ (Ctrl-C to stop)");
    for stream in listener.incoming() {
        let Ok(mut stream) = stream else { continue };
        let mut buf = [0u8; 1024];
        let _ = stream.read(&mut buf);
        let head = String::from_utf8_lossy(&buf);
        if !head.starts_with("GET ") {
            let _ = stream.write_all(b"HTTP/1.1 405 Method Not Allowed\r\nAllow: GET\r\n\r\n");
            continue;
        }
        let cache = crate::cache::ScanCache::load(root);
        let report = crate::check::check(root, &cache);
        let html = render(root, &report, repo_name);
        let _ = write!(
            stream,
            "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\n\r\n",
            html.len()
        );
        let _ = stream.write_all(html.as_bytes());
    }
    Ok(())
}

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

    #[test]
    fn escapes_hostile_content() {
        assert_eq!(esc("<script>"), "&lt;script&gt;");
        let html = body_html("- `<img onerror=x>` [a](<bad>)\n", "");
        assert!(!html.contains("<img"), "{html}");
        assert!(html.contains("&lt;img"), "{html}");
    }

    #[test]
    fn map_links_become_anchors() {
        let html = body_html("- [jwt/](jwt/MAP.md)\n", "auth");
        assert!(html.contains("href=\"#auth-jwt\""), "{html}");
    }

    #[test]
    fn inline_code_renders() {
        let html = body_html("`sig` text\n", "");
        assert!(html.contains("<code>sig</code>"), "{html}");
    }
}