Skip to main content

alizarin_core/
graph_model_access.rs

1/// Platform-agnostic ModelAccess implementation built from a StaticGraph.
2///
3/// Supports both eager and lazy initialization, with Arc-wrapped caches
4/// for cheap sharing across wrapper instances (e.g. WASM's MODEL_REGISTRY).
5///
6/// Used by WASM, NAPI, and Python bindings as the single source of truth
7/// for graph-index-building logic.
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use crate::graph::prune_graph as core_prune_graph;
12use crate::graph::{StaticEdge, StaticGraph, StaticNode, StaticNodegroup, StaticTile};
13use crate::instance_wrapper_core::ModelAccess;
14use crate::permissions::PermissionRule;
15
16/// ModelAccess implementation built from a StaticGraph.
17///
18/// Indices are lazily built on first access via [`ensure_built`] and
19/// stored as `Arc`-wrapped maps for cheap cloning.
20///
21/// # Construction
22///
23/// - [`GraphModelAccess::new`] — lazy, caches built on first `ensure_built()` call
24/// - [`GraphModelAccess::new_eager`] — builds caches immediately
25/// - [`GraphModelAccess::from_graph`] — backward-compat eager constructor
26#[derive(Clone)]
27pub struct GraphModelAccess {
28    graph: Arc<StaticGraph>,
29
30    // Lazy caches, Arc-wrapped for cheap sharing
31    nodes: Option<Arc<HashMap<String, Arc<StaticNode>>>>,
32    nodes_by_alias: Option<Arc<HashMap<String, Arc<StaticNode>>>>,
33    edges: Option<Arc<HashMap<String, Vec<String>>>>,
34    reverse_edges: Option<Arc<HashMap<String, Vec<String>>>>,
35    nodes_by_nodegroup: Option<Arc<HashMap<String, Vec<Arc<StaticNode>>>>>,
36    nodegroups: Option<Arc<HashMap<String, Arc<StaticNodegroup>>>>,
37    root_node_id: Option<String>,
38
39    permitted_nodegroups: HashMap<String, PermissionRule>,
40    default_allow: bool,
41}
42
43impl GraphModelAccess {
44    /// Create a new lazy `GraphModelAccess`. Caches are not built until
45    /// [`ensure_built`] is called (or a mutable accessor triggers it).
46    pub fn new(graph: Arc<StaticGraph>, default_allow: bool) -> Self {
47        GraphModelAccess {
48            graph,
49            nodes: None,
50            nodes_by_alias: None,
51            edges: None,
52            reverse_edges: None,
53            nodes_by_nodegroup: None,
54            nodegroups: None,
55            root_node_id: None,
56            permitted_nodegroups: HashMap::new(),
57            default_allow,
58        }
59    }
60
61    /// Create a `GraphModelAccess` with caches built immediately.
62    pub fn new_eager(graph: Arc<StaticGraph>, default_allow: bool) -> Self {
63        let mut access = Self::new(graph, default_allow);
64        // build_indices cannot fail for valid graphs, but we unwrap to
65        // surface malformed graphs early
66        access
67            .build_indices()
68            .expect("Failed to build graph indices");
69        access
70    }
71
72    /// Backward-compatible eager constructor from a borrowed `StaticGraph`.
73    pub fn from_graph(graph: &StaticGraph) -> Self {
74        Self::new_eager(Arc::new(graph.clone()), true)
75    }
76
77    // =========================================================================
78    // Lazy initialization
79    // =========================================================================
80
81    /// Ensure all caches are built. No-op if already built.
82    pub fn ensure_built(&mut self) -> Result<(), String> {
83        if self.nodes.is_none() {
84            self.build_indices()?;
85        }
86        Ok(())
87    }
88
89    /// Returns true if caches have been built.
90    pub fn is_built(&self) -> bool {
91        self.nodes.is_some()
92    }
93
94    /// Clear all cached indices. The next call to [`ensure_built`] will rebuild.
95    pub fn invalidate_caches(&mut self) {
96        self.nodes = None;
97        self.nodes_by_alias = None;
98        self.edges = None;
99        self.reverse_edges = None;
100        self.nodes_by_nodegroup = None;
101        self.nodegroups = None;
102        self.root_node_id = None;
103    }
104
105    /// Build all indices from the current graph.
106    fn build_indices(&mut self) -> Result<(), String> {
107        let graph = &self.graph;
108
109        let mut nodes: HashMap<String, Arc<StaticNode>> = HashMap::new();
110        let mut nodes_by_alias: HashMap<String, Arc<StaticNode>> = HashMap::new();
111        let mut edges_map: HashMap<String, Vec<String>> = HashMap::new();
112        let mut reverse_edges_map: HashMap<String, Vec<String>> = HashMap::new();
113        let mut nodes_by_nodegroup: HashMap<String, Vec<Arc<StaticNode>>> = HashMap::new();
114        let mut nodegroups: HashMap<String, Arc<StaticNodegroup>> = HashMap::new();
115        let mut root_node_id = String::new();
116
117        // Build node index
118        for node in &graph.nodes {
119            let mut node_copy = node.clone();
120            // Ensure root node (node without nodegroup_id) has alias set
121            if (node_copy.nodegroup_id.is_none()
122                || node_copy
123                    .nodegroup_id
124                    .as_ref()
125                    .map(|s| s.is_empty())
126                    .unwrap_or(false))
127                && node_copy.alias.is_none()
128            {
129                node_copy.alias = Some(String::new());
130            }
131            let arc_node = Arc::new(node_copy);
132            nodes.insert(arc_node.nodeid.clone(), Arc::clone(&arc_node));
133
134            // Build alias index
135            if let Some(ref alias) = arc_node.alias {
136                if !alias.is_empty() {
137                    nodes_by_alias.insert(alias.clone(), Arc::clone(&arc_node));
138                } else {
139                    nodes_by_alias.insert(String::new(), Arc::clone(&arc_node));
140                }
141            } else {
142                nodes_by_alias.insert(String::new(), Arc::clone(&arc_node));
143            }
144
145            // Root detection: prefer istopnode
146            if arc_node.istopnode {
147                root_node_id = arc_node.nodeid.clone();
148            }
149
150            if let Some(ref ng_id) = arc_node.nodegroup_id {
151                nodes_by_nodegroup
152                    .entry(ng_id.clone())
153                    .or_default()
154                    .push(Arc::clone(&arc_node));
155            }
156        }
157
158        // Fallback root detection for graphs without istopnode
159        if root_node_id.is_empty() {
160            for node in nodes.values() {
161                if node.nodegroup_id.is_none()
162                    || node
163                        .nodegroup_id
164                        .as_ref()
165                        .map(|s| s.is_empty())
166                        .unwrap_or(true)
167                {
168                    root_node_id = node.nodeid.clone();
169                    break;
170                }
171            }
172        }
173
174        // Build edge indices
175        for edge in &graph.edges {
176            let parent_id = edge.domainnode_id.clone();
177            let child_id = edge.rangenode_id.clone();
178
179            edges_map
180                .entry(parent_id.clone())
181                .or_default()
182                .push(child_id.clone());
183
184            reverse_edges_map
185                .entry(child_id)
186                .or_default()
187                .push(parent_id);
188        }
189
190        // Build nodegroup index — first from nodes, then merge actual nodegroups
191        for node in &graph.nodes {
192            if let Some(ref ng_id) = node.nodegroup_id {
193                if !ng_id.is_empty() && !nodegroups.contains_key(ng_id) {
194                    nodegroups.insert(
195                        ng_id.clone(),
196                        Arc::new(StaticNodegroup {
197                            cardinality: Some("n".to_string()),
198                            legacygroupid: None,
199                            nodegroupid: ng_id.clone(),
200                            parentnodegroup_id: None,
201                            grouping_node_id: None,
202                        }),
203                    );
204                }
205            }
206        }
207        for ng in &graph.nodegroups {
208            nodegroups.insert(ng.nodegroupid.clone(), Arc::new(ng.clone()));
209        }
210
211        self.nodes = Some(Arc::new(nodes));
212        self.nodes_by_alias = Some(Arc::new(nodes_by_alias));
213        self.edges = Some(Arc::new(edges_map));
214        self.reverse_edges = Some(Arc::new(reverse_edges_map));
215        self.nodes_by_nodegroup = Some(Arc::new(nodes_by_nodegroup));
216        self.nodegroups = Some(Arc::new(nodegroups));
217        self.root_node_id = Some(root_node_id);
218
219        // Pre-populate default permissions if none were explicitly set,
220        // so get_permitted_nodegroups() can return a cheap clone instead
221        // of rebuilding a HashMap on every call.
222        if self.permitted_nodegroups.is_empty() {
223            for key in self.nodegroups.as_ref().unwrap().keys() {
224                self.permitted_nodegroups
225                    .insert(key.clone(), PermissionRule::Boolean(self.default_allow));
226            }
227            self.permitted_nodegroups
228                .insert(String::new(), PermissionRule::Boolean(true));
229        }
230
231        Ok(())
232    }
233
234    // =========================================================================
235    // Internal reference accessors (deref through Arc)
236    // =========================================================================
237
238    /// Get nodes by ID (returns None if caches not built).
239    pub fn get_nodes_internal(&self) -> Option<&HashMap<String, Arc<StaticNode>>> {
240        self.nodes.as_ref().map(|arc| arc.as_ref())
241    }
242
243    /// Get nodes by alias (returns None if caches not built).
244    pub fn get_nodes_by_alias_internal(&self) -> Option<&HashMap<String, Arc<StaticNode>>> {
245        self.nodes_by_alias.as_ref().map(|arc| arc.as_ref())
246    }
247
248    /// Get edges (returns None if caches not built).
249    pub fn get_edges_internal(&self) -> Option<&HashMap<String, Vec<String>>> {
250        self.edges.as_ref().map(|arc| arc.as_ref())
251    }
252
253    /// Get reverse edges (returns None if caches not built).
254    pub fn get_reverse_edges_internal(&self) -> Option<&HashMap<String, Vec<String>>> {
255        self.reverse_edges.as_ref().map(|arc| arc.as_ref())
256    }
257
258    /// Get nodes grouped by nodegroup (returns None if caches not built).
259    pub fn get_nodes_by_nodegroup_internal(
260        &self,
261    ) -> Option<&HashMap<String, Vec<Arc<StaticNode>>>> {
262        self.nodes_by_nodegroup.as_ref().map(|arc| arc.as_ref())
263    }
264
265    /// Get nodegroups by ID (returns None if caches not built).
266    pub fn get_nodegroups_internal(&self) -> Option<&HashMap<String, Arc<StaticNodegroup>>> {
267        self.nodegroups.as_ref().map(|arc| arc.as_ref())
268    }
269
270    // =========================================================================
271    // Arc accessors (for cheap sharing via Arc clone)
272    // =========================================================================
273
274    pub fn get_nodes_arc(&self) -> Option<Arc<HashMap<String, Arc<StaticNode>>>> {
275        self.nodes.as_ref().map(Arc::clone)
276    }
277
278    pub fn get_nodes_by_alias_arc(&self) -> Option<Arc<HashMap<String, Arc<StaticNode>>>> {
279        self.nodes_by_alias.as_ref().map(Arc::clone)
280    }
281
282    pub fn get_edges_arc(&self) -> Option<Arc<HashMap<String, Vec<String>>>> {
283        self.edges.as_ref().map(Arc::clone)
284    }
285
286    pub fn get_reverse_edges_arc(&self) -> Option<Arc<HashMap<String, Vec<String>>>> {
287        self.reverse_edges.as_ref().map(Arc::clone)
288    }
289
290    pub fn get_nodes_by_nodegroup_arc(&self) -> Option<Arc<HashMap<String, Vec<Arc<StaticNode>>>>> {
291        self.nodes_by_nodegroup.as_ref().map(Arc::clone)
292    }
293
294    pub fn get_nodegroups_arc(&self) -> Option<Arc<HashMap<String, Arc<StaticNodegroup>>>> {
295        self.nodegroups.as_ref().map(Arc::clone)
296    }
297
298    // =========================================================================
299    // Mutable accessors (trigger lazy build)
300    // =========================================================================
301
302    /// Get nodes, building caches if needed.
303    pub fn get_node_objects(&mut self) -> Result<&HashMap<String, Arc<StaticNode>>, String> {
304        self.ensure_built()?;
305        self.nodes
306            .as_ref()
307            .map(|arc| arc.as_ref())
308            .ok_or_else(|| "Could not build nodes".to_string())
309    }
310
311    /// Get nodes by alias, building caches if needed.
312    pub fn get_node_objects_by_alias(
313        &mut self,
314    ) -> Result<&HashMap<String, Arc<StaticNode>>, String> {
315        self.ensure_built()?;
316        self.nodes_by_alias
317            .as_ref()
318            .map(|arc| arc.as_ref())
319            .ok_or_else(|| "Could not build nodes".to_string())
320    }
321
322    /// Get edges, building caches if needed.
323    pub fn get_edges(&mut self) -> Result<&HashMap<String, Vec<String>>, String> {
324        self.ensure_built()?;
325        self.edges
326            .as_ref()
327            .map(|arc| arc.as_ref())
328            .ok_or_else(|| "Could not build edges".to_string())
329    }
330
331    /// Get nodegroups, building caches if needed.
332    pub fn get_nodegroup_objects(
333        &mut self,
334    ) -> Result<&HashMap<String, Arc<StaticNodegroup>>, String> {
335        self.ensure_built()?;
336        self.nodegroups
337            .as_ref()
338            .map(|arc| arc.as_ref())
339            .ok_or_else(|| "Could not build nodegroups".to_string())
340    }
341
342    /// Get root node, building caches if needed.
343    pub fn get_root_node_mut(&mut self) -> Result<Arc<StaticNode>, String> {
344        self.ensure_built()?;
345        let root_id = self
346            .root_node_id
347            .as_ref()
348            .ok_or_else(|| "Root node ID not set".to_string())?;
349        self.nodes
350            .as_ref()
351            .and_then(|n| n.get(root_id))
352            .cloned()
353            .ok_or_else(|| "Root node not found in nodes cache".to_string())
354    }
355
356    /// Get child nodes for a given node, building caches if needed.
357    pub fn get_child_nodes_mut(
358        &mut self,
359        node_id: &str,
360    ) -> Result<HashMap<String, Arc<StaticNode>>, String> {
361        self.ensure_built()?;
362        // Delegates to the ModelAccess::get_child_nodes default impl
363        ModelAccess::get_child_nodes(self, node_id)
364    }
365
366    // =========================================================================
367    // Non-mutable child node lookup (requires caches already built)
368    // =========================================================================
369
370    /// Get child nodes from already-built caches. Returns empty map if caches not built.
371    pub fn get_child_nodes(&self, node_id: &str) -> HashMap<String, Arc<StaticNode>> {
372        ModelAccess::get_child_nodes(self, node_id).unwrap_or_default()
373    }
374
375    // =========================================================================
376    // Nodes by alias (non-mutable, backward compat)
377    // =========================================================================
378
379    /// Get nodes indexed by alias (requires caches already built).
380    pub fn get_nodes_by_alias(&self) -> Option<&HashMap<String, Arc<StaticNode>>> {
381        self.nodes_by_alias.as_ref().map(|arc| arc.as_ref())
382    }
383
384    // =========================================================================
385    // Graph access
386    // =========================================================================
387
388    /// Get a reference to the underlying graph.
389    pub fn get_graph(&self) -> &StaticGraph {
390        &self.graph
391    }
392
393    /// Get an Arc clone of the underlying graph.
394    pub fn get_graph_arc(&self) -> Arc<StaticGraph> {
395        Arc::clone(&self.graph)
396    }
397
398    /// Get the default_allow setting.
399    pub fn get_default_allow(&self) -> bool {
400        self.default_allow
401    }
402
403    // =========================================================================
404    // Graph mutation (with cache invalidation)
405    // =========================================================================
406
407    /// Replace the graph and invalidate all caches.
408    pub fn set_graph(&mut self, graph: Arc<StaticGraph>) {
409        self.graph = graph;
410        self.invalidate_caches();
411    }
412
413    /// Replace graph nodes and invalidate caches.
414    pub fn set_graph_nodes(&mut self, nodes: Vec<StaticNode>) {
415        let mut graph = (*self.graph).clone();
416        graph.nodes = nodes;
417        self.graph = Arc::new(graph);
418        self.invalidate_caches();
419    }
420
421    /// Replace graph edges and invalidate caches.
422    pub fn set_graph_edges(&mut self, edges: Vec<StaticEdge>) {
423        let mut graph = (*self.graph).clone();
424        graph.edges = edges;
425        self.graph = Arc::new(graph);
426        self.invalidate_caches();
427    }
428
429    /// Replace graph nodegroups and invalidate caches.
430    pub fn set_graph_nodegroups(&mut self, nodegroups: Vec<StaticNodegroup>) {
431        let mut graph = (*self.graph).clone();
432        graph.nodegroups = nodegroups;
433        self.graph = Arc::new(graph);
434        self.invalidate_caches();
435    }
436
437    /// Rebuild indices from a new graph, preserving permissions.
438    pub fn rebuild_from_graph(&mut self, graph: &StaticGraph) {
439        self.graph = Arc::new(graph.clone());
440        self.invalidate_caches();
441        // Eagerly rebuild — matches the old behavior where callers expected
442        // indices to be available immediately after rebuild.
443        let _ = self.ensure_built();
444    }
445
446    // =========================================================================
447    // Permission management
448    // =========================================================================
449
450    /// Set permitted nodegroups with full PermissionRule support.
451    pub fn set_permitted_nodegroups_rules(&mut self, permissions: HashMap<String, PermissionRule>) {
452        self.permitted_nodegroups = permissions;
453    }
454
455    /// Set permitted nodegroups from boolean map (backward compatibility).
456    pub fn set_permitted_nodegroups_bool(&mut self, permissions: HashMap<String, bool>) {
457        self.permitted_nodegroups = permissions
458            .into_iter()
459            .map(|(k, v)| (k, PermissionRule::Boolean(v)))
460            .collect();
461    }
462
463    /// Set the default permission for nodegroups not explicitly listed.
464    pub fn set_default_allow(&mut self, default_allow: bool) {
465        self.default_allow = default_allow;
466    }
467
468    /// Check if a nodegroup is permitted.
469    /// Conditional rules return true (nodegroup permitted, tiles filtered separately).
470    pub fn is_nodegroup_permitted(&self, nodegroup_id: &str) -> bool {
471        self.permitted_nodegroups
472            .get(nodegroup_id)
473            .map(|rule| rule.permits_nodegroup())
474            .unwrap_or(self.default_allow)
475    }
476
477    /// Check if a specific tile is permitted by its nodegroup's permission rule.
478    pub fn is_tile_permitted(&self, tile: &StaticTile) -> bool {
479        self.permitted_nodegroups
480            .get(&tile.nodegroup_id)
481            .map(|rule| rule.permits_tile(tile))
482            .unwrap_or(self.default_allow)
483    }
484
485    /// Get the permission rule for a nodegroup.
486    pub fn get_permission_rule(&self, nodegroup_id: &str) -> Option<&PermissionRule> {
487        self.permitted_nodegroups.get(nodegroup_id)
488    }
489
490    /// Get permitted nodegroups as a boolean map (for backward compat).
491    pub fn get_permitted_nodegroups_bool(&self) -> HashMap<String, bool> {
492        // Defaults are pre-populated in build_indices(), so just convert.
493        self.permitted_nodegroups
494            .iter()
495            .map(|(k, v)| (k.clone(), v.permits_nodegroup()))
496            .collect()
497    }
498
499    /// Get all permission rules.
500    pub fn get_permitted_nodegroups_rules(&self) -> &HashMap<String, PermissionRule> {
501        &self.permitted_nodegroups
502    }
503
504    // =========================================================================
505    // Graph pruning
506    // =========================================================================
507
508    /// Prune graph to only include permitted nodegroups and their dependencies.
509    /// Updates the graph and rebuilds caches.
510    pub fn prune_graph(&mut self, keep_functions: Option<&[String]>) -> Result<(), String> {
511        let is_permitted = |ng_id: &str| self.is_nodegroup_permitted(ng_id);
512        let pruned = core_prune_graph(&self.graph, is_permitted, keep_functions)
513            .map_err(|e| e.to_string())?;
514        self.graph = Arc::new(pruned);
515        self.invalidate_caches();
516        self.ensure_built()?;
517        Ok(())
518    }
519}
520
521impl ModelAccess for GraphModelAccess {
522    fn get_nodes(&self) -> Option<&HashMap<String, Arc<StaticNode>>> {
523        self.nodes.as_ref().map(|arc| arc.as_ref())
524    }
525
526    fn get_edges(&self) -> Option<&HashMap<String, Vec<String>>> {
527        self.edges.as_ref().map(|arc| arc.as_ref())
528    }
529
530    fn get_reverse_edges(&self) -> Option<&HashMap<String, Vec<String>>> {
531        self.reverse_edges.as_ref().map(|arc| arc.as_ref())
532    }
533
534    fn get_nodes_by_nodegroup(&self) -> Option<&HashMap<String, Vec<Arc<StaticNode>>>> {
535        self.nodes_by_nodegroup.as_ref().map(|arc| arc.as_ref())
536    }
537
538    fn get_nodegroups(&self) -> Option<&HashMap<String, Arc<StaticNodegroup>>> {
539        self.nodegroups.as_ref().map(|arc| arc.as_ref())
540    }
541
542    fn get_root_node(&self) -> Result<Arc<StaticNode>, String> {
543        let root_id = self
544            .root_node_id
545            .as_ref()
546            .ok_or_else(|| "Caches not built".to_string())?;
547        self.nodes
548            .as_ref()
549            .and_then(|n| n.get(root_id))
550            .cloned()
551            .ok_or_else(|| "Root node not found".to_string())
552    }
553
554    fn get_permitted_nodegroups(&self) -> HashMap<String, PermissionRule> {
555        // Defaults are pre-populated in build_indices(), so this is always a
556        // cheap clone rather than rebuilding on every call.
557        self.permitted_nodegroups.clone()
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use crate::graph::{StaticEdge, StaticNodegroup};
565    use crate::instance_wrapper_core::ModelAccess;
566    use serde_json::json;
567    use std::collections::HashSet;
568
569    /// Build a test graph:
570    /// ```
571    /// root (semantic, alias="root", istopnode=true, nodegroup_id=None)
572    /// ├── child_a (string, alias="name", nodegroup_id="ng1")
573    /// ├── child_b (semantic, alias="details", nodegroup_id="ng2")
574    /// │   └── grandchild (string, alias="description", nodegroup_id="ng2")
575    /// ```
576    fn create_test_graph() -> StaticGraph {
577        let root: StaticNode = serde_json::from_value(json!({
578            "nodeid": "root-id",
579            "name": "Root",
580            "alias": "root",
581            "datatype": "semantic",
582            "graph_id": "test-graph",
583            "is_collector": false,
584            "isrequired": false,
585            "issearchable": false,
586            "istopnode": true,
587            "sortorder": 0
588        }))
589        .unwrap();
590
591        let child_a: StaticNode = serde_json::from_value(json!({
592            "nodeid": "child-a-id",
593            "name": "Name",
594            "alias": "name",
595            "datatype": "string",
596            "nodegroup_id": "ng1",
597            "graph_id": "test-graph",
598            "is_collector": false,
599            "isrequired": false,
600            "issearchable": false,
601            "istopnode": false,
602            "sortorder": 0
603        }))
604        .unwrap();
605
606        let child_b: StaticNode = serde_json::from_value(json!({
607            "nodeid": "child-b-id",
608            "name": "Details",
609            "alias": "details",
610            "datatype": "semantic",
611            "nodegroup_id": "ng2",
612            "graph_id": "test-graph",
613            "is_collector": true,
614            "isrequired": false,
615            "issearchable": false,
616            "istopnode": false,
617            "sortorder": 1
618        }))
619        .unwrap();
620
621        let grandchild: StaticNode = serde_json::from_value(json!({
622            "nodeid": "grandchild-id",
623            "name": "Description",
624            "alias": "description",
625            "datatype": "string",
626            "nodegroup_id": "ng2",
627            "graph_id": "test-graph",
628            "is_collector": false,
629            "isrequired": false,
630            "issearchable": false,
631            "istopnode": false,
632            "sortorder": 0
633        }))
634        .unwrap();
635
636        let ng1 = StaticNodegroup {
637            cardinality: Some("n".to_string()),
638            legacygroupid: None,
639            nodegroupid: "ng1".to_string(),
640            parentnodegroup_id: None,
641            grouping_node_id: None,
642        };
643        let ng2 = StaticNodegroup {
644            cardinality: Some("1".to_string()),
645            legacygroupid: None,
646            nodegroupid: "ng2".to_string(),
647            parentnodegroup_id: None,
648            grouping_node_id: None,
649        };
650
651        let edge_root_a: StaticEdge = serde_json::from_value(json!({
652            "domainnode_id": "root-id",
653            "rangenode_id": "child-a-id",
654            "edgeid": "edge-1",
655            "graph_id": "test-graph"
656        }))
657        .unwrap();
658
659        let edge_root_b: StaticEdge = serde_json::from_value(json!({
660            "domainnode_id": "root-id",
661            "rangenode_id": "child-b-id",
662            "edgeid": "edge-2",
663            "graph_id": "test-graph"
664        }))
665        .unwrap();
666
667        let edge_b_gc: StaticEdge = serde_json::from_value(json!({
668            "domainnode_id": "child-b-id",
669            "rangenode_id": "grandchild-id",
670            "edgeid": "edge-3",
671            "graph_id": "test-graph"
672        }))
673        .unwrap();
674
675        serde_json::from_value(json!({
676            "graphid": "test-graph",
677            "name": {"en": "Test Graph"},
678            "root": root,
679            "nodes": [root.clone(), child_a, child_b, grandchild],
680            "edges": [edge_root_a, edge_root_b, edge_b_gc],
681            "nodegroups": [ng1, ng2]
682        }))
683        .unwrap()
684    }
685
686    #[test]
687    fn from_graph_builds_node_index() {
688        let graph = create_test_graph();
689        let access = GraphModelAccess::from_graph(&graph);
690        let nodes = access.nodes.as_ref().unwrap();
691        assert_eq!(nodes.len(), 4);
692        assert!(nodes.contains_key("root-id"));
693        assert!(nodes.contains_key("child-a-id"));
694        assert!(nodes.contains_key("child-b-id"));
695        assert!(nodes.contains_key("grandchild-id"));
696    }
697
698    #[test]
699    fn from_graph_builds_alias_index() {
700        let graph = create_test_graph();
701        let access = GraphModelAccess::from_graph(&graph);
702        let aliases = access.nodes_by_alias.as_ref().unwrap();
703        assert!(aliases.contains_key("name"));
704        assert!(aliases.contains_key("details"));
705        assert!(aliases.contains_key("description"));
706        assert_eq!(aliases.get("name").unwrap().nodeid, "child-a-id");
707    }
708
709    #[test]
710    fn from_graph_builds_edges() {
711        let graph = create_test_graph();
712        let access = GraphModelAccess::from_graph(&graph);
713        let edges = access.edges.as_ref().unwrap();
714        let root_children = edges.get("root-id").unwrap();
715        assert_eq!(root_children.len(), 2);
716        assert!(root_children.contains(&"child-a-id".to_string()));
717        assert!(root_children.contains(&"child-b-id".to_string()));
718        let b_children = edges.get("child-b-id").unwrap();
719        assert_eq!(b_children, &vec!["grandchild-id".to_string()]);
720    }
721
722    #[test]
723    fn from_graph_builds_reverse_edges() {
724        let graph = create_test_graph();
725        let access = GraphModelAccess::from_graph(&graph);
726        let rev = access.reverse_edges.as_ref().unwrap();
727        let gc_parents = rev.get("grandchild-id").unwrap();
728        assert_eq!(gc_parents, &vec!["child-b-id".to_string()]);
729        let a_parents = rev.get("child-a-id").unwrap();
730        assert_eq!(a_parents, &vec!["root-id".to_string()]);
731    }
732
733    #[test]
734    fn from_graph_builds_nodegroups() {
735        let graph = create_test_graph();
736        let access = GraphModelAccess::from_graph(&graph);
737        let ngs = access.nodegroups.as_ref().unwrap();
738        assert!(ngs.contains_key("ng1"));
739        assert!(ngs.contains_key("ng2"));
740        // ng2 should have cardinality from the explicit nodegroup definition
741        assert_eq!(ngs.get("ng2").unwrap().cardinality, Some("1".to_string()));
742    }
743
744    #[test]
745    fn from_graph_identifies_root() {
746        let graph = create_test_graph();
747        let access = GraphModelAccess::from_graph(&graph);
748        assert_eq!(access.root_node_id.as_deref(), Some("root-id"));
749    }
750
751    #[test]
752    fn from_graph_sets_root_alias_when_missing() {
753        let mut graph = create_test_graph();
754        // Modify root to have no alias and no nodegroup_id (top node pattern)
755        graph.nodes[0].alias = None;
756        graph.nodes[0].nodegroup_id = None;
757        let access = GraphModelAccess::from_graph(&graph);
758        let aliases = access.nodes_by_alias.as_ref().unwrap();
759        // Should have empty-string alias entry for root
760        assert!(aliases.contains_key(""));
761        assert_eq!(aliases.get("").unwrap().nodeid, "root-id");
762    }
763
764    #[test]
765    fn get_child_nodes_returns_children_by_alias() {
766        let graph = create_test_graph();
767        let access = GraphModelAccess::from_graph(&graph);
768        let children = access.get_child_nodes("root-id");
769        assert_eq!(children.len(), 2);
770        assert!(children.contains_key("name"));
771        assert!(children.contains_key("details"));
772        assert_eq!(children.get("name").unwrap().nodeid, "child-a-id");
773    }
774
775    #[test]
776    fn get_child_nodes_empty_for_leaf() {
777        let graph = create_test_graph();
778        let access = GraphModelAccess::from_graph(&graph);
779        let children = access.get_child_nodes("grandchild-id");
780        assert!(children.is_empty());
781    }
782
783    #[test]
784    fn is_nodegroup_permitted_default_allow() {
785        let graph = create_test_graph();
786        let access = GraphModelAccess::from_graph(&graph);
787        // No permissions set, default_allow=true (from_graph default)
788        assert!(access.is_nodegroup_permitted("ng1"));
789        // With default_allow=false
790        let access2 = GraphModelAccess::new_eager(Arc::new(graph), false);
791        assert!(!access2.is_nodegroup_permitted("ng1"));
792    }
793
794    #[test]
795    fn is_nodegroup_permitted_explicit_deny() {
796        let graph = create_test_graph();
797        let mut access = GraphModelAccess::from_graph(&graph);
798        let mut perms = HashMap::new();
799        perms.insert("ng1".to_string(), PermissionRule::Boolean(true));
800        perms.insert("ng2".to_string(), PermissionRule::Boolean(false));
801        access.set_permitted_nodegroups_rules(perms);
802        assert!(access.is_nodegroup_permitted("ng1"));
803        assert!(!access.is_nodegroup_permitted("ng2"));
804    }
805
806    #[test]
807    fn is_nodegroup_permitted_conditional() {
808        let graph = create_test_graph();
809        let mut access = GraphModelAccess::from_graph(&graph);
810        let mut perms = HashMap::new();
811        perms.insert(
812            "ng1".to_string(),
813            PermissionRule::Conditional {
814                path: ".data.field".to_string(),
815                allowed: HashSet::from(["value1".to_string()]),
816            },
817        );
818        access.set_permitted_nodegroups_rules(perms);
819        // Conditional permits the nodegroup itself (tile filtering happens separately)
820        assert!(access.is_nodegroup_permitted("ng1"));
821    }
822
823    #[test]
824    fn get_permitted_nodegroups_bool_returns_all_when_empty() {
825        let graph = create_test_graph();
826        let access = GraphModelAccess::from_graph(&graph);
827        let perms = access.get_permitted_nodegroups_bool();
828        // Should have all nodegroups + root empty-string
829        assert!(perms.contains_key("ng1"));
830        assert!(perms.contains_key("ng2"));
831        assert!(perms.contains_key(""));
832        assert!(perms.values().all(|&v| v));
833    }
834
835    #[test]
836    fn rebuild_from_graph_preserves_permissions() {
837        let graph = create_test_graph();
838        let mut access = GraphModelAccess::from_graph(&graph);
839        let mut perms = HashMap::new();
840        perms.insert("ng1".to_string(), PermissionRule::Boolean(false));
841        access.set_permitted_nodegroups_rules(perms);
842
843        // Rebuild from same graph
844        access.rebuild_from_graph(&graph);
845
846        // Permissions should be preserved
847        assert!(!access.is_nodegroup_permitted("ng1"));
848    }
849
850    #[test]
851    fn trait_get_root_node_returns_correct_node() {
852        let graph = create_test_graph();
853        let access = GraphModelAccess::from_graph(&graph);
854        let root = access.get_root_node().unwrap();
855        assert_eq!(root.nodeid, "root-id");
856        assert!(root.istopnode);
857    }
858
859    #[test]
860    fn nodes_by_nodegroup_indexed_correctly() {
861        let graph = create_test_graph();
862        let access = GraphModelAccess::from_graph(&graph);
863        let nbn = access.nodes_by_nodegroup.as_ref().unwrap();
864        let ng2_nodes = nbn.get("ng2").unwrap();
865        // ng2 has child_b and grandchild
866        assert_eq!(ng2_nodes.len(), 2);
867        let node_ids: Vec<&str> = ng2_nodes.iter().map(|n| n.nodeid.as_str()).collect();
868        assert!(node_ids.contains(&"child-b-id"));
869        assert!(node_ids.contains(&"grandchild-id"));
870    }
871
872    // --- New tests for lazy mode and cache invalidation ---
873
874    #[test]
875    fn lazy_new_does_not_build_caches() {
876        let graph = create_test_graph();
877        let access = GraphModelAccess::new(Arc::new(graph), true);
878        assert!(!access.is_built());
879        assert!(access.get_nodes_internal().is_none());
880        assert!(access.get_edges_internal().is_none());
881    }
882
883    #[test]
884    fn ensure_built_populates_caches() {
885        let graph = create_test_graph();
886        let mut access = GraphModelAccess::new(Arc::new(graph), true);
887        assert!(!access.is_built());
888        access.ensure_built().unwrap();
889        assert!(access.is_built());
890        assert_eq!(access.get_nodes_internal().unwrap().len(), 4);
891    }
892
893    #[test]
894    fn ensure_built_is_idempotent() {
895        let graph = create_test_graph();
896        let mut access = GraphModelAccess::new(Arc::new(graph), true);
897        access.ensure_built().unwrap();
898        let ptr1 = Arc::as_ptr(access.nodes.as_ref().unwrap());
899        access.ensure_built().unwrap();
900        let ptr2 = Arc::as_ptr(access.nodes.as_ref().unwrap());
901        // Same allocation — didn't rebuild
902        assert_eq!(ptr1, ptr2);
903    }
904
905    #[test]
906    fn invalidate_caches_clears_indices() {
907        let graph = create_test_graph();
908        let mut access = GraphModelAccess::from_graph(&graph);
909        assert!(access.is_built());
910        access.invalidate_caches();
911        assert!(!access.is_built());
912        assert!(access.get_nodes_internal().is_none());
913    }
914
915    #[test]
916    fn set_graph_nodes_invalidates_caches() {
917        let graph = create_test_graph();
918        let mut access = GraphModelAccess::from_graph(&graph);
919        assert_eq!(access.get_nodes_internal().unwrap().len(), 4);
920
921        // Mutate: remove all but root
922        let root_only: Vec<StaticNode> = graph
923            .nodes
924            .iter()
925            .filter(|n| n.istopnode)
926            .cloned()
927            .collect();
928        access.set_graph_nodes(root_only);
929        // Caches invalidated
930        assert!(!access.is_built());
931        // Rebuild
932        access.ensure_built().unwrap();
933        assert_eq!(access.get_nodes_internal().unwrap().len(), 1);
934    }
935
936    #[test]
937    fn arc_sharing_returns_same_allocation() {
938        let graph = create_test_graph();
939        let access = GraphModelAccess::from_graph(&graph);
940        let arc1 = access.get_nodes_arc().unwrap();
941        let arc2 = access.get_nodes_arc().unwrap();
942        assert!(Arc::ptr_eq(&arc1, &arc2));
943    }
944
945    #[test]
946    fn mutable_accessor_triggers_lazy_build() {
947        let graph = create_test_graph();
948        let mut access = GraphModelAccess::new(Arc::new(graph), true);
949        assert!(!access.is_built());
950        let nodes = access.get_node_objects().unwrap();
951        assert_eq!(nodes.len(), 4);
952        assert!(access.is_built());
953    }
954}