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, PluginOp, PluginOutput};
4
5use super::super::{NativePlugin, RegularPlugin};
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                ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
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            ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
172            errors,
173        }
174    }
175}
176
177impl RegularPlugin for CommodityAttrPlugin {}
178
179#[cfg(test)]
180mod commodity_attr_tests {
181    use super::*;
182    use crate::types::*;
183
184    #[test]
185    fn test_commodity_attr_missing_required() {
186        let plugin = CommodityAttrPlugin::new();
187
188        let input = PluginInput {
189            directives: vec![DirectiveWrapper {
190                directive_type: "commodity".to_string(),
191                date: "2024-01-01".to_string(),
192                filename: None,
193                lineno: None,
194                data: DirectiveData::Commodity(CommodityData {
195                    currency: "AAPL".to_string(),
196                    metadata: vec![], // Missing 'name'
197                }),
198            }],
199            options: PluginOptions {
200                operating_currencies: vec!["USD".to_string()],
201                title: None,
202            },
203            config: Some("{'name': null}".to_string()),
204        };
205
206        let output = plugin.process(input);
207        assert_eq!(output.errors.len(), 1);
208        assert!(output.errors[0].message.contains("missing required"));
209        assert!(output.errors[0].message.contains("name"));
210    }
211
212    #[test]
213    fn test_commodity_attr_has_required() {
214        let plugin = CommodityAttrPlugin::new();
215
216        let input = PluginInput {
217            directives: vec![DirectiveWrapper {
218                directive_type: "commodity".to_string(),
219                date: "2024-01-01".to_string(),
220                filename: None,
221                lineno: None,
222                data: DirectiveData::Commodity(CommodityData {
223                    currency: "AAPL".to_string(),
224                    metadata: vec![(
225                        "name".to_string(),
226                        MetaValueData::String("Apple Inc".to_string()),
227                    )],
228                }),
229            }],
230            options: PluginOptions {
231                operating_currencies: vec!["USD".to_string()],
232                title: None,
233            },
234            config: Some("{'name': null}".to_string()),
235        };
236
237        let output = plugin.process(input);
238        assert_eq!(output.errors.len(), 0);
239    }
240
241    #[test]
242    fn test_commodity_attr_invalid_value() {
243        let plugin = CommodityAttrPlugin::new();
244
245        let input = PluginInput {
246            directives: vec![DirectiveWrapper {
247                directive_type: "commodity".to_string(),
248                date: "2024-01-01".to_string(),
249                filename: None,
250                lineno: None,
251                data: DirectiveData::Commodity(CommodityData {
252                    currency: "AAPL".to_string(),
253                    metadata: vec![(
254                        "sector".to_string(),
255                        MetaValueData::String("Healthcare".to_string()),
256                    )],
257                }),
258            }],
259            options: PluginOptions {
260                operating_currencies: vec!["USD".to_string()],
261                title: None,
262            },
263            config: Some("{'sector': ['Tech', 'Finance']}".to_string()),
264        };
265
266        let output = plugin.process(input);
267        assert_eq!(output.errors.len(), 1);
268        assert!(output.errors[0].message.contains("invalid value"));
269        assert!(output.errors[0].message.contains("Healthcare"));
270    }
271
272    #[test]
273    fn test_commodity_attr_valid_value() {
274        let plugin = CommodityAttrPlugin::new();
275
276        let input = PluginInput {
277            directives: vec![DirectiveWrapper {
278                directive_type: "commodity".to_string(),
279                date: "2024-01-01".to_string(),
280                filename: None,
281                lineno: None,
282                data: DirectiveData::Commodity(CommodityData {
283                    currency: "AAPL".to_string(),
284                    metadata: vec![(
285                        "sector".to_string(),
286                        MetaValueData::String("Tech".to_string()),
287                    )],
288                }),
289            }],
290            options: PluginOptions {
291                operating_currencies: vec!["USD".to_string()],
292                title: None,
293            },
294            config: Some("{'sector': ['Tech', 'Finance']}".to_string()),
295        };
296
297        let output = plugin.process(input);
298        assert_eq!(output.errors.len(), 0);
299    }
300
301    #[test]
302    fn test_config_parsing() {
303        let config = "{'name': null, 'sector': ['Tech', 'Finance']}";
304        let parsed = CommodityAttrPlugin::parse_config(config);
305
306        assert_eq!(parsed.len(), 2);
307        assert_eq!(parsed[0].0, "name");
308        assert!(parsed[0].1.is_none());
309        assert_eq!(parsed[1].0, "sector");
310        assert_eq!(parsed[1].1.as_ref().unwrap(), &vec!["Tech", "Finance"]);
311    }
312}