Skip to main content

rustledger_plugin/native/plugins/
rename_accounts.rs

1//! Rename accounts plugin.
2//!
3//! This plugin renames accounts using regex patterns. It takes a configuration
4//! dict mapping regex patterns to replacement strings.
5//!
6//! Usage:
7//! ```beancount
8//! plugin "beancount_reds_plugins.rename_accounts.rename_accounts" "{'Expenses:Taxes': 'Income:Taxes'}"
9//! ```
10//!
11//! The configuration is a Python-style dict where keys are regex patterns and
12//! values are replacement strings. All accounts matching a pattern will be
13//! renamed using the corresponding replacement.
14
15use regex::Regex;
16
17use crate::types::{
18    DirectiveData, DirectiveWrapper, PadData, PluginInput, PluginOutput, PostingData,
19};
20
21use super::super::NativePlugin;
22
23/// Plugin for renaming accounts using regex patterns.
24pub 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        // Parse configuration to get renames
37        let renames = match &input.config {
38            Some(config) => match parse_config(config) {
39                Ok(r) => r,
40                Err(_) => {
41                    // If config parsing fails, return unchanged
42                    return PluginOutput {
43                        directives: input.directives,
44                        errors: Vec::new(),
45                    };
46                }
47            },
48            None => {
49                // No config, return unchanged
50                return PluginOutput {
51                    directives: input.directives,
52                    errors: Vec::new(),
53                };
54            }
55        };
56
57        // Process entries
58        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
71/// A rename rule: compiled regex and replacement string.
72struct RenameRule {
73    pattern: Regex,
74    replacement: String,
75}
76
77/// Apply renames to an account name.
78fn 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
91/// Apply renames to a posting.
92fn rename_in_posting(mut posting: PostingData, renames: &[RenameRule]) -> PostingData {
93    posting.account = rename_account(&posting.account, renames);
94    posting
95}
96
97/// Apply renames to a directive.
98fn 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(&note.account, renames);
130        }
131        DirectiveData::Document(doc) => {
132            doc.account = rename_account(&doc.account, renames);
133        }
134        // Price, Commodity, Event, Query, Custom don't have accounts
135        DirectiveData::Price(_)
136        | DirectiveData::Commodity(_)
137        | DirectiveData::Event(_)
138        | DirectiveData::Query(_)
139        | DirectiveData::Custom(_) => {}
140    }
141    directive
142}
143
144/// Parse configuration string into rename rules.
145/// Format: "{'pattern1': 'replacement1', 'pattern2': 'replacement2'}"
146fn parse_config(config: &str) -> Result<Vec<RenameRule>, String> {
147    let mut rules = Vec::new();
148
149    // Parse Python-style dict: {'key': 'value', ...}
150    // Use regex to extract key-value pairs
151    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        // Check Open directive was renamed
246        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        // Check Transaction posting was renamed
253        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            // Rename all Food sub-accounts to Dining
274            // In Rust regex, backreferences use $1 syntax
275            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}