Skip to main content

alizarin_core/graph/
card_index.rs

1//! Card hierarchy index for UI-oriented traversal.
2//!
3//! Builds a pre-computed index of the card tree from graph metadata,
4//! enabling card-based traversal as an alternative to node/edge traversal.
5
6use std::collections::HashMap;
7
8use super::cards::{StaticCard, StaticCardsXNodesXWidgets};
9use super::nodes::StaticNodegroup;
10use super::translatable::StaticTranslatableString;
11use super::StaticNode;
12
13/// A reference to a widget entry within a card, with resolved metadata.
14#[derive(Debug, Clone)]
15pub struct CardWidgetRef {
16    pub node_id: String,
17    pub node_alias: String,
18    pub widget_id: String,
19    /// Resolved widget name (e.g. "text-widget"), or empty if unknown
20    pub widget_name: String,
21    pub label: StaticTranslatableString,
22    pub config: serde_json::Value,
23    pub sortorder: i32,
24    pub visible: bool,
25}
26
27/// Pre-computed index of the card hierarchy for a graph.
28///
29/// Built once at graph load time. Enables card-based traversal that
30/// reuses the existing pseudo_cache (no data duplication).
31#[derive(Debug, Clone)]
32pub struct CardIndex {
33    /// card_id -> child card_ids, sorted by child card sortorder
34    pub card_children: HashMap<String, Vec<String>>,
35    /// Root cards (nodegroup has no parentnodegroup_id), sorted by sortorder
36    pub root_card_ids: Vec<String>,
37    /// nodegroup_id -> card_id
38    pub card_by_nodegroup: HashMap<String, String>,
39    /// card_id -> widget entries, sorted by sortorder
40    pub widgets_by_card: HashMap<String, Vec<CardWidgetRef>>,
41    /// node_id -> node alias
42    pub alias_by_node_id: HashMap<String, String>,
43    /// card_id -> StaticCard reference data (name, component_id, etc.)
44    pub cards_by_id: HashMap<String, CardRef>,
45}
46
47/// Lightweight card reference for traversal output.
48#[derive(Debug, Clone)]
49pub struct CardRef {
50    pub cardid: String,
51    pub name: StaticTranslatableString,
52    pub component_id: String,
53    pub nodegroup_id: String,
54    pub sortorder: Option<i32>,
55    pub visible: bool,
56    pub active: bool,
57}
58
59impl From<&StaticCard> for CardRef {
60    fn from(card: &StaticCard) -> Self {
61        CardRef {
62            cardid: card.cardid.clone(),
63            name: card.name.clone(),
64            component_id: card.component_id.clone(),
65            nodegroup_id: card.nodegroup_id.clone(),
66            sortorder: card.sortorder,
67            visible: card.visible,
68            active: card.active,
69        }
70    }
71}
72
73impl CardIndex {
74    /// Collect nodegroup IDs needed to serialize a card (and its descendants).
75    ///
76    /// `max_depth`: `None` = all descendants, `Some(0)` = this card only,
77    /// `Some(1)` = this card + immediate children, etc.
78    pub fn nodegroup_ids_for_card(&self, card_id: &str, max_depth: Option<usize>) -> Vec<String> {
79        let mut result = Vec::new();
80        self.collect_nodegroups(card_id, max_depth, &mut result);
81        result
82    }
83
84    /// Collect nodegroup IDs for all root cards (and their descendants).
85    pub fn nodegroup_ids_for_roots(&self, max_depth: Option<usize>) -> Vec<String> {
86        let mut result = Vec::new();
87        for card_id in &self.root_card_ids {
88            self.collect_nodegroups(card_id, max_depth, &mut result);
89        }
90        result
91    }
92
93    fn collect_nodegroups(&self, card_id: &str, max_depth: Option<usize>, out: &mut Vec<String>) {
94        if let Some(card) = self.cards_by_id.get(card_id) {
95            if !out.contains(&card.nodegroup_id) {
96                out.push(card.nodegroup_id.clone());
97            }
98
99            if max_depth == Some(0) {
100                return;
101            }
102
103            let child_depth = max_depth.map(|d| d.saturating_sub(1));
104            if let Some(children) = self.card_children.get(card_id) {
105                for child_id in children {
106                    self.collect_nodegroups(child_id, child_depth, out);
107                }
108            }
109        }
110    }
111}
112
113/// Build a CardIndex from graph components.
114///
115/// The `widget_name_resolver` function maps widget_id -> widget_name.
116/// Pass `crate::graph_mutator::get_widget_name_by_id` for the default resolver.
117pub fn build_card_index(
118    cards: &[StaticCard],
119    cxnxws: &[StaticCardsXNodesXWidgets],
120    nodegroups: &[StaticNodegroup],
121    nodes: &[StaticNode],
122    widget_name_resolver: impl Fn(&str) -> Option<String>,
123) -> CardIndex {
124    // 1. Build alias_by_node_id
125    let alias_by_node_id: HashMap<String, String> = nodes
126        .iter()
127        .filter_map(|n| {
128            n.alias
129                .as_ref()
130                .filter(|a| !a.is_empty())
131                .map(|a| (n.nodeid.clone(), a.clone()))
132        })
133        .collect();
134
135    // 2. Build card_by_nodegroup and cards_by_id
136    let mut card_by_nodegroup: HashMap<String, String> = HashMap::new();
137    let mut cards_by_id: HashMap<String, CardRef> = HashMap::new();
138    for card in cards {
139        card_by_nodegroup.insert(card.nodegroup_id.clone(), card.cardid.clone());
140        cards_by_id.insert(card.cardid.clone(), CardRef::from(card));
141    }
142
143    // 3. Build nodegroup lookup
144    let ng_by_id: HashMap<&str, &StaticNodegroup> = nodegroups
145        .iter()
146        .map(|ng| (ng.nodegroupid.as_str(), ng))
147        .collect();
148
149    // 4. Build card_children and root_card_ids
150    let mut card_children: HashMap<String, Vec<String>> = HashMap::new();
151    let mut root_card_ids: Vec<String> = Vec::new();
152
153    for card in cards {
154        let ng = ng_by_id.get(card.nodegroup_id.as_str());
155        let parent_ng_id = ng.and_then(|ng| ng.parentnodegroup_id.as_ref());
156
157        match parent_ng_id {
158            Some(parent_ng) => {
159                if let Some(parent_card_id) = card_by_nodegroup.get(parent_ng) {
160                    card_children
161                        .entry(parent_card_id.clone())
162                        .or_default()
163                        .push(card.cardid.clone());
164                } else {
165                    // Parent nodegroup has no card — treat as root
166                    root_card_ids.push(card.cardid.clone());
167                }
168            }
169            None => {
170                root_card_ids.push(card.cardid.clone());
171            }
172        }
173    }
174
175    // Sort children by sortorder
176    let card_sortorder = |card_id: &str| -> i32 {
177        cards_by_id
178            .get(card_id)
179            .and_then(|c| c.sortorder)
180            .unwrap_or(0)
181    };
182    for children in card_children.values_mut() {
183        children.sort_by_key(|id| card_sortorder(id));
184    }
185    root_card_ids.sort_by_key(|id| card_sortorder(id));
186
187    // 5. Build widgets_by_card
188    let mut widgets_by_card: HashMap<String, Vec<CardWidgetRef>> = HashMap::new();
189    for cxnxw in cxnxws {
190        let node_alias = alias_by_node_id
191            .get(&cxnxw.node_id)
192            .cloned()
193            .unwrap_or_default();
194        let widget_name = widget_name_resolver(&cxnxw.widget_id).unwrap_or_default();
195
196        let widget_ref = CardWidgetRef {
197            node_id: cxnxw.node_id.clone(),
198            node_alias,
199            widget_id: cxnxw.widget_id.clone(),
200            widget_name,
201            label: cxnxw.label.clone(),
202            config: cxnxw.config.clone(),
203            sortorder: cxnxw.sortorder.unwrap_or(0),
204            visible: cxnxw.visible,
205        };
206
207        widgets_by_card
208            .entry(cxnxw.card_id.clone())
209            .or_default()
210            .push(widget_ref);
211    }
212
213    // Sort widgets by sortorder within each card
214    for widgets in widgets_by_card.values_mut() {
215        widgets.sort_by_key(|w| w.sortorder);
216    }
217
218    CardIndex {
219        card_children,
220        root_card_ids,
221        card_by_nodegroup,
222        widgets_by_card,
223        alias_by_node_id,
224        cards_by_id,
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::graph::translatable::StaticTranslatableString;
232
233    fn make_node(id: &str, alias: &str, ng_id: &str) -> StaticNode {
234        StaticNode {
235            nodeid: id.to_string(),
236            name: alias.to_string(),
237            alias: Some(alias.to_string()),
238            datatype: "string".to_string(),
239            nodegroup_id: Some(ng_id.to_string()),
240            graph_id: "test-graph".to_string(),
241            is_collector: false,
242            isrequired: false,
243            exportable: false,
244            sortorder: Some(0),
245            config: Default::default(),
246            parentproperty: None,
247            ontologyclass: None,
248            description: None,
249            fieldname: None,
250            hascustomalias: false,
251            issearchable: false,
252            istopnode: false,
253            sourcebranchpublication_id: None,
254            source_identifier_id: None,
255            is_immutable: None,
256        }
257    }
258
259    fn make_card(id: &str, name: &str, ng_id: &str, sortorder: i32) -> StaticCard {
260        StaticCard {
261            cardid: id.to_string(),
262            name: StaticTranslatableString::from_string(name),
263            nodegroup_id: ng_id.to_string(),
264            component_id: "default".to_string(),
265            graph_id: "test-graph".to_string(),
266            active: true,
267            visible: true,
268            sortorder: Some(sortorder),
269            config: None,
270            constraints: vec![],
271            cssclass: None,
272            description: None,
273            helpenabled: false,
274            helptext: StaticTranslatableString::empty(),
275            helptitle: StaticTranslatableString::empty(),
276            instructions: StaticTranslatableString::empty(),
277            is_editable: Some(true),
278            source_identifier_id: None,
279        }
280    }
281
282    fn make_ng(id: &str, parent: Option<&str>) -> StaticNodegroup {
283        StaticNodegroup {
284            nodegroupid: id.to_string(),
285            cardinality: Some("1".to_string()),
286            parentnodegroup_id: parent.map(|s| s.to_string()),
287            legacygroupid: None,
288            grouping_node_id: None,
289        }
290    }
291
292    fn make_cxnxw(
293        card_id: &str,
294        node_id: &str,
295        widget_id: &str,
296        sortorder: i32,
297    ) -> StaticCardsXNodesXWidgets {
298        StaticCardsXNodesXWidgets {
299            card_id: card_id.to_string(),
300            node_id: node_id.to_string(),
301            widget_id: widget_id.to_string(),
302            id: format!("cxnxw-{}-{}", card_id, node_id),
303            label: StaticTranslatableString::from_string("Label"),
304            config: serde_json::Value::Object(serde_json::Map::new()),
305            sortorder: Some(sortorder),
306            visible: true,
307            source_identifier_id: None,
308        }
309    }
310
311    #[test]
312    fn test_build_card_index_basic() {
313        let nodes = vec![
314            make_node("n1", "field_a", "ng1"),
315            make_node("n2", "field_b", "ng1"),
316            make_node("n3", "field_c", "ng2"),
317        ];
318        let nodegroups = vec![make_ng("ng1", None), make_ng("ng2", Some("ng1"))];
319        let cards = vec![
320            make_card("card1", "Parent Card", "ng1", 0),
321            make_card("card2", "Child Card", "ng2", 0),
322        ];
323        let cxnxws = vec![
324            make_cxnxw("card1", "n1", "widget-text", 0),
325            make_cxnxw("card1", "n2", "widget-concept", 1),
326            make_cxnxw("card2", "n3", "widget-date", 0),
327        ];
328
329        let index = build_card_index(&cards, &cxnxws, &nodegroups, &nodes, |wid| {
330            Some(format!("resolved-{}", wid))
331        });
332
333        // Root cards
334        assert_eq!(index.root_card_ids, vec!["card1"]);
335
336        // Card children
337        assert_eq!(
338            index.card_children.get("card1").unwrap(),
339            &vec!["card2".to_string()]
340        );
341        assert!(index.card_children.get("card2").is_none());
342
343        // Widgets
344        let card1_widgets = index.widgets_by_card.get("card1").unwrap();
345        assert_eq!(card1_widgets.len(), 2);
346        assert_eq!(card1_widgets[0].node_alias, "field_a");
347        assert_eq!(card1_widgets[1].node_alias, "field_b");
348
349        let card2_widgets = index.widgets_by_card.get("card2").unwrap();
350        assert_eq!(card2_widgets.len(), 1);
351        assert_eq!(card2_widgets[0].node_alias, "field_c");
352
353        // Alias reverse lookup
354        assert_eq!(index.alias_by_node_id.get("n1").unwrap(), "field_a");
355    }
356
357    #[test]
358    fn test_nodegroup_ids_for_card() {
359        let nodes = vec![
360            make_node("n1", "a", "ng1"),
361            make_node("n2", "b", "ng2"),
362            make_node("n3", "c", "ng3"),
363        ];
364        let nodegroups = vec![
365            make_ng("ng1", None),
366            make_ng("ng2", Some("ng1")),
367            make_ng("ng3", Some("ng2")),
368        ];
369        let cards = vec![
370            make_card("card1", "Root", "ng1", 0),
371            make_card("card2", "Child", "ng2", 0),
372            make_card("card3", "Grandchild", "ng3", 0),
373        ];
374        let index = build_card_index(&cards, &[], &nodegroups, &nodes, |_| None);
375
376        // Unlimited depth: all nodegroups
377        let all = index.nodegroup_ids_for_card("card1", None);
378        assert_eq!(all, vec!["ng1", "ng2", "ng3"]);
379
380        // Depth 0: just this card
381        let just_root = index.nodegroup_ids_for_card("card1", Some(0));
382        assert_eq!(just_root, vec!["ng1"]);
383
384        // Depth 1: root + immediate children
385        let one_level = index.nodegroup_ids_for_card("card1", Some(1));
386        assert_eq!(one_level, vec!["ng1", "ng2"]);
387
388        // From middle of tree
389        let from_child = index.nodegroup_ids_for_card("card2", None);
390        assert_eq!(from_child, vec!["ng2", "ng3"]);
391
392        // All roots
393        let roots = index.nodegroup_ids_for_roots(Some(0));
394        assert_eq!(roots, vec!["ng1"]);
395    }
396
397    #[test]
398    fn test_card_index_sortorder() {
399        let nodes = vec![
400            make_node("n1", "a", "ng1"),
401            make_node("n2", "b", "ng2"),
402            make_node("n3", "c", "ng3"),
403        ];
404        let nodegroups = vec![
405            make_ng("ng1", None),
406            make_ng("ng2", None),
407            make_ng("ng3", None),
408        ];
409        let cards = vec![
410            make_card("card1", "Third", "ng1", 2),
411            make_card("card2", "First", "ng2", 0),
412            make_card("card3", "Second", "ng3", 1),
413        ];
414        let cxnxws = vec![];
415
416        let index = build_card_index(&cards, &cxnxws, &nodegroups, &nodes, |_| None);
417
418        // Root cards should be sorted by sortorder
419        assert_eq!(index.root_card_ids, vec!["card2", "card3", "card1"]);
420    }
421}