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