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