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, RegularPlugin};
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
84impl RegularPlugin for RenameAccountsPlugin {}
85
86fn directive_has_same_accounts(a: &DirectiveWrapper, b: &DirectiveWrapper) -> bool {
90 match (&a.data, &b.data) {
91 (DirectiveData::Transaction(ta), DirectiveData::Transaction(tb)) => {
92 ta.postings.len() == tb.postings.len()
93 && ta
94 .postings
95 .iter()
96 .zip(tb.postings.iter())
97 .all(|(pa, pb)| pa.account == pb.account)
98 }
99 (DirectiveData::Open(a), DirectiveData::Open(b)) => a.account == b.account,
100 (DirectiveData::Close(a), DirectiveData::Close(b)) => a.account == b.account,
101 (DirectiveData::Balance(a), DirectiveData::Balance(b)) => a.account == b.account,
102 (DirectiveData::Pad(a), DirectiveData::Pad(b)) => {
103 a.account == b.account && a.source_account == b.source_account
104 }
105 (DirectiveData::Note(a), DirectiveData::Note(b)) => a.account == b.account,
106 (DirectiveData::Document(a), DirectiveData::Document(b)) => a.account == b.account,
107 _ => true,
109 }
110}
111
112struct RenameRule {
114 pattern: Regex,
115 replacement: String,
116}
117
118fn rename_account(account: &str, renames: &[RenameRule]) -> String {
120 let mut result = account.to_string();
121 for rule in renames {
122 if rule.pattern.is_match(&result) {
123 result = rule
124 .pattern
125 .replace_all(&result, &rule.replacement)
126 .to_string();
127 }
128 }
129 result
130}
131
132fn rename_in_posting(mut posting: PostingData, renames: &[RenameRule]) -> PostingData {
134 posting.account = rename_account(&posting.account, renames);
135 posting
136}
137
138fn rename_in_directive(
140 mut directive: DirectiveWrapper,
141 renames: &[RenameRule],
142) -> DirectiveWrapper {
143 match &mut directive.data {
144 DirectiveData::Transaction(txn) => {
145 txn.postings = txn
146 .postings
147 .drain(..)
148 .map(|p| rename_in_posting(p, renames))
149 .collect();
150 }
151 DirectiveData::Open(open) => {
152 open.account = rename_account(&open.account, renames);
153 }
154 DirectiveData::Close(close) => {
155 close.account = rename_account(&close.account, renames);
156 }
157 DirectiveData::Balance(balance) => {
158 balance.account = rename_account(&balance.account, renames);
159 }
160 DirectiveData::Pad(pad) => {
161 let account = rename_account(&pad.account, renames);
162 let source_account = rename_account(&pad.source_account, renames);
163 *pad = PadData {
164 account,
165 source_account,
166 metadata: std::mem::take(&mut pad.metadata),
167 };
168 }
169 DirectiveData::Note(note) => {
170 note.account = rename_account(¬e.account, renames);
171 }
172 DirectiveData::Document(doc) => {
173 doc.account = rename_account(&doc.account, renames);
174 }
175 DirectiveData::Price(_)
177 | DirectiveData::Commodity(_)
178 | DirectiveData::Event(_)
179 | DirectiveData::Query(_)
180 | DirectiveData::Custom(_) => {}
181 }
182 directive
183}
184
185fn parse_config(config: &str) -> Result<Vec<RenameRule>, String> {
188 let mut rules = Vec::new();
189
190 for cap in CONFIG_KV_RE.captures_iter(config) {
193 let pattern_str = &cap[1];
194 let replacement = cap[2].to_string();
195
196 let pattern = Regex::new(pattern_str).map_err(|e| e.to_string())?;
197
198 rules.push(RenameRule {
199 pattern,
200 replacement,
201 });
202 }
203
204 if rules.is_empty() {
205 return Err("No rename rules found in config".to_string());
206 }
207
208 Ok(rules)
209}
210
211#[cfg(test)]
212mod tests {
213 use super::super::utils::materialize_ops;
214 use super::*;
215 use crate::types::*;
216
217 fn create_open(account: &str) -> DirectiveWrapper {
218 DirectiveWrapper {
219 directive_type: "open".to_string(),
220 date: "2024-01-01".to_string(),
221 filename: None,
222 lineno: None,
223 data: DirectiveData::Open(OpenData {
224 account: account.to_string(),
225 currencies: vec![],
226 booking: None,
227 metadata: vec![],
228 }),
229 }
230 }
231
232 fn create_transaction(postings: Vec<(&str, &str, &str)>) -> DirectiveWrapper {
233 DirectiveWrapper {
234 directive_type: "transaction".to_string(),
235 date: "2024-01-15".to_string(),
236 filename: None,
237 lineno: None,
238 data: DirectiveData::Transaction(TransactionData {
239 flag: "*".to_string(),
240 payee: None,
241 narration: "Test".to_string(),
242 tags: vec![],
243 links: vec![],
244 metadata: vec![],
245 postings: postings
246 .into_iter()
247 .map(|(account, number, currency)| PostingData {
248 account: account.to_string(),
249 units: Some(AmountData {
250 number: number.to_string(),
251 currency: currency.to_string(),
252 }),
253 cost: None,
254 price: None,
255 flag: None,
256 metadata: vec![],
257 span: None,
258 })
259 .collect(),
260 }),
261 }
262 }
263
264 #[test]
265 fn test_simple_rename() {
266 let plugin = RenameAccountsPlugin;
267
268 let input = PluginInput {
269 directives: vec![
270 create_open("Expenses:Taxes"),
271 create_transaction(vec![
272 ("Assets:Cash", "-100", "USD"),
273 ("Expenses:Taxes", "100", "USD"),
274 ]),
275 ],
276 options: PluginOptions {
277 operating_currencies: vec!["USD".to_string()],
278 title: None,
279 },
280 config: Some("{'Expenses:Taxes': 'Income:Taxes'}".to_string()),
281 };
282
283 let input_dirs = input.directives.clone();
284 let output = plugin.process(input);
285 assert_eq!(output.errors.len(), 0);
286 let directives = materialize_ops(&input_dirs, &output);
287
288 if let DirectiveData::Open(open) = &directives[0].data {
290 assert_eq!(open.account, "Income:Taxes");
291 } else {
292 panic!("Expected Open directive");
293 }
294
295 if let DirectiveData::Transaction(txn) = &directives[1].data {
297 assert_eq!(txn.postings[1].account, "Income:Taxes");
298 } else {
299 panic!("Expected Transaction directive");
300 }
301 }
302
303 #[test]
304 fn test_regex_rename() {
305 let plugin = RenameAccountsPlugin;
306
307 let input = PluginInput {
308 directives: vec![
309 create_open("Expenses:Food:Groceries"),
310 create_open("Expenses:Food:Restaurant"),
311 ],
312 options: PluginOptions {
313 operating_currencies: vec!["USD".to_string()],
314 title: None,
315 },
316 config: Some("{'Expenses:Food:(.*)': 'Expenses:Dining:$1'}".to_string()),
319 };
320
321 let input_dirs = input.directives.clone();
322 let output = plugin.process(input);
323 assert_eq!(output.errors.len(), 0);
324 let directives = materialize_ops(&input_dirs, &output);
325
326 if let DirectiveData::Open(open) = &directives[0].data {
327 assert_eq!(open.account, "Expenses:Dining:Groceries");
328 }
329
330 if let DirectiveData::Open(open) = &directives[1].data {
331 assert_eq!(open.account, "Expenses:Dining:Restaurant");
332 }
333 }
334
335 #[test]
336 fn test_no_config_unchanged() {
337 let plugin = RenameAccountsPlugin;
338
339 let input = PluginInput {
340 directives: vec![create_open("Expenses:Taxes")],
341 options: PluginOptions {
342 operating_currencies: vec!["USD".to_string()],
343 title: None,
344 },
345 config: None,
346 };
347
348 let input_dirs = input.directives.clone();
349 let output = plugin.process(input);
350 assert_eq!(output.errors.len(), 0);
351 let directives = materialize_ops(&input_dirs, &output);
352
353 if let DirectiveData::Open(open) = &directives[0].data {
354 assert_eq!(open.account, "Expenses:Taxes");
355 }
356 }
357}