Skip to main content

rustledger_plugin/native/plugins/
commodity_attr.rs

1//! Validate Commodity directives have required metadata attributes.
2
3use crate::types::{DirectiveData, PluginError, PluginInput, PluginOutput};
4
5use super::super::NativePlugin;
6
7/// Plugin that validates Commodity directives have required metadata attributes.
8///
9/// Can be configured with a string specifying required attributes and their allowed values:
10/// - `"{'name': null, 'sector': ['Tech', 'Finance']}"` means:
11///   - `name` is required but any value is allowed
12///   - `sector` is required and must be one of the allowed values
13pub struct CommodityAttrPlugin {
14    /// Required attributes and their allowed values (None means any value is allowed).
15    required_attrs: Vec<(String, Option<Vec<String>>)>,
16}
17
18impl CommodityAttrPlugin {
19    /// Create with default configuration (no required attributes).
20    pub const fn new() -> Self {
21        Self {
22            required_attrs: Vec::new(),
23        }
24    }
25
26    /// Create with required attributes.
27    pub const fn with_attrs(attrs: Vec<(String, Option<Vec<String>>)>) -> Self {
28        Self {
29            required_attrs: attrs,
30        }
31    }
32
33    /// Parse configuration string in Python dict-like format.
34    ///
35    /// Example: `"{'name': null, 'sector': ['Tech', 'Finance']}"`
36    fn parse_config(config: &str) -> Vec<(String, Option<Vec<String>>)> {
37        let mut result = Vec::new();
38
39        // Simple parser for the config format
40        // Strip outer braces and split by commas
41        let trimmed = config.trim();
42        let content = if trimmed.starts_with('{') && trimmed.ends_with('}') {
43            &trimmed[1..trimmed.len() - 1]
44        } else {
45            trimmed
46        };
47
48        // Split by comma (careful with nested arrays)
49        let mut depth = 0;
50        let mut current = String::new();
51        let mut entries = Vec::new();
52
53        for c in content.chars() {
54            match c {
55                '[' => {
56                    depth += 1;
57                    current.push(c);
58                }
59                ']' => {
60                    depth -= 1;
61                    current.push(c);
62                }
63                ',' if depth == 0 => {
64                    entries.push(current.trim().to_string());
65                    current.clear();
66                }
67                _ => current.push(c),
68            }
69        }
70        if !current.trim().is_empty() {
71            entries.push(current.trim().to_string());
72        }
73
74        // Parse each entry: "'key': value"
75        for entry in entries {
76            if let Some((key_part, value_part)) = entry.split_once(':') {
77                let key = key_part
78                    .trim()
79                    .trim_matches('\'')
80                    .trim_matches('"')
81                    .to_string();
82                let value = value_part.trim();
83
84                if value == "null" || value == "None" {
85                    result.push((key, None));
86                } else if value.starts_with('[') && value.ends_with(']') {
87                    // Parse array of allowed values
88                    let inner = &value[1..value.len() - 1];
89                    let allowed: Vec<String> = inner
90                        .split(',')
91                        .map(|s| s.trim().trim_matches('\'').trim_matches('"').to_string())
92                        .filter(|s| !s.is_empty())
93                        .collect();
94                    result.push((key, Some(allowed)));
95                }
96            }
97        }
98
99        result
100    }
101}
102
103impl Default for CommodityAttrPlugin {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl NativePlugin for CommodityAttrPlugin {
110    fn name(&self) -> &'static str {
111        "commodity_attr"
112    }
113
114    fn description(&self) -> &'static str {
115        "Validate commodity metadata attributes"
116    }
117
118    fn process(&self, input: PluginInput) -> PluginOutput {
119        // Parse config if provided
120        let required = if let Some(config) = &input.config {
121            Self::parse_config(config)
122        } else {
123            self.required_attrs.clone()
124        };
125
126        // If no required attributes configured, pass through
127        if required.is_empty() {
128            return PluginOutput {
129                directives: input.directives,
130                errors: Vec::new(),
131            };
132        }
133
134        let mut errors = Vec::new();
135
136        for wrapper in &input.directives {
137            if let DirectiveData::Commodity(comm) = &wrapper.data {
138                // Check each required attribute
139                for (attr_name, allowed_values) in &required {
140                    // Find the attribute in metadata
141                    let found = comm.metadata.iter().find(|(k, _)| k == attr_name);
142
143                    match found {
144                        None => {
145                            errors.push(PluginError::error(format!(
146                                "Commodity '{}' missing required attribute '{}'",
147                                comm.currency, attr_name
148                            )));
149                        }
150                        Some((_, value)) => {
151                            // Check if value is in allowed list (if specified)
152                            if let Some(allowed) = allowed_values {
153                                let value_str = match value {
154                                    crate::types::MetaValueData::String(s) => s.clone(),
155                                    other => format!("{other:?}"),
156                                };
157                                if !allowed.contains(&value_str) {
158                                    errors.push(PluginError::error(format!(
159                                        "Commodity '{}' attribute '{}' has invalid value '{}' (allowed: {:?})",
160                                        comm.currency, attr_name, value_str, allowed
161                                    )));
162                                }
163                            }
164                        }
165                    }
166                }
167            }
168        }
169
170        PluginOutput {
171            directives: input.directives,
172            errors,
173        }
174    }
175}
176
177#[cfg(test)]
178mod commodity_attr_tests {
179    use super::*;
180    use crate::types::*;
181
182    #[test]
183    fn test_commodity_attr_missing_required() {
184        let plugin = CommodityAttrPlugin::new();
185
186        let input = PluginInput {
187            directives: vec![DirectiveWrapper {
188                directive_type: "commodity".to_string(),
189                date: "2024-01-01".to_string(),
190                filename: None,
191                lineno: None,
192                data: DirectiveData::Commodity(CommodityData {
193                    currency: "AAPL".to_string(),
194                    metadata: vec![], // Missing 'name'
195                }),
196            }],
197            options: PluginOptions {
198                operating_currencies: vec!["USD".to_string()],
199                title: None,
200            },
201            config: Some("{'name': null}".to_string()),
202        };
203
204        let output = plugin.process(input);
205        assert_eq!(output.errors.len(), 1);
206        assert!(output.errors[0].message.contains("missing required"));
207        assert!(output.errors[0].message.contains("name"));
208    }
209
210    #[test]
211    fn test_commodity_attr_has_required() {
212        let plugin = CommodityAttrPlugin::new();
213
214        let input = PluginInput {
215            directives: vec![DirectiveWrapper {
216                directive_type: "commodity".to_string(),
217                date: "2024-01-01".to_string(),
218                filename: None,
219                lineno: None,
220                data: DirectiveData::Commodity(CommodityData {
221                    currency: "AAPL".to_string(),
222                    metadata: vec![(
223                        "name".to_string(),
224                        MetaValueData::String("Apple Inc".to_string()),
225                    )],
226                }),
227            }],
228            options: PluginOptions {
229                operating_currencies: vec!["USD".to_string()],
230                title: None,
231            },
232            config: Some("{'name': null}".to_string()),
233        };
234
235        let output = plugin.process(input);
236        assert_eq!(output.errors.len(), 0);
237    }
238
239    #[test]
240    fn test_commodity_attr_invalid_value() {
241        let plugin = CommodityAttrPlugin::new();
242
243        let input = PluginInput {
244            directives: vec![DirectiveWrapper {
245                directive_type: "commodity".to_string(),
246                date: "2024-01-01".to_string(),
247                filename: None,
248                lineno: None,
249                data: DirectiveData::Commodity(CommodityData {
250                    currency: "AAPL".to_string(),
251                    metadata: vec![(
252                        "sector".to_string(),
253                        MetaValueData::String("Healthcare".to_string()),
254                    )],
255                }),
256            }],
257            options: PluginOptions {
258                operating_currencies: vec!["USD".to_string()],
259                title: None,
260            },
261            config: Some("{'sector': ['Tech', 'Finance']}".to_string()),
262        };
263
264        let output = plugin.process(input);
265        assert_eq!(output.errors.len(), 1);
266        assert!(output.errors[0].message.contains("invalid value"));
267        assert!(output.errors[0].message.contains("Healthcare"));
268    }
269
270    #[test]
271    fn test_commodity_attr_valid_value() {
272        let plugin = CommodityAttrPlugin::new();
273
274        let input = PluginInput {
275            directives: vec![DirectiveWrapper {
276                directive_type: "commodity".to_string(),
277                date: "2024-01-01".to_string(),
278                filename: None,
279                lineno: None,
280                data: DirectiveData::Commodity(CommodityData {
281                    currency: "AAPL".to_string(),
282                    metadata: vec![(
283                        "sector".to_string(),
284                        MetaValueData::String("Tech".to_string()),
285                    )],
286                }),
287            }],
288            options: PluginOptions {
289                operating_currencies: vec!["USD".to_string()],
290                title: None,
291            },
292            config: Some("{'sector': ['Tech', 'Finance']}".to_string()),
293        };
294
295        let output = plugin.process(input);
296        assert_eq!(output.errors.len(), 0);
297    }
298
299    #[test]
300    fn test_config_parsing() {
301        let config = "{'name': null, 'sector': ['Tech', 'Finance']}";
302        let parsed = CommodityAttrPlugin::parse_config(config);
303
304        assert_eq!(parsed.len(), 2);
305        assert_eq!(parsed[0].0, "name");
306        assert!(parsed[0].1.is_none());
307        assert_eq!(parsed[1].0, "sector");
308        assert_eq!(parsed[1].1.as_ref().unwrap(), &vec!["Tech", "Finance"]);
309    }
310}