rustledger_plugin/native/plugins/
rename_accounts.rs1use regex::Regex;
16use std::sync::LazyLock;
17
18use crate::types::{
19 DirectiveData, DirectiveWrapper, PadData, PluginInput, PluginOp, PluginOutput, PostingData,
20};
21
22use super::super::NativePlugin;
23
24static CONFIG_KV_RE: LazyLock<Regex> = LazyLock::new(|| {
27 Regex::new(r"'([^']+)'\s*:\s*'([^']*)'").expect("CONFIG_KV_RE: invalid regex pattern")
28});
29
30pub struct RenameAccountsPlugin;
32
33impl NativePlugin for RenameAccountsPlugin {
34 fn name(&self) -> &'static str {
35 "rename_accounts"
36 }
37
38 fn description(&self) -> &'static str {
39 "Rename accounts using regex patterns"
40 }
41
42 fn process(&self, input: PluginInput) -> PluginOutput {
43 let renames = match &input.config {
45 Some(config) => match parse_config(config) {
46 Ok(r) => r,
47 Err(_) => {
48 return PluginOutput {
50 ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
51 errors: Vec::new(),
52 };
53 }
54 },
55 None => {
56 return PluginOutput {
58 ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
59 errors: Vec::new(),
60 };
61 }
62 };
63
64 let mut ops: Vec<PluginOp> = Vec::with_capacity(input.directives.len());
68 for (i, directive) in input.directives.iter().enumerate() {
69 let renamed = rename_in_directive(directive.clone(), &renames);
70 if directive_has_same_accounts(directive, &renamed) {
71 ops.push(PluginOp::Keep(i));
72 } else {
73 ops.push(PluginOp::Modify(i, renamed));
74 }
75 }
76
77 PluginOutput {
78 ops,
79 errors: Vec::new(),
80 }
81 }
82}
83
84fn directive_has_same_accounts(a: &DirectiveWrapper, b: &DirectiveWrapper) -> bool {
88 match (&a.data, &b.data) {
89 (DirectiveData::Transaction(ta), DirectiveData::Transaction(tb)) => {
90 ta.postings.len() == tb.postings.len()
91 && ta
92 .postings
93 .iter()
94 .zip(tb.postings.iter())
95 .all(|(pa, pb)| pa.account == pb.account)
96 }
97 (DirectiveData::Open(a), DirectiveData::Open(b)) => a.account == b.account,
98 (DirectiveData::Close(a), DirectiveData::Close(b)) => a.account == b.account,
99 (DirectiveData::Balance(a), DirectiveData::Balance(b)) => a.account == b.account,
100 (DirectiveData::Pad(a), DirectiveData::Pad(b)) => {
101 a.account == b.account && a.source_account == b.source_account
102 }
103 (DirectiveData::Note(a), DirectiveData::Note(b)) => a.account == b.account,
104 (DirectiveData::Document(a), DirectiveData::Document(b)) => a.account == b.account,
105 _ => true,
107 }
108}
109
110struct RenameRule {
112 pattern: Regex,
113 replacement: String,
114}
115
116fn rename_account(account: &str, renames: &[RenameRule]) -> String {
118 let mut result = account.to_string();
119 for rule in renames {
120 if rule.pattern.is_match(&result) {
121 result = rule
122 .pattern
123 .replace_all(&result, &rule.replacement)
124 .to_string();
125 }
126 }
127 result
128}
129
130fn rename_in_posting(mut posting: PostingData, renames: &[RenameRule]) -> PostingData {
132 posting.account = rename_account(&posting.account, renames);
133 posting
134}
135
136fn rename_in_directive(
138 mut directive: DirectiveWrapper,
139 renames: &[RenameRule],
140) -> DirectiveWrapper {
141 match &mut directive.data {
142 DirectiveData::Transaction(txn) => {
143 txn.postings = txn
144 .postings
145 .drain(..)
146 .map(|p| rename_in_posting(p, renames))
147 .collect();
148 }
149 DirectiveData::Open(open) => {
150 open.account = rename_account(&open.account, renames);
151 }
152 DirectiveData::Close(close) => {
153 close.account = rename_account(&close.account, renames);
154 }
155 DirectiveData::Balance(balance) => {
156 balance.account = rename_account(&balance.account, renames);
157 }
158 DirectiveData::Pad(pad) => {
159 let account = rename_account(&pad.account, renames);
160 let source_account = rename_account(&pad.source_account, renames);
161 *pad = PadData {
162 account,
163 source_account,
164 metadata: std::mem::take(&mut pad.metadata),
165 };
166 }
167 DirectiveData::Note(note) => {
168 note.account = rename_account(¬e.account, renames);
169 }
170 DirectiveData::Document(doc) => {
171 doc.account = rename_account(&doc.account, renames);
172 }
173 DirectiveData::Price(_)
175 | DirectiveData::Commodity(_)
176 | DirectiveData::Event(_)
177 | DirectiveData::Query(_)
178 | DirectiveData::Custom(_) => {}
179 }
180 directive
181}
182
183fn parse_config(config: &str) -> Result<Vec<RenameRule>, String> {
186 let mut rules = Vec::new();
187
188 for cap in CONFIG_KV_RE.captures_iter(config) {
191 let pattern_str = &cap[1];
192 let replacement = cap[2].to_string();
193
194 let pattern = Regex::new(pattern_str).map_err(|e| e.to_string())?;
195
196 rules.push(RenameRule {
197 pattern,
198 replacement,
199 });
200 }
201
202 if rules.is_empty() {
203 return Err("No rename rules found in config".to_string());
204 }
205
206 Ok(rules)
207}
208
209#[cfg(test)]
210mod tests {
211 use super::super::utils::materialize_ops;
212 use super::*;
213 use crate::types::*;
214
215 fn create_open(account: &str) -> DirectiveWrapper {
216 DirectiveWrapper {
217 directive_type: "open".to_string(),
218 date: "2024-01-01".to_string(),
219 filename: None,
220 lineno: None,
221 data: DirectiveData::Open(OpenData {
222 account: account.to_string(),
223 currencies: vec![],
224 booking: None,
225 metadata: vec![],
226 }),
227 }
228 }
229
230 fn create_transaction(postings: Vec<(&str, &str, &str)>) -> DirectiveWrapper {
231 DirectiveWrapper {
232 directive_type: "transaction".to_string(),
233 date: "2024-01-15".to_string(),
234 filename: None,
235 lineno: None,
236 data: DirectiveData::Transaction(TransactionData {
237 flag: "*".to_string(),
238 payee: None,
239 narration: "Test".to_string(),
240 tags: vec![],
241 links: vec![],
242 metadata: vec![],
243 postings: postings
244 .into_iter()
245 .map(|(account, number, currency)| PostingData {
246 account: account.to_string(),
247 units: Some(AmountData {
248 number: number.to_string(),
249 currency: currency.to_string(),
250 }),
251 cost: None,
252 price: None,
253 flag: None,
254 metadata: vec![],
255 })
256 .collect(),
257 }),
258 }
259 }
260
261 #[test]
262 fn test_simple_rename() {
263 let plugin = RenameAccountsPlugin;
264
265 let input = PluginInput {
266 directives: vec![
267 create_open("Expenses:Taxes"),
268 create_transaction(vec![
269 ("Assets:Cash", "-100", "USD"),
270 ("Expenses:Taxes", "100", "USD"),
271 ]),
272 ],
273 options: PluginOptions {
274 operating_currencies: vec!["USD".to_string()],
275 title: None,
276 },
277 config: Some("{'Expenses:Taxes': 'Income:Taxes'}".to_string()),
278 };
279
280 let input_dirs = input.directives.clone();
281 let output = plugin.process(input);
282 assert_eq!(output.errors.len(), 0);
283 let directives = materialize_ops(&input_dirs, &output);
284
285 if let DirectiveData::Open(open) = &directives[0].data {
287 assert_eq!(open.account, "Income:Taxes");
288 } else {
289 panic!("Expected Open directive");
290 }
291
292 if let DirectiveData::Transaction(txn) = &directives[1].data {
294 assert_eq!(txn.postings[1].account, "Income:Taxes");
295 } else {
296 panic!("Expected Transaction directive");
297 }
298 }
299
300 #[test]
301 fn test_regex_rename() {
302 let plugin = RenameAccountsPlugin;
303
304 let input = PluginInput {
305 directives: vec![
306 create_open("Expenses:Food:Groceries"),
307 create_open("Expenses:Food:Restaurant"),
308 ],
309 options: PluginOptions {
310 operating_currencies: vec!["USD".to_string()],
311 title: None,
312 },
313 config: Some("{'Expenses:Food:(.*)': 'Expenses:Dining:$1'}".to_string()),
316 };
317
318 let input_dirs = input.directives.clone();
319 let output = plugin.process(input);
320 assert_eq!(output.errors.len(), 0);
321 let directives = materialize_ops(&input_dirs, &output);
322
323 if let DirectiveData::Open(open) = &directives[0].data {
324 assert_eq!(open.account, "Expenses:Dining:Groceries");
325 }
326
327 if let DirectiveData::Open(open) = &directives[1].data {
328 assert_eq!(open.account, "Expenses:Dining:Restaurant");
329 }
330 }
331
332 #[test]
333 fn test_no_config_unchanged() {
334 let plugin = RenameAccountsPlugin;
335
336 let input = PluginInput {
337 directives: vec![create_open("Expenses:Taxes")],
338 options: PluginOptions {
339 operating_currencies: vec!["USD".to_string()],
340 title: None,
341 },
342 config: None,
343 };
344
345 let input_dirs = input.directives.clone();
346 let output = plugin.process(input);
347 assert_eq!(output.errors.len(), 0);
348 let directives = materialize_ops(&input_dirs, &output);
349
350 if let DirectiveData::Open(open) = &directives[0].data {
351 assert_eq!(open.account, "Expenses:Taxes");
352 }
353 }
354}