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,
#[serde(skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
}
const SNIPPET_MAX: usize = 300;
fn content_snippet(meta: &serde_json::Value) -> Option<String> {
let content = meta.get("content").and_then(|v| v.as_str())?;
let mut collapsed: Vec<char> = Vec::with_capacity(SNIPPET_MAX + 1);
let mut pending_space = false;
for ch in content.chars() {
if ch.is_whitespace() {
pending_space = !collapsed.is_empty();
continue;
}
if pending_space {
collapsed.push(' ');
pending_space = false;
if collapsed.len() > SNIPPET_MAX {
break;
}
}
collapsed.push(ch);
if collapsed.len() > SNIPPET_MAX {
break;
}
}
if collapsed.is_empty() {
return None;
}
if collapsed.len() > SNIPPET_MAX {
let snippet: String = collapsed[..SNIPPET_MAX - 1].iter().collect();
Some(format!("{snippet}…"))
} else {
Some(collapsed.into_iter().collect())
}
}
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),
snippet: content_snippet(&node.meta),
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)
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct GeneratedHit {
pub score: u32,
pub generated: bool,
pub producer: String,
pub model: String,
pub kind: &'static str,
pub blob: String,
pub path: String,
pub snippet: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct SearchResults {
pub schema: &'static str,
pub hits: Vec<SearchHit>,
pub generated: Vec<GeneratedHit>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SearchOptions {
pub limit: usize,
pub include_generated: bool,
}
impl Default for SearchOptions {
fn default() -> Self {
Self {
limit: 10,
include_generated: false,
}
}
}
pub fn search_channels(
store: &Store,
query: &str,
opts: SearchOptions,
) -> Result<SearchResults, StoreError> {
let hits = search(store, query, opts.limit)?;
let generated = if opts.include_generated {
search_generated(store, query, opts.limit)?
} else {
Vec::new()
};
Ok(SearchResults {
schema: SCHEMA,
hits,
generated,
})
}
fn search_generated(
store: &Store,
query: &str,
limit: usize,
) -> Result<Vec<GeneratedHit>, 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<GeneratedHit> = Vec::new();
for record in store.media_records(&crate::MediaFilter::default())? {
let Some(generated_text) = record.outcome.text() else {
continue;
};
let text = generated_text.to_lowercase();
let path = record.path.to_lowercase();
if !tokens.iter().all(|t| text.contains(t) || path.contains(t)) {
continue;
}
hits.push(GeneratedHit {
score: generated_score(&q, &tokens, &text, &path),
generated: true,
producer: record.producer_id.to_string(),
model: record.producer.model.clone(),
kind: record.producer.kind.as_str(),
blob: record.blob_id.clone(),
path: record.path.clone(),
snippet: content_snippet(&serde_json::json!({ "content": generated_text })),
});
}
hits.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| (&a.producer, &a.blob).cmp(&(&b.producer, &b.blob)))
});
hits.truncate(limit);
Ok(hits)
}
fn generated_score(q: &str, tokens: &[&str], text: &str, path: &str) -> u32 {
let mut relevance: i32 = 0;
if text.contains(q) {
relevance += 25;
}
for t in tokens {
if text.contains(t) {
relevance += 8;
} else if path.contains(t) {
relevance += 3;
}
}
u32::try_from(relevance.max(0)).unwrap_or(0)
}
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, SNIPPET_MAX, 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 search_hit_carries_a_bounded_content_snippet() {
use crate::Provenance;
let mut store = Store::open_in_memory().expect("store");
let long = "word ".repeat(200);
let mut adr =
Node::new("adr:0001", NodeKind::Adr, "Overview").with_provenance(Provenance::Authored);
adr.meta = serde_json::json!({ "content": format!("Roteiro is\n\na graph. {long}") });
let sym = Node::new("sym:rust:a.rs#main", NodeKind::Fn, "main");
store
.apply_factset(&FactSet::new().with_node(adr).with_node(sym))
.expect("apply");
let hits = search(&store, "roteiro", 10).expect("search");
let adr_hit = hits
.iter()
.find(|h| h.node.key == "adr:0001")
.expect("adr hit");
let snippet = adr_hit
.snippet
.as_deref()
.expect("a content-bearing node yields a snippet");
assert!(snippet.starts_with("Roteiro is a graph."), "got: {snippet}");
assert!(!snippet.contains(" "));
assert!(!snippet.contains('\n'));
assert!(
snippet.chars().count() <= SNIPPET_MAX,
"snippet is bounded: {} chars",
snippet.chars().count()
);
assert!(
snippet.ends_with('…'),
"over-long content is truncated with an ellipsis"
);
let hits = search(&store, "main", 10).expect("search");
let sym_hit = hits
.iter()
.find(|h| h.node.key == "sym:rust:a.rs#main")
.expect("sym hit");
assert!(
sym_hit.snippet.is_none(),
"a node with no content has no snippet"
);
}
#[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"));
}
}