use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "radar",
version = rdar::VERSION,
about,
after_help = "Docs: ARCHITECTURE.md and BENCHMARKS.md."
)]
struct Cli {
#[command(subcommand)]
cmd: Option<Cmd>,
}
#[derive(Subcommand)]
enum Cmd {
Scan {
path: Option<PathBuf>,
#[arg(long)]
json: bool,
#[arg(long)]
jobs: Option<usize>,
#[arg(long)]
quiet: bool,
},
Map {
path: Option<PathBuf>,
#[arg(long)]
jobs: Option<usize>,
#[arg(long)]
quiet: bool,
#[arg(long)]
prune: bool,
},
Check {
path: Option<PathBuf>,
#[arg(long)]
quiet: bool,
},
#[command(alias = "ls")]
Tree {
path: Option<PathBuf>,
#[arg(long)]
depth: Option<usize>,
#[arg(long)]
ascii: bool,
},
Refresh {
path: Option<PathBuf>,
#[arg(long)]
since: Option<String>,
#[arg(long)]
deep: bool,
#[arg(long)]
quiet: bool,
},
Status { path: Option<PathBuf> },
Impact {
path: Option<PathBuf>,
#[arg(long, default_value = "HEAD")]
since: String,
#[arg(long, default_value_t = 2, value_parser = impact_depth)]
depth: usize,
#[arg(long)]
json: bool,
#[arg(long)]
history: bool,
#[arg(long, default_value_t = 512, value_parser = history_commits)]
history_commits: usize,
#[arg(long, default_value_t = 32, value_parser = history_results)]
history_results: usize,
},
Watch {
path: Option<PathBuf>,
#[arg(long, default_value_t = 2)]
interval: u64,
},
#[cfg(feature = "tui")]
Browse { path: Option<PathBuf> },
Slots {
path: Option<PathBuf>,
#[arg(long)]
json: bool,
#[arg(long)]
prompt: bool,
#[arg(long)]
exec: Option<String>,
},
Fill {
map: String,
slot: String,
text: Option<String>,
},
Init { path: Option<PathBuf> },
#[cfg(feature = "man")]
#[command(hide = true)]
Man {
dir: PathBuf,
},
Export {
path: Option<PathBuf>,
#[arg(long)]
html: Option<PathBuf>,
},
Serve {
path: Option<PathBuf>,
#[arg(long, default_value = "127.0.0.1:0")]
web: String,
#[arg(long)]
mcp: bool,
},
Route {
#[command(subcommand)]
action: RouteAction,
},
Query {
query: String,
#[arg(long)]
path: Option<PathBuf>,
#[arg(long)]
source: bool,
},
Agent {
#[arg(long)]
prompt: Option<String>,
#[arg(long)]
no_refresh: bool,
#[arg(long)]
slots: bool,
#[arg(long, short = 'y')]
yes: bool,
#[arg(long)]
no_input: bool,
#[arg(last = true)]
cmd: Vec<String>,
},
}
#[derive(Subcommand)]
enum RouteAction {
Add {
query: String,
anchor: String,
#[arg(long)]
link: Vec<String>,
},
Find { query: String },
List,
}
fn main() {
let cli = Cli::parse();
match cli.cmd {
None => {
eprintln!("radar {}", rdar::VERSION);
eprintln!("Start with `radar map` (build the knowledge base) or `radar --help`.");
std::process::exit(rdar::exit::FIRST_RUN_NEEDED);
}
Some(Cmd::Scan {
path,
json,
jobs,
quiet,
}) => {
let opts = scan_opts(path, jobs, quiet || json);
match rdar::scan::scan(&opts) {
Ok(stats) => {
if json {
print_json(&stats);
} else {
print_stats(&stats);
}
}
Err(e) => fail("scan", &e),
}
}
Some(Cmd::Map {
path,
jobs,
quiet,
prune,
}) => {
let opts = scan_opts(path, jobs, quiet);
let _lock = acquire_lock(&opts.root);
match run_map_pruning(&opts, prune) {
Ok((written, unchanged, pending)) => {
if !quiet {
println!(
"radar map - {} written, {unchanged} unchanged, {pending} purpose slot(s) pending",
written
);
if pending > 0 {
println!("fill slots with `radar slots --exec`");
}
}
}
Err(e) => fail("map", &e),
}
}
Some(Cmd::Check { path, quiet }) => {
let opts = scan_opts(path, None, true);
match rdar::scan::scan_full(&opts) {
Ok((_, cache)) => {
let report = rdar::check::check(&opts.root, &cache);
if report.maps.is_empty() {
eprintln!("radar check: no maps found - run `radar map` first");
std::process::exit(rdar::exit::FIRST_RUN_NEEDED);
}
for v in &report.violations {
eprintln!("{v}");
}
if report.violations.is_empty() {
if !quiet {
println!("radar check: {} map(s) OK", report.maps.len());
}
std::process::exit(rdar::exit::OK);
}
std::process::exit(rdar::exit::VIOLATIONS);
}
Err(e) => fail("check", &e),
}
}
Some(Cmd::Tree { path, depth, ascii }) => {
let opts = scan_opts(path, None, true);
match rdar::scan::scan_full(&opts) {
Ok((_, cache)) => {
let report = rdar::check::check(&opts.root, &cache);
let rendered =
rdar::tree::render(&report, &rdar::tree::TreeOpts { ascii, depth });
print!("{rendered}");
}
Err(e) => fail("tree", &e),
}
}
Some(Cmd::Refresh {
path,
since,
deep,
quiet,
}) => {
let opts = scan_opts(path, None, quiet);
let _lock = acquire_lock(&opts.root);
match run_refresh(&opts, since.as_deref()) {
Ok(msg) => {
if !quiet {
println!("{msg}");
}
}
Err(e) => fail("refresh", &e),
}
if deep {
run_deep(&opts);
}
}
Some(Cmd::Status { path }) => {
let opts = scan_opts(path, None, true);
run_status(&opts);
}
Some(Cmd::Impact {
path,
since,
depth,
json,
history,
history_commits,
history_results,
}) => {
let opts = scan_opts(path, None, true);
let changed = match rdar::gitfacts::changed_since(&opts.root, &since) {
Some(changed) => changed,
None => fail(
"impact",
&std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Git revision {since:?} is unavailable"),
),
),
};
match rdar::scan::scan_full(&opts) {
Ok((_, cache)) => {
let mut report = rdar::impact::analyze(&cache, since, changed.clone(), depth);
if history {
let Some(facts) = rdar::gitfacts::history_cochanges(
&opts.root,
&changed,
history_commits,
history_results,
) else {
fail(
"impact history",
&std::io::Error::other("Git history could not be read"),
);
};
rdar::impact::attach_history(&mut report, facts);
}
if json {
print_json(&report);
} else {
print!("{}", rdar::impact::render(&report));
}
}
Err(e) => fail("impact", &e),
}
}
Some(Cmd::Watch { path, interval }) => {
let opts = scan_opts(path, None, true);
println!("radar watch - every {interval}s (Ctrl-C to stop)");
loop {
match rdar::lock::Lock::acquire(&opts.root) {
Ok(_lock) => match run_refresh(&opts, None) {
Ok(msg) => {
if !msg.contains("fresh") {
println!("{msg}");
}
}
Err(e) => eprintln!("radar: refresh failed: {e}"),
},
Err(_) => { }
}
std::thread::sleep(std::time::Duration::from_secs(interval.max(1)));
}
}
#[cfg(feature = "tui")]
Some(Cmd::Browse { path }) => {
let opts = scan_opts(path, None, true);
match rdar::scan::scan_full(&opts) {
Ok((_, cache)) => {
let report = rdar::check::check(&opts.root, &cache);
if let Err(e) = rdar::browse::run(&opts.root, &report) {
eprintln!("radar: browse: {e}");
std::process::exit(rdar::exit::FIRST_RUN_NEEDED);
}
}
Err(e) => fail("browse", &e),
}
}
Some(Cmd::Slots {
path,
json,
prompt,
exec,
}) => {
let opts = scan_opts(path, None, true);
if !opts.root.is_dir() {
fail(
"slots",
&std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{} is not a directory", opts.root.display()),
),
);
}
let packets = rdar::slots::pending(&opts.root);
if let Some(cmd) = exec {
let failed = {
let _lock = acquire_lock(&opts.root);
run_slot_exec(&opts.root, &packets, &cmd)
};
if failed > 0 {
std::process::exit(rdar::exit::VIOLATIONS);
}
} else if json {
print_json(&packets);
} else if prompt {
for p in &packets {
println!("=== {} ===", p.map);
println!("{}", rdar::slots::render_prompt(p));
}
} else {
for p in &packets {
println!("{} (purpose pending)", p.map);
}
println!("{} pending slot(s)", packets.len());
}
}
Some(Cmd::Fill { map, slot, text }) => {
if slot != "purpose" {
eprintln!("radar: unknown slot {slot:?} (only: purpose)");
std::process::exit(rdar::exit::FIRST_RUN_NEEDED);
}
let text = match text {
Some(t) => t,
None => {
let mut buf = String::new();
use std::io::Read;
if std::io::stdin().read_to_string(&mut buf).is_err() {
buf.clear();
}
buf
}
};
let code = {
let _lock = acquire_lock(Path::new("."));
match rdar::slots::fill(&PathBuf::from("."), &map, &text) {
Ok(()) => {
println!("filled {map}");
rdar::exit::OK
}
Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => {
eprintln!("radar: rejected: {e}");
rdar::exit::VIOLATIONS
}
Err(e) => fail("fill", &e),
}
};
std::process::exit(code);
}
Some(Cmd::Init { path }) => {
let root = path.unwrap_or_else(|| PathBuf::from("."));
match rdar::initcmd::init(&root) {
Ok(report) => println!("radar init - wrote: {}", report.touched.join(", ")),
Err(e) => fail("init", &e),
}
}
#[cfg(feature = "man")]
Some(Cmd::Man { dir }) => {
use clap::CommandFactory;
if let Err(e) = std::fs::create_dir_all(&dir) {
fail("man", &e);
}
let cmd = Cli::command();
let write_page = |c: &clap::Command, name: &str| -> std::io::Result<()> {
let man = clap_mangen::Man::new(c.clone());
let mut buf = Vec::new();
man.render(&mut buf)?;
std::fs::write(dir.join(format!("{name}.1")), buf)
};
if let Err(e) = write_page(&cmd, "radar") {
fail("man", &e);
}
let mut count = 1;
for sub in cmd.get_subcommands().filter(|s| !s.is_hide_set()) {
let full: &'static str =
Box::leak(format!("radar-{}", sub.get_name()).into_boxed_str());
let named = sub.clone().name(full);
if let Err(e) = write_page(&named, full) {
fail("man", &e);
}
count += 1;
}
println!("radar man - {count} page(s) → {}", dir.display());
}
Some(Cmd::Export { path, html }) => {
let opts = scan_opts(path, None, true);
match rdar::scan::scan_full(&opts) {
Ok((_, cache)) => {
let report = rdar::check::check(&opts.root, &cache);
let name = repo_display_name(&opts.root);
let out = html.unwrap_or_else(|| opts.root.join("radar-map.html"));
let doc = rdar::export::render(&opts.root, &report, &name);
match std::fs::write(&out, &doc) {
Ok(()) => println!(
"radar export - {} map(s) → {} ({} bytes)",
report.maps.len(),
out.display(),
doc.len()
),
Err(e) => fail("export", &e),
}
}
Err(e) => fail("export", &e),
}
}
Some(Cmd::Serve { path, web, mcp }) => {
let opts = scan_opts(path, None, true);
if mcp {
if let Err(e) = rdar::mcp::serve(&opts.root) {
fail("serve --mcp", &e);
}
} else {
let name = repo_display_name(&opts.root);
if let Err(e) = rdar::export::serve_web(&opts.root, &web, &name) {
fail("serve", &e);
}
}
}
Some(Cmd::Route { action }) => {
let root = PathBuf::from(".");
match action {
RouteAction::Add {
query,
anchor,
link,
} => {
let _lock = acquire_lock(&root);
let opts = scan_opts(Some(root.clone()), None, true);
match rdar::scan::scan_full(&opts) {
Ok((_, cache)) => {
let placement = rdar::place::place(&cache);
match rdar::routes::add(
&root, &cache, &placement, &query, &anchor, link,
) {
Ok(msg) => println!("{msg}"),
Err(e) => {
eprintln!("radar: route rejected: {e}");
std::process::exit(rdar::exit::VIOLATIONS);
}
}
}
Err(e) => fail("route add", &e),
}
}
RouteAction::Find { query } => {
let hits = rdar::routes::find(&root, &query);
if hits.is_empty() {
println!("no cached routes (miss)");
std::process::exit(rdar::exit::FIRST_RUN_NEEDED);
}
println!("state\tkeywords\tanchor\tmap\tscore\thits");
for (score, e) in hits {
println!(
"{}\t{}\t{}\t{}\t{score}\t{}",
if e.state == rdar::routes::RouteState::Ok {
"ok"
} else {
"stale"
},
e.keywords.join(" "),
e.anchor,
e.map,
e.hits
);
}
}
RouteAction::List => {
let path = root.join(".radar/ROUTES.md");
match std::fs::read_to_string(&path) {
Ok(doc) => print!("{doc}"),
Err(_) => {
println!("no route cache yet - record one with `radar route add`")
}
}
}
}
}
Some(Cmd::Query {
query,
path,
source,
}) => {
let root = path.unwrap_or_else(|| PathBuf::from("."));
let state = rdar::cache::radar_dir(&root).join("state.bin");
if !state.is_file() {
eprintln!("radar: no index at {} - run `radar map`", root.display());
std::process::exit(rdar::exit::FIRST_RUN_NEEDED);
}
let answer = if source {
rdar::mcp::query_source(&root, &query)
} else {
rdar::mcp::query(&root, &query)
};
println!("{answer}");
}
Some(Cmd::Agent {
prompt,
no_refresh,
slots,
yes,
no_input,
cmd,
}) => {
let root = PathBuf::from(".");
let agent_opts = rdar::agentcmd::AgentOpts {
prompt,
cmd,
no_refresh,
yes,
no_input,
};
run_agent(&root, agent_opts, slots);
}
}
}
fn impact_depth(value: &str) -> Result<usize, String> {
let depth = value
.parse::<usize>()
.map_err(|_| "depth must be an integer between 0 and 8".to_string())?;
(depth <= 8)
.then_some(depth)
.ok_or_else(|| "depth must be an integer between 0 and 8".to_string())
}
fn history_commits(value: &str) -> Result<usize, String> {
let count = value
.parse::<usize>()
.map_err(|_| "history-commits must be an integer between 1 and 10000".to_string())?;
(1..=10_000)
.contains(&count)
.then_some(count)
.ok_or_else(|| "history-commits must be an integer between 1 and 10000".to_string())
}
fn history_results(value: &str) -> Result<usize, String> {
let count = value
.parse::<usize>()
.map_err(|_| "history-results must be an integer between 1 and 256".to_string())?;
(1..=256)
.contains(&count)
.then_some(count)
.ok_or_else(|| "history-results must be an integer between 1 and 256".to_string())
}
fn run_agent(root: &Path, opts: rdar::agentcmd::AgentOpts, fill_slots: bool) -> ! {
let config = rdar::config::Config::load(root);
let Some(cmd) = rdar::agentcmd::resolve_cmd(&opts, &config) else {
eprintln!("radar: no agent found - set [agent] cmd in radar.toml or pass one after `--`");
std::process::exit(rdar::exit::FIRST_RUN_NEEDED);
};
if !opts.no_refresh {
let scan_opts = scan_opts(Some(root.to_path_buf()), None, true);
let cold_start = !root.join("MAP.md").exists() && !root.join(".radar/state.bin").exists();
if cold_start
&& !rdar::agentcmd::confirm(
"first map of this repository may take a moment - continue?",
opts.yes,
opts.no_input,
)
{
std::process::exit(rdar::exit::OK);
}
let _lock = acquire_lock(root);
if let Err(e) = run_map(&scan_opts) {
fail("agent (refresh)", &e);
}
}
let has_contract = ["AGENTS.md", "CLAUDE.md", "README.md"].iter().any(|f| {
std::fs::read_to_string(root.join(f))
.is_ok_and(|t| t.contains("<!-- radar:begin navigation -->"))
});
if !has_contract {
eprintln!("radar: navigation contract missing - running init");
if let Err(e) = rdar::initcmd::init(root) {
fail("agent (init)", &e);
}
}
if fill_slots {
let packets = rdar::slots::pending(root);
if run_slot_exec(root, &packets, &cmd.join(" ")) > 0 {
std::process::exit(rdar::exit::VIOLATIONS);
}
}
let root_body = std::fs::read_to_string(root.join("MAP.md"))
.ok()
.and_then(|doc| rdar::frontmatter::parse(&doc).map(|(_, body)| body.trim().to_string()))
.map(|body| rdar::initcmd::compact_embedded_root_map(&body));
let router = opts.prompt.as_deref().map(|t| {
let base = rdar::agentcmd::router_prompt(Some(t));
match &root_body {
Some(body) => format!("{base}\n\n--- root MAP.md ---\n{body}\n---"),
None => base,
}
});
match rdar::agentcmd::launch(root, &cmd, router.as_deref()) {
Ok(code) => std::process::exit(code),
Err(e) => fail("agent (launch)", &e),
}
}
const SLOT_ATTEMPTS: usize = 2;
const SLOT_RETRY_FEEDBACK_MAX_CHARS: usize = 512;
fn bounded_retry_feedback(text: &str) -> String {
let mut chars = text.chars();
let mut feedback: String = chars.by_ref().take(SLOT_RETRY_FEEDBACK_MAX_CHARS).collect();
if chars.next().is_some() {
const MARKER: &str = " [truncated]";
let keep = SLOT_RETRY_FEEDBACK_MAX_CHARS.saturating_sub(MARKER.chars().count());
feedback = feedback.chars().take(keep).collect();
feedback.push_str(MARKER);
}
feedback
}
fn run_slot_exec(root: &std::path::Path, packets: &[rdar::slots::SlotPacket], cmd: &str) -> usize {
let parts: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
let Some((program, args)) = parts.split_first() else {
eprintln!("radar: empty --exec command");
std::process::exit(rdar::exit::FIRST_RUN_NEEDED);
};
let (mut filled, mut failed) = (0usize, 0usize);
for p in packets {
let base = rdar::slots::render_prompt(p);
let mut prompt = base.clone();
let mut last_err = String::new();
let mut done = false;
for attempt in 1..=SLOT_ATTEMPTS {
let out = std::process::Command::new(program)
.args(args)
.arg(&prompt)
.current_dir(root)
.output();
match out {
Ok(out) if out.status.success() => {
let text = String::from_utf8_lossy(&out.stdout);
match rdar::slots::fill(root, &p.map, &text) {
Ok(()) => {
filled += 1;
last_err.clear();
println!("filled {}", p.map);
done = true;
}
Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => {
last_err = e.to_string();
if attempt < SLOT_ATTEMPTS {
prompt = format!(
"{base}\nYour previous answer was rejected: {}\n\
Previous answer: {}\n\
Rewrite it to satisfy the constraints exactly.",
bounded_retry_feedback(&last_err),
bounded_retry_feedback(&text),
);
eprintln!("radar: {} rejected: {last_err} - retrying", p.map);
}
}
Err(e) => {
last_err = e.to_string();
done = true;
}
}
}
Ok(out) => {
last_err = format!("{program} exited {}", out.status);
done = true;
}
Err(e) => {
last_err = format!("failed to run {program}: {e}");
done = true;
}
}
if done {
break;
}
}
if !last_err.is_empty() {
failed += 1;
eprintln!("radar: {} rejected: {last_err}", p.map);
}
}
println!("slots: {filled} filled, {failed} failed");
failed
}
fn scan_opts(path: Option<PathBuf>, jobs: Option<usize>, quiet: bool) -> rdar::scan::ScanOpts {
rdar::scan::ScanOpts {
root: path.unwrap_or_else(|| PathBuf::from(".")),
jobs,
quiet,
}
}
struct MapOutcome {
written: usize,
unchanged: usize,
pending: usize,
placement: rdar::place::Placement,
cache: rdar::cache::ScanCache,
}
fn run_map(opts: &rdar::scan::ScanOpts) -> std::io::Result<(usize, usize, usize)> {
run_map_full(opts).map(|o| (o.written, o.unchanged, o.pending))
}
fn run_map_full(opts: &rdar::scan::ScanOpts) -> std::io::Result<MapOutcome> {
let (_, cache) = rdar::scan::scan_full(opts)?;
let placement = rdar::place::place(&cache);
let ranking = rdar::graph::rank(&cache);
let plans = rdar::mapfile::plan_maps(&cache, &placement, &ranking);
let now = rdar::mapfile::now_iso();
let (mut written, mut unchanged, mut pending) = (0usize, 0usize, 0usize);
for plan in &plans {
match plan.emit(&opts.root, &now)? {
rdar::mapfile::Emit::Written => written += 1,
rdar::mapfile::Emit::Unchanged => unchanged += 1,
}
let path = rdar::mapfile::map_path(&opts.root, &plan.scope);
if let Ok(doc) = std::fs::read_to_string(&path)
&& let Some((_, body)) = rdar::frontmatter::parse(&doc)
&& rdar::mapfile::slot_text(body, "purpose").is_none()
{
pending += 1;
}
}
let _ = rdar::symbols::write(&opts.root, &cache, &placement, &ranking);
let config = rdar::config::Config::load(&opts.root);
if config.routes_enabled {
rdar::routes::invalidate(&opts.root, &cache, &placement);
if config.routes_auto {
rdar::routes::auto_seed(&opts.root, &cache, &placement, &ranking);
}
}
rdar::initcmd::sync_embedded_root_map(&opts.root);
Ok(MapOutcome {
written,
unchanged,
pending,
placement,
cache,
})
}
fn acquire_lock(root: &Path) -> rdar::lock::Lock {
match rdar::lock::Lock::acquire(root) {
Ok(lock) => lock,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
eprintln!("radar: {e}");
std::process::exit(rdar::exit::INTERNAL);
}
Err(e) => fail("lock", &e),
}
}
fn run_map_pruning(
opts: &rdar::scan::ScanOpts,
prune: bool,
) -> std::io::Result<(usize, usize, usize)> {
let pre_oid = rdar::gitfacts::head_tree_oid(&opts.root);
let outcome = run_map_full(opts)?;
let result = (outcome.written, outcome.unchanged, outcome.pending);
let placement = outcome.placement;
let cache = outcome.cache;
let disk = rdar::check::load_maps(&opts.root);
for scope in disk.keys() {
if !placement.anchors.contains(scope) {
let path = rdar::mapfile::map_path(&opts.root, scope);
if prune {
std::fs::remove_file(&path)?;
if !opts.quiet {
println!("pruned orphaned {}", path.display());
}
} else if !opts.quiet {
println!(
"warning: {} is orphaned (scope lost its anchor) - rerun with --prune",
path.display()
);
}
}
}
finalize_git_oid(&opts.root, pre_oid.as_deref(), Some(cache));
Ok(result)
}
fn finalize_git_oid(root: &Path, pre: Option<&str>, existing: Option<rdar::cache::ScanCache>) {
let mut cache = existing.unwrap_or_else(|| rdar::cache::ScanCache::load(root));
cache.root_tree_oid = match (
rdar::gitfacts::head_tree_oid(root),
rdar::gitfacts::worktree_clean(root),
pre,
) {
(Some(now), Some(true), Some(pre)) if now == pre => Some(now),
_ => None,
};
if cache.save(root).is_ok() {
let _ = rdar::mcp::save_query_snapshot(root, &cache);
}
}
fn run_refresh(opts: &rdar::scan::ScanOpts, since: Option<&str>) -> std::io::Result<String> {
if since.is_none() {
let cache = rdar::cache::ScanCache::load(&opts.root);
if let (Some(stored), Some(current), Some(true)) = (
cache.root_tree_oid.as_deref(),
rdar::gitfacts::head_tree_oid(&opts.root).as_deref(),
rdar::gitfacts::worktree_clean(&opts.root),
) && stored == current
{
return Ok("radar refresh - fresh (git L0), nothing to do".to_string());
}
}
if let Some(rev) = since
&& let Some(changed) = rdar::gitfacts::changed_since(&opts.root, rev)
{
rdar::mcp::invalidate_query_snapshot(&opts.root);
let pre_oid = rdar::gitfacts::head_tree_oid(&opts.root);
let salesforce = rdar::lang::is_salesforce_project(&opts.root);
let mut cache = rdar::cache::ScanCache::load(&opts.root);
let mut changed = changed;
if changed.iter().any(|rel| rel == "sfdx-project.json") {
changed.extend(
cache
.files
.keys()
.filter(|rel| {
Path::new(rel).extension().and_then(|ext| ext.to_str()) == Some("cls")
})
.cloned(),
);
changed.sort();
changed.dedup();
}
let mut touched = 0usize;
for rel in &changed {
let path = opts.root.join(rel);
let Ok(meta) = std::fs::symlink_metadata(&path) else {
cache.files.remove(rel);
touched += 1;
continue;
};
if !meta.is_file() || meta.file_type().is_symlink() {
cache.files.remove(rel);
touched += 1;
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let hash = *blake3::hash(&bytes).as_bytes();
let lang = rdar::lang::Lang::from_path(&path, salesforce)
.or_else(|| rdar::lang::Lang::from_shebang(&bytes));
if let Some(lang) = lang
&& !cache.parses.contains_key(&(lang, hash))
{
let src = String::from_utf8_lossy(&bytes);
cache
.parses
.insert((lang, hash), rdar::extract::extract(lang, &src));
}
cache.files.insert(
rel.clone(),
rdar::cache::FileEntry {
mtime: (0, 0), size: meta.len(),
ino: 0,
hash,
lang,
},
);
touched += 1;
}
cache.save(&opts.root)?;
let _ = rdar::mcp::save_query_snapshot(&opts.root, &cache);
let placement = rdar::place::place(&cache);
let ranking = rdar::graph::rank(&cache);
let plans = rdar::mapfile::plan_maps(&cache, &placement, &ranking);
let now = rdar::mapfile::now_iso();
let mut written = 0usize;
for plan in &plans {
if plan.emit(&opts.root, &now)? == rdar::mapfile::Emit::Written {
written += 1;
}
}
finalize_git_oid(&opts.root, pre_oid.as_deref(), Some(cache));
return Ok(format!(
"radar refresh - {touched} file(s) reprocessed since {rev}, {written} map(s) updated"
));
}
let pre_oid = rdar::gitfacts::head_tree_oid(&opts.root);
let outcome = run_map_full(opts)?;
let (written, unchanged, pending) = (outcome.written, outcome.unchanged, outcome.pending);
finalize_git_oid(&opts.root, pre_oid.as_deref(), Some(outcome.cache));
Ok(format!(
"radar refresh - {written} written, {unchanged} unchanged, {pending} slot(s) pending"
))
}
fn run_status(opts: &rdar::scan::ScanOpts) {
let git_line = match (
rdar::gitfacts::head_tree_oid(&opts.root),
rdar::gitfacts::worktree_clean(&opts.root),
) {
(Some(oid), Some(clean)) => {
let cache = rdar::cache::ScanCache::load(&opts.root);
let l0 = clean && cache.root_tree_oid.as_deref() == Some(oid.as_str());
format!(
"git: {} @ {}{}",
if clean { "clean" } else { "dirty" },
&oid[..8.min(oid.len())],
if l0 { " (L0 fresh)" } else { "" }
)
}
_ => "git: none (L1 change tracking)".to_string(),
};
match rdar::scan::scan_full(opts) {
Ok((_, cache)) => {
let report = rdar::check::check(&opts.root, &cache);
let stale = report.stale.values().filter(|s| **s).count();
let pending = rdar::slots::pending(&opts.root).len();
println!("radar status - {}", opts.root.display());
println!(" {git_line}");
println!(
" maps: {} ({} stale) slots pending: {} violations: {}",
report.maps.len(),
stale,
pending,
report.violations.len()
);
let routes = rdar::routes::load(&opts.root);
if !routes.is_empty() {
let count =
|s: rdar::routes::RouteState| routes.iter().filter(|r| r.state == s).count();
println!(
" routes cached: {} ({} learned, {} auto, {} stale)",
routes.len(),
count(rdar::routes::RouteState::Ok),
count(rdar::routes::RouteState::Auto),
count(rdar::routes::RouteState::Stale)
);
}
}
Err(e) => fail("status", &e),
}
}
fn run_deep(opts: &rdar::scan::ScanOpts) {
let Ok((_, cache)) = rdar::scan::scan_full(opts) else {
return;
};
let placement = rdar::place::place(&cache);
let mut upgraded = 0usize;
for scope in &placement.anchors {
let mut counts: std::collections::BTreeMap<rdar::lang::Lang, u64> = Default::default();
for (rel, entry) in &cache.files {
if placement.owner.get(rel).is_some_and(|s| s == scope)
&& let Some(lang) = entry.lang
{
*counts.entry(lang).or_default() += 1;
}
}
let Some((&lang, _)) = counts.iter().max_by_key(|(_, n)| **n) else {
continue;
};
if !rdar::lsp::available(lang) {
continue;
}
let symbols: Vec<(String, String, u32)> =
rdar::mapfile::scope_symbols(&cache, &placement, scope)
.filter(|(_, s)| s.vis == rdar::extract::Vis::Pub)
.take(5)
.map(|(rel, s)| (s.name.clone(), rel.clone(), s.line))
.collect();
if symbols.is_empty() {
continue;
}
match rdar::lsp::enrich(
&opts.root,
lang,
&symbols,
std::time::Duration::from_secs(120),
) {
Ok(e) if !e.verified_refs.is_empty() => {
if upgrade_fidelity(&opts.root, scope).is_ok() {
upgraded += 1;
if !opts.quiet {
println!(
"deep: {} verified {} symbol(s) via {} → fidelity: lsp",
if scope.is_empty() { "." } else { scope },
e.verified_refs.len(),
e.server
);
}
}
}
Ok(_) => {}
Err(e) => {
if !opts.quiet {
eprintln!(
"deep: {} stays at syntax fidelity ({e})",
if scope.is_empty() { "." } else { scope }
);
}
}
}
}
if !opts.quiet {
println!("deep: {upgraded} scope(s) upgraded to lsp fidelity");
}
}
fn upgrade_fidelity(root: &Path, scope: &str) -> std::io::Result<()> {
let path = rdar::mapfile::map_path(root, scope);
let doc = std::fs::read_to_string(&path)?;
let Some((mut fm, body)) = rdar::frontmatter::parse(&doc) else {
return Ok(());
};
if fm.get("fidelity") == Some("lsp") {
return Ok(());
}
fm.set("fidelity", "lsp");
fm.set("stamped", rdar::mapfile::now_iso());
std::fs::write(&path, fm.render() + body)
}
fn repo_display_name(root: &Path) -> String {
root.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| "repository".to_string())
}
fn fail(what: &str, e: &std::io::Error) -> ! {
eprintln!("radar: {what} failed: {e}");
let code = match e.kind() {
std::io::ErrorKind::NotFound
| std::io::ErrorKind::NotADirectory
| std::io::ErrorKind::PermissionDenied => rdar::exit::FIRST_RUN_NEEDED,
_ => rdar::exit::INTERNAL,
};
std::process::exit(code);
}
fn print_json<T: serde::Serialize>(value: &T) {
match serde_json::to_string_pretty(value) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("radar: failed to encode JSON: {e}");
std::process::exit(rdar::exit::INTERNAL);
}
}
}
fn print_stats(stats: &rdar::scan::ScanStats) {
println!("radar scan - {}", stats.root);
println!(
" files: {} source: {} parsed: {} stat-cached: {} hash-cached: {}",
stats.files_seen, stats.source_files, stats.parsed, stats.stat_hits, stats.hash_hits
);
if stats.skipped_large > 0 {
println!(" skipped (>16MB): {}", stats.skipped_large);
}
if !stats.cache_persisted {
println!(" note: cache not persisted (read-only checkout?)");
}
println!(" defs: {} refs: {}", stats.defs, stats.refs);
for (lang, ls) in &stats.per_lang {
println!(
" {:<12} {:>6} files {:>8} defs {:>8} refs",
lang, ls.files, ls.defs, ls.refs
);
}
println!(
" git: {} elapsed: {}ms",
if stats.git_repo { "yes" } else { "no" },
stats.elapsed_ms
);
}