Skip to main content

alizarin_core/graph/
static_graph.rs

1//! StaticGraph and IndexedGraph types.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use super::cards::{StaticCard, StaticCardsXNodesXWidgets, StaticFunctionsXGraphs};
8use super::descriptors::{DescriptorConfig, StaticResourceDescriptors, DESCRIPTOR_FUNCTION_ID};
9use super::nodes::{StaticEdge, StaticNode, StaticNodegroup};
10use super::tile::StaticTile;
11use super::translatable::StaticTranslatableString;
12
13/// Wrapper for loading from JSON files with {graph: [...]} structure
14#[derive(Debug, Deserialize)]
15pub struct GraphWrapper {
16    pub graph: Vec<StaticGraph>,
17}
18
19/// The main graph structure containing nodes, edges, and metadata
20#[derive(Clone, Debug, Serialize, Deserialize)]
21pub struct StaticGraph {
22    pub graphid: String,
23    pub name: StaticTranslatableString,
24    #[serde(default)]
25    pub author: Option<String>,
26    #[serde(default)]
27    pub subtitle: Option<StaticTranslatableString>,
28    #[serde(default)]
29    pub description: Option<StaticTranslatableString>,
30    pub nodes: Vec<StaticNode>,
31    #[serde(default)]
32    pub nodegroups: Vec<StaticNodegroup>,
33    #[serde(default)]
34    pub edges: Vec<StaticEdge>,
35    pub root: StaticNode,
36    #[serde(default)]
37    pub version: Option<String>,
38    #[serde(default)]
39    pub iconclass: Option<String>,
40    #[serde(default)]
41    pub color: Option<String>,
42    #[serde(default)]
43    pub isresource: Option<bool>,
44    #[serde(default)]
45    pub slug: Option<String>,
46    #[serde(default)]
47    pub is_editable: Option<bool>,
48    /// Ontology IDs used by this graph. Accepts a single string or an array
49    /// of strings on the wire; a single-element list is serialised as a plain
50    /// string for round-trip compatibility with upstream Arches.
51    #[serde(default, with = "super::serde_helpers::optional_string_or_vec")]
52    pub ontology_id: Option<Vec<String>>,
53    #[serde(default)]
54    pub template_id: Option<String>,
55    #[serde(default)]
56    pub deploymentdate: Option<String>,
57    #[serde(default)]
58    pub deploymentfile: Option<String>,
59    #[serde(default)]
60    pub jsonldcontext: Option<String>,
61    #[serde(default)]
62    pub config: serde_json::Value,
63    #[serde(default)]
64    pub relatable_resource_model_ids: Vec<String>,
65    #[serde(default)]
66    pub publication: Option<serde_json::Value>,
67    #[serde(default)]
68    pub resource_2_resource_constraints: Option<Vec<serde_json::Value>>,
69
70    // Arches-HER 2.0+ fields (backwards-compatible with older formats)
71    /// Source identifier for import/export tracking
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub source_identifier_id: Option<String>,
74    /// Whether graph is active
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub is_active: Option<bool>,
77    /// Whether graph has unpublished changes
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub has_unpublished_changes: Option<bool>,
80    /// Whether copy is immutable
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub is_copy_immutable: Option<bool>,
83    /// Resource instance lifecycle configuration
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub resource_instance_lifecycle: Option<serde_json::Value>,
86    /// Spatial views configuration
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub spatial_views: Option<serde_json::Value>,
89    /// Group permissions
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub group_permissions: Option<serde_json::Value>,
92    /// User permissions
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub user_permissions: Option<serde_json::Value>,
95
96    // UI-specific fields
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub cards: Option<Vec<StaticCard>>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub cards_x_nodes_x_widgets: Option<Vec<StaticCardsXNodesXWidgets>>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub functions_x_graphs: Option<Vec<StaticFunctionsXGraphs>>,
103
104    // Internal lookup tables (not serialized)
105    #[serde(skip)]
106    node_by_id: Option<HashMap<String, usize>>,
107    #[serde(skip)]
108    node_by_alias: Option<HashMap<String, usize>>,
109    #[serde(skip)]
110    edges_map: Option<HashMap<String, Vec<String>>>,
111    #[serde(skip)]
112    nodes_by_nodegroup: Option<HashMap<String, Vec<usize>>>,
113    #[serde(skip)]
114    nodegroup_by_id: Option<HashMap<String, usize>>,
115    // Arc-wrapped node caches for pseudo_value infrastructure (avoids cloning on every conversion)
116    #[serde(skip)]
117    nodes_by_alias_arc: Option<HashMap<String, Arc<StaticNode>>>,
118    // Card hierarchy index (built when cards are present)
119    #[serde(skip)]
120    card_index: Option<super::card_index::CardIndex>,
121}
122
123impl StaticGraph {
124    /// Load a graph from a JSON string
125    /// Handles both direct graph objects and wrapped format: {"graph": [...]}
126    pub fn from_json_string(json_str: &str) -> Result<StaticGraph, String> {
127        // Try parsing as a direct StaticGraph first
128        if let Ok(mut graph) = serde_json::from_str::<StaticGraph>(json_str) {
129            graph.build_indices();
130            return Ok(graph);
131        }
132
133        // Fall back to wrapped format {graph: [...]}
134        let wrapper: GraphWrapper =
135            serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
136        let mut graph = wrapper
137            .graph
138            .into_iter()
139            .next()
140            .ok_or_else(|| "No graphs found in JSON".to_string())?;
141        graph.build_indices();
142        Ok(graph)
143    }
144
145    /// Build the internal lookup indices
146    pub fn build_indices(&mut self) {
147        let mut node_by_id = HashMap::new();
148        let mut node_by_alias = HashMap::new();
149        let mut nodes_by_nodegroup: HashMap<String, Vec<usize>> = HashMap::new();
150
151        for (idx, node) in self.nodes.iter().enumerate() {
152            node_by_id.insert(node.nodeid.clone(), idx);
153            if let Some(ref alias) = node.alias {
154                if !alias.is_empty() {
155                    node_by_alias.insert(alias.clone(), idx);
156                }
157            }
158            if let Some(ref ng_id) = node.nodegroup_id {
159                if !ng_id.is_empty() {
160                    nodes_by_nodegroup
161                        .entry(ng_id.clone())
162                        .or_default()
163                        .push(idx);
164                }
165            }
166        }
167
168        // Build edges map (parent_nodeid -> child_nodeids)
169        let mut edges_map: HashMap<String, Vec<String>> = HashMap::new();
170        for edge in &self.edges {
171            edges_map
172                .entry(edge.domainnode_id.clone())
173                .or_default()
174                .push(edge.rangenode_id.clone());
175        }
176
177        // Build nodegroup index
178        let mut nodegroup_by_id = HashMap::new();
179        for (idx, ng) in self.nodegroups.iter().enumerate() {
180            nodegroup_by_id.insert(ng.nodegroupid.clone(), idx);
181        }
182
183        // Build Arc-wrapped nodes_by_alias for pseudo_value infrastructure
184        let nodes_by_alias_arc: HashMap<String, Arc<StaticNode>> = self
185            .nodes
186            .iter()
187            .filter_map(|n| {
188                n.alias
189                    .as_ref()
190                    .filter(|a| !a.is_empty())
191                    .map(|a| (a.clone(), Arc::new(n.clone())))
192            })
193            .collect();
194
195        self.node_by_id = Some(node_by_id);
196        self.node_by_alias = Some(node_by_alias);
197        self.edges_map = Some(edges_map);
198        self.nodes_by_nodegroup = Some(nodes_by_nodegroup);
199        self.nodegroup_by_id = Some(nodegroup_by_id);
200        self.nodes_by_alias_arc = Some(nodes_by_alias_arc);
201
202        // Build card index when cards are present
203        if self.cards.is_some() {
204            self.card_index = Some(super::card_index::build_card_index(
205                self.cards_slice(),
206                self.cards_x_nodes_x_widgets_slice(),
207                &self.nodegroups,
208                &self.nodes,
209                crate::graph_mutator::get_widget_name_by_id,
210            ));
211        }
212    }
213
214    /// Invalidate all internal lookup indices.
215    ///
216    /// This must be called after mutations that modify the nodes vector,
217    /// especially operations like `retain()` that shift element positions.
218    pub fn invalidate_indices(&mut self) {
219        self.node_by_id = None;
220        self.node_by_alias = None;
221        self.edges_map = None;
222        self.nodes_by_nodegroup = None;
223        self.nodegroup_by_id = None;
224        self.nodes_by_alias_arc = None;
225        self.card_index = None;
226    }
227
228    /// Get the root node
229    pub fn get_root(&self) -> &StaticNode {
230        &self.root
231    }
232
233    /// Get node by index
234    pub fn get_node_by_index(&self, idx: usize) -> Option<&StaticNode> {
235        self.nodes.get(idx)
236    }
237
238    /// Get node by ID
239    pub fn get_node_by_id(&self, id: &str) -> Option<&StaticNode> {
240        self.node_by_id
241            .as_ref()?
242            .get(id)
243            .and_then(|&idx| self.nodes.get(idx))
244    }
245
246    /// Get node by alias
247    pub fn get_node_by_alias(&self, alias: &str) -> Option<&StaticNode> {
248        self.node_by_alias
249            .as_ref()?
250            .get(alias)
251            .and_then(|&idx| self.nodes.get(idx))
252    }
253
254    /// Get display name
255    pub fn display_name(&self) -> String {
256        self.name.to_string_default()
257    }
258
259    /// Get subtitle
260    pub fn display_subtitle(&self) -> String {
261        self.subtitle
262            .as_ref()
263            .map(|s| s.to_string_default())
264            .unwrap_or_default()
265    }
266
267    /// Get author
268    pub fn display_author(&self) -> String {
269        self.author.clone().unwrap_or_default()
270    }
271
272    /// Get nodes slice
273    pub fn nodes_slice(&self) -> &[StaticNode] {
274        &self.nodes
275    }
276
277    /// Get nodegroups slice
278    pub fn nodegroups_slice(&self) -> &[StaticNodegroup] {
279        &self.nodegroups
280    }
281
282    /// Get edges slice
283    pub fn edges_slice(&self) -> &[StaticEdge] {
284        &self.edges
285    }
286
287    /// Get root node
288    pub fn root_node(&self) -> &StaticNode {
289        &self.root
290    }
291
292    /// Get graph ID
293    pub fn graph_id(&self) -> &str {
294        &self.graphid
295    }
296
297    /// Get the model class name for this graph.
298    ///
299    /// This returns the graph's display name, which is used as the
300    /// ResourceInstanceCacheEntry "type" field in TypeScript.
301    pub fn get_model_class_name(&self) -> Option<String> {
302        let name = self.name.to_string_default();
303        if name.is_empty() {
304            None
305        } else {
306            Some(name)
307        }
308    }
309
310    /// Get edges map (parent_nodeid -> child_nodeids)
311    /// Returns None if indices haven't been built
312    pub fn edges_map(&self) -> Option<&HashMap<String, Vec<String>>> {
313        self.edges_map.as_ref()
314    }
315
316    /// Get child node IDs for a given node
317    pub fn get_child_ids(&self, node_id: &str) -> Option<&Vec<String>> {
318        self.edges_map.as_ref()?.get(node_id)
319    }
320
321    /// Get nodes by nodegroup (nodegroup_id -> node indices)
322    /// Returns None if indices haven't been built
323    pub fn nodes_by_nodegroup(&self) -> Option<&HashMap<String, Vec<usize>>> {
324        self.nodes_by_nodegroup.as_ref()
325    }
326
327    /// Get nodes in a specific nodegroup
328    pub fn get_nodes_in_nodegroup(&self, nodegroup_id: &str) -> Vec<&StaticNode> {
329        self.nodes_by_nodegroup
330            .as_ref()
331            .and_then(|map| map.get(nodegroup_id))
332            .map(|indices| {
333                indices
334                    .iter()
335                    .filter_map(|&idx| self.nodes.get(idx))
336                    .collect()
337            })
338            .unwrap_or_default()
339    }
340
341    /// Get nodegroup by ID
342    pub fn get_nodegroup_by_id(&self, nodegroup_id: &str) -> Option<&StaticNodegroup> {
343        self.nodegroup_by_id
344            .as_ref()?
345            .get(nodegroup_id)
346            .and_then(|&idx| self.nodegroups.get(idx))
347    }
348
349    /// Get Arc-wrapped nodes by alias map (for pseudo_value infrastructure)
350    /// Returns None if indices haven't been built
351    pub fn nodes_by_alias_arc(&self) -> Option<&HashMap<String, Arc<StaticNode>>> {
352        self.nodes_by_alias_arc.as_ref()
353    }
354
355    /// Get Arc-wrapped node by alias
356    pub fn get_node_arc_by_alias(&self, alias: &str) -> Option<Arc<StaticNode>> {
357        self.nodes_by_alias_arc.as_ref()?.get(alias).cloned()
358    }
359
360    // =========================================================================
361    // Mutation Methods (for GraphMutator)
362    // =========================================================================
363
364    /// Create a deep clone of the graph with fresh indices
365    pub fn deep_clone(&self) -> Self {
366        let mut cloned = self.clone();
367        cloned.build_indices();
368        cloned
369    }
370
371    /// Push a new node to the graph
372    ///
373    /// Note: You must call `build_indices()` after all mutations to rebuild lookup tables.
374    pub fn push_node(&mut self, node: StaticNode) {
375        self.nodes.push(node);
376        self.invalidate_indices();
377    }
378
379    /// Push a new edge to the graph
380    pub fn push_edge(&mut self, edge: StaticEdge) {
381        self.edges.push(edge);
382        self.edges_map = None;
383    }
384
385    /// Push a new nodegroup to the graph
386    pub fn push_nodegroup(&mut self, nodegroup: StaticNodegroup) {
387        self.nodegroups.push(nodegroup);
388        self.nodegroup_by_id = None;
389    }
390
391    /// Push a new card to the graph
392    pub fn push_card(&mut self, card: StaticCard) {
393        if self.cards.is_none() {
394            self.cards = Some(Vec::new());
395        }
396        if let Some(ref mut cards) = self.cards {
397            cards.push(card);
398        }
399    }
400
401    /// Push a new cards_x_nodes_x_widgets entry
402    pub fn push_card_x_node_x_widget(&mut self, cxnxw: StaticCardsXNodesXWidgets) {
403        if self.cards_x_nodes_x_widgets.is_none() {
404            self.cards_x_nodes_x_widgets = Some(Vec::new());
405        }
406        if let Some(ref mut cxnxw_list) = self.cards_x_nodes_x_widgets {
407            cxnxw_list.push(cxnxw);
408        }
409    }
410
411    /// Get cards slice (for mutation checks)
412    pub fn cards_slice(&self) -> &[StaticCard] {
413        self.cards.as_deref().unwrap_or(&[])
414    }
415
416    /// Get the card hierarchy index (None if graph has no cards)
417    pub fn card_index(&self) -> Option<&super::card_index::CardIndex> {
418        self.card_index.as_ref()
419    }
420
421    /// Get cards_x_nodes_x_widgets slice
422    pub fn cards_x_nodes_x_widgets_slice(&self) -> &[StaticCardsXNodesXWidgets] {
423        self.cards_x_nodes_x_widgets.as_deref().unwrap_or(&[])
424    }
425
426    /// Find a card by nodegroup_id
427    pub fn find_card_by_nodegroup(&self, nodegroup_id: &str) -> Option<&StaticCard> {
428        self.cards
429            .as_ref()?
430            .iter()
431            .find(|c| c.nodegroup_id == nodegroup_id)
432    }
433
434    /// Find a node by alias (without requiring indices to be built)
435    pub fn find_node_by_alias(&self, alias: &str) -> Option<&StaticNode> {
436        // Try indexed lookup first
437        if let Some(node) = self.get_node_by_alias(alias) {
438            return Some(node);
439        }
440        // Fall back to linear search
441        self.nodes
442            .iter()
443            .find(|n| n.alias.as_deref() == Some(alias))
444    }
445
446    /// Get a simplified schema view of the graph showing node aliases and structure.
447    ///
448    /// Returns a nested structure representing the tree with:
449    /// - Keys are node aliases (or nodeid if no alias)
450    /// - Values contain 'datatype', 'nodeid', optionally 'required', and 'children'
451    ///
452    /// Useful for understanding what keys are available in tree output.
453    pub fn get_schema(&self) -> serde_json::Value {
454        // Build a map from nodeid to node for quick lookup
455        let node_map: HashMap<&str, &StaticNode> =
456            self.nodes.iter().map(|n| (n.nodeid.as_str(), n)).collect();
457
458        // Build parent -> children map based on edges
459        let mut children_map: HashMap<&str, Vec<&str>> = HashMap::new();
460        for edge in &self.edges {
461            children_map
462                .entry(edge.domainnode_id.as_str())
463                .or_default()
464                .push(&edge.rangenode_id);
465        }
466
467        // Recursive function to build node schema
468        fn build_node_schema(
469            nodeid: &str,
470            node_map: &HashMap<&str, &StaticNode>,
471            children_map: &HashMap<&str, Vec<&str>>,
472        ) -> serde_json::Value {
473            let node = match node_map.get(nodeid) {
474                Some(n) => n,
475                None => return serde_json::json!({}),
476            };
477
478            let mut schema = serde_json::json!({
479                "datatype": node.datatype,
480                "nodeid": node.nodeid,
481            });
482
483            if node.isrequired {
484                schema["required"] = serde_json::json!(true);
485            }
486
487            // Add children recursively
488            if let Some(child_ids) = children_map.get(nodeid) {
489                let mut children = serde_json::Map::new();
490                for child_id in child_ids {
491                    if let Some(child_node) = node_map.get(child_id) {
492                        let key = child_node.alias.as_deref().unwrap_or(&child_node.nodeid);
493                        children.insert(
494                            key.to_string(),
495                            build_node_schema(child_id, node_map, children_map),
496                        );
497                    }
498                }
499                if !children.is_empty() {
500                    schema["children"] = serde_json::Value::Object(children);
501                }
502            }
503
504            schema
505        }
506
507        // Start from root node and build tree
508        let root_id = &self.root.nodeid;
509        let mut root_schema = serde_json::Map::new();
510
511        if let Some(child_ids) = children_map.get(root_id.as_str()) {
512            for child_id in child_ids {
513                if let Some(child_node) = node_map.get(child_id) {
514                    let key = child_node.alias.as_deref().unwrap_or(&child_node.nodeid);
515                    root_schema.insert(
516                        key.to_string(),
517                        build_node_schema(child_id, &node_map, &children_map),
518                    );
519                }
520            }
521        }
522
523        serde_json::Value::Object(root_schema)
524    }
525
526    /// Set a descriptor template for a given descriptor type (e.g. "slug", "name").
527    ///
528    /// The `nodegroup_id` is inferred from the `<Node Name>` placeholders in the
529    /// template by looking up nodes in the graph. All placeholder nodes must belong
530    /// to exactly one nodegroup — returns an error otherwise.
531    ///
532    /// Creates or updates the descriptor function entry in `functions_x_graphs`.
533    pub fn set_descriptor_template(
534        &mut self,
535        descriptor_type: &str,
536        string_template: &str,
537    ) -> Result<(), String> {
538        use crate::graph::descriptors::DESCRIPTOR_FUNCTION_ID;
539        use crate::graph::StaticFunctionsXGraphs;
540        use std::collections::HashSet;
541
542        let fxg = self.functions_x_graphs.get_or_insert_with(Vec::new);
543
544        // Find existing descriptor function entry:
545        // 1. Non-default function with empty/null config (just added via add_function,
546        //    intended to replace the default descriptor — e.g. multicard descriptor)
547        // 2. Non-default function that already has descriptor_types config
548        // 3. Exact match on the default descriptor function ID
549        let idx = fxg
550            .iter()
551            .position(|f| {
552                f.function_id != DESCRIPTOR_FUNCTION_ID
553                    && (f.config.is_null() || f.config == serde_json::json!({}))
554            })
555            .or_else(|| {
556                fxg.iter().position(|f| {
557                    f.function_id != DESCRIPTOR_FUNCTION_ID
558                        && f.config
559                            .as_object()
560                            .is_some_and(|c| c.contains_key("descriptor_types"))
561                })
562            })
563            .or_else(|| {
564                fxg.iter()
565                    .position(|f| f.function_id == DESCRIPTOR_FUNCTION_ID)
566            });
567
568        let is_default_function = idx
569            .map(|i| fxg[i].function_id == DESCRIPTOR_FUNCTION_ID)
570            .unwrap_or(true); // will create default if no match
571
572        // Non-default functions (e.g. multicard descriptor) resolve templates
573        // at runtime using node aliases — alizarin stores the template verbatim
574        // with empty nodegroup_id. Default function requires name-based resolution
575        // to a single nodegroup at build time.
576        let entry = if is_default_function {
577            let placeholders = IndexedGraph::extract_placeholders(string_template);
578            if placeholders.is_empty() {
579                return Err(format!(
580                    "Template '{}' has no <Node Name> placeholders",
581                    string_template
582                ));
583            }
584
585            let mut nodegroup_ids = HashSet::new();
586            for placeholder in &placeholders {
587                let node_name = placeholder.trim_start_matches('<').trim_end_matches('>');
588                let node = self
589                    .nodes
590                    .iter()
591                    .find(|n| n.name == node_name)
592                    .ok_or_else(|| {
593                        format!("Node '{}' from template not found in graph", node_name)
594                    })?;
595                let ng_id = node
596                    .nodegroup_id
597                    .as_ref()
598                    .ok_or_else(|| format!("Node '{}' has no nodegroup_id", node_name))?;
599                nodegroup_ids.insert(ng_id.clone());
600            }
601
602            if nodegroup_ids.len() != 1 && descriptor_type != "slug" {
603                return Err(format!(
604                    "Template placeholders span {} nodegroups ({:?}), expected exactly 1",
605                    nodegroup_ids.len(),
606                    nodegroup_ids
607                ));
608            }
609
610            let nodegroup_id = nodegroup_ids.into_iter().next().unwrap();
611            serde_json::json!({
612                "nodegroup_id": nodegroup_id,
613                "string_template": string_template,
614            })
615        } else {
616            // Non-default: store template as-is with empty nodegroup_id.
617            // The function resolves aliases at runtime.
618            serde_json::json!({
619                "nodegroup_id": "",
620                "string_template": string_template,
621            })
622        };
623
624        let existing = idx.map(|i| &mut fxg[i]);
625
626        if let Some(func) = existing {
627            let needs_seed = !func.config.is_object()
628                || !func
629                    .config
630                    .as_object()
631                    .is_some_and(|c| c.contains_key("descriptor_types"));
632            if needs_seed && !is_default_function {
633                // Seed all descriptor types with empty defaults for non-default functions
634                func.config = serde_json::json!({
635                    "descriptor_types": {
636                        "name": {"nodegroup_id": "", "string_template": ""},
637                        "description": {"nodegroup_id": "", "string_template": ""},
638                        "map_popup": {"nodegroup_id": "", "string_template": ""},
639                    }
640                });
641            } else if !func.config.is_object() {
642                func.config = serde_json::json!({"descriptor_types": {}});
643            }
644            let config = func.config.as_object_mut().unwrap();
645            let dt = config
646                .entry("descriptor_types")
647                .or_insert_with(|| serde_json::json!({}));
648            if let Some(dt_obj) = dt.as_object_mut() {
649                dt_obj.insert(descriptor_type.to_string(), entry);
650            }
651        } else {
652            let mut dt_map = serde_json::Map::new();
653            dt_map.insert(descriptor_type.to_string(), entry);
654            fxg.push(StaticFunctionsXGraphs {
655                config: serde_json::json!({ "descriptor_types": dt_map }),
656                function_id: DESCRIPTOR_FUNCTION_ID.to_string(),
657                graph_id: self.graphid.clone(),
658                id: crate::graph_mutator::generate_uuid_v5(
659                    ("function", Some(&self.graphid)),
660                    DESCRIPTOR_FUNCTION_ID,
661                ),
662            });
663        }
664
665        Ok(())
666    }
667}
668
669/// Graph with precomputed indices for efficient tree traversal
670pub struct IndexedGraph {
671    pub graph: StaticGraph,
672    /// node_id -> StaticNode
673    pub nodes_by_id: HashMap<String, StaticNode>,
674    /// node_id -> [child_node_ids]
675    pub children_by_node: HashMap<String, Vec<String>>,
676    /// alias -> StaticNode
677    pub nodes_by_alias: HashMap<String, StaticNode>,
678    /// nodegroup_id -> StaticNodegroup
679    pub nodegroups_by_id: HashMap<String, StaticNodegroup>,
680}
681
682impl IndexedGraph {
683    /// Create an indexed graph from a StaticGraph
684    pub fn new(graph: StaticGraph) -> Self {
685        let mut nodes_by_id = HashMap::new();
686        let mut nodes_by_alias = HashMap::new();
687        let mut children_by_node: HashMap<String, Vec<String>> = HashMap::new();
688        let mut nodegroups_by_id = HashMap::new();
689
690        // Index nodes by ID and alias
691        for node in &graph.nodes {
692            nodes_by_id.insert(node.nodeid.clone(), node.clone());
693            if let Some(ref alias) = node.alias {
694                if !alias.is_empty() {
695                    nodes_by_alias.insert(alias.clone(), node.clone());
696                }
697            }
698        }
699
700        // Index edges (domainnode_id -> rangenode_id)
701        for edge in &graph.edges {
702            children_by_node
703                .entry(edge.domainnode_id.clone())
704                .or_default()
705                .push(edge.rangenode_id.clone());
706        }
707
708        // Index nodegroups
709        for ng in &graph.nodegroups {
710            nodegroups_by_id.insert(ng.nodegroupid.clone(), ng.clone());
711        }
712
713        IndexedGraph {
714            graph,
715            nodes_by_id,
716            nodes_by_alias,
717            children_by_node,
718            nodegroups_by_id,
719        }
720    }
721
722    /// Get root node
723    pub fn get_root(&self) -> &StaticNode {
724        self.graph.get_root()
725    }
726
727    /// Get node by ID
728    pub fn get_node(&self, node_id: &str) -> Option<&StaticNode> {
729        self.nodes_by_id.get(node_id)
730    }
731
732    /// Get node by alias
733    pub fn get_node_by_alias(&self, alias: &str) -> Option<&StaticNode> {
734        self.nodes_by_alias.get(alias)
735    }
736
737    /// Get child nodes for a given node ID
738    pub fn get_children(&self, node_id: &str) -> Vec<&StaticNode> {
739        self.children_by_node
740            .get(node_id)
741            .map(|ids| {
742                ids.iter()
743                    .filter_map(|id| self.nodes_by_id.get(id))
744                    .collect()
745            })
746            .unwrap_or_default()
747    }
748
749    /// Get child node IDs for a given node
750    pub fn get_child_ids(&self, node_id: &str) -> Vec<&String> {
751        self.children_by_node
752            .get(node_id)
753            .map(|v| v.iter().collect())
754            .unwrap_or_default()
755    }
756
757    /// Check if a node has children
758    pub fn has_children(&self, node_id: &str) -> bool {
759        self.children_by_node
760            .get(node_id)
761            .map(|v| !v.is_empty())
762            .unwrap_or(false)
763    }
764
765    /// Get the nodegroup for a node
766    pub fn get_nodegroup(&self, node: &StaticNode) -> Option<&StaticNodegroup> {
767        node.nodegroup_id
768            .as_ref()
769            .and_then(|id| self.nodegroups_by_id.get(id))
770    }
771
772    /// Build resource descriptors from tiles using graph configuration
773    ///
774    /// This replaces the TypeScript buildResourceDescriptors function, making
775    /// descriptor computation completely platform-independent.
776    ///
777    /// # Arguments
778    /// * `tiles` - The resource tiles containing data values
779    ///
780    /// # Returns
781    /// Populated StaticResourceDescriptors with name, description, map_popup fields
782    pub fn build_descriptors(&self, tiles: &[StaticTile]) -> StaticResourceDescriptors {
783        self.build_descriptors_with_context(tiles, &mut Vec::new(), None, None)
784    }
785
786    /// Build descriptors with diagnostic warnings for silent failure cases
787    pub fn build_descriptors_with_diagnostics(
788        &self,
789        tiles: &[StaticTile],
790        warnings: &mut Vec<String>,
791        cache: Option<&super::resources::ResourceCache>,
792    ) -> StaticResourceDescriptors {
793        self.build_descriptors_with_context(tiles, warnings, cache, None)
794    }
795
796    /// Build descriptors with full context (diagnostics, cache, extension registry)
797    pub fn build_descriptors_with_context(
798        &self,
799        tiles: &[StaticTile],
800        warnings: &mut Vec<String>,
801        cache: Option<&super::resources::ResourceCache>,
802        extension_registry: Option<&crate::extension_type_registry::ExtensionTypeRegistry>,
803    ) -> StaticResourceDescriptors {
804        // Get descriptor config from graph with diagnostics
805        let config = match self.get_descriptor_config_with_diagnostics(warnings) {
806            Some(c) => c,
807            None => {
808                // Warning already added by get_descriptor_config_with_diagnostics
809                return StaticResourceDescriptors::default();
810            }
811        };
812
813        let mut descriptors = StaticResourceDescriptors::default();
814
815        // Process each descriptor type (name, description, map_popup)
816        for (descriptor_type, type_config) in &config.descriptor_types {
817            let mut template = type_config.string_template.clone();
818
819            // Extract placeholders from template (e.g., <Node Name>)
820            let placeholders = Self::extract_placeholders(&template);
821            if placeholders.is_empty() {
822                warnings.push(format!(
823                    "Descriptor '{}': No placeholders found in template '{}'",
824                    descriptor_type, template
825                ));
826                continue;
827            }
828
829            // Replace each placeholder with actual value from tiles
830            for placeholder in &placeholders {
831                let node_name = placeholder.trim_start_matches('<').trim_end_matches('>');
832
833                if let Some(node) = self.find_node_by_name(node_name) {
834                    let node_ng = match node.nodegroup_id.as_ref() {
835                        Some(ng) => ng,
836                        None => {
837                            warnings.push(format!(
838                                "Descriptor '{}': Node '{}' has no nodegroup_id",
839                                descriptor_type, node_name
840                            ));
841                            continue;
842                        }
843                    };
844                    let node_tiles: Vec<&StaticTile> = tiles
845                        .iter()
846                        .filter(|t| &t.nodegroup_id == node_ng)
847                        .collect();
848                    if let Some(value) = Self::extract_display_value_from_tiles(
849                        &node_tiles,
850                        &node.nodeid,
851                        &node.datatype,
852                        cache,
853                        extension_registry,
854                    ) {
855                        template = template.replace(placeholder, &value);
856                    } else {
857                        let available_keys: Vec<_> =
858                            node_tiles.iter().flat_map(|t| t.data.keys()).collect();
859                        warnings.push(format!(
860                            "Descriptor '{}': No value found for node '{}' (nodeid '{}') in tiles. Available data keys: {:?}",
861                            descriptor_type, node_name, node.nodeid, available_keys
862                        ));
863                    }
864                } else {
865                    warnings.push(format!(
866                        "Descriptor '{}': Node '{}' not found in graph",
867                        descriptor_type, node_name
868                    ));
869                }
870            }
871
872            // Assign to appropriate descriptor field
873            match descriptor_type.as_str() {
874                "name" => descriptors.name = Some(template),
875                "description" => descriptors.description = Some(template),
876                "map_popup" => descriptors.map_popup = Some(template),
877                "slug" => {
878                    descriptors.slug =
879                        Some(crate::graph_mutator::slugify(&template).replace('_', "-"))
880                }
881                _ => {} // Unknown descriptor type, ignore
882            }
883        }
884
885        descriptors
886    }
887
888    /// Extract descriptor config with diagnostic warnings
889    fn get_descriptor_config_with_diagnostics(
890        &self,
891        warnings: &mut Vec<String>,
892    ) -> Option<DescriptorConfig> {
893        let functions_x_graphs = match self.graph.functions_x_graphs.as_ref() {
894            Some(fxg) => fxg,
895            None => {
896                warnings.push("Graph has no functions_x_graphs array".to_string());
897                return None;
898            }
899        };
900
901        // Find descriptor function: exact match on default ID, or any function
902        // with descriptor_types config (e.g. Multi-card Resource Descriptor).
903        let descriptor_func = functions_x_graphs
904            .iter()
905            .find(|f| f.function_id == DESCRIPTOR_FUNCTION_ID)
906            .or_else(|| {
907                functions_x_graphs.iter().find(|f| {
908                    f.config
909                        .as_object()
910                        .is_some_and(|c| c.contains_key("descriptor_types"))
911                })
912            });
913
914        if let Some(func) = descriptor_func {
915            match serde_json::from_value::<DescriptorConfig>(func.config.clone()) {
916                Ok(config) => return Some(config),
917                Err(e) => {
918                    warnings.push(format!(
919                        "Failed to parse descriptor config: {}. Raw config: {}",
920                        e,
921                        serde_json::to_string(&func.config).unwrap_or_default()
922                    ));
923                    return None;
924                }
925            }
926        }
927
928        warnings.push(format!(
929            "No descriptor function found in functions_x_graphs (looking for function_id {} or descriptor_types config). Available function_ids: {:?}",
930            DESCRIPTOR_FUNCTION_ID,
931            functions_x_graphs.iter().map(|f| &f.function_id).collect::<Vec<_>>()
932        ));
933        None
934    }
935
936    /// Extract placeholders from template using regex pattern
937    /// Finds patterns like <Node Name> in the template string
938    pub fn extract_placeholders(template: &str) -> Vec<String> {
939        // Pattern matches: <[A-Za-z _-]+>
940        // For now, use a simple manual parser to avoid regex dependency
941        let mut placeholders = Vec::new();
942        let mut in_placeholder = false;
943        let mut current = String::new();
944
945        for ch in template.chars() {
946            if ch == '<' {
947                in_placeholder = true;
948                current.clear();
949                current.push(ch);
950            } else if ch == '>' && in_placeholder {
951                current.push(ch);
952                placeholders.push(current.clone());
953                in_placeholder = false;
954                current.clear();
955            } else if in_placeholder {
956                current.push(ch);
957            }
958        }
959
960        placeholders
961    }
962
963    /// Find node by name (across all nodegroups)
964    fn find_node_by_name(&self, name: &str) -> Option<&StaticNode> {
965        self.nodes_by_id.values().find(|node| node.name == name)
966    }
967
968    /// Extract a display string from tiles for a given node, using:
969    /// 1. Cache lookup (for resource-instance / resource-instance-list, uses titles from __cache)
970    /// 2. Extension render_display (for extension datatypes like "reference")
971    /// 3. Built-in serialize_display (for string, number, date, concept, etc.)
972    /// 4. Fallback to extract_string_from_json (language maps, Arches format)
973    fn extract_display_value_from_tiles(
974        tiles: &[&StaticTile],
975        node_id: &str,
976        datatype: &str,
977        cache: Option<&super::resources::ResourceCache>,
978        extension_registry: Option<&crate::extension_type_registry::ExtensionTypeRegistry>,
979    ) -> Option<String> {
980        // For resource-instance / resource-instance-list, look up display names from __cache
981        // (mirrors how TS ViewModels use __cache entries with title for display)
982        if let Some(cache) = cache {
983            if datatype == "resource-instance" || datatype == "resource-instance-list" {
984                for tile in tiles {
985                    if let Some(tile_id) = &tile.tileid {
986                        if let Some(node_entries) = cache.get(tile_id) {
987                            if let Some(entry) = node_entries.get(node_id) {
988                                return Self::display_from_cache_entry(entry);
989                            }
990                        }
991                    }
992                }
993            }
994        }
995
996        for tile in tiles {
997            if let Some(value) = tile.data.get(node_id) {
998                // 1. Try extension render_display (optional — not all extensions have it)
999                if let Some(registry) = extension_registry {
1000                    if let Ok(Some(display)) = registry.render_display(datatype, value, "en") {
1001                        return Some(display);
1002                    }
1003                }
1004
1005                // 2. Try built-in type serialization
1006                let result = crate::type_serialization::serialize_display(datatype, value, "en");
1007                if !result.is_error() {
1008                    match &result.value {
1009                        serde_json::Value::String(s) if !s.is_empty() => return Some(s.clone()),
1010                        serde_json::Value::Number(n) => return Some(n.to_string()),
1011                        serde_json::Value::Bool(b) => return Some(b.to_string()),
1012                        _ => {}
1013                    }
1014                }
1015
1016                // 3. Fallback to raw JSON extraction (language maps, Arches format)
1017                if let Some(extracted) = Self::extract_string_from_json(value) {
1018                    return Some(extracted);
1019                }
1020            }
1021        }
1022        None
1023    }
1024
1025    /// Extract display string from a cache entry (title, or comma-separated titles for lists)
1026    fn display_from_cache_entry(entry: &super::resources::CacheEntry) -> Option<String> {
1027        match entry {
1028            super::resources::CacheEntry::Single(r) => r.title.clone(),
1029            super::resources::CacheEntry::List(list) => {
1030                let titles: Vec<&str> = list
1031                    .entries
1032                    .iter()
1033                    .filter_map(|e| e.title.as_deref())
1034                    .collect();
1035                if titles.is_empty() {
1036                    None
1037                } else {
1038                    Some(titles.join(", "))
1039                }
1040            }
1041        }
1042    }
1043
1044    /// Extract string value from JSON, handling language-nested objects
1045    /// Handles:
1046    /// - Simple strings: "value"
1047    /// - Language objects: {"en": "value"}
1048    /// - Arches localized strings: {"en": {"direction": "ltr", "value": "actual value"}}
1049    fn extract_string_from_json(value: &serde_json::Value) -> Option<String> {
1050        match value {
1051            serde_json::Value::String(s) => Some(s.clone()),
1052            serde_json::Value::Number(n) => Some(n.to_string()),
1053            serde_json::Value::Bool(b) => Some(b.to_string()),
1054            serde_json::Value::Object(map) => {
1055                // Try to get language-specific value
1056                Self::extract_lang_value(map, "en").or_else(|| {
1057                    // Fallback to first available language
1058                    map.values().find_map(Self::extract_single_lang_value)
1059                })
1060            }
1061            _ => None,
1062        }
1063    }
1064
1065    /// Extract value for a specific language key
1066    fn extract_lang_value(
1067        map: &serde_json::Map<String, serde_json::Value>,
1068        lang: &str,
1069    ) -> Option<String> {
1070        map.get(lang).and_then(Self::extract_single_lang_value)
1071    }
1072
1073    /// Extract string from a single language value, handling both formats:
1074    /// - Direct string: "value"
1075    /// - Arches format: {"direction": "ltr", "value": "actual value"}
1076    fn extract_single_lang_value(value: &serde_json::Value) -> Option<String> {
1077        match value {
1078            serde_json::Value::String(s) => Some(s.clone()),
1079            serde_json::Value::Object(obj) => {
1080                // Arches localized string format: {"direction": "...", "value": "..."}
1081                obj.get("value")
1082                    .and_then(|v| v.as_str())
1083                    .map(|s| s.to_string())
1084            }
1085            _ => None,
1086        }
1087    }
1088}