Skip to main content

datasynth_core/models/
graph_properties.rs

1//! Graph property mapping trait and types for node export.
2//!
3//! Provides a `ToNodeProperties` trait that each model struct implements
4//! to map typed Rust fields to camelCase graph properties expected by
5//! downstream consumers (e.g. AssureTwin).
6
7use chrono::NaiveDate;
8use rust_decimal::Decimal;
9use std::collections::HashMap;
10
11/// Property value for graph node export.
12///
13/// Mirrors `datasynth-graph` `NodeProperty` but lives in `datasynth-core`
14/// to avoid circular dependencies.
15#[derive(Debug, Clone, PartialEq)]
16pub enum GraphPropertyValue {
17    String(String),
18    Int(i64),
19    Float(f64),
20    Decimal(Decimal),
21    Bool(bool),
22    Date(NaiveDate),
23    StringList(Vec<String>),
24}
25
26impl GraphPropertyValue {
27    /// Convert any variant to a string representation.
28    pub fn to_string_value(&self) -> String {
29        match self {
30            Self::String(s) => s.clone(),
31            Self::Int(i) => i.to_string(),
32            Self::Float(f) => format!("{f:.6}"),
33            Self::Decimal(d) => d.to_string(),
34            Self::Bool(b) => b.to_string(),
35            Self::Date(d) => format!("{d}T00:00:00Z"),
36            Self::StringList(v) => v.join(";"),
37        }
38    }
39
40    /// Try to extract a string reference.
41    pub fn as_str(&self) -> Option<&str> {
42        match self {
43            Self::String(s) => Some(s),
44            _ => None,
45        }
46    }
47
48    /// Try to extract a bool value.
49    pub fn as_bool(&self) -> Option<bool> {
50        match self {
51            Self::Bool(b) => Some(*b),
52            _ => None,
53        }
54    }
55
56    /// Try to extract a Decimal value.
57    pub fn as_decimal(&self) -> Option<Decimal> {
58        match self {
59            Self::Decimal(d) => Some(*d),
60            _ => None,
61        }
62    }
63
64    /// Try to extract an i64 value.
65    pub fn as_int(&self) -> Option<i64> {
66        match self {
67            Self::Int(i) => Some(*i),
68            _ => None,
69        }
70    }
71
72    /// Try to extract an f64 value.
73    pub fn as_float(&self) -> Option<f64> {
74        match self {
75            Self::Float(f) => Some(*f),
76            _ => None,
77        }
78    }
79
80    /// Try to extract a date value.
81    pub fn as_date(&self) -> Option<NaiveDate> {
82        match self {
83            Self::Date(d) => Some(*d),
84            _ => None,
85        }
86    }
87}
88
89/// Convert a CamelCase or PascalCase string to snake_case.
90///
91/// Examples: `"CosoComponent"` → `"coso_component"`, `"P2PPool"` → `"p2p_pool"`.
92pub fn camel_to_snake(s: &str) -> String {
93    let mut result = String::with_capacity(s.len() + 4);
94    let chars: Vec<char> = s.chars().collect();
95    for (i, &c) in chars.iter().enumerate() {
96        if c.is_uppercase() {
97            // Insert underscore before uppercase if:
98            // - not the first character, AND
99            // - previous char is lowercase, OR next char is lowercase (for "XMLParser" → "xml_parser")
100            if i > 0 {
101                let prev_lower = chars[i - 1].is_lowercase();
102                let next_lower = chars.get(i + 1).is_some_and(|nc| nc.is_lowercase());
103                if prev_lower || (next_lower && chars[i - 1].is_uppercase()) {
104                    result.push('_');
105                }
106            }
107            result.push(c.to_lowercase().next().unwrap_or(c));
108        } else {
109            result.push(c);
110        }
111    }
112    result
113}
114
115/// Trait for converting typed model structs to graph node property maps.
116///
117/// Implementations map struct fields to camelCase property keys matching
118/// downstream consumer (AssureTwin) DTO expectations.
119pub trait ToNodeProperties {
120    /// Entity type name (snake_case), e.g. `"uncertain_tax_position"`.
121    fn node_type_name(&self) -> &'static str;
122
123    /// Numeric entity type code for registry, e.g. `416`.
124    fn node_type_code(&self) -> u16;
125
126    /// Convert all fields to a property map with camelCase keys.
127    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue>;
128}
129
130#[cfg(test)]
131#[allow(clippy::unwrap_used, clippy::approx_constant)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_graph_property_value_to_string() {
137        assert_eq!(GraphPropertyValue::Bool(true).to_string_value(), "true");
138        assert_eq!(GraphPropertyValue::Bool(false).to_string_value(), "false");
139        assert_eq!(GraphPropertyValue::Int(42).to_string_value(), "42");
140        assert_eq!(GraphPropertyValue::Int(-7).to_string_value(), "-7");
141        assert_eq!(
142            GraphPropertyValue::String("hello".into()).to_string_value(),
143            "hello"
144        );
145        assert_eq!(
146            GraphPropertyValue::Float(3.14).to_string_value(),
147            "3.140000"
148        );
149        assert_eq!(
150            GraphPropertyValue::Decimal(Decimal::new(1234, 2)).to_string_value(),
151            "12.34"
152        );
153        assert_eq!(
154            GraphPropertyValue::Date(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap())
155                .to_string_value(),
156            "2024-01-15T00:00:00Z"
157        );
158        assert_eq!(
159            GraphPropertyValue::StringList(vec!["a".into(), "b".into(), "c".into()])
160                .to_string_value(),
161            "a;b;c"
162        );
163    }
164
165    #[test]
166    fn test_accessor_methods() {
167        assert_eq!(
168            GraphPropertyValue::String("test".into()).as_str(),
169            Some("test")
170        );
171        assert_eq!(GraphPropertyValue::Int(42).as_str(), None);
172        assert_eq!(GraphPropertyValue::Bool(true).as_bool(), Some(true));
173        assert_eq!(GraphPropertyValue::String("x".into()).as_bool(), None);
174        assert_eq!(
175            GraphPropertyValue::Decimal(Decimal::new(100, 0)).as_decimal(),
176            Some(Decimal::new(100, 0))
177        );
178        assert_eq!(GraphPropertyValue::Bool(true).as_decimal(), None);
179        assert_eq!(GraphPropertyValue::Int(99).as_int(), Some(99));
180        assert_eq!(GraphPropertyValue::Float(1.5).as_float(), Some(1.5));
181        let d = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
182        assert_eq!(GraphPropertyValue::Date(d).as_date(), Some(d));
183    }
184
185    #[test]
186    fn test_empty_string_list() {
187        assert_eq!(GraphPropertyValue::StringList(vec![]).to_string_value(), "");
188    }
189
190    #[test]
191    fn test_date_rfc3339_format() {
192        let d = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap();
193        assert_eq!(
194            GraphPropertyValue::Date(d).to_string_value(),
195            "2024-12-01T00:00:00Z"
196        );
197    }
198
199    #[test]
200    fn test_camel_to_snake_basic() {
201        assert_eq!(super::camel_to_snake("CosoComponent"), "coso_component");
202        assert_eq!(super::camel_to_snake("InternalControl"), "internal_control");
203        assert_eq!(super::camel_to_snake("Account"), "account");
204        assert_eq!(super::camel_to_snake("JournalEntry"), "journal_entry");
205        assert_eq!(super::camel_to_snake("PurchaseOrder"), "purchase_order");
206        assert_eq!(super::camel_to_snake("SoxAssertion"), "sox_assertion");
207    }
208
209    #[test]
210    fn test_camel_to_snake_consecutive_uppercase() {
211        assert_eq!(super::camel_to_snake("P2PPool"), "p2p_pool");
212        assert_eq!(super::camel_to_snake("O2CPool"), "o2c_pool");
213        assert_eq!(super::camel_to_snake("BankTransaction"), "bank_transaction");
214    }
215
216    #[test]
217    fn test_camel_to_snake_already_snake() {
218        assert_eq!(super::camel_to_snake("already_snake"), "already_snake");
219        assert_eq!(super::camel_to_snake("vendor"), "vendor");
220    }
221
222    #[test]
223    fn test_camel_to_snake_single_word() {
224        assert_eq!(super::camel_to_snake("Vendor"), "vendor");
225        assert_eq!(super::camel_to_snake("Employee"), "employee");
226    }
227}