rustledger_plugin/native/plugins/
rename_accounts.rs1use regex::Regex;
16use std::sync::LazyLock;
17
18use crate::types::{
19 DirectiveData, DirectiveWrapper, PadData, PluginInput, 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 directives: input.directives,
51 errors: Vec::new(),
52 };
53 }
54 },
55 None => {
56 return PluginOutput {
58 directives: input.directives,
59 errors: Vec::new(),
60 };
61 }
62 };
63
64 let new_directives: Vec<DirectiveWrapper> = input
66 .directives
67 .into_iter()
68 .map(|directive| rename_in_directive(directive, &renames))
69 .collect();
70
71 PluginOutput {
72 directives: new_directives,
73 errors: Vec::new(),
74 }
75 }
76}
77
78struct RenameRule {
80 pattern: Regex,
81 replacement: String,
82}
83
84fn rename_account(account: &str, renames: &[RenameRule]) -> String {
86 let mut result = account.to_string();
87 for rule in renames {
88 if rule.pattern.is_match(&result) {
89 result = rule
90 .pattern
91 .replace_all(&result, &rule.replacement)
92 .to_string();
93 }
94 }
95 result
96}
97
98fn rename_in_posting(mut posting: PostingData, renames: &[RenameRule]) -> PostingData {
100 posting.account = rename_account(&posting.account, renames);
101 posting
102}
103
104fn rename_in_directive(
106 mut directive: DirectiveWrapper,
107 renames: &[RenameRule],
108) -> DirectiveWrapper {
109 match &mut directive.data {
110 DirectiveData::Transaction(txn) => {
111 txn.postings = txn
112 .postings
113 .drain(..)
114 .map(|p| rename_in_posting(p, renames))
115 .collect();
116 }
117 DirectiveData::Open(open) => {
118 open.account = rename_account(&open.account, renames);
119 }
120 DirectiveData::Close(close) => {
121 close.account = rename_account(&close.account, renames);
122 }
123 DirectiveData::Balance(balance) => {
124 balance.account = rename_account(&balance.account, renames);
125 }
126 DirectiveData::Pad(pad) => {
127 let account = rename_account(&pad.account, renames);
128 let source_account = rename_account(&pad.source_account, renames);
129 *pad = PadData {
130 account,
131 source_account,
132 metadata: std::mem::take(&mut pad.metadata),
133 };
134 }
135 DirectiveData::Note(note) => {
136 note.account = rename_account(¬e.account, renames);
137 }
138 DirectiveData::Document(doc) => {
139 doc.account = rename_account(&doc.account, renames);
140 }
141 DirectiveData::Price(_)
143 | DirectiveData::Commodity(_)
144 | DirectiveData::Event(_)
145 | DirectiveData::Query(_)
146 | DirectiveData::Custom(_) => {}
147 }
148 directive
149}
150
151fn parse_config(config: &str) -> Result<Vec<RenameRule>, String> {
154 let mut rules = Vec::new();
155
156 for cap in CONFIG_KV_RE.captures_iter(config) {
159 let pattern_str = &cap[1];
160 let replacement = cap[2].to_string();
161
162 let pattern = Regex::new(pattern_str).map_err(|e| e.to_string())?;
163
164 rules.push(RenameRule {
165 pattern,
166 replacement,
167 });
168 }
169
170 if rules.is_empty() {
171 return Err("No rename rules found in config".to_string());
172 }
173
174 Ok(rules)
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::types::*;
181
182 fn create_open(account: &str) -> DirectiveWrapper {
183 DirectiveWrapper {
184 directive_type: "open".to_string(),
185 date: "2024-01-01".to_string(),
186 filename: None,
187 lineno: None,
188 data: DirectiveData::Open(OpenData {
189 account: account.to_string(),
190 currencies: vec![],
191 booking: None,
192 metadata: vec![],
193 }),
194 }
195 }
196
197 fn create_transaction(postings: Vec<(&str, &str, &str)>) -> DirectiveWrapper {
198 DirectiveWrapper {
199 directive_type: "transaction".to_string(),
200 date: "2024-01-15".to_string(),
201 filename: None,
202 lineno: None,
203 data: DirectiveData::Transaction(TransactionData {
204 flag: "*".to_string(),
205 payee: None,
206 narration: "Test".to_string(),
207 tags: vec![],
208 links: vec![],
209 metadata: vec![],
210 postings: postings
211 .into_iter()
212 .map(|(account, number, currency)| PostingData {
213 account: account.to_string(),
214 units: Some(AmountData {
215 number: number.to_string(),
216 currency: currency.to_string(),
217 }),
218 cost: None,
219 price: None,
220 flag: None,
221 metadata: vec![],
222 })
223 .collect(),
224 }),
225 }
226 }
227
228 #[test]
229 fn test_simple_rename() {
230 let plugin = RenameAccountsPlugin;
231
232 let input = PluginInput {
233 directives: vec![
234 create_open("Expenses:Taxes"),
235 create_transaction(vec![
236 ("Assets:Cash", "-100", "USD"),
237 ("Expenses:Taxes", "100", "USD"),
238 ]),
239 ],
240 options: PluginOptions {
241 operating_currencies: vec!["USD".to_string()],
242 title: None,
243 },
244 config: Some("{'Expenses:Taxes': 'Income:Taxes'}".to_string()),
245 };
246
247 let output = plugin.process(input);
248 assert_eq!(output.errors.len(), 0);
249
250 if let DirectiveData::Open(open) = &output.directives[0].data {
252 assert_eq!(open.account, "Income:Taxes");
253 } else {
254 panic!("Expected Open directive");
255 }
256
257 if let DirectiveData::Transaction(txn) = &output.directives[1].data {
259 assert_eq!(txn.postings[1].account, "Income:Taxes");
260 } else {
261 panic!("Expected Transaction directive");
262 }
263 }
264
265 #[test]
266 fn test_regex_rename() {
267 let plugin = RenameAccountsPlugin;
268
269 let input = PluginInput {
270 directives: vec![
271 create_open("Expenses:Food:Groceries"),
272 create_open("Expenses:Food:Restaurant"),
273 ],
274 options: PluginOptions {
275 operating_currencies: vec!["USD".to_string()],
276 title: None,
277 },
278 config: Some("{'Expenses:Food:(.*)': 'Expenses:Dining:$1'}".to_string()),
281 };
282
283 let output = plugin.process(input);
284 assert_eq!(output.errors.len(), 0);
285
286 if let DirectiveData::Open(open) = &output.directives[0].data {
287 assert_eq!(open.account, "Expenses:Dining:Groceries");
288 }
289
290 if let DirectiveData::Open(open) = &output.directives[1].data {
291 assert_eq!(open.account, "Expenses:Dining:Restaurant");
292 }
293 }
294
295 #[test]
296 fn test_no_config_unchanged() {
297 let plugin = RenameAccountsPlugin;
298
299 let input = PluginInput {
300 directives: vec![create_open("Expenses:Taxes")],
301 options: PluginOptions {
302 operating_currencies: vec!["USD".to_string()],
303 title: None,
304 },
305 config: None,
306 };
307
308 let output = plugin.process(input);
309 assert_eq!(output.errors.len(), 0);
310
311 if let DirectiveData::Open(open) = &output.directives[0].data {
312 assert_eq!(open.account, "Expenses:Taxes");
313 }
314 }
315}