rustledger_plugin/native/plugins/
commodity_attr.rs1use crate::types::{DirectiveData, PluginError, PluginInput, PluginOp, PluginOutput};
4
5use super::super::{NativePlugin, RegularPlugin};
6
7pub struct CommodityAttrPlugin {
14 required_attrs: Vec<(String, Option<Vec<String>>)>,
16}
17
18impl CommodityAttrPlugin {
19 pub const fn new() -> Self {
21 Self {
22 required_attrs: Vec::new(),
23 }
24 }
25
26 pub const fn with_attrs(attrs: Vec<(String, Option<Vec<String>>)>) -> Self {
28 Self {
29 required_attrs: attrs,
30 }
31 }
32
33 fn parse_config(config: &str) -> Vec<(String, Option<Vec<String>>)> {
37 let mut result = Vec::new();
38
39 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 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 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 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 let required = if let Some(config) = &input.config {
121 Self::parse_config(config)
122 } else {
123 self.required_attrs.clone()
124 };
125
126 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 for (attr_name, allowed_values) in &required {
140 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 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![], }),
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}