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, PluginOp, PluginOutput, PostingData,
20};
21
22use super::super::{NativePlugin, RegularPlugin};
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                        ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
51                        errors: Vec::new(),
52                    };
53                }
54            },
55            None => {
56                // No config, return unchanged
57                return PluginOutput {
58                    ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
59                    errors: Vec::new(),
60                };
61            }
62        };
63
64        // Process entries — emit Modify when an account name changed,
65        // Keep otherwise. We compare before/after rename to avoid emitting
66        // a Modify wrapper for directives the rename rules didn't touch.
67        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
86/// Cheap structural check — only compares the account name fields the
87/// rename plugin can touch. Avoids a full `PartialEq` requirement and
88/// keeps the check tight to what changed.
89fn 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        // Other directive types don't carry account references.
108        _ => true,
109    }
110}
111
112/// A rename rule: compiled regex and replacement string.
113struct RenameRule {
114    pattern: Regex,
115    replacement: String,
116}
117
118/// Apply renames to an account name.
119fn 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
132/// Apply renames to a posting.
133fn rename_in_posting(mut posting: PostingData, renames: &[RenameRule]) -> PostingData {
134    posting.account = rename_account(&posting.account, renames);
135    posting
136}
137
138/// Apply renames to a directive.
139fn 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(&note.account, renames);
171        }
172        DirectiveData::Document(doc) => {
173            doc.account = rename_account(&doc.account, renames);
174        }
175        // Price, Commodity, Event, Query, Custom don't have accounts
176        DirectiveData::Price(_)
177        | DirectiveData::Commodity(_)
178        | DirectiveData::Event(_)
179        | DirectiveData::Query(_)
180        | DirectiveData::Custom(_) => {}
181    }
182    directive
183}
184
185/// Parse configuration string into rename rules.
186/// Format: "{'pattern1': 'replacement1', 'pattern2': 'replacement2'}"
187fn parse_config(config: &str) -> Result<Vec<RenameRule>, String> {
188    let mut rules = Vec::new();
189
190    // Parse Python-style dict: {'key': 'value', ...}
191    // Use cached regex to extract key-value pairs
192    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        // Check Open directive was renamed
289        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        // Check Transaction posting was renamed
296        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            // Rename all Food sub-accounts to Dining
317            // In Rust regex, backreferences use $1 syntax
318            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}