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;
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
84/// Cheap structural check — only compares the account name fields the
85/// rename plugin can touch. Avoids a full `PartialEq` requirement and
86/// keeps the check tight to what changed.
87fn directive_has_same_accounts(a: &DirectiveWrapper, b: &DirectiveWrapper) -> bool {
88    match (&a.data, &b.data) {
89        (DirectiveData::Transaction(ta), DirectiveData::Transaction(tb)) => {
90            ta.postings.len() == tb.postings.len()
91                && ta
92                    .postings
93                    .iter()
94                    .zip(tb.postings.iter())
95                    .all(|(pa, pb)| pa.account == pb.account)
96        }
97        (DirectiveData::Open(a), DirectiveData::Open(b)) => a.account == b.account,
98        (DirectiveData::Close(a), DirectiveData::Close(b)) => a.account == b.account,
99        (DirectiveData::Balance(a), DirectiveData::Balance(b)) => a.account == b.account,
100        (DirectiveData::Pad(a), DirectiveData::Pad(b)) => {
101            a.account == b.account && a.source_account == b.source_account
102        }
103        (DirectiveData::Note(a), DirectiveData::Note(b)) => a.account == b.account,
104        (DirectiveData::Document(a), DirectiveData::Document(b)) => a.account == b.account,
105        // Other directive types don't carry account references.
106        _ => true,
107    }
108}
109
110/// A rename rule: compiled regex and replacement string.
111struct RenameRule {
112    pattern: Regex,
113    replacement: String,
114}
115
116/// Apply renames to an account name.
117fn rename_account(account: &str, renames: &[RenameRule]) -> String {
118    let mut result = account.to_string();
119    for rule in renames {
120        if rule.pattern.is_match(&result) {
121            result = rule
122                .pattern
123                .replace_all(&result, &rule.replacement)
124                .to_string();
125        }
126    }
127    result
128}
129
130/// Apply renames to a posting.
131fn rename_in_posting(mut posting: PostingData, renames: &[RenameRule]) -> PostingData {
132    posting.account = rename_account(&posting.account, renames);
133    posting
134}
135
136/// Apply renames to a directive.
137fn rename_in_directive(
138    mut directive: DirectiveWrapper,
139    renames: &[RenameRule],
140) -> DirectiveWrapper {
141    match &mut directive.data {
142        DirectiveData::Transaction(txn) => {
143            txn.postings = txn
144                .postings
145                .drain(..)
146                .map(|p| rename_in_posting(p, renames))
147                .collect();
148        }
149        DirectiveData::Open(open) => {
150            open.account = rename_account(&open.account, renames);
151        }
152        DirectiveData::Close(close) => {
153            close.account = rename_account(&close.account, renames);
154        }
155        DirectiveData::Balance(balance) => {
156            balance.account = rename_account(&balance.account, renames);
157        }
158        DirectiveData::Pad(pad) => {
159            let account = rename_account(&pad.account, renames);
160            let source_account = rename_account(&pad.source_account, renames);
161            *pad = PadData {
162                account,
163                source_account,
164                metadata: std::mem::take(&mut pad.metadata),
165            };
166        }
167        DirectiveData::Note(note) => {
168            note.account = rename_account(&note.account, renames);
169        }
170        DirectiveData::Document(doc) => {
171            doc.account = rename_account(&doc.account, renames);
172        }
173        // Price, Commodity, Event, Query, Custom don't have accounts
174        DirectiveData::Price(_)
175        | DirectiveData::Commodity(_)
176        | DirectiveData::Event(_)
177        | DirectiveData::Query(_)
178        | DirectiveData::Custom(_) => {}
179    }
180    directive
181}
182
183/// Parse configuration string into rename rules.
184/// Format: "{'pattern1': 'replacement1', 'pattern2': 'replacement2'}"
185fn parse_config(config: &str) -> Result<Vec<RenameRule>, String> {
186    let mut rules = Vec::new();
187
188    // Parse Python-style dict: {'key': 'value', ...}
189    // Use cached regex to extract key-value pairs
190    for cap in CONFIG_KV_RE.captures_iter(config) {
191        let pattern_str = &cap[1];
192        let replacement = cap[2].to_string();
193
194        let pattern = Regex::new(pattern_str).map_err(|e| e.to_string())?;
195
196        rules.push(RenameRule {
197            pattern,
198            replacement,
199        });
200    }
201
202    if rules.is_empty() {
203        return Err("No rename rules found in config".to_string());
204    }
205
206    Ok(rules)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::super::utils::materialize_ops;
212    use super::*;
213    use crate::types::*;
214
215    fn create_open(account: &str) -> DirectiveWrapper {
216        DirectiveWrapper {
217            directive_type: "open".to_string(),
218            date: "2024-01-01".to_string(),
219            filename: None,
220            lineno: None,
221            data: DirectiveData::Open(OpenData {
222                account: account.to_string(),
223                currencies: vec![],
224                booking: None,
225                metadata: vec![],
226            }),
227        }
228    }
229
230    fn create_transaction(postings: Vec<(&str, &str, &str)>) -> DirectiveWrapper {
231        DirectiveWrapper {
232            directive_type: "transaction".to_string(),
233            date: "2024-01-15".to_string(),
234            filename: None,
235            lineno: None,
236            data: DirectiveData::Transaction(TransactionData {
237                flag: "*".to_string(),
238                payee: None,
239                narration: "Test".to_string(),
240                tags: vec![],
241                links: vec![],
242                metadata: vec![],
243                postings: postings
244                    .into_iter()
245                    .map(|(account, number, currency)| PostingData {
246                        account: account.to_string(),
247                        units: Some(AmountData {
248                            number: number.to_string(),
249                            currency: currency.to_string(),
250                        }),
251                        cost: None,
252                        price: None,
253                        flag: None,
254                        metadata: vec![],
255                    })
256                    .collect(),
257            }),
258        }
259    }
260
261    #[test]
262    fn test_simple_rename() {
263        let plugin = RenameAccountsPlugin;
264
265        let input = PluginInput {
266            directives: vec![
267                create_open("Expenses:Taxes"),
268                create_transaction(vec![
269                    ("Assets:Cash", "-100", "USD"),
270                    ("Expenses:Taxes", "100", "USD"),
271                ]),
272            ],
273            options: PluginOptions {
274                operating_currencies: vec!["USD".to_string()],
275                title: None,
276            },
277            config: Some("{'Expenses:Taxes': 'Income:Taxes'}".to_string()),
278        };
279
280        let input_dirs = input.directives.clone();
281        let output = plugin.process(input);
282        assert_eq!(output.errors.len(), 0);
283        let directives = materialize_ops(&input_dirs, &output);
284
285        // Check Open directive was renamed
286        if let DirectiveData::Open(open) = &directives[0].data {
287            assert_eq!(open.account, "Income:Taxes");
288        } else {
289            panic!("Expected Open directive");
290        }
291
292        // Check Transaction posting was renamed
293        if let DirectiveData::Transaction(txn) = &directives[1].data {
294            assert_eq!(txn.postings[1].account, "Income:Taxes");
295        } else {
296            panic!("Expected Transaction directive");
297        }
298    }
299
300    #[test]
301    fn test_regex_rename() {
302        let plugin = RenameAccountsPlugin;
303
304        let input = PluginInput {
305            directives: vec![
306                create_open("Expenses:Food:Groceries"),
307                create_open("Expenses:Food:Restaurant"),
308            ],
309            options: PluginOptions {
310                operating_currencies: vec!["USD".to_string()],
311                title: None,
312            },
313            // Rename all Food sub-accounts to Dining
314            // In Rust regex, backreferences use $1 syntax
315            config: Some("{'Expenses:Food:(.*)': 'Expenses:Dining:$1'}".to_string()),
316        };
317
318        let input_dirs = input.directives.clone();
319        let output = plugin.process(input);
320        assert_eq!(output.errors.len(), 0);
321        let directives = materialize_ops(&input_dirs, &output);
322
323        if let DirectiveData::Open(open) = &directives[0].data {
324            assert_eq!(open.account, "Expenses:Dining:Groceries");
325        }
326
327        if let DirectiveData::Open(open) = &directives[1].data {
328            assert_eq!(open.account, "Expenses:Dining:Restaurant");
329        }
330    }
331
332    #[test]
333    fn test_no_config_unchanged() {
334        let plugin = RenameAccountsPlugin;
335
336        let input = PluginInput {
337            directives: vec![create_open("Expenses:Taxes")],
338            options: PluginOptions {
339                operating_currencies: vec!["USD".to_string()],
340                title: None,
341            },
342            config: None,
343        };
344
345        let input_dirs = input.directives.clone();
346        let output = plugin.process(input);
347        assert_eq!(output.errors.len(), 0);
348        let directives = materialize_ops(&input_dirs, &output);
349
350        if let DirectiveData::Open(open) = &directives[0].data {
351            assert_eq!(open.account, "Expenses:Taxes");
352        }
353    }
354}