use regex::Regex;
use std::sync::LazyLock;
use crate::types::{
DirectiveData, DirectiveWrapper, PadData, PluginInput, PluginOp, PluginOutput, PostingData,
};
use super::super::NativePlugin;
static CONFIG_KV_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"'([^']+)'\s*:\s*'([^']*)'").expect("CONFIG_KV_RE: invalid regex pattern")
});
pub struct RenameAccountsPlugin;
impl NativePlugin for RenameAccountsPlugin {
fn name(&self) -> &'static str {
"rename_accounts"
}
fn description(&self) -> &'static str {
"Rename accounts using regex patterns"
}
fn process(&self, input: PluginInput) -> PluginOutput {
let renames = match &input.config {
Some(config) => match parse_config(config) {
Ok(r) => r,
Err(_) => {
return PluginOutput {
ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
errors: Vec::new(),
};
}
},
None => {
return PluginOutput {
ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
errors: Vec::new(),
};
}
};
let mut ops: Vec<PluginOp> = Vec::with_capacity(input.directives.len());
for (i, directive) in input.directives.iter().enumerate() {
let renamed = rename_in_directive(directive.clone(), &renames);
if directive_has_same_accounts(directive, &renamed) {
ops.push(PluginOp::Keep(i));
} else {
ops.push(PluginOp::Modify(i, renamed));
}
}
PluginOutput {
ops,
errors: Vec::new(),
}
}
}
fn directive_has_same_accounts(a: &DirectiveWrapper, b: &DirectiveWrapper) -> bool {
match (&a.data, &b.data) {
(DirectiveData::Transaction(ta), DirectiveData::Transaction(tb)) => {
ta.postings.len() == tb.postings.len()
&& ta
.postings
.iter()
.zip(tb.postings.iter())
.all(|(pa, pb)| pa.account == pb.account)
}
(DirectiveData::Open(a), DirectiveData::Open(b)) => a.account == b.account,
(DirectiveData::Close(a), DirectiveData::Close(b)) => a.account == b.account,
(DirectiveData::Balance(a), DirectiveData::Balance(b)) => a.account == b.account,
(DirectiveData::Pad(a), DirectiveData::Pad(b)) => {
a.account == b.account && a.source_account == b.source_account
}
(DirectiveData::Note(a), DirectiveData::Note(b)) => a.account == b.account,
(DirectiveData::Document(a), DirectiveData::Document(b)) => a.account == b.account,
_ => true,
}
}
struct RenameRule {
pattern: Regex,
replacement: String,
}
fn rename_account(account: &str, renames: &[RenameRule]) -> String {
let mut result = account.to_string();
for rule in renames {
if rule.pattern.is_match(&result) {
result = rule
.pattern
.replace_all(&result, &rule.replacement)
.to_string();
}
}
result
}
fn rename_in_posting(mut posting: PostingData, renames: &[RenameRule]) -> PostingData {
posting.account = rename_account(&posting.account, renames);
posting
}
fn rename_in_directive(
mut directive: DirectiveWrapper,
renames: &[RenameRule],
) -> DirectiveWrapper {
match &mut directive.data {
DirectiveData::Transaction(txn) => {
txn.postings = txn
.postings
.drain(..)
.map(|p| rename_in_posting(p, renames))
.collect();
}
DirectiveData::Open(open) => {
open.account = rename_account(&open.account, renames);
}
DirectiveData::Close(close) => {
close.account = rename_account(&close.account, renames);
}
DirectiveData::Balance(balance) => {
balance.account = rename_account(&balance.account, renames);
}
DirectiveData::Pad(pad) => {
let account = rename_account(&pad.account, renames);
let source_account = rename_account(&pad.source_account, renames);
*pad = PadData {
account,
source_account,
metadata: std::mem::take(&mut pad.metadata),
};
}
DirectiveData::Note(note) => {
note.account = rename_account(¬e.account, renames);
}
DirectiveData::Document(doc) => {
doc.account = rename_account(&doc.account, renames);
}
DirectiveData::Price(_)
| DirectiveData::Commodity(_)
| DirectiveData::Event(_)
| DirectiveData::Query(_)
| DirectiveData::Custom(_) => {}
}
directive
}
fn parse_config(config: &str) -> Result<Vec<RenameRule>, String> {
let mut rules = Vec::new();
for cap in CONFIG_KV_RE.captures_iter(config) {
let pattern_str = &cap[1];
let replacement = cap[2].to_string();
let pattern = Regex::new(pattern_str).map_err(|e| e.to_string())?;
rules.push(RenameRule {
pattern,
replacement,
});
}
if rules.is_empty() {
return Err("No rename rules found in config".to_string());
}
Ok(rules)
}
#[cfg(test)]
mod tests {
use super::super::utils::materialize_ops;
use super::*;
use crate::types::*;
fn create_open(account: &str) -> DirectiveWrapper {
DirectiveWrapper {
directive_type: "open".to_string(),
date: "2024-01-01".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Open(OpenData {
account: account.to_string(),
currencies: vec![],
booking: None,
metadata: vec![],
}),
}
}
fn create_transaction(postings: Vec<(&str, &str, &str)>) -> DirectiveWrapper {
DirectiveWrapper {
directive_type: "transaction".to_string(),
date: "2024-01-15".to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "*".to_string(),
payee: None,
narration: "Test".to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: postings
.into_iter()
.map(|(account, number, currency)| PostingData {
account: account.to_string(),
units: Some(AmountData {
number: number.to_string(),
currency: currency.to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
})
.collect(),
}),
}
}
#[test]
fn test_simple_rename() {
let plugin = RenameAccountsPlugin;
let input = PluginInput {
directives: vec![
create_open("Expenses:Taxes"),
create_transaction(vec![
("Assets:Cash", "-100", "USD"),
("Expenses:Taxes", "100", "USD"),
]),
],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: Some("{'Expenses:Taxes': 'Income:Taxes'}".to_string()),
};
let input_dirs = input.directives.clone();
let output = plugin.process(input);
assert_eq!(output.errors.len(), 0);
let directives = materialize_ops(&input_dirs, &output);
if let DirectiveData::Open(open) = &directives[0].data {
assert_eq!(open.account, "Income:Taxes");
} else {
panic!("Expected Open directive");
}
if let DirectiveData::Transaction(txn) = &directives[1].data {
assert_eq!(txn.postings[1].account, "Income:Taxes");
} else {
panic!("Expected Transaction directive");
}
}
#[test]
fn test_regex_rename() {
let plugin = RenameAccountsPlugin;
let input = PluginInput {
directives: vec![
create_open("Expenses:Food:Groceries"),
create_open("Expenses:Food:Restaurant"),
],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: Some("{'Expenses:Food:(.*)': 'Expenses:Dining:$1'}".to_string()),
};
let input_dirs = input.directives.clone();
let output = plugin.process(input);
assert_eq!(output.errors.len(), 0);
let directives = materialize_ops(&input_dirs, &output);
if let DirectiveData::Open(open) = &directives[0].data {
assert_eq!(open.account, "Expenses:Dining:Groceries");
}
if let DirectiveData::Open(open) = &directives[1].data {
assert_eq!(open.account, "Expenses:Dining:Restaurant");
}
}
#[test]
fn test_no_config_unchanged() {
let plugin = RenameAccountsPlugin;
let input = PluginInput {
directives: vec![create_open("Expenses:Taxes")],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: None,
};
let input_dirs = input.directives.clone();
let output = plugin.process(input);
assert_eq!(output.errors.len(), 0);
let directives = materialize_ops(&input_dirs, &output);
if let DirectiveData::Open(open) = &directives[0].data {
assert_eq!(open.account, "Expenses:Taxes");
}
}
}