1use std::collections::{BTreeMap, VecDeque};
13
14use serde::Serialize;
15
16use crate::store::{Store, StoreError};
17use crate::{Edge, NodeKind, Provenance};
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(
129 store: &Store,
130 categories: &[String],
131 ignore: &[String],
132) -> Result<DebtReport, StoreError> {
133 let filter: std::collections::BTreeSet<&str> = categories.iter().map(String::as_str).collect();
134 let mut items = Vec::new();
135 let mut by_category: BTreeMap<String, usize> = BTreeMap::new();
136 for node in store.nodes_by_kind(&NodeKind::Marker)? {
137 let category = node
138 .meta
139 .get("category")
140 .and_then(serde_json::Value::as_str)
141 .unwrap_or("other")
142 .to_owned();
143 if !filter.is_empty() && !filter.contains(category.as_str()) {
144 continue;
145 }
146 if let Some(path) = node.path.as_deref()
148 && ignore.iter().any(|glob| glob_match(glob, path))
149 {
150 continue;
151 }
152 let text = node
153 .meta
154 .get("text")
155 .and_then(serde_json::Value::as_str)
156 .unwrap_or(node.name.as_str())
157 .to_owned();
158 let line = node
159 .meta
160 .get("line")
161 .and_then(serde_json::Value::as_u64)
162 .and_then(|l| u32::try_from(l).ok());
163 *by_category.entry(category.clone()).or_default() += 1;
164 items.push(DebtItem {
165 key: node.key.clone(),
166 category,
167 text,
168 path: node.path.clone(),
169 line,
170 });
171 }
172 items.sort_by(|a, b| (&a.path, a.line, &a.key).cmp(&(&b.path, b.line, &b.key)));
173 Ok(DebtReport {
174 schema: SCHEMA,
175 total: items.len(),
176 by_category,
177 items,
178 })
179}
180
181#[must_use]
186fn glob_match(pattern: &str, path: &str) -> bool {
187 let pat: Vec<&str> = pattern.split('/').collect();
188 let seg: Vec<&str> = path.split('/').collect();
189 match_segments(&pat, &seg)
190}
191
192fn match_segments(pat: &[&str], seg: &[&str]) -> bool {
195 match pat.first() {
196 None => seg.is_empty(),
197 Some(&"**") => (0..=seg.len()).any(|i| match_segments(&pat[1..], &seg[i..])),
198 Some(token) => {
199 !seg.is_empty() && match_token(token, seg[0]) && match_segments(&pat[1..], &seg[1..])
200 }
201 }
202}
203
204fn match_token(pattern: &str, s: &str) -> bool {
207 let pat: Vec<char> = pattern.chars().collect();
208 let chars: Vec<char> = s.chars().collect();
209 match_token_chars(&pat, &chars)
210}
211
212fn match_token_chars(pat: &[char], chars: &[char]) -> bool {
214 match pat.first() {
215 None => chars.is_empty(),
216 Some('*') => (0..=chars.len()).any(|i| match_token_chars(&pat[1..], &chars[i..])),
217 Some('?') => !chars.is_empty() && match_token_chars(&pat[1..], &chars[1..]),
218 Some(&ch) => {
219 !chars.is_empty() && chars[0] == ch && match_token_chars(&pat[1..], &chars[1..])
220 }
221 }
222}
223
224#[derive(Debug, Clone, PartialEq, Serialize)]
226pub struct PathHop {
227 pub kind: String,
229 pub provenance: &'static str,
231 pub confidence: Option<f64>,
233 pub direction: &'static str,
236 pub node: String,
238}
239
240#[derive(Debug, Clone, PartialEq, Serialize)]
244pub struct Path {
245 pub schema: &'static str,
247 pub from: String,
249 pub to: String,
251 pub found: bool,
253 pub length: usize,
255 pub hops: Vec<PathHop>,
257}
258
259fn out_ref(edge: &Edge) -> EdgeRef {
260 EdgeRef {
261 kind: edge.kind.as_str().to_owned(),
262 provenance: edge.provenance.as_str(),
263 confidence: edge.confidence,
264 node: edge.dst.clone(),
265 }
266}
267
268fn in_ref(edge: &Edge) -> EdgeRef {
269 EdgeRef {
270 kind: edge.kind.as_str().to_owned(),
271 provenance: edge.provenance.as_str(),
272 confidence: edge.confidence,
273 node: edge.src.clone(),
274 }
275}
276
277fn sort_refs(refs: &mut [EdgeRef]) {
278 refs.sort_by(|a, b| (&a.kind, &a.node, a.provenance).cmp(&(&b.kind, &b.node, b.provenance)));
281}
282
283pub fn explain(store: &Store, key: &str) -> Result<Option<Explanation>, StoreError> {
289 let Some(node) = store.get_node(key)? else {
290 return Ok(None);
291 };
292 let mut outgoing: Vec<EdgeRef> = store.edges_from(key)?.iter().map(out_ref).collect();
293 let mut incoming: Vec<EdgeRef> = store.edges_to(key)?.iter().map(in_ref).collect();
294 sort_refs(&mut outgoing);
295 sort_refs(&mut incoming);
296 Ok(Some(Explanation {
297 schema: SCHEMA,
298 node: NodeSummary::from_node(&node),
299 meta: node.meta,
300 outgoing,
301 incoming,
302 }))
303}
304
305pub fn list_kind(store: &Store, kind: &NodeKind) -> Result<Listing, StoreError> {
310 let nodes = store
311 .nodes_by_kind(kind)?
312 .iter()
313 .map(NodeSummary::from_node)
314 .collect();
315 Ok(Listing {
316 schema: SCHEMA,
317 kind: kind.as_str().to_owned(),
318 nodes,
319 })
320}
321
322#[derive(Debug, Clone, PartialEq, Serialize)]
324pub struct SearchHit {
325 pub score: u32,
327 #[serde(flatten)]
329 pub node: NodeSummary,
330}
331
332pub fn search(store: &Store, query: &str, limit: usize) -> Result<Vec<SearchHit>, StoreError> {
345 if limit == 0 {
346 return Ok(Vec::new());
347 }
348 let q = query.trim().to_lowercase();
349 let tokens: Vec<&str> = q.split("::").flat_map(str::split_whitespace).collect();
352 if tokens.is_empty() {
353 return Ok(Vec::new());
354 }
355
356 let mut hits: Vec<SearchHit> = Vec::new();
357 for node in store.all_nodes()? {
358 let name = node.name.to_lowercase();
359 let key = node.key.to_lowercase();
360 let path = node.path.as_deref().unwrap_or("").to_lowercase();
361 let content = node
366 .meta
367 .get("content")
368 .and_then(|v| v.as_str())
369 .map(str::to_lowercase);
370 let content = content.as_deref().unwrap_or("");
371 if !tokens
374 .iter()
375 .all(|t| name.contains(t) || key.contains(t) || path.contains(t) || content.contains(t))
376 {
377 continue;
378 }
379 let mut relevance: i32 = 0;
380 if name == q {
381 relevance += 100;
382 } else if name.contains(&q) {
383 relevance += 60;
384 } else if content.contains(&q) {
385 relevance += 25;
386 }
387 for t in &tokens {
388 if name.contains(t) {
389 relevance += 12;
390 } else if key.contains(t) {
391 relevance += 6;
392 } else if content.contains(t) {
393 relevance += 8;
394 } else if path.contains(t) {
395 relevance += 3;
396 }
397 }
398 if node.provenance == Provenance::Authored {
402 relevance += 40;
403 }
404 if is_overview_path(&path) {
405 relevance += 30;
406 }
407 if is_test_path(&path) {
408 relevance -= 60;
409 }
410 hits.push(SearchHit {
411 score: u32::try_from(relevance.max(0)).unwrap_or(0),
412 node: NodeSummary::from_node(&node),
413 });
414 }
415 hits.sort_by(|a, b| {
417 b.score
418 .cmp(&a.score)
419 .then_with(|| a.node.key.cmp(&b.node.key))
420 });
421 hits.truncate(limit);
422 Ok(hits)
423}
424
425fn is_overview_path(path: &str) -> bool {
430 path.rsplit('/')
431 .next()
432 .is_some_and(|base| base.starts_with("readme") || base.starts_with("overview"))
433}
434
435fn is_test_path(path: &str) -> bool {
438 path.contains("/tests/") || path.contains("/test/")
439}
440
441struct Step {
444 node: String,
445 hop: PathHop,
446}
447
448fn steps_from(store: &Store, key: &str) -> Result<Vec<Step>, StoreError> {
451 let mut steps = Vec::new();
452 for edge in store.edges_from(key)? {
453 steps.push(Step {
454 node: edge.dst.clone(),
455 hop: hop(&edge, "outgoing", edge.dst.clone()),
456 });
457 }
458 for edge in store.edges_to(key)? {
459 steps.push(Step {
460 node: edge.src.clone(),
461 hop: hop(&edge, "incoming", edge.src.clone()),
462 });
463 }
464 steps.sort_by(|a, b| {
465 (&a.node, &a.hop.kind, a.hop.provenance, a.hop.direction).cmp(&(
466 &b.node,
467 &b.hop.kind,
468 b.hop.provenance,
469 b.hop.direction,
470 ))
471 });
472 Ok(steps)
473}
474
475fn hop(edge: &Edge, direction: &'static str, node: String) -> PathHop {
476 PathHop {
477 kind: edge.kind.as_str().to_owned(),
478 provenance: edge.provenance.as_str(),
479 confidence: edge.confidence,
480 direction,
481 node,
482 }
483}
484
485pub fn path(store: &Store, from: &str, to: &str) -> Result<Path, StoreError> {
496 let not_found = |found: bool, hops: Vec<PathHop>| Path {
497 schema: SCHEMA,
498 from: from.to_owned(),
499 to: to.to_owned(),
500 found,
501 length: hops.len(),
502 hops,
503 };
504
505 if store.get_node(from)?.is_none() || store.get_node(to)?.is_none() {
507 return Ok(not_found(false, Vec::new()));
508 }
509 if from == to {
510 return Ok(not_found(true, Vec::new()));
511 }
512
513 let mut came_from: BTreeMap<String, (String, PathHop)> = BTreeMap::new();
516 let mut queue: VecDeque<String> = VecDeque::new();
517 queue.push_back(from.to_owned());
518 came_from.insert(from.to_owned(), (String::new(), placeholder_hop()));
519
520 while let Some(current) = queue.pop_front() {
521 if current == to {
522 break;
523 }
524 for step in steps_from(store, ¤t)? {
525 if came_from.contains_key(&step.node) {
526 continue;
527 }
528 came_from.insert(step.node.clone(), (current.clone(), step.hop));
529 queue.push_back(step.node);
530 }
531 }
532
533 let mut hops = Vec::new();
538 let mut cursor = to.to_owned();
539 while cursor != from {
540 let Some((prev, hop)) = came_from.get(&cursor) else {
541 return Ok(not_found(false, Vec::new()));
542 };
543 hops.push(hop.clone());
544 cursor = prev.clone();
545 }
546 hops.reverse();
547 Ok(not_found(true, hops))
548}
549
550fn placeholder_hop() -> PathHop {
552 PathHop {
553 kind: String::new(),
554 provenance: "derived",
555 confidence: None,
556 direction: "outgoing",
557 node: String::new(),
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::{SCHEMA, explain, glob_match, list_kind, path, search};
564 use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
565
566 fn seeded() -> Store {
567 let mut store = Store::open_in_memory().expect("store");
568 let facts = FactSet::new()
569 .with_node(Node::new("sym:rust:a.rs#main", NodeKind::Fn, "main"))
570 .with_node(Node::new("sym:rust:a.rs#helper", NodeKind::Fn, "helper"))
571 .with_node(Node::new("adr:0001", NodeKind::Adr, "Build Roteiro"))
572 .with_edge(Edge::derived(
573 "sym:rust:a.rs#main",
574 "sym:rust:a.rs#helper",
575 EdgeKind::Calls,
576 ))
577 .with_edge(Edge::authored(
578 "adr:0001",
579 "sym:rust:a.rs#main",
580 EdgeKind::References,
581 ));
582 store.apply_factset(&facts).expect("apply");
583 store
584 }
585
586 #[test]
587 fn search_ranks_by_relevance_and_is_bounded() {
588 let store = seeded();
589 let hits = search(&store, "helper", 10).expect("search");
591 assert_eq!(hits[0].node.key, "sym:rust:a.rs#helper");
592 assert!(hits[0].score >= 100, "exact name match scores high");
593
594 assert!(
596 search(&store, "main roteiro", 10)
597 .expect("search")
598 .is_empty()
599 );
600
601 let by_prefix = search(&store, "sym:rust", 10).expect("search");
604 assert!(!by_prefix.is_empty());
605 assert!(
606 by_prefix
607 .iter()
608 .all(|h| h.node.key.starts_with("sym:rust:"))
609 );
610
611 assert!(search(&store, " ", 10).expect("search").is_empty());
613 assert!(search(&store, "a.rs", 1).expect("search").len() <= 1);
614 }
615
616 #[test]
617 fn search_prefers_curated_content_over_same_named_test_symbols() {
618 use crate::Provenance;
619 let mut store = Store::open_in_memory().expect("store");
620 let mut test_fn = Node::new(
622 "sym:rust:crates/x/tests/cli.rs#roteiro",
623 NodeKind::Fn,
624 "roteiro",
625 );
626 test_fn.path = Some("crates/x/tests/cli.rs".into());
627 let mut adr = Node::new("adr:0001", NodeKind::Adr, "Build Roteiro")
629 .with_provenance(Provenance::Authored);
630 adr.path = Some("docs/adr/0001.md".into());
631 adr.meta = serde_json::json!({ "content": "Roteiro is a provenance-tagged codebase knowledge graph." });
632 let mut readme = Node::new("file:README.md", NodeKind::File, "README.md");
634 readme.path = Some("README.md".into());
635 readme.meta =
636 serde_json::json!({ "content": "Roteiro turns a repo into one knowledge graph." });
637 store
638 .apply_factset(
639 &FactSet::new()
640 .with_node(test_fn)
641 .with_node(adr)
642 .with_node(readme),
643 )
644 .expect("apply");
645
646 let hits = search(&store, "roteiro", 10).expect("search");
647 let keys: Vec<&str> = hits.iter().map(|h| h.node.key.as_str()).collect();
648 let idx = |k: &str| keys.iter().position(|x| *x == k).expect("present");
649 assert!(
652 idx("adr:0001") < idx("sym:rust:crates/x/tests/cli.rs#roteiro"),
653 "authored ADR outranks the test symbol: {keys:?}"
654 );
655 assert!(
656 idx("file:README.md") < idx("sym:rust:crates/x/tests/cli.rs#roteiro"),
657 "README (matched via content) outranks the test symbol: {keys:?}"
658 );
659
660 let by_content = search(&store, "provenance-tagged", 10).expect("search");
662 assert_eq!(
663 by_content.first().map(|h| h.node.key.as_str()),
664 Some("adr:0001"),
665 "content search matches the ADR by its captured text"
666 );
667 }
668
669 #[test]
670 fn explain_reports_labelled_neighbourhood() {
671 let store = seeded();
672 let ex = explain(&store, "sym:rust:a.rs#main")
673 .expect("query")
674 .expect("present");
675 assert_eq!(ex.schema, SCHEMA);
676 assert_eq!(ex.node.kind, "fn");
677
678 assert_eq!(ex.outgoing.len(), 1);
680 assert_eq!(ex.outgoing[0].kind, "calls");
681 assert_eq!(ex.outgoing[0].provenance, "derived");
682 assert_eq!(ex.outgoing[0].node, "sym:rust:a.rs#helper");
683
684 assert_eq!(ex.incoming.len(), 1);
686 assert_eq!(ex.incoming[0].provenance, "authored");
687 assert_eq!(ex.incoming[0].node, "adr:0001");
688 }
689
690 #[test]
691 fn explain_missing_node_is_none() {
692 let store = seeded();
693 assert!(explain(&store, "sym:rust:a.rs#ghost").expect("q").is_none());
694 }
695
696 #[test]
697 fn edges_differing_only_in_provenance_are_ordered() {
698 let mut store = Store::open_in_memory().expect("store");
701 let facts = FactSet::new()
702 .with_node(Node::new("a", NodeKind::Fn, "a"))
703 .with_node(Node::new("b", NodeKind::Fn, "b"))
704 .with_edge(Edge::derived("a", "b", EdgeKind::References))
705 .with_edge(Edge::authored("a", "b", EdgeKind::References));
706 store.apply_factset(&facts).expect("apply");
707
708 let ex = explain(&store, "a").expect("q").expect("present");
709 let provs: Vec<_> = ex.outgoing.iter().map(|e| e.provenance).collect();
710 assert_eq!(provs, ["authored", "derived"]);
711 }
712
713 #[test]
714 fn list_kind_is_ordered() {
715 let store = seeded();
716 let listing = list_kind(&store, &NodeKind::Fn).expect("list");
717 let keys: Vec<_> = listing.nodes.iter().map(|n| n.key.as_str()).collect();
718 assert_eq!(keys, ["sym:rust:a.rs#helper", "sym:rust:a.rs#main"]);
719 }
720
721 #[test]
722 fn json_schema_is_stable() {
723 let store = seeded();
724 let ex = explain(&store, "adr:0001").expect("q").expect("present");
725 let json = serde_json::to_value(&ex).expect("json");
726 assert_eq!(json["schema"], SCHEMA);
727 assert_eq!(json["node"]["key"], "adr:0001");
728 assert_eq!(json["node"]["kind"], "adr");
729 assert_eq!(json["outgoing"][0]["kind"], "references");
731 assert_eq!(json["outgoing"][0]["provenance"], "authored");
732 assert_eq!(json["outgoing"][0]["node"], "sym:rust:a.rs#main");
733 assert!(json["outgoing"][0]["confidence"].is_null());
734 }
735
736 #[test]
737 fn path_crosses_provenance_and_direction() {
738 let store = seeded();
741 let p = path(&store, "adr:0001", "sym:rust:a.rs#helper").expect("path");
742 assert!(p.found);
743 assert_eq!(p.length, 2);
744 assert_eq!(p.schema, SCHEMA);
745
746 assert_eq!(p.hops[0].kind, "references");
747 assert_eq!(p.hops[0].provenance, "authored");
748 assert_eq!(p.hops[0].direction, "outgoing");
749 assert_eq!(p.hops[0].node, "sym:rust:a.rs#main");
750
751 assert_eq!(p.hops[1].kind, "calls");
752 assert_eq!(p.hops[1].provenance, "derived");
753 assert_eq!(p.hops[1].node, "sym:rust:a.rs#helper");
754 }
755
756 #[test]
757 fn path_follows_edges_against_direction() {
758 let store = seeded();
761 let p = path(&store, "sym:rust:a.rs#helper", "adr:0001").expect("path");
762 assert!(p.found);
763 assert_eq!(p.length, 2);
764 assert!(p.hops.iter().all(|h| h.direction == "incoming"));
765 assert_eq!(p.hops.last().unwrap().node, "adr:0001");
766 }
767
768 #[test]
769 fn path_same_node_is_trivial() {
770 let store = seeded();
771 let p = path(&store, "adr:0001", "adr:0001").expect("path");
772 assert!(p.found);
773 assert_eq!(p.length, 0);
774 assert!(p.hops.is_empty());
775 }
776
777 #[test]
778 fn path_missing_endpoint_or_unreachable_is_not_found() {
779 let mut store = Store::open_in_memory().expect("store");
780 let facts = FactSet::new()
782 .with_node(Node::new("a", NodeKind::Fn, "a"))
783 .with_node(Node::new("b", NodeKind::Fn, "b"))
784 .with_node(Node::new("island", NodeKind::Fn, "island"))
785 .with_edge(Edge::derived("a", "b", EdgeKind::Calls));
786 store.apply_factset(&facts).expect("apply");
787
788 let missing = path(&store, "a", "ghost").expect("path");
790 assert!(!missing.found);
791 assert!(missing.hops.is_empty());
792
793 let unreachable = path(&store, "a", "island").expect("path");
795 assert!(!unreachable.found);
796 assert!(unreachable.hops.is_empty());
797 }
798
799 #[test]
800 fn path_is_shortest() {
801 let mut store = Store::open_in_memory().expect("store");
803 let facts = FactSet::new()
804 .with_node(Node::new("a", NodeKind::Fn, "a"))
805 .with_node(Node::new("b", NodeKind::Fn, "b"))
806 .with_node(Node::new("c", NodeKind::Fn, "c"))
807 .with_node(Node::new("d", NodeKind::Fn, "d"))
808 .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
809 .with_edge(Edge::derived("b", "c", EdgeKind::Calls))
810 .with_edge(Edge::derived("c", "d", EdgeKind::Calls))
811 .with_edge(Edge::derived("a", "d", EdgeKind::Calls));
812 store.apply_factset(&facts).expect("apply");
813
814 let p = path(&store, "a", "d").expect("path");
815 assert!(p.found);
816 assert_eq!(p.length, 1, "the direct a->d edge is the shortest path");
817 assert_eq!(p.hops[0].node, "d");
818 }
819
820 #[test]
821 fn glob_matches_segments_and_wildcards() {
822 assert!(glob_match("vendor/**", "vendor/lib/a.rs"));
824 assert!(glob_match("vendor/**", "vendor")); assert!(glob_match("**/generated/*", "src/gen/generated/x.rs"));
826 assert!(glob_match("**/*.rs", "a/b/c.rs"));
827 assert!(glob_match("src/*.rs", "src/main.rs"));
829 assert!(!glob_match("src/*.rs", "src/sub/main.rs"));
830 assert!(glob_match("a?c.rs", "abc.rs"));
831 assert!(!glob_match("a?c.rs", "ac.rs"));
832 assert!(!glob_match("generated", "src/generated"));
834 assert!(!glob_match("vendor/**", "third_party/vendor/a.rs"));
835 }
836}