Skip to main content

rustbrain_core/
graph.rs

1//! Neighborhood inspection of the knowledge graph.
2//!
3//! Complements FTS [`crate::query`] and agent [`crate::context`]: those pack
4//! content under a token budget; this module **shows structure** — who links to
5//! whom, with relation types and weights — as ASCII or JSON for humans/agents.
6//!
7//! Edges come from SQLite (preserves `relation_type`). CSR `graph.mmap` is not
8//! required; run `sync` so edges exist.
9
10use 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/// Which edge directions to follow when expanding from the root.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
22#[serde(rename_all = "snake_case")]
23pub enum GraphDirection {
24    /// Only follow `source → target` edges from the frontier.
25    Out,
26    /// Only follow reverse edges into the frontier.
27    In,
28    /// Expand along both outgoing and incoming edges (default).
29    #[default]
30    Both,
31}
32
33impl GraphDirection {
34    /// Parse `out` / `in` / `both` (case-insensitive).
35    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/// Options for [`neighborhood`].
46#[derive(Debug, Clone)]
47pub struct GraphOptions {
48    /// Max BFS depth from the root (`1` = direct neighbors only).
49    pub hops: usize,
50    /// Include soft `auto_*` edges (default true).
51    pub include_auto: bool,
52    /// Include symbol nodes as neighbors (default true).
53    pub include_symbols: bool,
54    /// Edge direction filter.
55    pub direction: GraphDirection,
56    /// Cap on edges returned (prevents huge dumps).
57    pub max_edges: usize,
58    /// If set, only include neighbors whose type is in this list.
59    pub type_filter: Option<Vec<NodeType>>,
60    /// Optional SubBrain filter: keep neighbors in this scope (or hubs / MainBrain mix).
61    pub scope: Option<String>,
62    /// How much MainBrain content to allow as neighbors when `scope` is set.
63    pub scope_main: crate::query::ScopeMainInclude,
64    /// MainBrain id for scope filtering (default `main`).
65    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/// Compact node identity for graph reports.
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103pub struct GraphNodeRef {
104    /// Stable node id.
105    pub id: String,
106    /// Domain type as snake_case string.
107    pub node_type: String,
108    /// Display title.
109    pub title: String,
110    /// Repo-relative path when known.
111    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/// One edge in the neighborhood expansion (BFS order).
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct GraphHopEdge {
128    /// Hop distance from the root (1 = direct neighbor).
129    pub hop: usize,
130    /// `out` if root-side was source; `in` if root-side was target.
131    pub direction: String,
132    /// Relation label (`relates_to`, `anchors`, `doc_links`, `auto_*`, …).
133    pub relation_type: String,
134    /// Edge weight.
135    pub weight: f32,
136    /// Node id on the parent side of this hop (closer to root).
137    pub from_id: String,
138    /// Neighbor node reached by this hop.
139    pub to: GraphNodeRef,
140}
141
142/// Result of a neighborhood query around one node.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct GraphNeighborhood {
145    /// Resolved seed node.
146    pub root: GraphNodeRef,
147    /// Requested hop depth.
148    pub hops: usize,
149    /// Unique nodes in the subgraph including the root.
150    pub nodes_in_subgraph: usize,
151    /// Edges returned (may be capped by [`GraphOptions::max_edges`]).
152    pub edges: Vec<GraphHopEdge>,
153    /// True when `max_edges` truncated the expansion.
154    pub truncated: bool,
155    /// Total nodes in the database (context).
156    pub db_nodes: usize,
157    /// Total edges in the database (context).
158    pub db_edges: usize,
159}
160
161impl GraphNeighborhood {
162    /// Human/agent-readable ASCII (tree-style for hop 1+, indented by hop).
163    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        // Tree: children of root first, then recursively by parent id.
189        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/// Aggregate graph statistics (no seed required).
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct GraphStats {
229    /// Total nodes.
230    pub nodes: usize,
231    /// Total edges.
232    pub edges: usize,
233    /// Counts by `node_type`.
234    pub by_type: Vec<(String, usize)>,
235    /// Counts by `relation_type`.
236    pub by_relation: Vec<(String, usize)>,
237    /// Highest-degree nodes (undirected: in+out), up to 15.
238    pub hubs: Vec<GraphHub>,
239}
240
241/// A high-degree node for [`GraphStats`].
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct GraphHub {
244    /// Node id.
245    pub id: String,
246    /// Type string.
247    pub node_type: String,
248    /// Title.
249    pub title: String,
250    /// Degree (in + out edge endpoints).
251    pub degree: usize,
252}
253
254impl GraphStats {
255    /// Human-readable summary.
256    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
287/// Database-wide graph statistics and hubs.
288pub 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
336/// Expand the k-hop neighborhood around `target` (path, id, title, or `symbol:…`).
337pub 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    // Adjacency: out and in lists of (edge, neighbor_id)
346    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    // queue: (node_id, hop_from_root)
359    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(); // edge, neighbor, direction
375
376        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        // Stable order: higher weight first, then id
392        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
450/// Resolve a user target string to a [`Node`].
451///
452/// Accepts:
453/// - exact node id
454/// - file path (`docs/concepts/raft.md` or without extension)
455/// - `symbol:Name` / `symbol:mod::Name`
456/// - exact title (case-insensitive), unique match only
457pub 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    // Exact id
466    if let Some(n) = db.get_node(&raw)? {
467        return Ok(n);
468    }
469
470    // Path → id slug
471    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    // docs/ prefix variants
477    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    // symbol: refs
489    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    // file_path match among all nodes
509    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    // Unique title match (case-insensitive)
537    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    // Substring title (unique)
553    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        // out relates_to logcompaction, out anchors symbol, in from adr, + auto
674        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}