use std::collections::{BTreeMap, VecDeque};
use serde::Serialize;
use crate::store::{Store, StoreError};
use crate::{Edge, NodeKind, Provenance};
pub const SCHEMA: &str = "roteiro.query/v1";
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct NodeSummary {
pub key: String,
pub kind: String,
pub name: String,
pub path: Option<String>,
pub lang: Option<String>,
}
impl NodeSummary {
fn from_node(node: &crate::Node) -> Self {
Self {
key: node.key.clone(),
kind: node.kind.as_str().to_owned(),
name: node.name.clone(),
path: node.path.clone(),
lang: node.lang.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct EdgeRef {
pub kind: String,
pub provenance: &'static str,
pub confidence: Option<f64>,
pub node: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Explanation {
pub schema: &'static str,
pub node: NodeSummary,
pub meta: serde_json::Value,
pub outgoing: Vec<EdgeRef>,
pub incoming: Vec<EdgeRef>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Listing {
pub schema: &'static str,
pub kind: String,
pub nodes: Vec<NodeSummary>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DebtItem {
pub key: String,
pub category: String,
pub text: String,
pub path: Option<String>,
pub line: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DebtReport {
pub schema: &'static str,
pub total: usize,
pub by_category: BTreeMap<String, usize>,
pub items: Vec<DebtItem>,
}
pub fn debt(
store: &Store,
categories: &[String],
ignore: &[String],
) -> Result<DebtReport, StoreError> {
let filter: std::collections::BTreeSet<&str> = categories.iter().map(String::as_str).collect();
let mut items = Vec::new();
let mut by_category: BTreeMap<String, usize> = BTreeMap::new();
for node in store.nodes_by_kind(&NodeKind::Marker)? {
let category = node
.meta
.get("category")
.and_then(serde_json::Value::as_str)
.unwrap_or("other")
.to_owned();
if !filter.is_empty() && !filter.contains(category.as_str()) {
continue;
}
if let Some(path) = node.path.as_deref()
&& ignore.iter().any(|glob| glob_match(glob, path))
{
continue;
}
let text = node
.meta
.get("text")
.and_then(serde_json::Value::as_str)
.unwrap_or(node.name.as_str())
.to_owned();
let line = node
.meta
.get("line")
.and_then(serde_json::Value::as_u64)
.and_then(|l| u32::try_from(l).ok());
*by_category.entry(category.clone()).or_default() += 1;
items.push(DebtItem {
key: node.key.clone(),
category,
text,
path: node.path.clone(),
line,
});
}
items.sort_by(|a, b| (&a.path, a.line, &a.key).cmp(&(&b.path, b.line, &b.key)));
Ok(DebtReport {
schema: SCHEMA,
total: items.len(),
by_category,
items,
})
}
#[must_use]
fn glob_match(pattern: &str, path: &str) -> bool {
let pat: Vec<&str> = pattern.split('/').collect();
let seg: Vec<&str> = path.split('/').collect();
match_segments(&pat, &seg)
}
fn match_segments(pat: &[&str], seg: &[&str]) -> bool {
match pat.first() {
None => seg.is_empty(),
Some(&"**") => (0..=seg.len()).any(|i| match_segments(&pat[1..], &seg[i..])),
Some(token) => {
!seg.is_empty() && match_token(token, seg[0]) && match_segments(&pat[1..], &seg[1..])
}
}
}
fn match_token(pattern: &str, s: &str) -> bool {
let pat: Vec<char> = pattern.chars().collect();
let chars: Vec<char> = s.chars().collect();
match_token_chars(&pat, &chars)
}
fn match_token_chars(pat: &[char], chars: &[char]) -> bool {
match pat.first() {
None => chars.is_empty(),
Some('*') => (0..=chars.len()).any(|i| match_token_chars(&pat[1..], &chars[i..])),
Some('?') => !chars.is_empty() && match_token_chars(&pat[1..], &chars[1..]),
Some(&ch) => {
!chars.is_empty() && chars[0] == ch && match_token_chars(&pat[1..], &chars[1..])
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PathHop {
pub kind: String,
pub provenance: &'static str,
pub confidence: Option<f64>,
pub direction: &'static str,
pub node: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Path {
pub schema: &'static str,
pub from: String,
pub to: String,
pub found: bool,
pub length: usize,
pub hops: Vec<PathHop>,
}
fn out_ref(edge: &Edge) -> EdgeRef {
EdgeRef {
kind: edge.kind.as_str().to_owned(),
provenance: edge.provenance.as_str(),
confidence: edge.confidence,
node: edge.dst.clone(),
}
}
fn in_ref(edge: &Edge) -> EdgeRef {
EdgeRef {
kind: edge.kind.as_str().to_owned(),
provenance: edge.provenance.as_str(),
confidence: edge.confidence,
node: edge.src.clone(),
}
}
fn sort_refs(refs: &mut [EdgeRef]) {
refs.sort_by(|a, b| (&a.kind, &a.node, a.provenance).cmp(&(&b.kind, &b.node, b.provenance)));
}
pub fn explain(store: &Store, key: &str) -> Result<Option<Explanation>, StoreError> {
let Some(node) = store.get_node(key)? else {
return Ok(None);
};
let mut outgoing: Vec<EdgeRef> = store.edges_from(key)?.iter().map(out_ref).collect();
let mut incoming: Vec<EdgeRef> = store.edges_to(key)?.iter().map(in_ref).collect();
sort_refs(&mut outgoing);
sort_refs(&mut incoming);
Ok(Some(Explanation {
schema: SCHEMA,
node: NodeSummary::from_node(&node),
meta: node.meta,
outgoing,
incoming,
}))
}
pub fn list_kind(store: &Store, kind: &NodeKind) -> Result<Listing, StoreError> {
let nodes = store
.nodes_by_kind(kind)?
.iter()
.map(NodeSummary::from_node)
.collect();
Ok(Listing {
schema: SCHEMA,
kind: kind.as_str().to_owned(),
nodes,
})
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct SearchHit {
pub score: u32,
#[serde(flatten)]
pub node: NodeSummary,
}
pub fn search(store: &Store, query: &str, limit: usize) -> Result<Vec<SearchHit>, StoreError> {
if limit == 0 {
return Ok(Vec::new());
}
let q = query.trim().to_lowercase();
let tokens: Vec<&str> = q.split("::").flat_map(str::split_whitespace).collect();
if tokens.is_empty() {
return Ok(Vec::new());
}
let mut hits: Vec<SearchHit> = Vec::new();
for node in store.all_nodes()? {
let name = node.name.to_lowercase();
let key = node.key.to_lowercase();
let path = node.path.as_deref().unwrap_or("").to_lowercase();
let content = node
.meta
.get("content")
.and_then(|v| v.as_str())
.map(str::to_lowercase);
let content = content.as_deref().unwrap_or("");
if !tokens
.iter()
.all(|t| name.contains(t) || key.contains(t) || path.contains(t) || content.contains(t))
{
continue;
}
let mut relevance: i32 = 0;
if name == q {
relevance += 100;
} else if name.contains(&q) {
relevance += 60;
} else if content.contains(&q) {
relevance += 25;
}
for t in &tokens {
if name.contains(t) {
relevance += 12;
} else if key.contains(t) {
relevance += 6;
} else if content.contains(t) {
relevance += 8;
} else if path.contains(t) {
relevance += 3;
}
}
if node.provenance == Provenance::Authored {
relevance += 40;
}
if is_overview_path(&path) {
relevance += 30;
}
if is_test_path(&path) {
relevance -= 60;
}
hits.push(SearchHit {
score: u32::try_from(relevance.max(0)).unwrap_or(0),
node: NodeSummary::from_node(&node),
});
}
hits.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| a.node.key.cmp(&b.node.key))
});
hits.truncate(limit);
Ok(hits)
}
fn is_overview_path(path: &str) -> bool {
path.rsplit('/')
.next()
.is_some_and(|base| base.starts_with("readme") || base.starts_with("overview"))
}
fn is_test_path(path: &str) -> bool {
path.contains("/tests/") || path.contains("/test/")
}
struct Step {
node: String,
hop: PathHop,
}
fn steps_from(store: &Store, key: &str) -> Result<Vec<Step>, StoreError> {
let mut steps = Vec::new();
for edge in store.edges_from(key)? {
steps.push(Step {
node: edge.dst.clone(),
hop: hop(&edge, "outgoing", edge.dst.clone()),
});
}
for edge in store.edges_to(key)? {
steps.push(Step {
node: edge.src.clone(),
hop: hop(&edge, "incoming", edge.src.clone()),
});
}
steps.sort_by(|a, b| {
(&a.node, &a.hop.kind, a.hop.provenance, a.hop.direction).cmp(&(
&b.node,
&b.hop.kind,
b.hop.provenance,
b.hop.direction,
))
});
Ok(steps)
}
fn hop(edge: &Edge, direction: &'static str, node: String) -> PathHop {
PathHop {
kind: edge.kind.as_str().to_owned(),
provenance: edge.provenance.as_str(),
confidence: edge.confidence,
direction,
node,
}
}
pub fn path(store: &Store, from: &str, to: &str) -> Result<Path, StoreError> {
let not_found = |found: bool, hops: Vec<PathHop>| Path {
schema: SCHEMA,
from: from.to_owned(),
to: to.to_owned(),
found,
length: hops.len(),
hops,
};
if store.get_node(from)?.is_none() || store.get_node(to)?.is_none() {
return Ok(not_found(false, Vec::new()));
}
if from == to {
return Ok(not_found(true, Vec::new()));
}
let mut came_from: BTreeMap<String, (String, PathHop)> = BTreeMap::new();
let mut queue: VecDeque<String> = VecDeque::new();
queue.push_back(from.to_owned());
came_from.insert(from.to_owned(), (String::new(), placeholder_hop()));
while let Some(current) = queue.pop_front() {
if current == to {
break;
}
for step in steps_from(store, ¤t)? {
if came_from.contains_key(&step.node) {
continue;
}
came_from.insert(step.node.clone(), (current.clone(), step.hop));
queue.push_back(step.node);
}
}
let mut hops = Vec::new();
let mut cursor = to.to_owned();
while cursor != from {
let Some((prev, hop)) = came_from.get(&cursor) else {
return Ok(not_found(false, Vec::new()));
};
hops.push(hop.clone());
cursor = prev.clone();
}
hops.reverse();
Ok(not_found(true, hops))
}
fn placeholder_hop() -> PathHop {
PathHop {
kind: String::new(),
provenance: "derived",
confidence: None,
direction: "outgoing",
node: String::new(),
}
}
#[cfg(test)]
mod tests {
use super::{SCHEMA, explain, glob_match, list_kind, path, search};
use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
fn seeded() -> Store {
let mut store = Store::open_in_memory().expect("store");
let facts = FactSet::new()
.with_node(Node::new("sym:rust:a.rs#main", NodeKind::Fn, "main"))
.with_node(Node::new("sym:rust:a.rs#helper", NodeKind::Fn, "helper"))
.with_node(Node::new("adr:0001", NodeKind::Adr, "Build Roteiro"))
.with_edge(Edge::derived(
"sym:rust:a.rs#main",
"sym:rust:a.rs#helper",
EdgeKind::Calls,
))
.with_edge(Edge::authored(
"adr:0001",
"sym:rust:a.rs#main",
EdgeKind::References,
));
store.apply_factset(&facts).expect("apply");
store
}
#[test]
fn search_ranks_by_relevance_and_is_bounded() {
let store = seeded();
let hits = search(&store, "helper", 10).expect("search");
assert_eq!(hits[0].node.key, "sym:rust:a.rs#helper");
assert!(hits[0].score >= 100, "exact name match scores high");
assert!(
search(&store, "main roteiro", 10)
.expect("search")
.is_empty()
);
let by_prefix = search(&store, "sym:rust", 10).expect("search");
assert!(!by_prefix.is_empty());
assert!(
by_prefix
.iter()
.all(|h| h.node.key.starts_with("sym:rust:"))
);
assert!(search(&store, " ", 10).expect("search").is_empty());
assert!(search(&store, "a.rs", 1).expect("search").len() <= 1);
}
#[test]
fn search_prefers_curated_content_over_same_named_test_symbols() {
use crate::Provenance;
let mut store = Store::open_in_memory().expect("store");
let mut test_fn = Node::new(
"sym:rust:crates/x/tests/cli.rs#roteiro",
NodeKind::Fn,
"roteiro",
);
test_fn.path = Some("crates/x/tests/cli.rs".into());
let mut adr = Node::new("adr:0001", NodeKind::Adr, "Build Roteiro")
.with_provenance(Provenance::Authored);
adr.path = Some("docs/adr/0001.md".into());
adr.meta = serde_json::json!({ "content": "Roteiro is a provenance-tagged codebase knowledge graph." });
let mut readme = Node::new("file:README.md", NodeKind::File, "README.md");
readme.path = Some("README.md".into());
readme.meta =
serde_json::json!({ "content": "Roteiro turns a repo into one knowledge graph." });
store
.apply_factset(
&FactSet::new()
.with_node(test_fn)
.with_node(adr)
.with_node(readme),
)
.expect("apply");
let hits = search(&store, "roteiro", 10).expect("search");
let keys: Vec<&str> = hits.iter().map(|h| h.node.key.as_str()).collect();
let idx = |k: &str| keys.iter().position(|x| *x == k).expect("present");
assert!(
idx("adr:0001") < idx("sym:rust:crates/x/tests/cli.rs#roteiro"),
"authored ADR outranks the test symbol: {keys:?}"
);
assert!(
idx("file:README.md") < idx("sym:rust:crates/x/tests/cli.rs#roteiro"),
"README (matched via content) outranks the test symbol: {keys:?}"
);
let by_content = search(&store, "provenance-tagged", 10).expect("search");
assert_eq!(
by_content.first().map(|h| h.node.key.as_str()),
Some("adr:0001"),
"content search matches the ADR by its captured text"
);
}
#[test]
fn explain_reports_labelled_neighbourhood() {
let store = seeded();
let ex = explain(&store, "sym:rust:a.rs#main")
.expect("query")
.expect("present");
assert_eq!(ex.schema, SCHEMA);
assert_eq!(ex.node.kind, "fn");
assert_eq!(ex.outgoing.len(), 1);
assert_eq!(ex.outgoing[0].kind, "calls");
assert_eq!(ex.outgoing[0].provenance, "derived");
assert_eq!(ex.outgoing[0].node, "sym:rust:a.rs#helper");
assert_eq!(ex.incoming.len(), 1);
assert_eq!(ex.incoming[0].provenance, "authored");
assert_eq!(ex.incoming[0].node, "adr:0001");
}
#[test]
fn explain_missing_node_is_none() {
let store = seeded();
assert!(explain(&store, "sym:rust:a.rs#ghost").expect("q").is_none());
}
#[test]
fn edges_differing_only_in_provenance_are_ordered() {
let mut store = Store::open_in_memory().expect("store");
let facts = FactSet::new()
.with_node(Node::new("a", NodeKind::Fn, "a"))
.with_node(Node::new("b", NodeKind::Fn, "b"))
.with_edge(Edge::derived("a", "b", EdgeKind::References))
.with_edge(Edge::authored("a", "b", EdgeKind::References));
store.apply_factset(&facts).expect("apply");
let ex = explain(&store, "a").expect("q").expect("present");
let provs: Vec<_> = ex.outgoing.iter().map(|e| e.provenance).collect();
assert_eq!(provs, ["authored", "derived"]);
}
#[test]
fn list_kind_is_ordered() {
let store = seeded();
let listing = list_kind(&store, &NodeKind::Fn).expect("list");
let keys: Vec<_> = listing.nodes.iter().map(|n| n.key.as_str()).collect();
assert_eq!(keys, ["sym:rust:a.rs#helper", "sym:rust:a.rs#main"]);
}
#[test]
fn json_schema_is_stable() {
let store = seeded();
let ex = explain(&store, "adr:0001").expect("q").expect("present");
let json = serde_json::to_value(&ex).expect("json");
assert_eq!(json["schema"], SCHEMA);
assert_eq!(json["node"]["key"], "adr:0001");
assert_eq!(json["node"]["kind"], "adr");
assert_eq!(json["outgoing"][0]["kind"], "references");
assert_eq!(json["outgoing"][0]["provenance"], "authored");
assert_eq!(json["outgoing"][0]["node"], "sym:rust:a.rs#main");
assert!(json["outgoing"][0]["confidence"].is_null());
}
#[test]
fn path_crosses_provenance_and_direction() {
let store = seeded();
let p = path(&store, "adr:0001", "sym:rust:a.rs#helper").expect("path");
assert!(p.found);
assert_eq!(p.length, 2);
assert_eq!(p.schema, SCHEMA);
assert_eq!(p.hops[0].kind, "references");
assert_eq!(p.hops[0].provenance, "authored");
assert_eq!(p.hops[0].direction, "outgoing");
assert_eq!(p.hops[0].node, "sym:rust:a.rs#main");
assert_eq!(p.hops[1].kind, "calls");
assert_eq!(p.hops[1].provenance, "derived");
assert_eq!(p.hops[1].node, "sym:rust:a.rs#helper");
}
#[test]
fn path_follows_edges_against_direction() {
let store = seeded();
let p = path(&store, "sym:rust:a.rs#helper", "adr:0001").expect("path");
assert!(p.found);
assert_eq!(p.length, 2);
assert!(p.hops.iter().all(|h| h.direction == "incoming"));
assert_eq!(p.hops.last().unwrap().node, "adr:0001");
}
#[test]
fn path_same_node_is_trivial() {
let store = seeded();
let p = path(&store, "adr:0001", "adr:0001").expect("path");
assert!(p.found);
assert_eq!(p.length, 0);
assert!(p.hops.is_empty());
}
#[test]
fn path_missing_endpoint_or_unreachable_is_not_found() {
let mut store = Store::open_in_memory().expect("store");
let facts = FactSet::new()
.with_node(Node::new("a", NodeKind::Fn, "a"))
.with_node(Node::new("b", NodeKind::Fn, "b"))
.with_node(Node::new("island", NodeKind::Fn, "island"))
.with_edge(Edge::derived("a", "b", EdgeKind::Calls));
store.apply_factset(&facts).expect("apply");
let missing = path(&store, "a", "ghost").expect("path");
assert!(!missing.found);
assert!(missing.hops.is_empty());
let unreachable = path(&store, "a", "island").expect("path");
assert!(!unreachable.found);
assert!(unreachable.hops.is_empty());
}
#[test]
fn path_is_shortest() {
let mut store = Store::open_in_memory().expect("store");
let facts = FactSet::new()
.with_node(Node::new("a", NodeKind::Fn, "a"))
.with_node(Node::new("b", NodeKind::Fn, "b"))
.with_node(Node::new("c", NodeKind::Fn, "c"))
.with_node(Node::new("d", NodeKind::Fn, "d"))
.with_edge(Edge::derived("a", "b", EdgeKind::Calls))
.with_edge(Edge::derived("b", "c", EdgeKind::Calls))
.with_edge(Edge::derived("c", "d", EdgeKind::Calls))
.with_edge(Edge::derived("a", "d", EdgeKind::Calls));
store.apply_factset(&facts).expect("apply");
let p = path(&store, "a", "d").expect("path");
assert!(p.found);
assert_eq!(p.length, 1, "the direct a->d edge is the shortest path");
assert_eq!(p.hops[0].node, "d");
}
#[test]
fn glob_matches_segments_and_wildcards() {
assert!(glob_match("vendor/**", "vendor/lib/a.rs"));
assert!(glob_match("vendor/**", "vendor")); assert!(glob_match("**/generated/*", "src/gen/generated/x.rs"));
assert!(glob_match("**/*.rs", "a/b/c.rs"));
assert!(glob_match("src/*.rs", "src/main.rs"));
assert!(!glob_match("src/*.rs", "src/sub/main.rs"));
assert!(glob_match("a?c.rs", "abc.rs"));
assert!(!glob_match("a?c.rs", "ac.rs"));
assert!(!glob_match("generated", "src/generated"));
assert!(!glob_match("vendor/**", "third_party/vendor/a.rs"));
}
}