1use std::collections::{BTreeMap, VecDeque};
13
14use serde::Serialize;
15
16use crate::store::{Store, StoreError};
17use crate::{Edge, NodeKind};
18
19pub const SCHEMA: &str = "roteiro.query/v1";
22
23#[derive(Debug, Clone, PartialEq, Serialize)]
26pub struct NodeSummary {
27 pub key: String,
29 pub kind: String,
31 pub name: String,
33 pub path: Option<String>,
35 pub lang: Option<String>,
37}
38
39impl NodeSummary {
40 fn from_node(node: &crate::Node) -> Self {
41 Self {
42 key: node.key.clone(),
43 kind: node.kind.as_str().to_owned(),
44 name: node.name.clone(),
45 path: node.path.clone(),
46 lang: node.lang.clone(),
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize)]
54pub struct EdgeRef {
55 pub kind: String,
57 pub provenance: &'static str,
59 pub confidence: Option<f64>,
61 pub node: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize)]
67pub struct Explanation {
68 pub schema: &'static str,
70 pub node: NodeSummary,
72 pub meta: serde_json::Value,
74 pub outgoing: Vec<EdgeRef>,
76 pub incoming: Vec<EdgeRef>,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize)]
82pub struct Listing {
83 pub schema: &'static str,
85 pub kind: String,
87 pub nodes: Vec<NodeSummary>,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize)]
93pub struct DebtItem {
94 pub key: String,
96 pub category: String,
98 pub text: String,
100 pub path: Option<String>,
102 pub line: Option<u32>,
104}
105
106#[derive(Debug, Clone, PartialEq, Serialize)]
109pub struct DebtReport {
110 pub schema: &'static str,
112 pub total: usize,
114 pub by_category: BTreeMap<String, usize>,
116 pub items: Vec<DebtItem>,
118}
119
120pub fn debt(store: &Store, categories: &[String]) -> Result<DebtReport, StoreError> {
127 let filter: std::collections::BTreeSet<&str> = categories.iter().map(String::as_str).collect();
128 let mut items = Vec::new();
129 let mut by_category: BTreeMap<String, usize> = BTreeMap::new();
130 for node in store.nodes_by_kind(&NodeKind::Marker)? {
131 let category = node
132 .meta
133 .get("category")
134 .and_then(serde_json::Value::as_str)
135 .unwrap_or("other")
136 .to_owned();
137 if !filter.is_empty() && !filter.contains(category.as_str()) {
138 continue;
139 }
140 let text = node
141 .meta
142 .get("text")
143 .and_then(serde_json::Value::as_str)
144 .unwrap_or(node.name.as_str())
145 .to_owned();
146 let line = node
147 .meta
148 .get("line")
149 .and_then(serde_json::Value::as_u64)
150 .and_then(|l| u32::try_from(l).ok());
151 *by_category.entry(category.clone()).or_default() += 1;
152 items.push(DebtItem {
153 key: node.key.clone(),
154 category,
155 text,
156 path: node.path.clone(),
157 line,
158 });
159 }
160 items.sort_by(|a, b| (&a.path, a.line, &a.key).cmp(&(&b.path, b.line, &b.key)));
161 Ok(DebtReport {
162 schema: SCHEMA,
163 total: items.len(),
164 by_category,
165 items,
166 })
167}
168
169#[derive(Debug, Clone, PartialEq, Serialize)]
171pub struct PathHop {
172 pub kind: String,
174 pub provenance: &'static str,
176 pub confidence: Option<f64>,
178 pub direction: &'static str,
181 pub node: String,
183}
184
185#[derive(Debug, Clone, PartialEq, Serialize)]
189pub struct Path {
190 pub schema: &'static str,
192 pub from: String,
194 pub to: String,
196 pub found: bool,
198 pub length: usize,
200 pub hops: Vec<PathHop>,
202}
203
204fn out_ref(edge: &Edge) -> EdgeRef {
205 EdgeRef {
206 kind: edge.kind.as_str().to_owned(),
207 provenance: edge.provenance.as_str(),
208 confidence: edge.confidence,
209 node: edge.dst.clone(),
210 }
211}
212
213fn in_ref(edge: &Edge) -> EdgeRef {
214 EdgeRef {
215 kind: edge.kind.as_str().to_owned(),
216 provenance: edge.provenance.as_str(),
217 confidence: edge.confidence,
218 node: edge.src.clone(),
219 }
220}
221
222fn sort_refs(refs: &mut [EdgeRef]) {
223 refs.sort_by(|a, b| (&a.kind, &a.node, a.provenance).cmp(&(&b.kind, &b.node, b.provenance)));
226}
227
228pub fn explain(store: &Store, key: &str) -> Result<Option<Explanation>, StoreError> {
234 let Some(node) = store.get_node(key)? else {
235 return Ok(None);
236 };
237 let mut outgoing: Vec<EdgeRef> = store.edges_from(key)?.iter().map(out_ref).collect();
238 let mut incoming: Vec<EdgeRef> = store.edges_to(key)?.iter().map(in_ref).collect();
239 sort_refs(&mut outgoing);
240 sort_refs(&mut incoming);
241 Ok(Some(Explanation {
242 schema: SCHEMA,
243 node: NodeSummary::from_node(&node),
244 meta: node.meta,
245 outgoing,
246 incoming,
247 }))
248}
249
250pub fn list_kind(store: &Store, kind: &NodeKind) -> Result<Listing, StoreError> {
255 let nodes = store
256 .nodes_by_kind(kind)?
257 .iter()
258 .map(NodeSummary::from_node)
259 .collect();
260 Ok(Listing {
261 schema: SCHEMA,
262 kind: kind.as_str().to_owned(),
263 nodes,
264 })
265}
266
267#[derive(Debug, Clone, PartialEq, Serialize)]
269pub struct SearchHit {
270 pub score: u32,
272 #[serde(flatten)]
274 pub node: NodeSummary,
275}
276
277pub fn search(store: &Store, query: &str, limit: usize) -> Result<Vec<SearchHit>, StoreError> {
286 if limit == 0 {
287 return Ok(Vec::new());
288 }
289 let q = query.trim().to_lowercase();
290 let tokens: Vec<&str> = q.split("::").flat_map(str::split_whitespace).collect();
293 if tokens.is_empty() {
294 return Ok(Vec::new());
295 }
296
297 let mut hits: Vec<SearchHit> = Vec::new();
298 for node in store.all_nodes()? {
299 let name = node.name.to_lowercase();
300 let key = node.key.to_lowercase();
301 let path = node.path.as_deref().unwrap_or("").to_lowercase();
302 if !tokens
304 .iter()
305 .all(|t| name.contains(t) || key.contains(t) || path.contains(t))
306 {
307 continue;
308 }
309 let mut relevance = 0u32;
310 if name == q {
311 relevance += 100;
312 } else if name.contains(&q) {
313 relevance += 60;
314 }
315 for t in &tokens {
316 if name.contains(t) {
317 relevance += 12;
318 } else if key.contains(t) {
319 relevance += 6;
320 } else if path.contains(t) {
321 relevance += 3;
322 }
323 }
324 hits.push(SearchHit {
325 score: relevance,
326 node: NodeSummary::from_node(&node),
327 });
328 }
329 hits.sort_by(|a, b| {
331 b.score
332 .cmp(&a.score)
333 .then_with(|| a.node.key.cmp(&b.node.key))
334 });
335 hits.truncate(limit);
336 Ok(hits)
337}
338
339struct Step {
342 node: String,
343 hop: PathHop,
344}
345
346fn steps_from(store: &Store, key: &str) -> Result<Vec<Step>, StoreError> {
349 let mut steps = Vec::new();
350 for edge in store.edges_from(key)? {
351 steps.push(Step {
352 node: edge.dst.clone(),
353 hop: hop(&edge, "outgoing", edge.dst.clone()),
354 });
355 }
356 for edge in store.edges_to(key)? {
357 steps.push(Step {
358 node: edge.src.clone(),
359 hop: hop(&edge, "incoming", edge.src.clone()),
360 });
361 }
362 steps.sort_by(|a, b| {
363 (&a.node, &a.hop.kind, a.hop.provenance, a.hop.direction).cmp(&(
364 &b.node,
365 &b.hop.kind,
366 b.hop.provenance,
367 b.hop.direction,
368 ))
369 });
370 Ok(steps)
371}
372
373fn hop(edge: &Edge, direction: &'static str, node: String) -> PathHop {
374 PathHop {
375 kind: edge.kind.as_str().to_owned(),
376 provenance: edge.provenance.as_str(),
377 confidence: edge.confidence,
378 direction,
379 node,
380 }
381}
382
383pub fn path(store: &Store, from: &str, to: &str) -> Result<Path, StoreError> {
394 let not_found = |found: bool, hops: Vec<PathHop>| Path {
395 schema: SCHEMA,
396 from: from.to_owned(),
397 to: to.to_owned(),
398 found,
399 length: hops.len(),
400 hops,
401 };
402
403 if store.get_node(from)?.is_none() || store.get_node(to)?.is_none() {
405 return Ok(not_found(false, Vec::new()));
406 }
407 if from == to {
408 return Ok(not_found(true, Vec::new()));
409 }
410
411 let mut came_from: BTreeMap<String, (String, PathHop)> = BTreeMap::new();
414 let mut queue: VecDeque<String> = VecDeque::new();
415 queue.push_back(from.to_owned());
416 came_from.insert(from.to_owned(), (String::new(), placeholder_hop()));
417
418 while let Some(current) = queue.pop_front() {
419 if current == to {
420 break;
421 }
422 for step in steps_from(store, ¤t)? {
423 if came_from.contains_key(&step.node) {
424 continue;
425 }
426 came_from.insert(step.node.clone(), (current.clone(), step.hop));
427 queue.push_back(step.node);
428 }
429 }
430
431 let mut hops = Vec::new();
436 let mut cursor = to.to_owned();
437 while cursor != from {
438 let Some((prev, hop)) = came_from.get(&cursor) else {
439 return Ok(not_found(false, Vec::new()));
440 };
441 hops.push(hop.clone());
442 cursor = prev.clone();
443 }
444 hops.reverse();
445 Ok(not_found(true, hops))
446}
447
448fn placeholder_hop() -> PathHop {
450 PathHop {
451 kind: String::new(),
452 provenance: "derived",
453 confidence: None,
454 direction: "outgoing",
455 node: String::new(),
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::{SCHEMA, explain, list_kind, path, search};
462 use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
463
464 fn seeded() -> Store {
465 let mut store = Store::open_in_memory().expect("store");
466 let facts = FactSet::new()
467 .with_node(Node::new("sym:rust:a.rs#main", NodeKind::Fn, "main"))
468 .with_node(Node::new("sym:rust:a.rs#helper", NodeKind::Fn, "helper"))
469 .with_node(Node::new("adr:0001", NodeKind::Adr, "Build Roteiro"))
470 .with_edge(Edge::derived(
471 "sym:rust:a.rs#main",
472 "sym:rust:a.rs#helper",
473 EdgeKind::Calls,
474 ))
475 .with_edge(Edge::authored(
476 "adr:0001",
477 "sym:rust:a.rs#main",
478 EdgeKind::References,
479 ));
480 store.apply_factset(&facts).expect("apply");
481 store
482 }
483
484 #[test]
485 fn search_ranks_by_relevance_and_is_bounded() {
486 let store = seeded();
487 let hits = search(&store, "helper", 10).expect("search");
489 assert_eq!(hits[0].node.key, "sym:rust:a.rs#helper");
490 assert!(hits[0].score >= 100, "exact name match scores high");
491
492 assert!(
494 search(&store, "main roteiro", 10)
495 .expect("search")
496 .is_empty()
497 );
498
499 let by_prefix = search(&store, "sym:rust", 10).expect("search");
502 assert!(!by_prefix.is_empty());
503 assert!(
504 by_prefix
505 .iter()
506 .all(|h| h.node.key.starts_with("sym:rust:"))
507 );
508
509 assert!(search(&store, " ", 10).expect("search").is_empty());
511 assert!(search(&store, "a.rs", 1).expect("search").len() <= 1);
512 }
513
514 #[test]
515 fn explain_reports_labelled_neighbourhood() {
516 let store = seeded();
517 let ex = explain(&store, "sym:rust:a.rs#main")
518 .expect("query")
519 .expect("present");
520 assert_eq!(ex.schema, SCHEMA);
521 assert_eq!(ex.node.kind, "fn");
522
523 assert_eq!(ex.outgoing.len(), 1);
525 assert_eq!(ex.outgoing[0].kind, "calls");
526 assert_eq!(ex.outgoing[0].provenance, "derived");
527 assert_eq!(ex.outgoing[0].node, "sym:rust:a.rs#helper");
528
529 assert_eq!(ex.incoming.len(), 1);
531 assert_eq!(ex.incoming[0].provenance, "authored");
532 assert_eq!(ex.incoming[0].node, "adr:0001");
533 }
534
535 #[test]
536 fn explain_missing_node_is_none() {
537 let store = seeded();
538 assert!(explain(&store, "sym:rust:a.rs#ghost").expect("q").is_none());
539 }
540
541 #[test]
542 fn edges_differing_only_in_provenance_are_ordered() {
543 let mut store = Store::open_in_memory().expect("store");
546 let facts = FactSet::new()
547 .with_node(Node::new("a", NodeKind::Fn, "a"))
548 .with_node(Node::new("b", NodeKind::Fn, "b"))
549 .with_edge(Edge::derived("a", "b", EdgeKind::References))
550 .with_edge(Edge::authored("a", "b", EdgeKind::References));
551 store.apply_factset(&facts).expect("apply");
552
553 let ex = explain(&store, "a").expect("q").expect("present");
554 let provs: Vec<_> = ex.outgoing.iter().map(|e| e.provenance).collect();
555 assert_eq!(provs, ["authored", "derived"]);
556 }
557
558 #[test]
559 fn list_kind_is_ordered() {
560 let store = seeded();
561 let listing = list_kind(&store, &NodeKind::Fn).expect("list");
562 let keys: Vec<_> = listing.nodes.iter().map(|n| n.key.as_str()).collect();
563 assert_eq!(keys, ["sym:rust:a.rs#helper", "sym:rust:a.rs#main"]);
564 }
565
566 #[test]
567 fn json_schema_is_stable() {
568 let store = seeded();
569 let ex = explain(&store, "adr:0001").expect("q").expect("present");
570 let json = serde_json::to_value(&ex).expect("json");
571 assert_eq!(json["schema"], SCHEMA);
572 assert_eq!(json["node"]["key"], "adr:0001");
573 assert_eq!(json["node"]["kind"], "adr");
574 assert_eq!(json["outgoing"][0]["kind"], "references");
576 assert_eq!(json["outgoing"][0]["provenance"], "authored");
577 assert_eq!(json["outgoing"][0]["node"], "sym:rust:a.rs#main");
578 assert!(json["outgoing"][0]["confidence"].is_null());
579 }
580
581 #[test]
582 fn path_crosses_provenance_and_direction() {
583 let store = seeded();
586 let p = path(&store, "adr:0001", "sym:rust:a.rs#helper").expect("path");
587 assert!(p.found);
588 assert_eq!(p.length, 2);
589 assert_eq!(p.schema, SCHEMA);
590
591 assert_eq!(p.hops[0].kind, "references");
592 assert_eq!(p.hops[0].provenance, "authored");
593 assert_eq!(p.hops[0].direction, "outgoing");
594 assert_eq!(p.hops[0].node, "sym:rust:a.rs#main");
595
596 assert_eq!(p.hops[1].kind, "calls");
597 assert_eq!(p.hops[1].provenance, "derived");
598 assert_eq!(p.hops[1].node, "sym:rust:a.rs#helper");
599 }
600
601 #[test]
602 fn path_follows_edges_against_direction() {
603 let store = seeded();
606 let p = path(&store, "sym:rust:a.rs#helper", "adr:0001").expect("path");
607 assert!(p.found);
608 assert_eq!(p.length, 2);
609 assert!(p.hops.iter().all(|h| h.direction == "incoming"));
610 assert_eq!(p.hops.last().unwrap().node, "adr:0001");
611 }
612
613 #[test]
614 fn path_same_node_is_trivial() {
615 let store = seeded();
616 let p = path(&store, "adr:0001", "adr:0001").expect("path");
617 assert!(p.found);
618 assert_eq!(p.length, 0);
619 assert!(p.hops.is_empty());
620 }
621
622 #[test]
623 fn path_missing_endpoint_or_unreachable_is_not_found() {
624 let mut store = Store::open_in_memory().expect("store");
625 let facts = FactSet::new()
627 .with_node(Node::new("a", NodeKind::Fn, "a"))
628 .with_node(Node::new("b", NodeKind::Fn, "b"))
629 .with_node(Node::new("island", NodeKind::Fn, "island"))
630 .with_edge(Edge::derived("a", "b", EdgeKind::Calls));
631 store.apply_factset(&facts).expect("apply");
632
633 let missing = path(&store, "a", "ghost").expect("path");
635 assert!(!missing.found);
636 assert!(missing.hops.is_empty());
637
638 let unreachable = path(&store, "a", "island").expect("path");
640 assert!(!unreachable.found);
641 assert!(unreachable.hops.is_empty());
642 }
643
644 #[test]
645 fn path_is_shortest() {
646 let mut store = Store::open_in_memory().expect("store");
648 let facts = FactSet::new()
649 .with_node(Node::new("a", NodeKind::Fn, "a"))
650 .with_node(Node::new("b", NodeKind::Fn, "b"))
651 .with_node(Node::new("c", NodeKind::Fn, "c"))
652 .with_node(Node::new("d", NodeKind::Fn, "d"))
653 .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
654 .with_edge(Edge::derived("b", "c", EdgeKind::Calls))
655 .with_edge(Edge::derived("c", "d", EdgeKind::Calls))
656 .with_edge(Edge::derived("a", "d", EdgeKind::Calls));
657 store.apply_factset(&facts).expect("apply");
658
659 let p = path(&store, "a", "d").expect("path");
660 assert!(p.found);
661 assert_eq!(p.length, 1, "the direct a->d edge is the shortest path");
662 assert_eq!(p.hops[0].node, "d");
663 }
664}