Skip to main content

graphify_serve/
lib.rs

1//! MCP server for graph queries.
2//!
3//! Provides graph traversal and scoring functions used by the query
4//! engine and MCP protocol server. Port of Python query tools.
5
6pub mod mcp;
7
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::path::Path;
10
11use graphify_core::graph::KnowledgeGraph;
12use serde_json::Value;
13use thiserror::Error;
14
15/// Errors from the server.
16#[derive(Debug, Error)]
17pub enum ServeError {
18    #[error("IO error: {0}")]
19    Io(#[from] std::io::Error),
20
21    #[error("graph load error: {0}")]
22    GraphLoad(String),
23
24    #[error("serialization error: {0}")]
25    Serialization(#[from] serde_json::Error),
26}
27
28/// Score nodes by relevance to search terms.
29///
30/// Returns `(score, node_id)` pairs sorted by descending score.
31/// Scoring: +2.0 for exact label match, +1.0 for label contains,
32/// +0.5 for id contains, plus a small degree-based boost.
33pub fn score_nodes(graph: &KnowledgeGraph, terms: &[String]) -> Vec<(f64, String)> {
34    let lower_terms: Vec<String> = terms.iter().map(|t| t.to_lowercase()).collect();
35
36    let mut scored = Vec::new();
37    for node_id in graph.node_ids() {
38        if let Some(node) = graph.get_node(&node_id) {
39            let label_lower = node.label.to_lowercase();
40            let id_lower = node.id.to_lowercase();
41
42            let mut score: f64 = 0.0;
43
44            for term in &lower_terms {
45                if label_lower == *term {
46                    score += 2.0;
47                } else if label_lower.contains(term.as_str()) {
48                    score += 1.0;
49                }
50
51                if id_lower.contains(term.as_str()) {
52                    score += 0.5;
53                }
54            }
55
56            if score > 0.0 {
57                let degree_boost = (graph.degree(&node_id) as f64).ln_1p() * 0.1;
58                score += degree_boost;
59                scored.push((score, node_id.clone()));
60            }
61        }
62    }
63
64    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
65    scored
66}
67
68/// BFS traversal from start nodes up to a maximum depth.
69///
70/// Returns `(visited_nodes, edges_traversed)` where edges are `(source, target)` pairs.
71pub fn bfs(
72    graph: &KnowledgeGraph,
73    start: &[String],
74    depth: usize,
75) -> (Vec<String>, Vec<(String, String)>) {
76    let mut visited: HashSet<String> = HashSet::new();
77    let mut edges: Vec<(String, String)> = Vec::new();
78    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
79
80    for s in start {
81        if graph.get_node(s).is_some() {
82            visited.insert(s.clone());
83            queue.push_back((s.clone(), 0));
84        }
85    }
86
87    while let Some((current, current_depth)) = queue.pop_front() {
88        if current_depth >= depth {
89            continue;
90        }
91
92        for neighbor_id in graph.neighbor_ids(&current) {
93            edges.push((current.clone(), neighbor_id.clone()));
94
95            if !visited.contains(&neighbor_id) {
96                visited.insert(neighbor_id.clone());
97                queue.push_back((neighbor_id, current_depth + 1));
98            }
99        }
100    }
101
102    let visited_vec: Vec<String> = visited.into_iter().collect();
103    (visited_vec, edges)
104}
105
106/// DFS traversal from start nodes up to a maximum depth.
107///
108/// Returns `(visited_nodes, edges_traversed)` where edges are `(source, target)` pairs.
109pub fn dfs(
110    graph: &KnowledgeGraph,
111    start: &[String],
112    depth: usize,
113) -> (Vec<String>, Vec<(String, String)>) {
114    let mut visited: HashSet<String> = HashSet::new();
115    let mut edges: Vec<(String, String)> = Vec::new();
116    let mut stack: Vec<(String, usize)> = Vec::new();
117
118    for s in start {
119        if graph.get_node(s).is_some() {
120            visited.insert(s.clone());
121            stack.push((s.clone(), 0));
122        }
123    }
124
125    while let Some((current, current_depth)) = stack.pop() {
126        if current_depth >= depth {
127            continue;
128        }
129
130        for neighbor_id in graph.neighbor_ids(&current) {
131            edges.push((current.clone(), neighbor_id.clone()));
132
133            if !visited.contains(&neighbor_id) {
134                visited.insert(neighbor_id.clone());
135                stack.push((neighbor_id, current_depth + 1));
136            }
137        }
138    }
139
140    let visited_vec: Vec<String> = visited.into_iter().collect();
141    (visited_vec, edges)
142}
143
144/// Convert a subgraph (set of nodes and edges) to a text representation
145/// suitable for LLM context windows.
146///
147/// Respects a `token_budget` (approximate: 1 token ≈ 4 chars).
148pub fn subgraph_to_text(
149    graph: &KnowledgeGraph,
150    nodes: &[String],
151    edges: &[(String, String)],
152    token_budget: usize,
153) -> String {
154    let char_budget = token_budget * 4;
155    let mut output = String::with_capacity(char_budget.min(64 * 1024));
156
157    output.push_str(&format!(
158        "=== Knowledge Graph Context ({} nodes, {} edges) ===\n\n",
159        nodes.len(),
160        edges.len()
161    ));
162
163    output.push_str("## Nodes\n\n");
164    for node_id in nodes {
165        if output.len() >= char_budget {
166            output.push_str("\n... (truncated due to token budget)\n");
167            break;
168        }
169
170        if let Some(node) = graph.get_node(node_id) {
171            output.push_str(&format!(
172                "- **{}** [{}] (type: {:?}",
173                node.label, node.id, node.node_type
174            ));
175            if let Some(community) = node.community {
176                output.push_str(&format!(", community: {}", community));
177            }
178            output.push_str(&format!(", file: {})\n", node.source_file));
179        }
180    }
181
182    if output.len() < char_budget {
183        output.push_str("\n## Relationships\n\n");
184
185        let mut seen: HashSet<(&str, &str)> = HashSet::new();
186        for (src, tgt) in edges {
187            if output.len() >= char_budget {
188                output.push_str("\n... (truncated due to token budget)\n");
189                break;
190            }
191
192            if seen.insert((src.as_str(), tgt.as_str())) {
193                let src_label = graph.get_node(src).map(|n| n.label.as_str()).unwrap_or(src);
194                let tgt_label = graph.get_node(tgt).map(|n| n.label.as_str()).unwrap_or(tgt);
195                output.push_str(&format!("- {} -> {}\n", src_label, tgt_label));
196            }
197        }
198    }
199
200    output
201}
202
203/// Abstraction level for graph summaries.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum SummaryLevel {
206    /// Full detail: all nodes and edges within budget.
207    Detailed,
208    /// Community representatives + cross-community edges.
209    Community,
210    /// Directory-level super-nodes with aggregated dependencies.
211    Architecture,
212}
213
214/// Generate a multi-level graph summary within a token budget.
215///
216/// - `Detailed`: Equivalent to `subgraph_to_text` on the full graph.
217/// - `Community`: One representative node per community (highest degree) + cross-community edges.
218/// - `Architecture`: Groups nodes by directory into super-nodes, merges edges.
219pub fn smart_summary(
220    graph: &KnowledgeGraph,
221    communities: &HashMap<usize, Vec<String>>,
222    level: SummaryLevel,
223    token_budget: usize,
224) -> String {
225    match level {
226        SummaryLevel::Detailed => {
227            let all_nodes = graph.node_ids();
228            let all_edges: Vec<(String, String)> = graph
229                .edges_with_endpoints()
230                .iter()
231                .map(|(s, t, _)| (s.to_string(), t.to_string()))
232                .collect();
233            subgraph_to_text(graph, &all_nodes, &all_edges, token_budget)
234        }
235        SummaryLevel::Community => community_level_summary(graph, communities, token_budget),
236        SummaryLevel::Architecture => architecture_level_summary(graph, token_budget),
237    }
238}
239
240/// Community-level summary: one representative per community + cross-community edges.
241fn community_level_summary(
242    graph: &KnowledgeGraph,
243    communities: &HashMap<usize, Vec<String>>,
244    token_budget: usize,
245) -> String {
246    let char_budget = token_budget * 4;
247    let mut output = String::with_capacity(char_budget.min(64 * 1024));
248
249    let mut representatives: HashMap<usize, (&str, usize)> = HashMap::new();
250    for (&cid, members) in communities {
251        let best = members
252            .iter()
253            .map(|id| (id.as_str(), graph.degree(id)))
254            .max_by_key(|(_, d)| *d)
255            .unwrap_or(("", 0));
256        representatives.insert(cid, best);
257    }
258
259    output.push_str(&format!(
260        "=== Architecture Summary ({} communities, {} total nodes) ===\n\n",
261        communities.len(),
262        graph.node_count()
263    ));
264
265    let mut node_cid: HashMap<&str, usize> = HashMap::new();
266    for (&cid, members) in communities {
267        for m in members {
268            node_cid.insert(m.as_str(), cid);
269        }
270    }
271
272    output.push_str("## Communities\n\n");
273    let mut sorted_cids: Vec<usize> = communities.keys().copied().collect();
274    sorted_cids.sort();
275    for cid in &sorted_cids {
276        if output.len() >= char_budget {
277            output.push_str("\n... (truncated)\n");
278            break;
279        }
280        let members = &communities[cid];
281        let (rep_id, rep_deg) = representatives[cid];
282        let rep_label = graph
283            .get_node(rep_id)
284            .map(|n| n.label.as_str())
285            .unwrap_or(rep_id);
286        output.push_str(&format!(
287            "- Community {} ({} nodes): representative **{}** (degree {})\n",
288            cid,
289            members.len(),
290            rep_label,
291            rep_deg
292        ));
293    }
294
295    output.push_str("\n## Cross-Community Dependencies\n\n");
296    let mut cross_edges: HashMap<(usize, usize), usize> = HashMap::new();
297    for (src, tgt, _) in graph.edges_with_endpoints() {
298        let sc = node_cid.get(src).copied().unwrap_or(usize::MAX);
299        let tc = node_cid.get(tgt).copied().unwrap_or(usize::MAX);
300        if sc != tc && sc != usize::MAX && tc != usize::MAX {
301            let key = if sc < tc { (sc, tc) } else { (tc, sc) };
302            *cross_edges.entry(key).or_default() += 1;
303        }
304    }
305    let mut sorted_cross: Vec<_> = cross_edges.into_iter().collect();
306    sorted_cross.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
307    for ((c1, c2), count) in sorted_cross.iter().take(20) {
308        if output.len() >= char_budget {
309            break;
310        }
311        output.push_str(&format!(
312            "- Community {} ↔ Community {}: {} edges\n",
313            c1, c2, count
314        ));
315    }
316
317    output
318}
319
320/// Architecture-level summary: group by directory, aggregate edges.
321fn architecture_level_summary(graph: &KnowledgeGraph, token_budget: usize) -> String {
322    let char_budget = token_budget * 4;
323    let mut output = String::with_capacity(char_budget.min(64 * 1024));
324
325    let mut dir_nodes: HashMap<String, Vec<&str>> = HashMap::new();
326    for node in graph.nodes() {
327        let dir = std::path::Path::new(&node.source_file)
328            .parent()
329            .and_then(|p| p.to_str())
330            .unwrap_or(".")
331            .to_string();
332        dir_nodes.entry(dir).or_default().push(&node.id);
333    }
334
335    let mut node_dir: HashMap<&str, &str> = HashMap::new();
336    for (dir, nodes) in &dir_nodes {
337        for &nid in nodes {
338            node_dir.insert(nid, dir.as_str());
339        }
340    }
341
342    output.push_str(&format!(
343        "=== Architecture Overview ({} packages/directories) ===\n\n",
344        dir_nodes.len()
345    ));
346
347    output.push_str("## Packages\n\n");
348    let mut sorted_dirs: Vec<_> = dir_nodes.iter().collect();
349    sorted_dirs.sort_by_key(|(_, nodes)| std::cmp::Reverse(nodes.len()));
350    for (dir, nodes) in sorted_dirs.iter().take(30) {
351        if output.len() >= char_budget {
352            output.push_str("\n... (truncated)\n");
353            break;
354        }
355        output.push_str(&format!("- **{}** ({} entities)\n", dir, nodes.len()));
356    }
357
358    output.push_str("\n## Dependencies\n\n");
359    let mut dir_edges: HashMap<(&str, &str), usize> = HashMap::new();
360    for (src, tgt, _) in graph.edges_with_endpoints() {
361        let sd = node_dir.get(src).copied().unwrap_or("?");
362        let td = node_dir.get(tgt).copied().unwrap_or("?");
363        if sd != td {
364            let key = if sd < td { (sd, td) } else { (td, sd) };
365            *dir_edges.entry(key).or_default() += 1;
366        }
367    }
368    let mut sorted_deps: Vec<_> = dir_edges.into_iter().collect();
369    sorted_deps.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
370    for ((d1, d2), count) in sorted_deps.iter().take(20) {
371        if output.len() >= char_budget {
372            break;
373        }
374        output.push_str(&format!("- {} → {}: {} edges\n", d1, d2, count));
375    }
376
377    output
378}
379
380/// Load a knowledge graph from a JSON file.
381pub fn load_graph(graph_path: &Path) -> Result<KnowledgeGraph, ServeError> {
382    let content = std::fs::read_to_string(graph_path)?;
383    let value: Value = serde_json::from_str(&content)?;
384    KnowledgeGraph::from_node_link_json(&value).map_err(|e| ServeError::GraphLoad(e.to_string()))
385}
386
387/// Get basic statistics about the graph.
388pub fn graph_stats(graph: &KnowledgeGraph) -> HashMap<String, Value> {
389    let mut stats = HashMap::new();
390    stats.insert("node_count".to_string(), Value::from(graph.node_count()));
391    stats.insert("edge_count".to_string(), Value::from(graph.edge_count()));
392    stats.insert(
393        "community_count".to_string(),
394        Value::from(graph.communities.len()),
395    );
396
397    let node_ids = graph.node_ids();
398    if !node_ids.is_empty() {
399        let degrees: Vec<usize> = node_ids.iter().map(|id| graph.degree(id)).collect();
400        let max_degree = degrees.iter().copied().max().unwrap_or(0);
401        let avg_degree = degrees.iter().sum::<usize>() as f64 / degrees.len() as f64;
402        stats.insert("max_degree".to_string(), Value::from(max_degree));
403        stats.insert(
404            "avg_degree".to_string(),
405            Value::from(format!("{:.2}", avg_degree)),
406        );
407    }
408
409    stats
410}
411
412/// Start the MCP server over stdio (JSON-RPC 2.0).
413///
414/// Reads requests from stdin, writes responses to stdout.
415/// This is the entry point called by the CLI `serve` command.
416pub async fn start_server(graph_path: &Path) -> Result<(), ServeError> {
417    let path = graph_path.to_path_buf();
418    tokio::task::spawn_blocking(move || mcp::run_mcp_server(&path))
419        .await
420        .map_err(|e| ServeError::Io(std::io::Error::other(e)))??;
421    Ok(())
422}
423
424/// Find all simple paths between `source` and `target` up to `max_length` edges.
425///
426/// Returns a vec of paths, each path being a vec of node IDs.
427/// Limits to at most 50 paths to prevent combinatorial explosion.
428pub fn all_simple_paths(
429    graph: &KnowledgeGraph,
430    source: &str,
431    target: &str,
432    max_length: usize,
433) -> Vec<Vec<String>> {
434    const MAX_PATHS: usize = 50;
435    let mut result: Vec<Vec<String>> = Vec::new();
436    let mut stack: Vec<(String, Vec<String>)> = Vec::new();
437
438    if graph.get_node(source).is_none() || graph.get_node(target).is_none() {
439        return result;
440    }
441
442    stack.push((source.to_string(), vec![source.to_string()]));
443
444    while let Some((current, path)) = stack.pop() {
445        if result.len() >= MAX_PATHS {
446            break;
447        }
448        if current == target && path.len() > 1 {
449            result.push(path);
450            continue;
451        }
452        if path.len() > max_length + 1 {
453            continue;
454        }
455
456        for neighbor_id in graph.neighbor_ids(&current) {
457            if !path.contains(&neighbor_id) {
458                let mut new_path = path.clone();
459                new_path.push(neighbor_id.clone());
460                stack.push((neighbor_id, new_path));
461            }
462        }
463    }
464
465    result.sort_by_key(|p| p.len());
466    result
467}
468
469/// Edge detail in a weighted path: (from_id, to_id, cost, relation).
470pub type EdgeDetail = (String, String, f64, String);
471
472/// Dijkstra shortest path using edge weights.
473///
474/// Cost = 1.0 / edge.weight (higher weight = shorter distance).
475/// Optionally filters edges below `min_confidence` score.
476/// Returns `(path, total_cost, edge_details)` or None if no path exists.
477pub fn dijkstra_path(
478    graph: &KnowledgeGraph,
479    source: &str,
480    target: &str,
481    min_confidence: f64,
482) -> Option<(Vec<String>, f64, Vec<EdgeDetail>)> {
483    use std::cmp::Ordering;
484    use std::collections::BinaryHeap;
485
486    if graph.get_node(source).is_none() || graph.get_node(target).is_none() {
487        return None;
488    }
489    if source == target {
490        return Some((vec![source.to_string()], 0.0, Vec::new()));
491    }
492
493    let mut adj: HashMap<String, Vec<(String, f64, String)>> = HashMap::new();
494    for (src, tgt, edge) in graph.edges_with_endpoints() {
495        if edge.confidence_score < min_confidence {
496            continue;
497        }
498        let cost = if edge.weight > 0.0 {
499            1.0 / edge.weight
500        } else {
501            f64::MAX
502        };
503        adj.entry(src.to_string()).or_default().push((
504            tgt.to_string(),
505            cost,
506            edge.relation.clone(),
507        ));
508        adj.entry(tgt.to_string()).or_default().push((
509            src.to_string(),
510            cost,
511            edge.relation.clone(),
512        ));
513    }
514
515    #[derive(PartialEq)]
516    struct State {
517        cost: f64,
518        node: String,
519    }
520    impl Eq for State {}
521    impl PartialOrd for State {
522        fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
523            Some(self.cmp(other))
524        }
525    }
526    impl Ord for State {
527        fn cmp(&self, other: &Self) -> Ordering {
528            other
529                .cost
530                .partial_cmp(&self.cost)
531                .unwrap_or(Ordering::Equal)
532        }
533    }
534
535    let mut dist: HashMap<String, f64> = HashMap::new();
536    let mut prev: HashMap<String, (String, f64, String)> = HashMap::new();
537    let mut heap = BinaryHeap::new();
538
539    dist.insert(source.to_string(), 0.0);
540    heap.push(State {
541        cost: 0.0,
542        node: source.to_string(),
543    });
544
545    while let Some(State { cost, node }) = heap.pop() {
546        if node == target {
547            break;
548        }
549        if cost > *dist.get(&node).unwrap_or(&f64::MAX) {
550            continue;
551        }
552        if let Some(neighbors) = adj.get(&node) {
553            for (next, edge_cost, relation) in neighbors {
554                let new_cost = cost + edge_cost;
555                if new_cost < *dist.get(next).unwrap_or(&f64::MAX) {
556                    dist.insert(next.clone(), new_cost);
557                    prev.insert(next.clone(), (node.clone(), *edge_cost, relation.clone()));
558                    heap.push(State {
559                        cost: new_cost,
560                        node: next.clone(),
561                    });
562                }
563            }
564        }
565    }
566
567    if !prev.contains_key(target) {
568        return None;
569    }
570
571    let mut path = vec![target.to_string()];
572    let mut edge_details: Vec<(String, String, f64, String)> = Vec::new();
573    let mut current = target.to_string();
574    while let Some((from, cost, relation)) = prev.get(&current) {
575        edge_details.push((from.clone(), current.clone(), *cost, relation.clone()));
576        path.push(from.clone());
577        current = from.clone();
578    }
579    path.reverse();
580    edge_details.reverse();
581
582    let total_cost = *dist.get(target).unwrap_or(&f64::MAX);
583    Some((path, total_cost, edge_details))
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use graphify_core::confidence::Confidence;
590    use graphify_core::model::{GraphEdge, GraphNode, NodeType};
591
592    fn make_node(id: &str, label: &str) -> GraphNode {
593        GraphNode {
594            id: id.into(),
595            label: label.into(),
596            source_file: "test.rs".into(),
597            source_location: None,
598            node_type: NodeType::Class,
599            community: None,
600            extra: HashMap::new(),
601        }
602    }
603
604    fn make_edge(src: &str, tgt: &str) -> GraphEdge {
605        GraphEdge {
606            source: src.into(),
607            target: tgt.into(),
608            relation: "calls".into(),
609            confidence: Confidence::Extracted,
610            confidence_score: 1.0,
611            source_file: "test.rs".into(),
612            source_location: None,
613            weight: 1.0,
614            extra: HashMap::new(),
615        }
616    }
617
618    fn make_test_graph() -> KnowledgeGraph {
619        let mut g = KnowledgeGraph::new();
620        g.add_node(make_node("auth", "AuthService")).unwrap();
621        g.add_node(make_node("user", "UserManager")).unwrap();
622        g.add_node(make_node("db", "Database")).unwrap();
623        g.add_node(make_node("cache", "CacheLayer")).unwrap();
624        g.add_edge(make_edge("auth", "user")).unwrap();
625        g.add_edge(make_edge("auth", "db")).unwrap();
626        g.add_edge(make_edge("user", "db")).unwrap();
627        g.add_edge(make_edge("user", "cache")).unwrap();
628        g
629    }
630
631    #[test]
632    fn test_score_nodes_basic() {
633        let g = make_test_graph();
634        let results = score_nodes(&g, &["auth".to_string()]);
635        assert!(!results.is_empty());
636        let top_id = &results[0].1;
637        assert_eq!(top_id, "auth");
638    }
639
640    #[test]
641    fn test_score_nodes_no_match() {
642        let g = make_test_graph();
643        let results = score_nodes(&g, &["nonexistent".to_string()]);
644        assert!(results.is_empty());
645    }
646
647    #[test]
648    fn test_score_nodes_multiple_terms() {
649        let g = make_test_graph();
650        let results = score_nodes(&g, &["user".to_string(), "manager".to_string()]);
651        assert!(!results.is_empty());
652        assert!(results.iter().any(|(_, id)| id == "user"));
653    }
654
655    #[test]
656    fn test_bfs_depth_0() {
657        let g = make_test_graph();
658        let (nodes, edges) = bfs(&g, &["auth".to_string()], 0);
659        assert_eq!(nodes.len(), 1);
660        assert!(edges.is_empty());
661    }
662
663    #[test]
664    fn test_bfs_depth_1() {
665        let g = make_test_graph();
666        let (nodes, edges) = bfs(&g, &["auth".to_string()], 1);
667        assert!(nodes.len() >= 3); // auth, user, db
668        assert!(!edges.is_empty());
669    }
670
671    #[test]
672    fn test_bfs_depth_2() {
673        let g = make_test_graph();
674        let (nodes, _edges) = bfs(&g, &["auth".to_string()], 2);
675        assert_eq!(nodes.len(), 4);
676    }
677
678    #[test]
679    fn test_dfs_depth_1() {
680        let g = make_test_graph();
681        let (nodes, edges) = dfs(&g, &["auth".to_string()], 1);
682        assert!(nodes.len() >= 3);
683        assert!(!edges.is_empty());
684    }
685
686    #[test]
687    fn test_bfs_nonexistent_start() {
688        let g = make_test_graph();
689        let (nodes, edges) = bfs(&g, &["nonexistent".to_string()], 3);
690        assert!(nodes.is_empty());
691        assert!(edges.is_empty());
692    }
693
694    #[test]
695    fn test_subgraph_to_text() {
696        let g = make_test_graph();
697        let nodes = vec!["auth".to_string(), "user".to_string()];
698        let edges = vec![("auth".to_string(), "user".to_string())];
699        let text = subgraph_to_text(&g, &nodes, &edges, 1000);
700
701        assert!(text.contains("Knowledge Graph Context"));
702        assert!(text.contains("AuthService"));
703        assert!(text.contains("UserManager"));
704        assert!(text.contains("Relationships"));
705    }
706
707    #[test]
708    fn test_subgraph_to_text_budget() {
709        let g = make_test_graph();
710        let nodes: Vec<String> = g.node_ids();
711        let edges = vec![
712            ("auth".to_string(), "user".to_string()),
713            ("auth".to_string(), "db".to_string()),
714        ];
715        let text = subgraph_to_text(&g, &nodes, &edges, 10);
716        assert!(text.contains("truncated") || text.len() < 200);
717    }
718
719    #[test]
720    fn test_graph_stats() {
721        let g = make_test_graph();
722        let stats = graph_stats(&g);
723        assert_eq!(stats["node_count"], 4);
724        assert_eq!(stats["edge_count"], 4);
725    }
726
727    #[test]
728    fn test_bfs_multiple_starts() {
729        let g = make_test_graph();
730        let (nodes, _) = bfs(&g, &["auth".to_string(), "cache".to_string()], 1);
731        assert!(nodes.len() >= 4);
732    }
733
734    #[test]
735    fn test_all_simple_paths_direct() {
736        let g = make_test_graph();
737        let paths = all_simple_paths(&g, "auth", "user", 4);
738        assert!(!paths.is_empty());
739        assert!(paths.iter().any(|p| p.len() == 2));
740    }
741
742    #[test]
743    fn test_all_simple_paths_indirect() {
744        let g = make_test_graph();
745        let paths = all_simple_paths(&g, "auth", "db", 4);
746        assert!(
747            paths.len() >= 2,
748            "should find multiple paths, got {}",
749            paths.len()
750        );
751    }
752
753    #[test]
754    fn test_all_simple_paths_no_path() {
755        let mut g = KnowledgeGraph::new();
756        g.add_node(make_node("a", "A")).unwrap();
757        g.add_node(make_node("b", "B")).unwrap();
758        let paths = all_simple_paths(&g, "a", "b", 4);
759        assert!(paths.is_empty());
760    }
761
762    #[test]
763    fn test_all_simple_paths_nonexistent_node() {
764        let g = make_test_graph();
765        let paths = all_simple_paths(&g, "auth", "nonexistent", 4);
766        assert!(paths.is_empty());
767    }
768
769    #[test]
770    fn test_all_simple_paths_sorted_by_length() {
771        let g = make_test_graph();
772        let paths = all_simple_paths(&g, "auth", "cache", 5);
773        for w in paths.windows(2) {
774            assert!(w[0].len() <= w[1].len(), "paths should be sorted by length");
775        }
776    }
777
778    #[test]
779    fn test_dijkstra_direct_path() {
780        let g = make_test_graph();
781        let result = dijkstra_path(&g, "auth", "user", 0.0);
782        assert!(result.is_some());
783        let (path, cost, edges) = result.unwrap();
784        assert_eq!(path.first().unwrap(), "auth");
785        assert_eq!(path.last().unwrap(), "user");
786        assert!(cost > 0.0);
787        assert!(!edges.is_empty());
788    }
789
790    #[test]
791    fn test_dijkstra_same_node() {
792        let g = make_test_graph();
793        let result = dijkstra_path(&g, "auth", "auth", 0.0);
794        assert!(result.is_some());
795        let (path, cost, _) = result.unwrap();
796        assert_eq!(path.len(), 1);
797        assert!((cost - 0.0).abs() < f64::EPSILON);
798    }
799
800    #[test]
801    fn test_dijkstra_no_path() {
802        let mut g = KnowledgeGraph::new();
803        g.add_node(make_node("a", "A")).unwrap();
804        g.add_node(make_node("b", "B")).unwrap();
805        let result = dijkstra_path(&g, "a", "b", 0.0);
806        assert!(result.is_none());
807    }
808
809    #[test]
810    fn test_dijkstra_nonexistent_node() {
811        let g = make_test_graph();
812        assert!(dijkstra_path(&g, "auth", "nonexistent", 0.0).is_none());
813    }
814
815    #[test]
816    fn test_dijkstra_min_confidence_filter() {
817        let mut g = KnowledgeGraph::new();
818        g.add_node(make_node("a", "A")).unwrap();
819        g.add_node(make_node("b", "B")).unwrap();
820        g.add_node(make_node("c", "C")).unwrap();
821
822        let mut low_edge = make_edge("a", "b");
823        low_edge.confidence_score = 0.3;
824        g.add_edge(low_edge).unwrap();
825
826        let mut high1 = make_edge("a", "c");
827        high1.confidence_score = 1.0;
828        g.add_edge(high1).unwrap();
829
830        let mut high2 = make_edge("c", "b");
831        high2.confidence_score = 1.0;
832        g.add_edge(high2).unwrap();
833
834        let result = dijkstra_path(&g, "a", "b", 0.5);
835        assert!(result.is_some());
836        let (path, _, _) = result.unwrap();
837        assert_eq!(path.len(), 3, "should go through c, got path: {path:?}");
838    }
839}