Skip to main content

alizarin_core/graph/
prune.rs

1//! Graph pruning utilities
2//!
3//! Provides functions for pruning a graph to only include permitted nodegroups
4//! and their dependencies. This is useful for permission-based filtering.
5
6use super::{StaticGraph, StaticNode};
7use std::collections::{HashMap, HashSet};
8
9/// Maximum depth for edge traversal to prevent infinite loops
10const MAX_GRAPH_DEPTH: usize = 100;
11
12/// Error type for graph pruning operations
13#[derive(Debug, Clone)]
14pub enum PruneError {
15    /// Node has multiple parents (malformed graph)
16    MultipleParents {
17        node: String,
18        parent1: String,
19        parent2: String,
20    },
21    /// Node has no parent but is not root (disconnected)
22    NoParent { node: String },
23    /// Edge traversal hit depth limit (likely cycle)
24    CycleDetected,
25    /// Graph has no root node
26    NoRootNode,
27}
28
29impl std::fmt::Display for PruneError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            PruneError::MultipleParents {
33                node,
34                parent1,
35                parent2,
36            } => {
37                write!(
38                    f,
39                    "Graph is malformed, node {} has multiple parents: {} and {}",
40                    node, parent1, parent2
41                )
42            }
43            PruneError::NoParent { node } => {
44                write!(f, "Graph does not have a parent for {}", node)
45            }
46            PruneError::CycleDetected => {
47                write!(f, "Hit edge traversal limit when pruning, is the graph well-formed without cycles?")
48            }
49            PruneError::NoRootNode => {
50                write!(f, "Could not find root node in graph")
51            }
52        }
53    }
54}
55
56impl std::error::Error for PruneError {}
57
58/// Find the root node of a graph
59///
60/// The root node is the node with no nodegroup_id or an empty nodegroup_id.
61pub fn find_root_node(graph: &StaticGraph) -> Option<&StaticNode> {
62    graph.nodes.iter().find(|node| {
63        node.nodegroup_id.is_none()
64            || node
65                .nodegroup_id
66                .as_ref()
67                .map(|s| s.is_empty())
68                .unwrap_or(true)
69    })
70}
71
72/// Build backedges map (child -> parent) from a graph's edges
73///
74/// Returns an error if any node has multiple parents.
75pub fn build_backedges(graph: &StaticGraph) -> Result<HashMap<String, String>, PruneError> {
76    let mut backedges: HashMap<String, String> = HashMap::new();
77
78    for edge in &graph.edges {
79        if let Some(existing_parent) = backedges.get(&edge.rangenode_id) {
80            return Err(PruneError::MultipleParents {
81                node: edge.rangenode_id.clone(),
82                parent1: existing_parent.clone(),
83                parent2: edge.domainnode_id.clone(),
84            });
85        }
86        backedges.insert(edge.rangenode_id.clone(), edge.domainnode_id.clone());
87    }
88
89    Ok(backedges)
90}
91
92/// Prune a graph to only include permitted nodegroups and their dependencies.
93///
94/// # Arguments
95/// * `graph` - The graph to prune
96/// * `is_nodegroup_permitted` - Function that returns true if a nodegroup is permitted
97/// * `keep_functions` - Optional list of function IDs to keep (if None, all functions are removed)
98///
99/// # Returns
100/// A new pruned graph containing only permitted nodes, edges, cards, etc.
101///
102/// # Errors
103/// Returns an error if the graph is malformed (multiple parents, cycles, etc.)
104pub fn prune_graph<F>(
105    graph: &StaticGraph,
106    is_nodegroup_permitted: F,
107    keep_functions: Option<&[String]>,
108) -> Result<StaticGraph, PruneError>
109where
110    F: Fn(&str) -> bool,
111{
112    // Find root node
113    let root_node = find_root_node(graph).ok_or(PruneError::NoRootNode)?;
114    let root = root_node.nodeid.clone();
115
116    // Build nodegroup set from nodes
117    let all_nodegroups: HashSet<String> = graph
118        .nodes
119        .iter()
120        .filter_map(|n| n.nodegroup_id.clone())
121        .collect();
122
123    // Build allowed_nodegroups map: nodegroup_id -> is_rooted
124    // Filter to only permitted nodegroups
125    let mut allowed_nodegroups: HashMap<String, bool> = all_nodegroups
126        .iter()
127        .filter(|ng_id| is_nodegroup_permitted(ng_id))
128        .map(|ng_id| {
129            let is_root = ng_id.is_empty() || *ng_id == root;
130            (ng_id.clone(), is_root)
131        })
132        .collect();
133
134    // Build backedges map (child -> parent)
135    let backedges = build_backedges(graph)?;
136
137    // Mark root as rooted
138    allowed_nodegroups.insert(root.clone(), true);
139
140    // Iteratively ensure all kept nodegroups have path to root
141    let mut loops = 0;
142    while loops < MAX_GRAPH_DEPTH {
143        let unrooted: Vec<String> = allowed_nodegroups
144            .iter()
145            .filter(|(_, &rooted)| !rooted)
146            .map(|(ng, _)| ng.clone())
147            .collect();
148
149        if unrooted.is_empty() {
150            break;
151        }
152
153        for ng in unrooted {
154            if ng == root {
155                continue;
156            }
157
158            let next = backedges
159                .get(&ng)
160                .ok_or_else(|| PruneError::NoParent { node: ng.clone() })?;
161
162            allowed_nodegroups.insert(ng.clone(), true);
163            if !allowed_nodegroups.contains_key(next) {
164                allowed_nodegroups.insert(next.clone(), false);
165            }
166        }
167
168        loops += 1;
169    }
170
171    if loops >= MAX_GRAPH_DEPTH {
172        return Err(PruneError::CycleDetected);
173    }
174
175    // Build set of allowed node IDs
176    let allowed_nodes: HashSet<String> = graph
177        .nodes
178        .iter()
179        .filter(|node| {
180            node.nodegroup_id
181                .as_ref()
182                .and_then(|ng_id| allowed_nodegroups.get(ng_id))
183                .copied()
184                .unwrap_or(false)
185                || node.nodeid == root
186        })
187        .map(|node| node.nodeid.clone())
188        .collect();
189
190    // Create pruned graph
191    let mut pruned = graph.clone();
192
193    // Filter cards
194    pruned.cards = pruned.cards.map(|cards| {
195        cards
196            .into_iter()
197            .filter(|card| {
198                allowed_nodegroups
199                    .get(&card.nodegroup_id)
200                    .copied()
201                    .unwrap_or(false)
202            })
203            .collect()
204    });
205
206    // Filter cards_x_nodes_x_widgets
207    pruned.cards_x_nodes_x_widgets = pruned.cards_x_nodes_x_widgets.map(|cxnxws| {
208        cxnxws
209            .into_iter()
210            .filter(|cxnxw| allowed_nodes.contains(&cxnxw.node_id))
211            .collect()
212    });
213
214    // Filter edges
215    pruned.edges.retain(|edge| {
216        (edge.domainnode_id == root || allowed_nodes.contains(&edge.domainnode_id))
217            && allowed_nodes.contains(&edge.rangenode_id)
218    });
219
220    // Filter nodegroups
221    pruned
222        .nodegroups
223        .retain(|ng| allowed_nodegroups.contains_key(&ng.nodegroupid));
224
225    // Filter nodes
226    pruned
227        .nodes
228        .retain(|node| allowed_nodes.contains(&node.nodeid));
229
230    // Filter functions_x_graphs
231    if let Some(keep_fns) = keep_functions {
232        pruned.functions_x_graphs = pruned.functions_x_graphs.map(|fxgs| {
233            fxgs.into_iter()
234                .filter(|fxg| keep_fns.contains(&fxg.function_id))
235                .collect()
236        });
237    } else {
238        pruned.functions_x_graphs = Some(Vec::new());
239    }
240
241    Ok(pruned)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::graph::{StaticEdge, StaticNodegroup};
248    use serde_json::json;
249
250    fn create_test_node(nodeid: &str, nodegroup_id: Option<&str>) -> StaticNode {
251        let node_json = json!({
252            "nodeid": nodeid,
253            "name": nodeid,
254            "datatype": if nodegroup_id.is_none() { "semantic" } else { "string" },
255            "nodegroup_id": nodegroup_id,
256            "alias": nodeid,
257            "graph_id": "test_graph",
258            "is_collector": false,
259            "isrequired": false,
260            "exportable": true,
261            "issearchable": false,
262            "istopnode": nodegroup_id.is_none(),
263        });
264        serde_json::from_value(node_json).expect("Failed to create test node")
265    }
266
267    fn create_test_edge(domain: &str, range: &str) -> StaticEdge {
268        StaticEdge {
269            edgeid: format!("{}->{}", domain, range),
270            domainnode_id: domain.to_string(),
271            rangenode_id: range.to_string(),
272            graph_id: "test_graph".to_string(),
273            name: None,
274            description: None,
275            ontologyproperty: None,
276            source_identifier_id: None,
277        }
278    }
279
280    fn create_test_nodegroup(nodegroupid: &str) -> StaticNodegroup {
281        StaticNodegroup {
282            nodegroupid: nodegroupid.to_string(),
283            cardinality: Some("n".to_string()),
284            parentnodegroup_id: None,
285            legacygroupid: None,
286            grouping_node_id: None,
287        }
288    }
289
290    #[test]
291    fn test_prune_graph_filters_unpermitted_nodegroups() {
292        // Create nodes: root, child1 (permitted), child2 (not permitted)
293        let root = create_test_node("root", None);
294        let child1 = create_test_node("child1", Some("child1"));
295        let child2 = create_test_node("child2", Some("child2"));
296
297        let edge1 = create_test_edge("root", "child1");
298        let edge2 = create_test_edge("root", "child2");
299
300        let ng1 = create_test_nodegroup("child1");
301        let ng2 = create_test_nodegroup("child2");
302
303        let graph_json = json!({
304            "graphid": "test_graph",
305            "name": {"en": "Test Graph"},
306            "nodes": [root.clone(), child1, child2],
307            "edges": [edge1, edge2],
308            "nodegroups": [ng1, ng2],
309            "root": root,
310            "cards": [],
311            "cards_x_nodes_x_widgets": [],
312            "functions_x_graphs": [],
313            "config": {}
314        });
315
316        let graph: StaticGraph =
317            serde_json::from_value(graph_json).expect("Failed to create graph");
318
319        // Only permit child1
320        let permitted = |ng: &str| ng == "child1";
321
322        let pruned = prune_graph(&graph, permitted, None).expect("Prune failed");
323
324        // Verify child1 is included, child2 is not
325        assert!(
326            pruned.nodes.iter().any(|n| n.nodeid == "root"),
327            "Root should be included"
328        );
329        assert!(
330            pruned.nodes.iter().any(|n| n.nodeid == "child1"),
331            "child1 should be included"
332        );
333        assert!(
334            !pruned.nodes.iter().any(|n| n.nodeid == "child2"),
335            "child2 should NOT be included"
336        );
337
338        // Verify nodegroups
339        assert!(pruned
340            .nodegroups
341            .iter()
342            .any(|ng| ng.nodegroupid == "child1"));
343        assert!(!pruned
344            .nodegroups
345            .iter()
346            .any(|ng| ng.nodegroupid == "child2"));
347
348        // Verify edges
349        assert!(pruned.edges.iter().any(|e| e.rangenode_id == "child1"));
350        assert!(!pruned.edges.iter().any(|e| e.rangenode_id == "child2"));
351    }
352
353    #[test]
354    fn test_prune_graph_includes_path_to_root() {
355        // Create a chain: root -> middle -> leaf
356        // If only leaf is permitted, middle should also be included to maintain path
357        let root = create_test_node("root", None);
358        let middle = create_test_node("middle", Some("middle"));
359        let leaf = create_test_node("leaf", Some("leaf"));
360
361        let edge1 = create_test_edge("root", "middle");
362        let edge2 = create_test_edge("middle", "leaf");
363
364        let ng_middle = create_test_nodegroup("middle");
365        let ng_leaf = create_test_nodegroup("leaf");
366
367        let graph_json = json!({
368            "graphid": "test_graph",
369            "name": {"en": "Test Graph"},
370            "nodes": [root.clone(), middle, leaf],
371            "edges": [edge1, edge2],
372            "nodegroups": [ng_middle, ng_leaf],
373            "root": root,
374            "cards": [],
375            "cards_x_nodes_x_widgets": [],
376            "functions_x_graphs": [],
377            "config": {}
378        });
379
380        let graph: StaticGraph =
381            serde_json::from_value(graph_json).expect("Failed to create graph");
382
383        // Only permit leaf - middle should still be included for path to root
384        let permitted = |ng: &str| ng == "leaf";
385
386        let pruned = prune_graph(&graph, permitted, None).expect("Prune failed");
387
388        // All nodes should be included to maintain path
389        assert!(pruned.nodes.iter().any(|n| n.nodeid == "root"));
390        assert!(
391            pruned.nodes.iter().any(|n| n.nodeid == "middle"),
392            "middle should be included for path"
393        );
394        assert!(pruned.nodes.iter().any(|n| n.nodeid == "leaf"));
395    }
396
397    #[test]
398    fn test_prune_graph_detects_multiple_parents() {
399        let root = create_test_node("root", None);
400        let child = create_test_node("child", Some("child"));
401
402        // Two edges pointing to same child = multiple parents
403        let edge1 = create_test_edge("root", "child");
404        let mut edge2 = create_test_edge("root", "child");
405        edge2.domainnode_id = "other_parent".to_string();
406
407        let ng = create_test_nodegroup("child");
408
409        let graph_json = json!({
410            "graphid": "test_graph",
411            "name": {"en": "Test Graph"},
412            "nodes": [root.clone(), child],
413            "edges": [edge1, edge2],
414            "nodegroups": [ng],
415            "root": root,
416            "cards": [],
417            "cards_x_nodes_x_widgets": [],
418            "functions_x_graphs": [],
419            "config": {}
420        });
421
422        let graph: StaticGraph =
423            serde_json::from_value(graph_json).expect("Failed to create graph");
424
425        let result = prune_graph(&graph, |_| true, None);
426        assert!(matches!(result, Err(PruneError::MultipleParents { .. })));
427    }
428}