rdar 0.6.9

radar - the repository cartographer for AI agents: compiles a repo into tiny committed MAP.md routers, with measured token benchmarks
Documentation
//! `radar browse` - read-only two-pane TUI over the map tree.
//!
//! Deliberately boring for reliability: no editing, no slot-filling, no
//! async. crossterm handles the terminal (raw mode/input);
//! the rendering is a hand-rolled two-pane loop. A panic hook restores the
//! terminal so a crash never leaves the shell in the alternate screen.
//!
//! Keys: ↑/k ↓/j select · PgUp/PgDn or J/K scroll the map pane ·
//! o open the map in $EDITOR · / filter · Esc clear filter · q quit.

use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use crossterm::{
    cursor, event, execute, queue,
    style::{self, Attribute},
    terminal,
};

use crate::check::CheckReport;

/// One row of the left pane (pure data - unit-testable without a terminal).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
    pub scope: String,
    pub label: String,
    pub depth: usize,
    pub stale: bool,
    pub tokens: String,
}

/// Flatten the map tree into display rows (DFS, deterministic).
pub fn rows(report: &CheckReport, filter: &str) -> Vec<Row> {
    let maps = &report.maps;
    let mut kids: std::collections::BTreeMap<&str, Vec<&str>> = Default::default();
    for scope in maps.keys() {
        if scope.is_empty() {
            continue;
        }
        let mut cur = scope.as_str();
        let parent = loop {
            let p = match cur.rfind('/') {
                Some(i) => &cur[..i],
                None => "",
            };
            if maps.contains_key(p) {
                break p;
            }
            if p.is_empty() {
                break "";
            }
            cur = p;
        };
        kids.entry(parent).or_default().push(scope);
    }
    let mut out = Vec::new();
    fn walk(
        scope: &str,
        depth: usize,
        report: &CheckReport,
        kids: &std::collections::BTreeMap<&str, Vec<&str>>,
        out: &mut Vec<Row>,
    ) {
        if let Some(map) = report.maps.get(scope) {
            out.push(Row {
                scope: scope.to_string(),
                label: if scope.is_empty() {
                    ".".to_string()
                } else {
                    scope.rsplit('/').next().unwrap_or(scope).to_string()
                },
                depth,
                stale: report.stale.get(scope).copied().unwrap_or(false),
                tokens: map.tokens.clone().unwrap_or_else(|| "~?".into()),
            });
        }
        for child in kids.get(scope).cloned().unwrap_or_default() {
            walk(child, depth + 1, report, kids, out);
        }
    }
    if report.maps.contains_key("") {
        walk("", 0, report, &kids, &mut out);
    }
    if filter.is_empty() {
        out
    } else {
        let f = filter.to_lowercase();
        out.into_iter()
            .filter(|r| r.scope.to_lowercase().contains(&f) || r.label.to_lowercase().contains(&f))
            .collect()
    }
}

/// Char-boundary-safe truncation (String::truncate panics mid-codepoint -
/// the header and markers contain multibyte chars).
fn clip(s: &str, max: usize) -> String {
    s.chars().take(max).collect()
}

struct Restore;
impl Drop for Restore {
    fn drop(&mut self) {
        let _ = terminal::disable_raw_mode();
        let _ = execute!(
            std::io::stderr(),
            terminal::LeaveAlternateScreen,
            cursor::Show
        );
    }
}

type PanicHook = Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + Send + Sync + 'static>;

struct PanicHookRestore {
    previous: Arc<Mutex<Option<PanicHook>>>,
}

impl Drop for PanicHookRestore {
    fn drop(&mut self) {
        // set_hook/take_hook cannot be called while this thread is already
        // unwinding. The installed hook has restored the terminal by then.
        if std::thread::panicking() {
            return;
        }
        let _installed = std::panic::take_hook();
        if let Ok(mut previous) = self.previous.lock()
            && let Some(hook) = previous.take()
        {
            std::panic::set_hook(hook);
        }
    }
}

/// Run the browser. Errors when stderr isn't a TTY (caller prints the hint).
pub fn run(root: &Path, report: &CheckReport) -> std::io::Result<()> {
    use std::io::IsTerminal;
    if !std::io::stderr().is_terminal() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::Unsupported,
            "not a terminal - use `radar tree` instead",
        ));
    }

    // Panic-safe terminal restore prevents alternate-screen leakage.
    let previous = Arc::new(Mutex::new(Some(std::panic::take_hook())));
    let hook_previous = Arc::clone(&previous);
    std::panic::set_hook(Box::new(move |info| {
        let _ = terminal::disable_raw_mode();
        let _ = execute!(
            std::io::stderr(),
            terminal::LeaveAlternateScreen,
            cursor::Show
        );
        if let Ok(previous) = hook_previous.lock()
            && let Some(hook) = previous.as_ref()
        {
            hook(info);
        }
    }));
    let _hook_restore = PanicHookRestore { previous };
    terminal::enable_raw_mode()?;
    execute!(
        std::io::stderr(),
        terminal::EnterAlternateScreen,
        cursor::Hide
    )?;
    let _restore = Restore;

    let mut selected = 0usize;
    let mut scroll = 0usize;
    let mut filter = String::new();
    let mut filtering = false;
    let mut body_cache: Option<(String, Vec<String>)> = None;

    loop {
        let list = rows(report, &filter);
        if selected >= list.len() {
            selected = list.len().saturating_sub(1);
        }
        let (w, h) = terminal::size()?;
        let width = w as usize;
        let left_w = (width / 3)
            .clamp(20, 46)
            .min(width.saturating_sub(2).max(1));
        let body_h = (h as usize).saturating_sub(2);

        // Load the selected map body (cached per selection).
        let scope = list
            .get(selected)
            .map(|r| r.scope.clone())
            .unwrap_or_default();
        if body_cache.as_ref().map(|(s, _)| s.as_str()) != Some(scope.as_str()) {
            let path = crate::mapfile::map_path(root, &scope);
            let text = std::fs::read_to_string(&path).unwrap_or_else(|_| "no map".into());
            let lines: Vec<String> = text.lines().map(str::to_string).collect();
            body_cache = Some((scope.clone(), lines));
            scroll = 0;
        }

        let mut err = std::io::stderr().lock();
        queue!(
            err,
            terminal::Clear(terminal::ClearType::All),
            cursor::MoveTo(0, 0)
        )?;
        let title = if filtering || !filter.is_empty() {
            format!(" radar browse - filter: {filter}▏(Esc clears)")
        } else {
            " radar browse - j/k select · J/K scroll · o editor · / filter · q quit".to_string()
        };
        queue!(err, style::SetAttribute(Attribute::Reverse))?;
        let header = clip(&title, w as usize);
        queue!(
            err,
            style::Print(format!("{header:<width$}", width = w as usize))
        )?;
        queue!(err, style::SetAttribute(Attribute::Reset))?;

        for i in 0..body_h {
            queue!(err, cursor::MoveTo(0, (i + 1) as u16))?;
            if let Some(row) = list.get(i) {
                let marker = if row.stale { "" } else { " " };
                let line = clip(
                    &format!(
                        "{}{} {} {}",
                        "  ".repeat(row.depth),
                        marker,
                        row.label,
                        row.tokens
                    ),
                    left_w.saturating_sub(1),
                );
                if i == selected {
                    queue!(err, style::SetAttribute(Attribute::Reverse))?;
                    queue!(err, style::Print(format!("{line:<left_w$}")))?;
                    queue!(err, style::SetAttribute(Attribute::Reset))?;
                } else {
                    queue!(err, style::Print(format!("{line:<left_w$}")))?;
                }
            } else {
                queue!(err, style::Print(" ".repeat(left_w)))?;
            }
            queue!(err, cursor::MoveTo(left_w as u16, (i + 1) as u16))?;
            queue!(err, style::Print(""))?;
            if let Some((_, lines)) = &body_cache
                && let Some(line) = lines.get(scroll + i)
            {
                let avail = (w as usize).saturating_sub(left_w + 2);
                let line = clip(line, avail);
                if line.starts_with('#') || line.starts_with("## ") {
                    queue!(err, style::SetAttribute(Attribute::Bold))?;
                    queue!(err, style::Print(&line))?;
                    queue!(err, style::SetAttribute(Attribute::Reset))?;
                } else {
                    queue!(err, style::Print(&line))?;
                }
            }
        }
        err.flush()?;
        drop(err);

        match event::read()? {
            event::Event::Key(key) if key.kind != event::KeyEventKind::Release => {
                use event::KeyCode::*;
                if filtering {
                    match key.code {
                        Esc => {
                            filter.clear();
                            filtering = false;
                        }
                        Enter => filtering = false,
                        Backspace => {
                            filter.pop();
                        }
                        Char(c) => filter.push(c),
                        _ => {}
                    }
                    continue;
                }
                match key.code {
                    Char('q') | Esc => break,
                    Char('j') | Down => selected = (selected + 1).min(list.len().saturating_sub(1)),
                    Char('k') | Up => selected = selected.saturating_sub(1),
                    Char('J') | PageDown => scroll += 10,
                    Char('K') | PageUp => scroll = scroll.saturating_sub(10),
                    Char('/') => {
                        filtering = true;
                        filter.clear();
                    }
                    Char('o') => {
                        if let Some(row) = list.get(selected) {
                            open_in_editor(root, &row.scope)?;
                        }
                    }
                    _ => {}
                }
            }
            event::Event::Resize(..) => {}
            _ => {}
        }
    }
    Ok(())
}

fn open_in_editor(root: &Path, scope: &str) -> std::io::Result<()> {
    let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
    let mut editor_parts = editor.split_whitespace();
    let program = editor_parts
        .next()
        .filter(|part| !part.is_empty())
        .unwrap_or("vi");
    let path: PathBuf = crate::mapfile::map_path(root, scope);
    // Suspend the TUI cleanly around the editor.
    terminal::disable_raw_mode()?;
    execute!(
        std::io::stderr(),
        terminal::LeaveAlternateScreen,
        cursor::Show
    )?;
    let _ = std::process::Command::new(program)
        .args(editor_parts)
        .arg(&path)
        .status();
    terminal::enable_raw_mode()?;
    execute!(
        std::io::stderr(),
        terminal::EnterAlternateScreen,
        cursor::Hide
    )?;
    Ok(())
}

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

    fn report() -> CheckReport {
        let mut r = CheckReport::default();
        for (scope, stale) in [
            ("", false),
            ("auth", false),
            ("auth/jwt", true),
            ("web", false),
        ] {
            r.maps.insert(
                scope.to_string(),
                DiskMap {
                    parent_link: None,
                    children_links: vec![],
                    api_hash: None,
                    bytes_actual: 100,
                    tokens: Some("~25".into()),
                    fidelity: "syntax".into(),
                    slot_filled: true,
                },
            );
            r.stale.insert(scope.to_string(), stale);
        }
        r
    }

    #[test]
    fn rows_are_depth_ordered_and_labeled() {
        let rows = rows(&report(), "");
        let labels: Vec<(&str, usize, bool)> = rows
            .iter()
            .map(|r| (r.label.as_str(), r.depth, r.stale))
            .collect();
        assert_eq!(
            labels,
            vec![
                (".", 0, false),
                ("auth", 1, false),
                ("jwt", 2, true),
                ("web", 1, false)
            ]
        );
    }

    #[test]
    fn filter_matches_scope_paths() {
        let rows = rows(&report(), "jwt");
        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].scope, "auth/jwt");
    }
}