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