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;
16use std::sync::LazyLock;
17
18use crate::types::{
19    DirectiveData, DirectiveWrapper, PadData, PluginInput, PluginOutput, PostingData,
20};
21
22use super::super::NativePlugin;
23
24/// Regex for parsing config key-value pairs.
25/// Format: `'pattern': 'replacement'`
26static CONFIG_KV_RE: LazyLock<Regex> = LazyLock::new(|| {
27    Regex::new(r"'([^']+)'\s*:\s*'([^']*)'").expect("CONFIG_KV_RE: invalid regex pattern")
28});
29
30/// Plugin for renaming accounts using regex patterns.
31pub 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        // Parse configuration to get renames
44        let renames = match &input.config {
45            Some(config) => match parse_config(config) {
46                Ok(r) => r,
47                Err(_) => {
48                    // If config parsing fails, return unchanged
49                    return PluginOutput {
50                        directives: input.directives,
51                        errors: Vec::new(),
52                    };
53                }
54            },
55            None => {
56                // No config, return unchanged
57                return PluginOutput {
58                    directives: input.directives,
59                    errors: Vec::new(),
60                };
61            }
62        };
63
64        // Process entries
65        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
78/// A rename rule: compiled regex and replacement string.
79struct RenameRule {
80    pattern: Regex,
81    replacement: String,
82}
83
84/// Apply renames to an account name.
85fn 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
98/// Apply renames to a posting.
99fn rename_in_posting(mut posting: PostingData, renames: &[RenameRule]) -> PostingData {
100    posting.account = rename_account(&posting.account, renames);
101    posting
102}
103
104/// Apply renames to a directive.
105fn 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(&note.account, renames);
137        }
138        DirectiveData::Document(doc) => {
139            doc.account = rename_account(&doc.account, renames);
140        }
141        // Price, Commodity, Event, Query, Custom don't have accounts
142        DirectiveData::Price(_)
143        | DirectiveData::Commodity(_)
144        | DirectiveData::Event(_)
145        | DirectiveData::Query(_)
146        | DirectiveData::Custom(_) => {}
147    }
148    directive
149}
150
151/// Parse configuration string into rename rules.
152/// Format: "{'pattern1': 'replacement1', 'pattern2': 'replacement2'}"
153fn parse_config(config: &str) -> Result<Vec<RenameRule>, String> {
154    let mut rules = Vec::new();
155
156    // Parse Python-style dict: {'key': 'value', ...}
157    // Use cached regex to extract key-value pairs
158    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        // Check Open directive was renamed
251        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        // Check Transaction posting was renamed
258        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            // Rename all Food sub-accounts to Dining
279            // In Rust regex, backreferences use $1 syntax
280            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}