use crate::types::{DirectiveData, PluginError, PluginInput, PluginOp, PluginOutput};
use super::super::NativePlugin;
pub struct CommodityAttrPlugin {
required_attrs: Vec<(String, Option<Vec<String>>)>,
}
impl CommodityAttrPlugin {
pub const fn new() -> Self {
Self {
required_attrs: Vec::new(),
}
}
pub const fn with_attrs(attrs: Vec<(String, Option<Vec<String>>)>) -> Self {
Self {
required_attrs: attrs,
}
}
fn parse_config(config: &str) -> Vec<(String, Option<Vec<String>>)> {
let mut result = Vec::new();
let trimmed = config.trim();
let content = if trimmed.starts_with('{') && trimmed.ends_with('}') {
&trimmed[1..trimmed.len() - 1]
} else {
trimmed
};
let mut depth = 0;
let mut current = String::new();
let mut entries = Vec::new();
for c in content.chars() {
match c {
'[' => {
depth += 1;
current.push(c);
}
']' => {
depth -= 1;
current.push(c);
}
',' if depth == 0 => {
entries.push(current.trim().to_string());
current.clear();
}
_ => current.push(c),
}
}
if !current.trim().is_empty() {
entries.push(current.trim().to_string());
}
for entry in entries {
if let Some((key_part, value_part)) = entry.split_once(':') {
let key = key_part
.trim()
.trim_matches('\'')
.trim_matches('"')
.to_string();
let value = value_part.trim();
if value == "null" || value == "None" {
result.push((key, None));
} else if value.starts_with('[') && value.ends_with(']') {
let inner = &value[1..value.len() - 1];
let allowed: Vec<String> = inner
.split(',')
.map(|s| s.trim().trim_matches('\'').trim_matches('"').to_string())
.filter(|s| !s.is_empty())
.collect();
result.push((key, Some(allowed)));
}
}
}
result
}
}
impl Default for CommodityAttrPlugin {
fn default() -> Self {
Self::new()
}
}
impl NativePlugin for CommodityAttrPlugin {
fn name(&self) -> &'static str {
"commodity_attr"
}
fn description(&self) -> &'static str {
"Validate commodity metadata attributes"
}
fn process(&self, input: PluginInput) -> PluginOutput {
let required = if let Some(config) = &input.config {
Self::parse_config(config)
} else {
self.required_attrs.clone()
};
if required.is_empty() {
return PluginOutput {
ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
errors: Vec::new(),
};
}
let mut errors = Vec::new();
for wrapper in &input.directives {
if let DirectiveData::Commodity(comm) = &wrapper.data {
for (attr_name, allowed_values) in &required {
let found = comm.metadata.iter().find(|(k, _)| k == attr_name);
match found {
None => {
errors.push(PluginError::error(format!(
"Commodity '{}' missing required attribute '{}'",
comm.currency, attr_name
)));
}
Some((_, value)) => {
if let Some(allowed) = allowed_values {
let value_str = match value {
crate::types::MetaValueData::String(s) => s.clone(),
other => format!("{other:?}"),
};
if !allowed.contains(&value_str) {
errors.push(PluginError::error(format!(
"Commodity '{}' attribute '{}' has invalid value '{}' (allowed: {:?})",
comm.currency, attr_name, value_str, allowed
)));
}
}
}
}
}
}
}
PluginOutput {
ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
errors,
}
}
}
#[cfg(test)]
mod commodity_attr_tests {
use super::*;
use crate::types::*;
#[test]
fn test_commodity_attr_missing_required() {
let plugin = CommodityAttrPlugin::new();
let input = PluginInput {
directives: vec![DirectiveWrapper {
directive_type: "commodity".to_string(),
date: "2024-01-01".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Commodity(CommodityData {
currency: "AAPL".to_string(),
metadata: vec![], }),
}],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: Some("{'name': null}".to_string()),
};
let output = plugin.process(input);
assert_eq!(output.errors.len(), 1);
assert!(output.errors[0].message.contains("missing required"));
assert!(output.errors[0].message.contains("name"));
}
#[test]
fn test_commodity_attr_has_required() {
let plugin = CommodityAttrPlugin::new();
let input = PluginInput {
directives: vec![DirectiveWrapper {
directive_type: "commodity".to_string(),
date: "2024-01-01".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Commodity(CommodityData {
currency: "AAPL".to_string(),
metadata: vec![(
"name".to_string(),
MetaValueData::String("Apple Inc".to_string()),
)],
}),
}],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: Some("{'name': null}".to_string()),
};
let output = plugin.process(input);
assert_eq!(output.errors.len(), 0);
}
#[test]
fn test_commodity_attr_invalid_value() {
let plugin = CommodityAttrPlugin::new();
let input = PluginInput {
directives: vec![DirectiveWrapper {
directive_type: "commodity".to_string(),
date: "2024-01-01".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Commodity(CommodityData {
currency: "AAPL".to_string(),
metadata: vec![(
"sector".to_string(),
MetaValueData::String("Healthcare".to_string()),
)],
}),
}],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: Some("{'sector': ['Tech', 'Finance']}".to_string()),
};
let output = plugin.process(input);
assert_eq!(output.errors.len(), 1);
assert!(output.errors[0].message.contains("invalid value"));
assert!(output.errors[0].message.contains("Healthcare"));
}
#[test]
fn test_commodity_attr_valid_value() {
let plugin = CommodityAttrPlugin::new();
let input = PluginInput {
directives: vec![DirectiveWrapper {
directive_type: "commodity".to_string(),
date: "2024-01-01".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Commodity(CommodityData {
currency: "AAPL".to_string(),
metadata: vec![(
"sector".to_string(),
MetaValueData::String("Tech".to_string()),
)],
}),
}],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: Some("{'sector': ['Tech', 'Finance']}".to_string()),
};
let output = plugin.process(input);
assert_eq!(output.errors.len(), 0);
}
#[test]
fn test_config_parsing() {
let config = "{'name': null, 'sector': ['Tech', 'Finance']}";
let parsed = CommodityAttrPlugin::parse_config(config);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].0, "name");
assert!(parsed[0].1.is_none());
assert_eq!(parsed[1].0, "sector");
assert_eq!(parsed[1].1.as_ref().unwrap(), &vec!["Tech", "Finance"]);
}
}