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 tree` - bash-`tree`-style topology view of the maps:
//! bare minimum per node - scope, fidelity, ~tokens, staleness marker.

use std::collections::BTreeMap;

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

pub struct TreeOpts {
    pub ascii: bool,
    pub depth: Option<usize>,
}

/// Render the topology. Staleness markers come from the check report.
pub fn render(report: &CheckReport, opts: &TreeOpts) -> String {
    let maps = &report.maps;
    if maps.is_empty() {
        return "no maps - run `radar map` first\n".to_string();
    }
    // scope → direct child scopes (by nearest-ancestor relation).
    let mut kids: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
    for scope in maps.keys() {
        if scope.is_empty() {
            continue;
        }
        let parent = nearest_ancestor(maps, scope).unwrap_or("");
        kids.entry(parent).or_default().push(scope);
    }

    let mut out = String::new();
    let name_width = maps
        .keys()
        .map(|s| display_name(s, maps).len() + s.matches('/').count() * 4)
        .max()
        .unwrap_or(8)
        .max(8);
    render_node(
        report, &kids, "", "", true, true, opts, name_width, 0, &mut out,
    );
    out
}

fn display_name<'a>(scope: &'a str, maps: &BTreeMap<String, DiskMap>) -> &'a str {
    if scope.is_empty() {
        return ".";
    }
    // Show the path segment relative to the parent map's scope.
    match nearest_ancestor(maps, scope) {
        Some(p) if !p.is_empty() => scope
            .strip_prefix(p)
            .map_or(scope, |s| s.trim_start_matches('/')),
        _ => scope,
    }
}

/// Find the nearest mapped ancestor used as a parent during tree rendering.
fn nearest_ancestor<'m>(maps: &'m BTreeMap<String, DiskMap>, scope: &str) -> Option<&'m str> {
    let mut cur = scope;
    loop {
        let parent = match cur.rfind('/') {
            Some(i) => &cur[..i],
            None if !cur.is_empty() => "",
            None => return None,
        };
        if let Some((k, _)) = maps.get_key_value(parent) {
            return Some(k.as_str());
        }
        if parent.is_empty() {
            return None;
        }
        cur = parent;
    }
}

#[allow(clippy::too_many_arguments)]
fn render_node(
    report: &CheckReport,
    kids: &BTreeMap<&str, Vec<&str>>,
    scope: &str,
    prefix: &str,
    is_last: bool,
    is_root: bool,
    opts: &TreeOpts,
    name_width: usize,
    depth: usize,
    out: &mut String,
) {
    let Some(map) = report.maps.get(scope) else {
        return;
    };
    let (tee, ell, pipe) = if opts.ascii {
        ("|-- ", "`-- ", "|   ")
    } else {
        ("├── ", "└── ", "")
    };
    let connector = if is_root {
        ""
    } else if is_last {
        ell
    } else {
        tee
    };
    let name = display_name(scope, &report.maps);
    let stale = report.stale.get(scope).copied().unwrap_or(false);
    let marker = match (stale, opts.ascii) {
        (true, false) => "✗ stale",
        (true, true) => "x stale",
        (false, false) => "",
        (false, true) => "ok",
    };
    let tokens = map.tokens.as_deref().unwrap_or("?");
    let label = format!("{prefix}{connector}{name}");
    let pad = name_width.saturating_sub(label.chars().count()) + 2;
    out.push_str(&format!(
        "{label}{:pad$}{:<8} {:>7}  {marker}\n",
        "", map.fidelity, tokens,
    ));

    if opts.depth.is_some_and(|d| depth + 1 > d) {
        return;
    }
    let children = kids.get(scope).cloned().unwrap_or_default();
    let child_prefix = if is_root {
        String::new()
    } else if is_last {
        format!("{prefix}    ")
    } else {
        format!("{prefix}{pipe}")
    };
    for (i, child) in children.iter().enumerate() {
        let last = i + 1 == children.len();
        render_node(
            report,
            kids,
            child,
            &child_prefix,
            last,
            false,
            opts,
            name_width,
            depth + 1,
            out,
        );
    }
}