use regex::Regex;
use std::sync::LazyLock;
use crate::types::{
DirectiveData, DirectiveWrapper, PadData, PluginInput, 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 {
directives: input.directives,
errors: Vec::new(),
};
}
},
None => {
return PluginOutput {
directives: input.directives,
errors: Vec::new(),
};
}
};
let new_directives: Vec<DirectiveWrapper> = input
.directives
.into_iter()
.map(|directive| rename_in_directive(directive, &renames))
.collect();
PluginOutput {
directives: new_directives,
errors: Vec::new(),
}
}
}
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::*;
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 output = plugin.process(input);
assert_eq!(output.errors.len(), 0);
if let DirectiveData::Open(open) = &output.directives[0].data {
assert_eq!(open.account, "Income:Taxes");
} else {
panic!("Expected Open directive");
}
if let DirectiveData::Transaction(txn) = &output.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 output = plugin.process(input);
assert_eq!(output.errors.len(), 0);
if let DirectiveData::Open(open) = &output.directives[0].data {
assert_eq!(open.account, "Expenses:Dining:Groceries");
}
if let DirectiveData::Open(open) = &output.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 output = plugin.process(input);
assert_eq!(output.errors.len(), 0);
if let DirectiveData::Open(open) = &output.directives[0].data {
assert_eq!(open.account, "Expenses:Taxes");
}
}
}