use std::io;
use std::path::{Path, PathBuf};
use crate::cache::ScanCache;
use crate::mapfile;
use crate::place::Placement;
const MAX_ENTRIES: usize = 500;
const HEADER: &str = "# ROUTES - solved-navigation cache\n\
# state\tkeywords\tanchor\tmap\ttoken\thits\tseq\tlinks\n";
const STOPWORDS: [&str; 34] = [
"about", "can", "could", "me", "need", "needs", "please", "tell", "want", "wants", "would",
"you", "the", "a", "an", "is", "are", "in", "on", "of", "to", "for", "and", "or", "where",
"how", "what", "which", "this", "that", "it", "repo", "file", "code",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RouteState {
Ok,
Stale,
Auto,
}
impl RouteState {
pub fn label(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Stale => "stale",
Self::Auto => "auto",
}
}
}
#[derive(Clone, Debug)]
pub struct RouteEntry {
pub state: RouteState,
pub keywords: Vec<String>,
pub anchor: String,
pub map: String,
pub token: String,
pub hits: u64,
pub seq: u64,
pub links: Vec<String>,
}
impl RouteEntry {
fn key(&self) -> String {
let mut k = self.keywords.clone();
k.sort();
k.join(" ")
}
fn render(&self) -> String {
let state = self.state.label();
let links = if self.links.is_empty() {
String::new()
} else {
format!("\t{}", self.links.join("; "))
};
format!(
"[{state}]\t{}\t{}\t{}\t{}\t{}\t{}{links}\n",
self.keywords.join(" "),
self.anchor,
self.map,
self.token,
self.hits,
self.seq,
)
}
}
pub fn normalize(query: &str) -> Vec<String> {
if query.is_ascii() {
return normalize_ascii(query);
}
normalize_unicode(query)
}
fn normalize_unicode(query: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for tok in query
.to_lowercase()
.split(|c: char| !c.is_alphanumeric() && c != '_')
{
let tok = tok.trim_matches('_');
if tok.len() < 2 || STOPWORDS.contains(&tok) {
continue;
}
if !out.iter().any(|t| t == tok) {
out.push(tok.to_string());
}
}
out
}
fn normalize_ascii(query: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes = query.as_bytes();
let mut start = 0usize;
for i in 0..=bytes.len() {
if i == bytes.len() || !(bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
push_ascii_token(query, &mut out, start, i);
start = i + 1;
}
}
out
}
fn push_ascii_token(query: &str, out: &mut Vec<String>, start: usize, end: usize) {
let bytes = query.as_bytes();
let mut left = start;
let mut right = end;
while left < right && bytes[left] == b'_' {
left += 1;
}
while right > left && bytes[right - 1] == b'_' {
right -= 1;
}
if right.saturating_sub(left) < 2 {
return;
}
let token = &query[left..right];
if STOPWORDS
.iter()
.any(|stopword| token.eq_ignore_ascii_case(stopword))
{
return;
}
if out
.iter()
.any(|existing: &String| existing.eq_ignore_ascii_case(token))
{
return;
}
out.push(token.to_ascii_lowercase());
}
fn routes_path(root: &Path) -> PathBuf {
crate::cache::radar_dir(root).join("ROUTES.md")
}
pub fn load(root: &Path) -> Vec<RouteEntry> {
let Ok(text) = std::fs::read_to_string(routes_path(root)) else {
return Vec::new();
};
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some(rest) = line.strip_prefix('[') else {
continue;
};
let Some((state, rest)) = rest.split_once("] ").or_else(|| rest.split_once("]\t")) else {
continue;
};
let state = match state {
"ok" => RouteState::Ok,
"stale" => RouteState::Stale,
"auto" => RouteState::Auto,
_ => continue,
};
let (parts, links): (Vec<&str>, Vec<String>) = if rest.contains('\t') {
let mut parts: Vec<&str> = rest.split('\t').collect();
let links = if parts.len() == 7 {
parts
.pop()
.unwrap()
.split("; ")
.map(str::to_string)
.collect()
} else {
Vec::new()
};
(parts, links)
} else {
let (main, links) = match rest.split_once(" → see: ") {
Some((m, l)) => (m, l.split("; ").map(str::to_string).collect()),
None => (rest, Vec::new()),
};
(main.split(" | ").collect(), links)
};
if parts.len() != 6 {
continue;
}
let (Ok(hits), Ok(seq)) = (parts[4].trim().parse(), parts[5].trim().parse()) else {
continue;
};
out.push(RouteEntry {
state,
keywords: parts[0].split_whitespace().map(str::to_string).collect(),
anchor: parts[1].trim().to_string(),
map: parts[2].trim().to_string(),
token: parts[3].trim().to_string(),
hits,
seq,
links,
});
}
out
}
fn save(root: &Path, mut entries: Vec<RouteEntry>) -> io::Result<()> {
entries.sort_by_key(|e| e.key());
let dir = crate::cache::radar_dir(root);
std::fs::create_dir_all(&dir)?;
let mut doc = String::from(HEADER);
for e in &entries {
doc.push_str(&e.render());
}
let tmp = dir.join("ROUTES.md.tmp");
std::fs::write(&tmp, doc)?;
std::fs::rename(tmp, routes_path(root))
}
fn scope_token(cache: &ScanCache, placement: &Placement, rel: &str) -> (String, String) {
let scope = placement.owner.get(rel).cloned().unwrap_or_default();
let hash =
mapfile::api_hash(mapfile::scope_symbols(cache, placement, &scope).map(|(_, s)| s.clone()));
let map = if scope.is_empty() {
"MAP.md".to_string()
} else {
format!("{scope}/MAP.md")
};
let token = hash.get(..8).unwrap_or(hash.as_str()).to_string();
(map, token)
}
fn clean_links(links: Vec<String>) -> Result<Vec<String>, String> {
let mut cleaned = Vec::new();
for link in links {
if link.contains('|')
|| link.contains('\t')
|| link.contains('\n')
|| link.contains('\r')
|| link.contains("; ")
|| link.contains(" → see: ")
{
return Err(format!("route link {link:?} contains a reserved delimiter"));
}
let link = link.split_whitespace().collect::<Vec<_>>().join(" ");
if link.is_empty() {
return Err("route links cannot be empty".into());
}
if link.chars().count() > 160 {
return Err("route links are limited to 160 characters".into());
}
if !cleaned.contains(&link) {
cleaned.push(link);
}
}
Ok(cleaned)
}
pub fn add(
root: &Path,
cache: &ScanCache,
placement: &Placement,
query: &str,
anchor: &str,
links: Vec<String>,
) -> Result<String, String> {
let keywords = normalize(query);
if keywords.is_empty() {
return Err("query has no usable keywords".into());
}
if anchor.trim() != anchor
|| anchor.is_empty()
|| anchor.contains('|')
|| anchor.contains('\t')
|| anchor.contains('\n')
|| anchor.contains('\r')
{
return Err("anchor contains whitespace or a reserved delimiter".into());
}
let anchor = anchor.to_string();
let (file, symbol) = match anchor.split_once('#') {
Some((f, s)) => (f, Some(s)),
None => (anchor.as_str(), None),
};
if file.is_empty() || symbol.is_some_and(str::is_empty) {
return Err("anchor must name a file and an optional symbol or line".into());
}
let Some(entry) = cache.files.get(file) else {
return Err(format!("anchor file {file:?} is not in the scanned repo"));
};
if let Some(symbol) = symbol {
let known = if let Ok(line) = symbol.parse::<usize>() {
line > 0
&& std::fs::read_to_string(root.join(file))
.is_ok_and(|source| line <= source.lines().count().max(1))
} else {
entry.lang.is_some_and(|lang| {
cache
.parses
.get(&(lang, entry.hash))
.is_some_and(|extraction| extraction.defs.iter().any(|def| def.name == symbol))
})
};
if !known {
return Err(format!(
"symbol or line {symbol:?} is not valid in {file} (anchor rejected)"
));
}
}
let links = clean_links(links)?;
let (map, token) = scope_token(cache, placement, file);
let mut entries = load(root);
let key = {
let mut k = keywords.clone();
k.sort();
k.join(" ")
};
let next_seq = entries.iter().map(|e| e.seq).max().unwrap_or(0) + 1;
match entries.iter_mut().find(|e| e.key() == key) {
Some(existing) => {
existing.anchor = anchor.clone();
existing.map = map;
existing.token = token;
existing.state = RouteState::Ok;
existing.hits += 1;
if !links.is_empty() {
existing.links = links;
}
}
None => {
entries.push(RouteEntry {
state: RouteState::Ok,
keywords,
anchor: anchor.clone(),
map,
token,
hits: 1,
seq: next_seq,
links,
});
}
}
while entries.len() > MAX_ENTRIES {
let evict_rank = |e: &RouteEntry| match e.state {
RouteState::Auto => 0,
RouteState::Stale => 1,
RouteState::Ok => 2,
};
let idx = entries
.iter()
.enumerate()
.min_by_key(|(_, e)| (evict_rank(e), e.hits, std::cmp::Reverse(e.seq)))
.map(|(i, _)| i)
.unwrap_or(0);
entries.remove(idx);
}
save(root, entries).map_err(|e| e.to_string())?;
Ok(format!("route recorded → {anchor}"))
}
pub(crate) fn tokens_match(a: &str, b: &str) -> bool {
if a == b {
return true;
}
let common = a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count();
let shorter = a.len().min(b.len());
(common >= 4 || (common == 3 && shorter == 3)) && common * 10 >= shorter * 6
}
pub(crate) fn term_fingerprint(word: &str) -> Option<u32> {
if word.is_ascii() {
return term_fingerprint_ascii(word);
}
term_fingerprint_unicode(word)
}
fn term_fingerprint_unicode(word: &str) -> Option<u32> {
let mut normalized = String::new();
for character in word.chars().filter(|character| character.is_alphanumeric()) {
normalized.extend(character.to_lowercase());
if normalized.chars().count() == 4 {
break;
}
}
if normalized.chars().count() < 3 {
return None;
}
Some(normalized.bytes().fold(2_166_136_261u32, |hash, byte| {
(hash ^ u32::from(byte)).wrapping_mul(16_777_619)
}))
}
fn term_fingerprint_ascii(word: &str) -> Option<u32> {
let mut count = 0usize;
let mut hash = 2_166_136_261u32;
for byte in word.bytes().filter(u8::is_ascii_alphanumeric) {
hash = (hash ^ u32::from(byte.to_ascii_lowercase())).wrapping_mul(16_777_619);
count += 1;
if count == 4 {
break;
}
}
(count >= 3).then_some(hash)
}
pub fn find(root: &Path, query: &str) -> Vec<(usize, RouteEntry)> {
find_in(&load(root), query)
}
pub fn find_in(entries: &[RouteEntry], query: &str) -> Vec<(usize, RouteEntry)> {
let want = normalize(query);
struct Hit<'a> {
score: usize,
entry: &'a RouteEntry,
key: Vec<&'a str>,
}
let mut hits: Vec<Hit<'_>> = entries
.iter()
.filter_map(|e| {
let score = e
.keywords
.iter()
.filter(|k| want.iter().any(|w| tokens_match(k, w)))
.count();
if score == 0 {
return None;
}
let mut key: Vec<&str> = e.keywords.iter().map(String::as_str).collect();
key.sort_unstable();
Some(Hit {
score,
entry: e,
key,
})
})
.collect();
let trust = |s: RouteState| match s {
RouteState::Ok => 0,
RouteState::Auto => 1,
RouteState::Stale => 2,
};
hits.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| trust(a.entry.state).cmp(&trust(b.entry.state)))
.then_with(|| b.entry.hits.cmp(&a.entry.hits))
.then_with(|| a.key.cmp(&b.key))
});
hits.truncate(5);
hits.into_iter()
.map(|hit| (hit.score, hit.entry.clone()))
.collect()
}
pub fn invalidate(root: &Path, cache: &ScanCache, placement: &Placement) -> (usize, usize) {
let entries = load(root);
if entries.is_empty() {
return (0, 0);
}
let mut kept = Vec::new();
let (mut ok, mut stale) = (0usize, 0usize);
for mut e in entries {
let file = e.anchor.split('#').next().unwrap_or(&e.anchor).to_string();
if !cache.files.contains_key(&file) {
continue; }
let (_, token) = scope_token(cache, placement, &file);
if token == e.token {
if e.state == RouteState::Stale {
e.state = RouteState::Ok;
}
ok += 1;
} else if e.state == RouteState::Auto {
continue; } else {
e.state = RouteState::Stale;
stale += 1;
}
kept.push(e);
}
let _ = save(root, kept);
(ok, stale)
}
pub(crate) fn split_ident(name: &str) -> Vec<String> {
let chars: Vec<char> = name.chars().collect();
let mut words: Vec<String> = Vec::new();
let mut cur = String::new();
for (i, &c) in chars.iter().enumerate() {
if c == '_' || c == '-' {
if !cur.is_empty() {
words.push(std::mem::take(&mut cur));
}
continue;
}
let boundary = c.is_uppercase()
&& i > 0
&& (chars[i - 1].is_lowercase() || chars.get(i + 1).is_some_and(|n| n.is_lowercase()));
if boundary && !cur.is_empty() {
words.push(std::mem::take(&mut cur));
}
cur.push(c.to_ascii_lowercase());
}
if !cur.is_empty() {
words.push(cur);
}
words.retain(|w| w.len() >= 2);
if words.len() == 1
&& let Some(remainder) = words[0].strip_prefix("un")
&& remainder.len() >= 4
{
words.push(remainder.to_string());
}
words
}
pub fn auto_seed(
root: &Path,
cache: &ScanCache,
placement: &Placement,
ranking: &crate::graph::Ranking,
) -> usize {
let mut entries: Vec<RouteEntry> = load(root)
.into_iter()
.filter(|e| e.state != RouteState::Auto)
.collect();
let mut taken_keys: std::collections::BTreeSet<String> =
entries.iter().map(|e| e.key()).collect();
let mut next_seq = entries.iter().map(|e| e.seq).max().unwrap_or(0) + 1;
let mut seeded = 0usize;
let cap = 300usize.min(MAX_ENTRIES.saturating_sub(entries.len()));
'scopes: for scope in &placement.anchors {
let mut symbols: Vec<(u64, &String, &crate::extract::Symbol)> =
mapfile::scope_symbols(cache, placement, scope)
.filter(|(_, s)| s.vis == crate::extract::Vis::Pub)
.map(|(rel, s)| (ranking.name_refs.get(&s.name).copied().unwrap_or(0), rel, s))
.collect();
symbols.sort_by(|a, b| {
b.0.cmp(&a.0)
.then_with(|| a.1.cmp(b.1))
.then_with(|| a.2.line.cmp(&b.2.line))
});
for (_, rel, sym) in symbols.into_iter().take(3) {
let mut keywords = split_ident(&sym.name);
for part in scope.split('/') {
for w in split_ident(part) {
if !keywords.contains(&w) {
keywords.push(w);
}
}
}
if keywords.len() < 2 {
continue;
}
let entry = RouteEntry {
state: RouteState::Auto,
keywords,
anchor: format!("{rel}#{}", sym.name),
map: if scope.is_empty() {
"MAP.md".to_string()
} else {
format!("{scope}/MAP.md")
},
token: scope_token(cache, placement, rel).1,
hits: 0,
seq: next_seq,
links: Vec::new(),
};
if !taken_keys.insert(entry.key()) {
continue;
}
next_seq += 1;
entries.push(entry);
seeded += 1;
if seeded >= cap {
break 'scopes;
}
}
}
let _ = save(root, entries);
seeded
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::FileEntry;
use crate::extract::{Extraction, SymKind, Symbol, Vis};
use crate::lang::Lang;
fn setup(tag: &str) -> (PathBuf, ScanCache, Placement) {
let dir = std::env::temp_dir().join(format!("radar-routes-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
let mut cache = ScanCache::default();
let hash = [9u8; 32];
cache.files.insert(
"pay/settle.py".into(),
FileEntry {
mtime: (1, 0),
size: 1,
ino: 0,
hash,
lang: Some(Lang::Python),
},
);
cache.parses.insert(
(Lang::Python, hash),
Extraction {
defs: vec![Symbol {
line: 4,
end_line: 8,
name: "settle_batch".into(),
kind: SymKind::Fn,
vis: Vis::Pub,
sig: "def settle_batch(batch)".into(),
terms: Vec::new(),
}],
refs: vec![],
},
);
std::fs::create_dir_all(dir.join("pay")).expect("create fixture directory");
std::fs::write(
dir.join("pay/settle.py"),
"\n\n\n\ndef settle_batch(batch):\n return batch\n",
)
.expect("write source fixture");
let placement = crate::place::place(&cache);
(dir, cache, placement)
}
#[test]
fn normalize_strips_and_dedups() {
assert_eq!(
normalize("Where is THE payment batch settlement handled in this repo?"),
vec!["payment", "batch", "settlement", "handled"]
);
assert_eq!(
normalize("Can you please tell me about payment batch settlement?"),
vec!["payment", "batch", "settlement"]
);
assert!(normalize("a of the").is_empty());
}
#[test]
fn add_find_roundtrip_and_dedup_bump() {
let (dir, cache, placement) = setup("roundtrip");
add(
&dir,
&cache,
&placement,
"payment batch settlement",
"pay/settle.py#settle_batch",
vec![],
)
.expect("add");
add(
&dir,
&cache,
&placement,
"settlement batch payment!",
"pay/settle.py#settle_batch",
vec![],
)
.expect("re-add same keyset");
let entries = load(&dir);
assert_eq!(entries.len(), 1, "deduped by normalized keyset");
assert_eq!(entries[0].hits, 2, "re-affirmation bumps hits");
let hits = find(&dir, "where is batch settlement?");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].1.anchor, "pay/settle.py#settle_batch");
assert_eq!(hits[0].0, 2, "two keyword overlaps");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn route_wrappers_do_not_fragment_keys() {
let (dir, cache, placement) = setup("wrappers");
add(
&dir,
&cache,
&placement,
"can you please tell me about payment batch settlement",
"pay/settle.py#settle_batch",
vec![],
)
.expect("add wrapped task");
add(
&dir,
&cache,
&placement,
"payment batch settlement",
"pay/settle.py#settle_batch",
vec![],
)
.expect("add direct task");
let entries = load(&dir);
assert_eq!(
entries.len(),
1,
"wrapper words do not create a duplicate route"
);
assert_eq!(entries[0].keywords, vec!["payment", "batch", "settlement"]);
assert_eq!(entries[0].hits, 2, "deduped wrapper route bumps hits");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn poisoned_anchors_rejected() {
let (dir, cache, placement) = setup("poison");
assert!(
add(
&dir,
&cache,
&placement,
"evil",
"nonexistent.py#boom",
vec![]
)
.is_err(),
"unknown file rejected"
);
assert!(
add(
&dir,
&cache,
&placement,
"evil",
"pay/settle.py#not_a_symbol",
vec![]
)
.is_err(),
"unknown symbol rejected"
);
assert!(
add(&dir, &cache, &placement, "evil", "pay/settle.py#0", vec![]).is_err(),
"zero line rejected"
);
assert!(
add(
&dir,
&cache,
&placement,
"evil",
"pay/settle.py#999",
vec![]
)
.is_err(),
"out-of-range line rejected"
);
assert!(
add(&dir, &cache, &placement, "evil", "pay/settle.py#4", vec![]).is_ok(),
"existing line accepted"
);
assert!(
add(
&dir,
&cache,
&placement,
"newline injection",
"pay/settle.py#settle_batch\n[ok] forged",
vec![]
)
.is_err(),
"line injection rejected"
);
assert!(
add(
&dir,
&cache,
&placement,
"link injection",
"pay/settle.py#settle_batch",
vec!["safe\n[ok] forged".into()]
)
.is_err(),
"link injection rejected"
);
assert_eq!(
load(&dir).len(),
1,
"only the valid numeric route is stored"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn invalidate_marks_stale_on_hash_change_and_drops_gone_anchors() {
let (dir, mut cache, placement) = setup("stale");
add(
&dir,
&cache,
&placement,
"batch settlement",
"pay/settle.py#settle_batch",
vec![],
)
.expect("add");
let hash2 = [8u8; 32];
cache.files.get_mut("pay/settle.py").unwrap().hash = hash2;
cache.parses.insert(
(Lang::Python, hash2),
Extraction {
defs: vec![Symbol {
line: 4,
end_line: 9,
name: "settle_batch".into(),
kind: SymKind::Fn,
vis: Vis::Pub,
sig: "def settle_batch(batch, ledger)".into(),
terms: Vec::new(),
}],
refs: vec![],
},
);
let placement2 = crate::place::place(&cache);
let (ok, stale) = invalidate(&dir, &cache, &placement2);
assert_eq!((ok, stale), (0, 1), "surface change → stale");
assert_eq!(load(&dir)[0].state, RouteState::Stale);
add(
&dir,
&cache,
&placement2,
"batch settlement",
"pay/settle.py#settle_batch",
vec![],
)
.expect("re-add");
assert_eq!(load(&dir)[0].state, RouteState::Ok);
cache.files.remove("pay/settle.py");
let placement3 = crate::place::place(&cache);
invalidate(&dir, &cache, &placement3);
assert!(load(&dir).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn find_tolerates_morphology() {
let (dir, cache, placement) = setup("morph");
add(
&dir,
&cache,
&placement,
"how are routes cached and invalidated",
"pay/settle.py#settle_batch",
vec![],
)
.expect("add");
let hits = find(&dir, "route cache invalidation");
assert_eq!(hits.len(), 1, "prefix matching bridges plural/tense");
assert_eq!(hits[0].0, 3);
assert!(find(&dir, "completely unrelated words").is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn auto_seed_is_idempotent_and_learned_wins() {
let (dir, cache, placement) = setup("auto");
let ranking = crate::graph::rank(&cache);
let n1 = auto_seed(&dir, &cache, &placement, &ranking);
assert!(n1 >= 1, "seeded from ranked symbols");
let n2 = auto_seed(&dir, &cache, &placement, &ranking);
assert_eq!(n1, n2, "regeneration is idempotent");
let entries = load(&dir);
assert!(entries.iter().all(|e| e.state == RouteState::Auto));
assert!(
entries
.iter()
.any(|e| e.keywords.contains(&"settle".to_string())
&& e.keywords.contains(&"batch".to_string())),
"{entries:?}"
);
add(
&dir,
&cache,
&placement,
"settle batch pay",
"pay/settle.py#settle_batch",
vec![],
)
.expect("learn");
auto_seed(&dir, &cache, &placement, &ranking);
let hits = find(&dir, "settle batch");
assert_eq!(hits[0].1.state, RouteState::Ok, "learned outranks auto");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn split_ident_handles_snake_and_camel() {
assert_eq!(split_ident("settle_batch"), vec!["settle", "batch"]);
assert_eq!(split_ident("fetchOrders"), vec!["fetch", "orders"]);
assert_eq!(split_ident("HTTPServer"), vec!["http", "server"]);
assert_eq!(split_ident("x"), Vec::<String>::new());
}
#[test]
fn links_round_trip() {
let (dir, cache, placement) = setup("links");
add(
&dir,
&cache,
&placement,
"batch settlement",
"pay/settle.py#settle_batch",
vec!["refunds ledger".into(), "payment intake".into()],
)
.expect("add");
let e = &load(&dir)[0];
assert_eq!(e.links, vec!["refunds ledger", "payment intake"]);
let text = std::fs::read_to_string(routes_path(&dir)).expect("read");
assert!(text.contains("\trefunds ledger; payment intake"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_accepts_legacy_pipe_routes() {
let dir = std::env::temp_dir().join(format!("radar-routes-legacy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::create_dir_all(dir.join(".radar")).expect("mkdir radar");
std::fs::write(
routes_path(&dir),
"# legacy\n[ok] batch settlement | pay/settle.py#settle_batch | pay/MAP.md | abcdef12 | 3 | 9 → see: refunds ledger\n",
)
.expect("write route");
let entries = load(&dir);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].state, RouteState::Ok);
assert_eq!(entries[0].links, vec!["refunds ledger"]);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod route_lookup_optimization_tests {
use super::*;
fn route(anchor: &str, state: RouteState, hits: u64, keywords: &[&str]) -> RouteEntry {
RouteEntry {
state,
keywords: keywords.iter().map(|word| (*word).to_string()).collect(),
anchor: anchor.to_string(),
map: "src/MAP.md".to_string(),
token: "deadbeef".to_string(),
hits,
seq: 0,
links: Vec::new(),
}
}
#[test]
fn normalize_ascii_fast_path_preserves_route_key_semantics() {
assert_eq!(
normalize("Please ROUTE, route __Cache__ _x_ an AA"),
vec!["route", "cache", "aa"]
);
}
#[test]
fn normalize_unicode_fallback_preserves_lowercase_and_dedupe() {
assert_eq!(
normalize("École école __δelta__ ROUTE"),
vec!["école", "δelta", "route"]
);
}
#[test]
fn find_in_keeps_score_trust_hits_and_key_ordering() {
let entries = vec![
route("score1-ok-high", RouteState::Ok, 99, &["route"]),
route(
"score2-auto-high",
RouteState::Auto,
99,
&["route", "cache"],
),
route(
"score2-stale-high",
RouteState::Stale,
99,
&["route", "cache"],
),
route(
"score2-ok-z",
RouteState::Ok,
9,
&["route", "cache", "zulu"],
),
route(
"score2-ok-a",
RouteState::Ok,
9,
&["route", "cache", "alpha"],
),
route("score2-ok-top", RouteState::Ok, 10, &["route", "cache"]),
];
let anchors: Vec<String> = find_in(&entries, "route cache")
.into_iter()
.map(|(_, entry)| entry.anchor)
.collect();
assert_eq!(
anchors,
vec![
"score2-ok-top",
"score2-ok-a",
"score2-ok-z",
"score2-auto-high",
"score2-stale-high",
]
);
}
}
#[cfg(test)]
mod route_hit_key_cache_tests {
use super::*;
fn route(anchor: &str, keywords: &[&str]) -> RouteEntry {
RouteEntry {
state: RouteState::Ok,
keywords: keywords.iter().map(|word| (*word).to_string()).collect(),
anchor: anchor.to_string(),
map: "src/MAP.md".to_string(),
token: "deadbeef".to_string(),
hits: 1,
seq: 0,
links: Vec::new(),
}
}
#[test]
fn find_in_tie_breaks_by_sorted_keywords_once_per_hit() {
let entries = vec![
route("zeta", &["route", "cache", "zeta"]),
route("alpha", &["cache", "route", "alpha"]),
route("prefix", &["route", "cache"]),
];
let anchors: Vec<String> = find_in(&entries, "route cache")
.into_iter()
.map(|(_, entry)| entry.anchor)
.collect();
assert_eq!(anchors, vec!["alpha", "prefix", "zeta"]);
}
}
#[cfg(test)]
mod term_fingerprint_fast_path_tests {
use super::*;
#[test]
fn ascii_term_fingerprint_uses_first_four_lowercase_alnum_bytes() {
assert_eq!(term_fingerprint("RouteCache"), term_fingerprint("rout"));
assert_eq!(term_fingerprint("ro-UTe"), term_fingerprint("rout"));
assert_eq!(term_fingerprint("ab"), None);
}
#[test]
fn unicode_term_fingerprint_keeps_unicode_lowercase_path() {
assert_eq!(term_fingerprint("École"), term_fingerprint_unicode("École"));
assert_eq!(term_fingerprint("İtem"), term_fingerprint_unicode("İtem"));
}
}