1use crate::autolink::is_auto_relation;
11use crate::error::{BrainError, Result};
12use crate::id::node_id_from_rel_path;
13use crate::storage::Database;
14use crate::symbols::{parse_symbol_path, resolve_symbol_ref};
15use crate::types::{Edge, Node, NodeType};
16use serde::{Deserialize, Serialize};
17use std::collections::{HashMap, HashSet, VecDeque};
18use std::path::Path;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
22#[serde(rename_all = "snake_case")]
23pub enum GraphDirection {
24 Out,
26 In,
28 #[default]
30 Both,
31}
32
33impl GraphDirection {
34 pub fn parse(s: &str) -> Option<Self> {
36 match s.trim().to_ascii_lowercase().as_str() {
37 "out" | "outgoing" | "from" => Some(Self::Out),
38 "in" | "incoming" | "to" => Some(Self::In),
39 "both" | "all" | "undirected" => Some(Self::Both),
40 _ => None,
41 }
42 }
43}
44
45#[derive(Debug, Clone)]
47pub struct GraphOptions {
48 pub hops: usize,
50 pub include_auto: bool,
52 pub include_symbols: bool,
54 pub direction: GraphDirection,
56 pub max_edges: usize,
58 pub type_filter: Option<Vec<NodeType>>,
60 pub scope: Option<String>,
62 pub scope_main: crate::query::ScopeMainInclude,
64 pub main_scope: String,
66}
67
68impl Default for GraphOptions {
69 fn default() -> Self {
70 Self {
71 hops: 1,
72 include_auto: true,
73 include_symbols: true,
74 direction: GraphDirection::Both,
75 max_edges: 200,
76 type_filter: None,
77 scope: None,
78 scope_main: crate::query::ScopeMainInclude::HubsOnly,
79 main_scope: crate::scopes::MAIN_SCOPE.to_string(),
80 }
81 }
82}
83
84impl GraphOptions {
85 fn allows_neighbor(&self, node: &Node) -> bool {
86 if let Some(ref want) = self.scope {
87 let q = crate::query::QueryOptions {
88 scope: Some(want.clone()),
89 main_scope: self.main_scope.clone(),
90 scope_main: self.scope_main,
91 ..crate::query::QueryOptions::default()
92 };
93 if !q.allows_scope(&node.scope, &node.id) {
94 return false;
95 }
96 }
97 true
98 }
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103pub struct GraphNodeRef {
104 pub id: String,
106 pub node_type: String,
108 pub title: String,
110 pub file_path: Option<String>,
112}
113
114impl GraphNodeRef {
115 fn from_node(n: &Node) -> Self {
116 Self {
117 id: n.id.clone(),
118 node_type: n.node_type.as_str().to_string(),
119 title: n.title.clone(),
120 file_path: n.file_path.clone(),
121 }
122 }
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct GraphHopEdge {
128 pub hop: usize,
130 pub direction: String,
132 pub relation_type: String,
134 pub weight: f32,
136 pub from_id: String,
138 pub to: GraphNodeRef,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct GraphNeighborhood {
145 pub root: GraphNodeRef,
147 pub hops: usize,
149 pub nodes_in_subgraph: usize,
151 pub edges: Vec<GraphHopEdge>,
153 pub truncated: bool,
155 pub db_nodes: usize,
157 pub db_edges: usize,
159}
160
161impl GraphNeighborhood {
162 pub fn to_ascii(&self) -> String {
164 let mut out = String::new();
165 let path = self.root.file_path.as_deref().unwrap_or("-");
166 out.push_str(&format!(
167 "graph: {} ({}) \"{}\"\n",
168 self.root.id, self.root.node_type, self.root.title
169 ));
170 out.push_str(&format!(" path: {path}\n"));
171 out.push_str(&format!(
172 " hops={} edges_shown={} nodes_in_subgraph={} db={}/{} n/e{}\n",
173 self.hops,
174 self.edges.len(),
175 self.nodes_in_subgraph,
176 self.db_nodes,
177 self.db_edges,
178 if self.truncated { " [truncated]" } else { "" }
179 ));
180 if self.edges.is_empty() {
181 out.push_str(" (no neighbors — orphan or filters excluded all edges)\n");
182 out.push_str(
183 " tip: `rustbrain links --auto` soft-links orphans; explicit WikiLinks preferred\n",
184 );
185 return out;
186 }
187
188 render_tree_level(&mut out, &self.edges, &self.root.id, 1, "");
190 out
191 }
192}
193
194fn render_tree_level(
195 out: &mut String,
196 all: &[GraphHopEdge],
197 parent_id: &str,
198 hop: usize,
199 prefix: &str,
200) {
201 let kids: Vec<&GraphHopEdge> = all
202 .iter()
203 .filter(|e| e.from_id == parent_id && e.hop == hop)
204 .collect();
205 let n = kids.len();
206 for (i, e) in kids.iter().enumerate() {
207 let last = i + 1 == n;
208 let branch = if last { "└──" } else { "├──" };
209 let cont = if last { " " } else { "│ " };
210 let arrow = if e.direction == "in" { "←" } else { "→" };
211 let path = e
212 .to
213 .file_path
214 .as_deref()
215 .map(|p| format!(" [{p}]"))
216 .unwrap_or_default();
217 out.push_str(&format!(
218 "{prefix}{branch}[{arrow} {} w={:.2}] {} ({}) \"{}\"{path}\n",
219 e.relation_type, e.weight, e.to.id, e.to.node_type, e.to.title
220 ));
221 let child_prefix = format!("{prefix}{cont}");
222 render_tree_level(out, all, &e.to.id, hop + 1, &child_prefix);
223 }
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct GraphStats {
229 pub nodes: usize,
231 pub edges: usize,
233 pub by_type: Vec<(String, usize)>,
235 pub by_relation: Vec<(String, usize)>,
237 pub hubs: Vec<GraphHub>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct GraphHub {
244 pub id: String,
246 pub node_type: String,
248 pub title: String,
250 pub degree: usize,
252}
253
254impl GraphStats {
255 pub fn to_ascii(&self) -> String {
257 let mut out = String::new();
258 out.push_str(&format!(
259 "graph stats: nodes={} edges={}\n",
260 self.nodes, self.edges
261 ));
262 if !self.by_type.is_empty() {
263 out.push_str(" by type:\n");
264 for (t, n) in &self.by_type {
265 out.push_str(&format!(" {t}: {n}\n"));
266 }
267 }
268 if !self.by_relation.is_empty() {
269 out.push_str(" by relation:\n");
270 for (r, n) in &self.by_relation {
271 out.push_str(&format!(" {r}: {n}\n"));
272 }
273 }
274 if !self.hubs.is_empty() {
275 out.push_str(" hubs (degree = in+out):\n");
276 for h in &self.hubs {
277 out.push_str(&format!(
278 " deg={:>3} {} ({}) \"{}\"\n",
279 h.degree, h.id, h.node_type, h.title
280 ));
281 }
282 }
283 out
284 }
285}
286
287pub fn graph_stats(db: &Database) -> Result<GraphStats> {
289 let nodes = db.get_all_nodes()?;
290 let edges = db.get_all_edges()?;
291 let mut by_type: HashMap<String, usize> = HashMap::new();
292 for n in &nodes {
293 *by_type.entry(n.node_type.as_str().to_string()).or_default() += 1;
294 }
295 let mut by_relation: HashMap<String, usize> = HashMap::new();
296 let mut degree: HashMap<String, usize> = HashMap::new();
297 for e in &edges {
298 *by_relation.entry(e.relation_type.clone()).or_default() += 1;
299 *degree.entry(e.source_id.clone()).or_default() += 1;
300 *degree.entry(e.target_id.clone()).or_default() += 1;
301 }
302 let mut by_type: Vec<(String, usize)> = by_type.into_iter().collect();
303 by_type.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
304 let mut by_relation: Vec<(String, usize)> = by_relation.into_iter().collect();
305 by_relation.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
306
307 let id_to_node: HashMap<&str, &Node> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
308 let mut hubs: Vec<GraphHub> = degree
309 .into_iter()
310 .filter_map(|(id, deg)| {
311 let n = id_to_node.get(id.as_str())?;
312 Some(GraphHub {
313 id,
314 node_type: n.node_type.as_str().to_string(),
315 title: n.title.clone(),
316 degree: deg,
317 })
318 })
319 .collect();
320 hubs.sort_by(|a, b| {
321 b.degree
322 .cmp(&a.degree)
323 .then_with(|| a.id.cmp(&b.id))
324 });
325 hubs.truncate(15);
326
327 Ok(GraphStats {
328 nodes: nodes.len(),
329 edges: edges.len(),
330 by_type,
331 by_relation,
332 hubs,
333 })
334}
335
336pub fn neighborhood(db: &Database, target: &str, opts: &GraphOptions) -> Result<GraphNeighborhood> {
338 let root = resolve_graph_target(db, target)?;
339 let all_edges = db.get_all_edges()?;
340 let all_nodes = db.get_all_nodes()?;
341 let node_map: HashMap<String, Node> = all_nodes.into_iter().map(|n| (n.id.clone(), n)).collect();
342
343 let hops = opts.hops.max(1);
344
345 let mut out_adj: HashMap<String, Vec<&Edge>> = HashMap::new();
347 let mut in_adj: HashMap<String, Vec<&Edge>> = HashMap::new();
348 for e in &all_edges {
349 if !opts.include_auto && is_auto_relation(&e.relation_type) {
350 continue;
351 }
352 out_adj.entry(e.source_id.clone()).or_default().push(e);
353 in_adj.entry(e.target_id.clone()).or_default().push(e);
354 }
355
356 let mut visited: HashSet<String> = HashSet::new();
357 visited.insert(root.id.clone());
358 let mut queue: VecDeque<(String, usize)> = VecDeque::new();
360 queue.push_back((root.id.clone(), 0));
361
362 let mut result_edges: Vec<GraphHopEdge> = Vec::new();
363 let mut truncated = false;
364
365 while let Some((curr, depth)) = queue.pop_front() {
366 if depth >= hops {
367 continue;
368 }
369 if result_edges.len() >= opts.max_edges {
370 truncated = true;
371 break;
372 }
373
374 let mut candidates: Vec<(&Edge, &str, &str)> = Vec::new(); if matches!(opts.direction, GraphDirection::Out | GraphDirection::Both) {
377 if let Some(list) = out_adj.get(&curr) {
378 for e in list {
379 candidates.push((e, e.target_id.as_str(), "out"));
380 }
381 }
382 }
383 if matches!(opts.direction, GraphDirection::In | GraphDirection::Both) {
384 if let Some(list) = in_adj.get(&curr) {
385 for e in list {
386 candidates.push((e, e.source_id.as_str(), "in"));
387 }
388 }
389 }
390
391 candidates.sort_by(|a, b| {
393 b.0.weight
394 .partial_cmp(&a.0.weight)
395 .unwrap_or(std::cmp::Ordering::Equal)
396 .then_with(|| a.1.cmp(b.1))
397 .then_with(|| a.0.relation_type.cmp(&b.0.relation_type))
398 });
399
400 for (e, neighbor_id, dir) in candidates {
401 if result_edges.len() >= opts.max_edges {
402 truncated = true;
403 break;
404 }
405 if visited.contains(neighbor_id) {
406 continue;
407 }
408 let Some(neighbor) = node_map.get(neighbor_id) else {
409 continue;
410 };
411 if !opts.include_symbols && neighbor.node_type == NodeType::Symbol {
412 continue;
413 }
414 if let Some(filter) = &opts.type_filter {
415 if !filter.contains(&neighbor.node_type) {
416 continue;
417 }
418 }
419 if !opts.allows_neighbor(neighbor) {
420 continue;
421 }
422
423 visited.insert(neighbor_id.to_string());
424 let hop = depth + 1;
425 result_edges.push(GraphHopEdge {
426 hop,
427 direction: dir.to_string(),
428 relation_type: e.relation_type.clone(),
429 weight: e.weight,
430 from_id: curr.clone(),
431 to: GraphNodeRef::from_node(neighbor),
432 });
433 if hop < hops {
434 queue.push_back((neighbor_id.to_string(), hop));
435 }
436 }
437 }
438
439 Ok(GraphNeighborhood {
440 root: GraphNodeRef::from_node(&root),
441 hops,
442 nodes_in_subgraph: visited.len(),
443 edges: result_edges,
444 truncated,
445 db_nodes: node_map.len(),
446 db_edges: all_edges.len(),
447 })
448}
449
450pub fn resolve_graph_target(db: &Database, target: &str) -> Result<Node> {
458 let raw = target.trim().trim_start_matches("./").replace('\\', "/");
459 if raw.is_empty() {
460 return Err(BrainError::other(
461 "empty graph target — pass a node id, path, title, or symbol:…",
462 ));
463 }
464
465 if let Some(n) = db.get_node(&raw)? {
467 return Ok(n);
468 }
469
470 let as_path = Path::new(&raw);
472 let slug = node_id_from_rel_path(as_path);
473 if let Some(n) = db.get_node(&slug)? {
474 return Ok(n);
475 }
476 if !raw.starts_with("docs/") {
478 let with_docs = format!("docs/{raw}");
479 if let Some(n) = db.get_node(&with_docs)? {
480 return Ok(n);
481 }
482 let slug2 = node_id_from_rel_path(Path::new(&with_docs));
483 if let Some(n) = db.get_node(&slug2)? {
484 return Ok(n);
485 }
486 }
487
488 let sym_raw = raw
490 .strip_prefix("symbol:")
491 .or_else(|| raw.strip_prefix("[[symbol:").and_then(|s| s.strip_suffix("]]")))
492 .unwrap_or(&raw);
493 if raw.starts_with("symbol:") || raw.contains("::") {
494 if let Some(sym) = parse_symbol_path(sym_raw) {
495 let ids: HashSet<String> = db
496 .get_all_node_ids()?
497 .into_iter()
498 .filter(|id| id.starts_with("symbol/"))
499 .collect();
500 if let Some(id) = resolve_symbol_ref(&sym, &ids) {
501 if let Some(n) = db.get_node(&id)? {
502 return Ok(n);
503 }
504 }
505 }
506 }
507
508 let all = db.get_all_nodes()?;
510 let path_hits: Vec<&Node> = all
511 .iter()
512 .filter(|n| {
513 n.file_path
514 .as_ref()
515 .map(|p| {
516 let p = p.replace('\\', "/");
517 p == raw
518 || p.ends_with(&raw)
519 || p.ends_with(&format!("/{raw}"))
520 || p.trim_end_matches(".md") == raw
521 || p.trim_end_matches(".md").ends_with(&format!("/{raw}"))
522 })
523 .unwrap_or(false)
524 })
525 .collect();
526 if path_hits.len() == 1 {
527 return Ok(path_hits[0].clone());
528 }
529 if path_hits.len() > 1 {
530 return Err(BrainError::other(format!(
531 "ambiguous path `{raw}` matches {} nodes — use a fuller path or exact id",
532 path_hits.len()
533 )));
534 }
535
536 let lower = raw.to_ascii_lowercase();
538 let title_hits: Vec<&Node> = all
539 .iter()
540 .filter(|n| n.title.to_ascii_lowercase() == lower)
541 .collect();
542 if title_hits.len() == 1 {
543 return Ok(title_hits[0].clone());
544 }
545 if title_hits.len() > 1 {
546 return Err(BrainError::other(format!(
547 "ambiguous title `{raw}` matches {} nodes — use path or id",
548 title_hits.len()
549 )));
550 }
551
552 let sub_hits: Vec<&Node> = all
554 .iter()
555 .filter(|n| n.title.to_ascii_lowercase().contains(&lower) && lower.len() >= 3)
556 .collect();
557 if sub_hits.len() == 1 {
558 return Ok(sub_hits[0].clone());
559 }
560
561 Err(BrainError::other(format!(
562 "no node found for `{raw}` — try path (docs/…md), node id, exact title, or symbol:Name after `rustbrain sync`"
563 )))
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::types::{Edge, Node, NodeType};
570 use tempfile::tempdir;
571
572 fn mk_db() -> (tempfile::TempDir, Database) {
573 let dir = tempdir().unwrap();
574 let db = Database::open(dir.path().join("db.sqlite")).unwrap();
575 let now = 1_700_000_000i64;
576 let nodes = [
577 Node {
578 id: "docs/concepts/raft".into(),
579 node_type: NodeType::Concept,
580 title: "Raft".into(),
581 file_path: Some("docs/concepts/raft.md".into()),
582 symbol_hash: None,
583 summary: Some("consensus".into()),
584 content_hash: None,
585 scope: crate::scopes::MAIN_SCOPE.to_string(),
586 created_at: now,
587 updated_at: now,
588 },
589 Node {
590 id: "docs/concepts/logcompaction".into(),
591 node_type: NodeType::Concept,
592 title: "Log Compaction".into(),
593 file_path: Some("docs/concepts/logcompaction.md".into()),
594 symbol_hash: None,
595 summary: None,
596 content_hash: None,
597 scope: crate::scopes::MAIN_SCOPE.to_string(),
598 created_at: now,
599 updated_at: now,
600 },
601 Node {
602 id: "docs/adr/0001-use-raft".into(),
603 node_type: NodeType::Adr,
604 title: "Use Raft".into(),
605 file_path: Some("docs/adr/0001-use-raft.md".into()),
606 symbol_hash: None,
607 summary: None,
608 content_hash: None,
609 scope: crate::scopes::MAIN_SCOPE.to_string(),
610 created_at: now,
611 updated_at: now,
612 },
613 Node {
614 id: "symbol/demo/lib/storageengine".into(),
615 node_type: NodeType::Symbol,
616 title: "StorageEngine".into(),
617 file_path: Some("src/lib.rs".into()),
618 symbol_hash: Some(1),
619 summary: None,
620 content_hash: None,
621 scope: crate::scopes::MAIN_SCOPE.to_string(),
622 created_at: now,
623 updated_at: now,
624 },
625 ];
626 for n in &nodes {
627 db.insert_node(n).unwrap();
628 }
629 db.insert_edge(&Edge {
630 source_id: "docs/concepts/raft".into(),
631 target_id: "docs/concepts/logcompaction".into(),
632 relation_type: "relates_to".into(),
633 weight: 1.0,
634 decay_rate: 0.0,
635 created_at: now,
636 })
637 .unwrap();
638 db.insert_edge(&Edge {
639 source_id: "docs/concepts/raft".into(),
640 target_id: "symbol/demo/lib/storageengine".into(),
641 relation_type: "anchors".into(),
642 weight: 1.0,
643 decay_rate: 0.0,
644 created_at: now,
645 })
646 .unwrap();
647 db.insert_edge(&Edge {
648 source_id: "docs/adr/0001-use-raft".into(),
649 target_id: "docs/concepts/raft".into(),
650 relation_type: "relates_to".into(),
651 weight: 0.9,
652 decay_rate: 0.0,
653 created_at: now,
654 })
655 .unwrap();
656 db.insert_edge(&Edge {
657 source_id: "docs/concepts/raft".into(),
658 target_id: "docs/concepts/logcompaction".into(),
659 relation_type: "auto_filename".into(),
660 weight: 0.4,
661 decay_rate: 0.0,
662 created_at: now,
663 })
664 .unwrap();
665 (dir, db)
666 }
667
668 #[test]
669 fn neighborhood_both_directions() {
670 let (_dir, db) = mk_db();
671 let nb = neighborhood(&db, "docs/concepts/raft", &GraphOptions::default()).unwrap();
672 assert_eq!(nb.root.id, "docs/concepts/raft");
673 assert!(nb.edges.len() >= 3);
675 let ids: HashSet<_> = nb.edges.iter().map(|e| e.to.id.as_str()).collect();
676 assert!(ids.contains("docs/concepts/logcompaction"));
677 assert!(ids.contains("symbol/demo/lib/storageengine"));
678 assert!(ids.contains("docs/adr/0001-use-raft"));
679 let ascii = nb.to_ascii();
680 assert!(ascii.contains("relates_to"));
681 assert!(ascii.contains("anchors"));
682 }
683
684 #[test]
685 fn no_auto_and_no_symbols_filters() {
686 let (_dir, db) = mk_db();
687 let opts = GraphOptions {
688 include_auto: false,
689 include_symbols: false,
690 ..GraphOptions::default()
691 };
692 let nb = neighborhood(&db, "docs/concepts/raft.md", &opts).unwrap();
693 for e in &nb.edges {
694 assert!(!e.relation_type.starts_with("auto_"));
695 assert_ne!(e.to.node_type, "symbol");
696 }
697 assert!(nb.edges.iter().any(|e| e.to.id == "docs/concepts/logcompaction"));
698 assert!(nb.edges.iter().any(|e| e.to.id == "docs/adr/0001-use-raft"));
699 }
700
701 #[test]
702 fn resolve_by_title() {
703 let (_dir, db) = mk_db();
704 let n = resolve_graph_target(&db, "Raft").unwrap();
705 assert_eq!(n.id, "docs/concepts/raft");
706 }
707
708 #[test]
709 fn stats_counts() {
710 let (_dir, db) = mk_db();
711 let s = graph_stats(&db).unwrap();
712 assert_eq!(s.nodes, 4);
713 assert!(s.edges >= 3);
714 assert!(!s.hubs.is_empty());
715 assert!(s.to_ascii().contains("by type"));
716 }
717
718 #[test]
719 fn direction_out_only() {
720 let (_dir, db) = mk_db();
721 let opts = GraphOptions {
722 direction: GraphDirection::Out,
723 include_auto: false,
724 ..GraphOptions::default()
725 };
726 let nb = neighborhood(&db, "docs/concepts/raft", &opts).unwrap();
727 assert!(nb.edges.iter().all(|e| e.direction == "out"));
728 assert!(!nb.edges.iter().any(|e| e.to.id == "docs/adr/0001-use-raft"));
729 }
730}