rustledger_plugin/native/plugins/
commodity_attr.rs1use crate::types::{DirectiveData, PluginError, PluginInput, PluginOutput};
4
5use super::super::NativePlugin;
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 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 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 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![], }),
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}