Skip to main content

alizarin_core/
node_config.rs

1/// Node configuration types and manager for type coercion.
2///
3/// This module provides platform-agnostic types for:
4/// - StaticDomainValue: Domain value option from graph config
5/// - NodeConfig: Enum of config types (Domain, Boolean, Concept, Reference)
6/// - NodeConfigManager: Cache and lookup for node configs
7///
8/// These types can be used from:
9/// - TypeScript via WASM bindings
10/// - Python via PyO3 bindings
11/// - Native Rust applications
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15use crate::graph::StaticGraph;
16
17// =============================================================================
18// Domain Value Types
19// =============================================================================
20
21/// A domain value option from node config.
22///
23/// Represents a selectable option with id, selected state, and i18n labels.
24#[derive(Clone, Debug, Serialize, Deserialize, Default)]
25pub struct StaticDomainValue {
26    /// Unique identifier for this value
27    pub id: String,
28    /// Whether this is the selected/default value
29    #[serde(default)]
30    pub selected: bool,
31    /// Translatable text labels by language code
32    #[serde(default)]
33    pub text: HashMap<String, String>,
34}
35
36impl StaticDomainValue {
37    /// Create a new domain value
38    pub fn new(id: String, selected: bool, text: HashMap<String, String>) -> Self {
39        Self { id, selected, text }
40    }
41
42    /// Get text for a specific language, with fallback
43    pub fn lang(&self, language: &str) -> Option<&str> {
44        self.text
45            .get(language)
46            .or_else(|| self.text.get("en"))
47            .or_else(|| self.text.values().next())
48            .map(|s| s.as_str())
49    }
50
51    /// Get display string (defaults to English)
52    pub fn display(&self) -> &str {
53        self.lang("en").unwrap_or_default()
54    }
55}
56
57// =============================================================================
58// Node Config Types
59// =============================================================================
60
61/// Boolean node configuration with true/false labels.
62#[derive(Clone, Debug, Serialize, Deserialize, Default)]
63pub struct NodeConfigBoolean {
64    /// Language code -> label for true value
65    #[serde(default, rename = "trueLabel")]
66    pub true_label: HashMap<String, String>,
67    /// Language code -> label for false value
68    #[serde(default, rename = "falseLabel")]
69    pub false_label: HashMap<String, String>,
70    /// i18n property names
71    #[serde(default)]
72    pub i18n_properties: Vec<String>,
73}
74
75impl NodeConfigBoolean {
76    /// Create a new boolean config
77    pub fn new(true_label: HashMap<String, String>, false_label: HashMap<String, String>) -> Self {
78        Self {
79            true_label,
80            false_label,
81            i18n_properties: vec![],
82        }
83    }
84
85    /// Get label for a boolean value in a specific language
86    pub fn get_label(&self, value: bool, language: &str) -> Option<&str> {
87        let labels = if value {
88            &self.true_label
89        } else {
90            &self.false_label
91        };
92        labels
93            .get(language)
94            .or_else(|| labels.get("en"))
95            .or_else(|| labels.values().next())
96            .map(|s| s.as_str())
97    }
98}
99
100/// Concept node configuration (RDM collection reference).
101#[derive(Clone, Debug, Serialize, Deserialize, Default)]
102pub struct NodeConfigConcept {
103    /// RDM collection ID
104    #[serde(default, rename = "rdmCollection")]
105    pub rdm_collection: String,
106}
107
108impl NodeConfigConcept {
109    /// Create a new concept config
110    pub fn new(rdm_collection: String) -> Self {
111        Self { rdm_collection }
112    }
113}
114
115/// Reference node configuration (CLM controlled list).
116#[derive(Clone, Debug, Serialize, Deserialize, Default)]
117pub struct NodeConfigReference {
118    /// Controlled list ID
119    #[serde(default, rename = "controlledList")]
120    pub controlled_list: String,
121    /// RDM collection ID (alternative)
122    #[serde(default, rename = "rdmCollection")]
123    pub rdm_collection: String,
124    /// Whether multiple values are allowed
125    #[serde(default, rename = "multiValue")]
126    pub multi_value: bool,
127}
128
129impl NodeConfigReference {
130    /// Create a new reference config
131    pub fn new(controlled_list: String, rdm_collection: String, multi_value: bool) -> Self {
132        Self {
133            controlled_list,
134            rdm_collection,
135            multi_value,
136        }
137    }
138
139    /// Get the collection ID (controlledList takes precedence over rdmCollection)
140    pub fn get_collection_id(&self) -> Option<&str> {
141        if !self.controlled_list.is_empty() {
142            Some(&self.controlled_list)
143        } else if !self.rdm_collection.is_empty() {
144            Some(&self.rdm_collection)
145        } else {
146            None
147        }
148    }
149}
150
151/// Domain value node configuration with options list.
152#[derive(Clone, Debug, Serialize, Deserialize, Default)]
153pub struct NodeConfigDomain {
154    /// Available domain value options
155    #[serde(default)]
156    pub options: Vec<StaticDomainValue>,
157    /// i18n configuration
158    #[serde(default)]
159    pub i18n_config: HashMap<String, String>,
160}
161
162impl NodeConfigDomain {
163    /// Create a new domain config
164    pub fn new(options: Vec<StaticDomainValue>) -> Self {
165        Self {
166            options,
167            i18n_config: HashMap::new(),
168        }
169    }
170
171    /// Get the selected option (if any)
172    pub fn get_selected(&self) -> Option<&StaticDomainValue> {
173        self.options.iter().find(|opt| opt.selected)
174    }
175
176    /// Find option by ID
177    pub fn value_from_id(&self, id: &str) -> Option<&StaticDomainValue> {
178        self.options.iter().find(|opt| opt.id == id)
179    }
180
181    /// Get all option IDs
182    pub fn get_option_ids(&self) -> Vec<&str> {
183        self.options.iter().map(|opt| opt.id.as_str()).collect()
184    }
185}
186
187// =============================================================================
188// Node Config Enum
189// =============================================================================
190
191/// Enum of all node config types
192#[derive(Clone, Debug)]
193pub enum NodeConfig {
194    Boolean(NodeConfigBoolean),
195    Concept(NodeConfigConcept),
196    Reference(NodeConfigReference),
197    Domain(NodeConfigDomain),
198}
199
200impl NodeConfig {
201    /// Get as boolean config
202    pub fn as_boolean(&self) -> Option<&NodeConfigBoolean> {
203        match self {
204            NodeConfig::Boolean(c) => Some(c),
205            _ => None,
206        }
207    }
208
209    /// Get as concept config
210    pub fn as_concept(&self) -> Option<&NodeConfigConcept> {
211        match self {
212            NodeConfig::Concept(c) => Some(c),
213            _ => None,
214        }
215    }
216
217    /// Get as reference config
218    pub fn as_reference(&self) -> Option<&NodeConfigReference> {
219        match self {
220            NodeConfig::Reference(c) => Some(c),
221            _ => None,
222        }
223    }
224
225    /// Get as domain config
226    pub fn as_domain(&self) -> Option<&NodeConfigDomain> {
227        match self {
228            NodeConfig::Domain(c) => Some(c),
229            _ => None,
230        }
231    }
232
233    /// Get the config type name
234    pub fn type_name(&self) -> &'static str {
235        match self {
236            NodeConfig::Boolean(_) => "boolean",
237            NodeConfig::Concept(_) => "concept",
238            NodeConfig::Reference(_) => "reference",
239            NodeConfig::Domain(_) => "domain-value",
240        }
241    }
242}
243
244// =============================================================================
245// Node Config Manager
246// =============================================================================
247
248/// Manager for caching and retrieving node configurations.
249///
250/// Builds configs from graph node data and caches them by node ID.
251#[derive(Debug, Default)]
252pub struct NodeConfigManager {
253    /// Cache of configs by node ID
254    configs: HashMap<String, NodeConfig>,
255}
256
257impl NodeConfigManager {
258    /// Create a new empty manager
259    pub fn new() -> Self {
260        Self {
261            configs: HashMap::new(),
262        }
263    }
264
265    /// Build configs from a graph
266    pub fn build_from_graph(&mut self, graph: &StaticGraph) {
267        for node in graph.nodes.iter() {
268            if let Some(config) = self.build_config_for_node(&node.datatype, &node.config) {
269                self.configs.insert(node.nodeid.clone(), config);
270            }
271        }
272    }
273
274    /// Build configs from a JSON string
275    pub fn from_graph_json(&mut self, graph_json: &str) -> Result<(), String> {
276        let graph: StaticGraph = serde_json::from_str(graph_json)
277            .map_err(|e| format!("Failed to parse graph: {}", e))?;
278
279        self.build_from_graph(&graph);
280        Ok(())
281    }
282
283    /// Get boolean config for a node
284    pub fn get_boolean(&self, nodeid: &str) -> Option<&NodeConfigBoolean> {
285        self.configs.get(nodeid).and_then(|c| c.as_boolean())
286    }
287
288    /// Get concept config for a node
289    pub fn get_concept(&self, nodeid: &str) -> Option<&NodeConfigConcept> {
290        self.configs.get(nodeid).and_then(|c| c.as_concept())
291    }
292
293    /// Get reference config for a node
294    pub fn get_reference(&self, nodeid: &str) -> Option<&NodeConfigReference> {
295        self.configs.get(nodeid).and_then(|c| c.as_reference())
296    }
297
298    /// Get domain config for a node
299    pub fn get_domain(&self, nodeid: &str) -> Option<&NodeConfigDomain> {
300        self.configs.get(nodeid).and_then(|c| c.as_domain())
301    }
302
303    /// Look up domain value by ID
304    pub fn lookup_domain_value(&self, nodeid: &str, value_id: &str) -> Option<&StaticDomainValue> {
305        self.configs
306            .get(nodeid)
307            .and_then(|c| c.as_domain())
308            .and_then(|d| d.value_from_id(value_id))
309    }
310
311    /// Check if a node has config
312    pub fn has_config(&self, nodeid: &str) -> bool {
313        self.configs.contains_key(nodeid)
314    }
315
316    /// Get the config type for a node
317    pub fn get_config_type(&self, nodeid: &str) -> Option<&'static str> {
318        self.configs.get(nodeid).map(|c| c.type_name())
319    }
320
321    /// Get config for a node
322    pub fn get(&self, nodeid: &str) -> Option<&NodeConfig> {
323        self.configs.get(nodeid)
324    }
325
326    /// Clear all cached configs
327    pub fn clear(&mut self) {
328        self.configs.clear();
329    }
330
331    /// Get number of cached configs
332    pub fn len(&self) -> usize {
333        self.configs.len()
334    }
335
336    /// Check if empty
337    pub fn is_empty(&self) -> bool {
338        self.configs.is_empty()
339    }
340
341    /// Build config for a single node based on its datatype
342    fn build_config_for_node(
343        &self,
344        datatype: &str,
345        config: &HashMap<String, serde_json::Value>,
346    ) -> Option<NodeConfig> {
347        match datatype {
348            "boolean" => {
349                let cfg = self.parse_boolean_config(config);
350                Some(NodeConfig::Boolean(cfg))
351            }
352            "domain-value" | "domain-value-list" => {
353                let cfg = self.parse_domain_config(config);
354                Some(NodeConfig::Domain(cfg))
355            }
356            "concept" | "concept-list" => {
357                let cfg = self.parse_concept_config(config);
358                Some(NodeConfig::Concept(cfg))
359            }
360            "reference" => {
361                let cfg = self.parse_reference_config(config);
362                Some(NodeConfig::Reference(cfg))
363            }
364            _ => None,
365        }
366    }
367
368    /// Parse boolean config from node.config HashMap
369    fn parse_boolean_config(
370        &self,
371        config: &HashMap<String, serde_json::Value>,
372    ) -> NodeConfigBoolean {
373        let true_label = config
374            .get("trueLabel")
375            .and_then(|v| serde_json::from_value(v.clone()).ok())
376            .unwrap_or_default();
377
378        let false_label = config
379            .get("falseLabel")
380            .and_then(|v| serde_json::from_value(v.clone()).ok())
381            .unwrap_or_default();
382
383        let i18n_properties = config
384            .get("i18n_properties")
385            .and_then(|v| serde_json::from_value(v.clone()).ok())
386            .unwrap_or_default();
387
388        NodeConfigBoolean {
389            true_label,
390            false_label,
391            i18n_properties,
392        }
393    }
394
395    /// Parse domain config from node.config HashMap
396    fn parse_domain_config(&self, config: &HashMap<String, serde_json::Value>) -> NodeConfigDomain {
397        let options: Vec<StaticDomainValue> = config
398            .get("options")
399            .and_then(|v| serde_json::from_value(v.clone()).ok())
400            .unwrap_or_default();
401
402        let i18n_config = config
403            .get("i18n_config")
404            .and_then(|v| serde_json::from_value(v.clone()).ok())
405            .unwrap_or_default();
406
407        NodeConfigDomain {
408            options,
409            i18n_config,
410        }
411    }
412
413    /// Parse concept config from node.config HashMap
414    fn parse_concept_config(
415        &self,
416        config: &HashMap<String, serde_json::Value>,
417    ) -> NodeConfigConcept {
418        let rdm_collection = config
419            .get("rdmCollection")
420            .and_then(|v| v.as_str())
421            .unwrap_or("")
422            .to_string();
423
424        NodeConfigConcept { rdm_collection }
425    }
426
427    /// Parse reference config from node.config HashMap
428    fn parse_reference_config(
429        &self,
430        config: &HashMap<String, serde_json::Value>,
431    ) -> NodeConfigReference {
432        let controlled_list = config
433            .get("controlledList")
434            .and_then(|v| v.as_str())
435            .unwrap_or("")
436            .to_string();
437
438        let rdm_collection = config
439            .get("rdmCollection")
440            .and_then(|v| v.as_str())
441            .unwrap_or("")
442            .to_string();
443
444        let multi_value = config
445            .get("multiValue")
446            .and_then(|v| v.as_bool())
447            .unwrap_or(false);
448
449        NodeConfigReference {
450            controlled_list,
451            rdm_collection,
452            multi_value,
453        }
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn test_static_domain_value() {
463        let mut text = HashMap::new();
464        text.insert("en".to_string(), "English".to_string());
465        text.insert("es".to_string(), "Español".to_string());
466
467        let dv = StaticDomainValue::new("test-id".to_string(), true, text);
468
469        assert_eq!(dv.id, "test-id");
470        assert!(dv.selected);
471        assert_eq!(dv.lang("en"), Some("English"));
472        assert_eq!(dv.lang("es"), Some("Español"));
473        assert_eq!(dv.lang("de"), Some("English")); // Falls back to en
474    }
475
476    #[test]
477    fn test_node_config_boolean() {
478        let mut true_label = HashMap::new();
479        true_label.insert("en".to_string(), "Yes".to_string());
480
481        let mut false_label = HashMap::new();
482        false_label.insert("en".to_string(), "No".to_string());
483
484        let config = NodeConfigBoolean::new(true_label, false_label);
485
486        assert_eq!(config.get_label(true, "en"), Some("Yes"));
487        assert_eq!(config.get_label(false, "en"), Some("No"));
488    }
489
490    #[test]
491    fn test_node_config_reference() {
492        let config1 = NodeConfigReference::new("clm-id".to_string(), "".to_string(), false);
493        assert_eq!(config1.get_collection_id(), Some("clm-id"));
494
495        let config2 = NodeConfigReference::new("".to_string(), "rdm-id".to_string(), true);
496        assert_eq!(config2.get_collection_id(), Some("rdm-id"));
497    }
498
499    #[test]
500    fn test_node_config_domain() {
501        let opt1 = StaticDomainValue::new("opt-1".to_string(), false, HashMap::new());
502        let opt2 = StaticDomainValue::new("opt-2".to_string(), true, HashMap::new());
503
504        let config = NodeConfigDomain::new(vec![opt1, opt2]);
505
506        assert_eq!(config.options.len(), 2);
507        assert_eq!(config.get_selected().map(|o| o.id.as_str()), Some("opt-2"));
508        assert!(config.value_from_id("opt-1").is_some());
509    }
510}