Skip to main content

gid_core/
query.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use crate::graph::{Graph, Node};
3
4/// Query engine for graph traversal and analysis.
5pub struct QueryEngine<'a> {
6    graph: &'a Graph,
7}
8
9impl<'a> QueryEngine<'a> {
10    pub fn new(graph: &'a Graph) -> Self {
11        Self { graph }
12    }
13
14    /// Impact analysis: what nodes are affected if `node_id` changes?
15    /// Follows reverse dependency edges (who depends on this node?).
16    /// Traverses all edge relations by default.
17    pub fn impact(&self, node_id: &str) -> Vec<&'a Node> {
18        self.impact_filtered(node_id, None)
19    }
20
21    /// Impact analysis with optional relation filter.
22    /// If `relations` is None, traverses all edge types.
23    pub fn impact_filtered(&self, node_id: &str, relations: Option<&[&str]>) -> Vec<&'a Node> {
24        let mut visited = HashSet::new();
25        let mut queue = VecDeque::new();
26        queue.push_back(node_id.to_string());
27        visited.insert(node_id.to_string());
28
29        while let Some(current) = queue.pop_front() {
30            // Find nodes that point to current (edges where to == current)
31            for edge in &self.graph.edges {
32                if edge.to != current {
33                    continue;
34                }
35                // Apply relation filter if set
36                if let Some(rels) = relations {
37                    if !rels.contains(&edge.relation.as_str()) {
38                        continue;
39                    }
40                }
41                if visited.insert(edge.from.clone()) {
42                    queue.push_back(edge.from.clone());
43                }
44            }
45        }
46
47        visited.remove(node_id);
48        self.graph.nodes.iter()
49            .filter(|n| visited.contains(&n.id))
50            .collect()
51    }
52
53    /// Dependencies: what does `node_id` depend on? (transitive)
54    /// Traverses all edge relations by default.
55    pub fn deps(&self, node_id: &str, transitive: bool) -> Vec<&'a Node> {
56        self.deps_filtered(node_id, transitive, None)
57    }
58
59    /// Dependencies with optional relation filter.
60    /// If `relations` is None, traverses all edge types.
61    pub fn deps_filtered(&self, node_id: &str, transitive: bool, relations: Option<&[&str]>) -> Vec<&'a Node> {
62        if !transitive {
63            // Direct deps only
64            let dep_ids: HashSet<&str> = self.graph.edges.iter()
65                .filter(|e| {
66                    if e.from != node_id {
67                        return false;
68                    }
69                    if let Some(rels) = relations {
70                        rels.contains(&e.relation.as_str())
71                    } else {
72                        true
73                    }
74                })
75                .map(|e| e.to.as_str())
76                .collect();
77            return self.graph.nodes.iter()
78                .filter(|n| dep_ids.contains(n.id.as_str()))
79                .collect();
80        }
81
82        let mut visited = HashSet::new();
83        let mut queue = VecDeque::new();
84        queue.push_back(node_id.to_string());
85        visited.insert(node_id.to_string());
86
87        while let Some(current) = queue.pop_front() {
88            for edge in &self.graph.edges {
89                if edge.from != current {
90                    continue;
91                }
92                if let Some(rels) = relations {
93                    if !rels.contains(&edge.relation.as_str()) {
94                        continue;
95                    }
96                }
97                if visited.insert(edge.to.clone()) {
98                    queue.push_back(edge.to.clone());
99                }
100            }
101        }
102
103        visited.remove(node_id);
104        self.graph.nodes.iter()
105            .filter(|n| visited.contains(&n.id))
106            .collect()
107    }
108
109    /// Find shortest path between two nodes (any edge direction).
110    pub fn path(&self, from: &str, to: &str) -> Option<Vec<String>> {
111        let mut visited = HashSet::new();
112        let mut queue = VecDeque::new();
113        let mut parent: HashMap<String, String> = HashMap::new();
114
115        queue.push_back(from.to_string());
116        visited.insert(from.to_string());
117
118        while let Some(current) = queue.pop_front() {
119            if current == to {
120                // Reconstruct path
121                let mut path = vec![to.to_string()];
122                let mut cur = to.to_string();
123                while let Some(p) = parent.get(&cur) {
124                    path.push(p.clone());
125                    cur = p.clone();
126                }
127                path.reverse();
128                return Some(path);
129            }
130
131            // Follow edges in both directions
132            for edge in &self.graph.edges {
133                let neighbor = if edge.from == current {
134                    &edge.to
135                } else if edge.to == current {
136                    &edge.from
137                } else {
138                    continue;
139                };
140                if visited.insert(neighbor.clone()) {
141                    parent.insert(neighbor.clone(), current.clone());
142                    queue.push_back(neighbor.clone());
143                }
144            }
145        }
146
147        None
148    }
149
150    /// Common cause: find shared dependencies of two nodes.
151    pub fn common_cause(&self, node_a: &str, node_b: &str) -> Vec<&'a Node> {
152        let deps_a: HashSet<String> = self.deps(node_a, true)
153            .iter().map(|n| n.id.clone()).collect();
154        let deps_b: HashSet<String> = self.deps(node_b, true)
155            .iter().map(|n| n.id.clone()).collect();
156        let common: HashSet<&String> = deps_a.intersection(&deps_b).collect();
157
158        self.graph.nodes.iter()
159            .filter(|n| common.contains(&n.id))
160            .collect()
161    }
162
163    /// Topological sort (returns error if cycle detected).
164    pub fn topological_sort(&self) -> anyhow::Result<Vec<String>> {
165        let mut in_degree: HashMap<&str, usize> = HashMap::new();
166        for node in &self.graph.nodes {
167            in_degree.entry(&node.id).or_insert(0);
168        }
169        for edge in &self.graph.edges {
170            if edge.relation == "depends_on" {
171                *in_degree.entry(&edge.from).or_insert(0) += 1;
172            }
173        }
174
175        let mut queue: VecDeque<&str> = in_degree.iter()
176            .filter(|(_, &deg)| deg == 0)
177            .map(|(&id, _)| id)
178            .collect();
179
180        let mut sorted = Vec::new();
181        while let Some(node) = queue.pop_front() {
182            sorted.push(node.to_string());
183            for edge in &self.graph.edges {
184                if edge.to == node && edge.relation == "depends_on" {
185                    if let Some(deg) = in_degree.get_mut(edge.from.as_str()) {
186                        *deg -= 1;
187                        if *deg == 0 {
188                            queue.push_back(&edge.from);
189                        }
190                    }
191                }
192            }
193        }
194
195        if sorted.len() != self.graph.nodes.len() {
196            anyhow::bail!("Cycle detected in graph");
197        }
198
199        Ok(sorted)
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::graph::{Graph, Node, Edge};
207
208    fn make_edge(from: &str, to: &str, relation: &str) -> Edge {
209        Edge {
210            from: from.into(),
211            to: to.into(),
212            relation: relation.into(),
213            weight: None,
214            confidence: None,
215            metadata: None,
216        }
217    }
218
219    fn make_test_graph() -> Graph {
220        // A → depends_on → B → depends_on → C
221        // A → implements → D
222        // E → belongs_to → D
223        let nodes = vec![
224            Node::new("A", "A"),
225            Node::new("B", "B"),
226            Node::new("C", "C"),
227            Node::new("D", "D"),
228            Node::new("E", "E"),
229        ];
230        let edges = vec![
231            make_edge("A", "B", "depends_on"),
232            make_edge("B", "C", "depends_on"),
233            make_edge("A", "D", "implements"),
234            make_edge("E", "D", "belongs_to"),
235        ];
236        Graph { nodes, edges, ..Default::default() }
237    }
238
239    #[test]
240    fn test_impact_multi_relation() {
241        let graph = make_test_graph();
242        let qe = QueryEngine::new(&graph);
243
244        // Default impact traverses all relations
245        // Changing C: B depends_on C → A depends_on B → impacted: A, B
246        let impacted = qe.impact("C");
247        let ids: Vec<&str> = impacted.iter().map(|n| n.id.as_str()).collect();
248        assert!(ids.contains(&"B"));
249        assert!(ids.contains(&"A"));
250    }
251
252    #[test]
253    fn test_impact_filtered_depends_on_only() {
254        let graph = make_test_graph();
255        let qe = QueryEngine::new(&graph);
256
257        // Filter to depends_on: changing D, A implements D but with depends_on filter, not traversed
258        let impacted = qe.impact_filtered("D", Some(&["depends_on"]));
259        let ids: Vec<&str> = impacted.iter().map(|n| n.id.as_str()).collect();
260        // No node has depends_on edge to D
261        assert!(ids.is_empty());
262    }
263
264    #[test]
265    fn test_impact_filtered_all_relations() {
266        let graph = make_test_graph();
267        let qe = QueryEngine::new(&graph);
268
269        // Changing D: A implements D, E belongs_to D → both impacted
270        let impacted = qe.impact_filtered("D", None);
271        let ids: Vec<&str> = impacted.iter().map(|n| n.id.as_str()).collect();
272        assert!(ids.contains(&"A"), "A implements D");
273        assert!(ids.contains(&"E"), "E belongs_to D");
274    }
275
276    #[test]
277    fn test_deps_multi_relation() {
278        let graph = make_test_graph();
279        let qe = QueryEngine::new(&graph);
280
281        // A's deps (all relations): B (depends_on), D (implements), and transitively C (via B)
282        let deps = qe.deps("A", true);
283        let ids: Vec<&str> = deps.iter().map(|n| n.id.as_str()).collect();
284        assert!(ids.contains(&"B"));
285        assert!(ids.contains(&"C"));
286        assert!(ids.contains(&"D"));
287    }
288
289    #[test]
290    fn test_deps_filtered_depends_on_only() {
291        let graph = make_test_graph();
292        let qe = QueryEngine::new(&graph);
293
294        // A's deps with depends_on filter: B, C — but NOT D (implements edge)
295        let deps = qe.deps_filtered("A", true, Some(&["depends_on"]));
296        let ids: Vec<&str> = deps.iter().map(|n| n.id.as_str()).collect();
297        assert!(ids.contains(&"B"));
298        assert!(ids.contains(&"C"));
299        assert!(!ids.contains(&"D"), "D should be excluded — implements, not depends_on");
300    }
301
302    #[test]
303    fn test_deps_non_transitive_filtered() {
304        let graph = make_test_graph();
305        let qe = QueryEngine::new(&graph);
306
307        // A direct deps only, all relations: B (depends_on) and D (implements)
308        let deps = qe.deps_filtered("A", false, None);
309        let ids: Vec<&str> = deps.iter().map(|n| n.id.as_str()).collect();
310        assert!(ids.contains(&"B"));
311        assert!(ids.contains(&"D"));
312        assert!(!ids.contains(&"C"), "C is transitive, should be excluded");
313    }
314
315    #[test]
316    fn test_backward_compat_impact_uses_all_relations() {
317        let graph = make_test_graph();
318        let qe = QueryEngine::new(&graph);
319
320        // impact() without filter should traverse all relations (backward compat)
321        let impacted = qe.impact("D");
322        let ids: Vec<&str> = impacted.iter().map(|n| n.id.as_str()).collect();
323        // A implements D, E belongs_to D
324        assert!(ids.contains(&"A"));
325        assert!(ids.contains(&"E"));
326    }
327}