rdar 0.6.16

radar - the repository cartographer for AI agents: compiles a repo into tiny committed MAP.md routers, with measured token benchmarks
Documentation
//! `radar agent` starts the selected coding agent through radar:
//! refresh the maps, verify the navigation contract, exec the agent already
//! pointed at the front door. radar spawns and exits - never a session
//! manager.

use std::io::{self, IsTerminal, Write};
use std::path::Path;
use std::process::Command;

use crate::config::Config;

/// Agents auto-detected on PATH, in preference order.
const KNOWN_AGENTS: [&str; 4] = ["claude", "codex", "opencode", "aider"];

pub struct AgentOpts {
    pub prompt: Option<String>,
    /// Explicit command override (everything after `--`).
    pub cmd: Vec<String>,
    pub no_refresh: bool,
    pub yes: bool,
    pub no_input: bool,
}

/// TTY-gated y/N confirmation that never blocks automation.
pub fn confirm(question: &str, yes: bool, no_input: bool) -> bool {
    if yes {
        return true;
    }
    if no_input || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
        eprintln!("radar: {question} - proceeding (non-interactive default)");
        return true;
    }
    eprint!("radar: {question} [Y/n] ");
    let _ = io::stderr().flush();
    let mut line = String::new();
    if io::stdin().read_line(&mut line).is_err() {
        return true;
    }
    let ans = line.trim().to_lowercase();
    ans.is_empty() || ans == "y" || ans == "yes"
}

fn which(program: &str) -> bool {
    let Some(paths) = std::env::var_os("PATH") else {
        return false;
    };
    std::env::split_paths(&paths).any(|dir| {
        let candidate = dir.join(program);
        if is_executable(&candidate) {
            return true;
        }
        #[cfg(windows)]
        {
            // Platform executable extension conventions.
            ["exe", "cmd", "bat"]
                .iter()
                .any(|ext| is_executable(&dir.join(format!("{program}.{ext}"))))
        }
        #[cfg(not(windows))]
        false
    })
}

fn is_executable(path: &Path) -> bool {
    if !path.is_file() {
        return false;
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        path.metadata()
            .is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0)
    }
    #[cfg(not(unix))]
    true
}

/// Resolve the agent command: explicit `--` > radar.toml > PATH detection.
pub fn resolve_cmd(opts: &AgentOpts, config: &Config) -> Option<Vec<String>> {
    if !opts.cmd.is_empty() {
        return Some(opts.cmd.clone());
    }
    if let Some(cmd) = &config.agent_cmd {
        return Some(cmd.split_whitespace().map(str::to_string).collect());
    }
    KNOWN_AGENTS
        .iter()
        .find(|a| which(a))
        .map(|a| vec![a.to_string()])
}

/// The router prefix that points the agent at the front door (ยง6.2).
pub fn router_prompt(task: Option<&str>) -> String {
    match task {
        Some(task) => {
            format!("Knowledge base is fresh. Start at ./MAP.md and navigate by maps. Task: {task}")
        }
        None => "Knowledge base is fresh. Start at ./MAP.md and navigate by maps.".to_string(),
    }
}

/// Spawn the agent in `root` and wait; returns its exit code.
pub fn launch(root: &Path, cmd: &[String], prompt: Option<&str>) -> io::Result<i32> {
    let (program, args) = cmd
        .split_first()
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "empty agent command"))?;
    let mut command = Command::new(program);
    command.args(args).current_dir(root);
    if let Some(p) = prompt {
        command.arg(p);
    }
    let status = command.status()?;
    Ok(status.code().unwrap_or(1))
}

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

    #[test]
    fn explicit_cmd_wins_over_config() {
        let opts = AgentOpts {
            prompt: None,
            cmd: vec!["my-agent".into(), "--flag".into()],
            no_refresh: true,
            yes: true,
            no_input: true,
        };
        let config = Config {
            agent_cmd: Some("other".into()),
            ..Config::default()
        };
        assert_eq!(
            resolve_cmd(&opts, &config),
            Some(vec!["my-agent".to_string(), "--flag".to_string()])
        );
    }

    #[test]
    fn config_cmd_is_split_on_whitespace() {
        let opts = AgentOpts {
            prompt: None,
            cmd: vec![],
            no_refresh: true,
            yes: true,
            no_input: true,
        };
        let config = Config {
            agent_cmd: Some("claude --model opus".into()),
            ..Config::default()
        };
        assert_eq!(
            resolve_cmd(&opts, &config),
            Some(vec![
                "claude".to_string(),
                "--model".to_string(),
                "opus".to_string()
            ])
        );
    }

    #[test]
    fn router_prompt_includes_task() {
        assert!(router_prompt(Some("fix the bug")).contains("Task: fix the bug"));
        assert!(router_prompt(None).contains("./MAP.md"));
    }

    #[cfg(unix)]
    #[test]
    fn executable_detection_rejects_plain_files() {
        use std::os::unix::fs::PermissionsExt;

        let path = std::env::temp_dir().join(format!(
            "radar-agent-executable-test-{}",
            std::process::id()
        ));
        std::fs::write(&path, "#!/bin/sh\nexit 0\n").expect("write fixture");
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
            .expect("set plain permissions");
        assert!(!is_executable(&path));
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
            .expect("set executable permissions");
        assert!(is_executable(&path));
        let _ = std::fs::remove_file(path);
    }
}