Skip to main content

rustledger_plugin/native/plugins/
no_unused.rs

1//! Warn about unused accounts.
2
3use crate::types::{
4    DirectiveData, MetaValueData, PluginError, PluginInput, PluginOp, PluginOutput,
5};
6
7use super::super::{NativePlugin, RegularPlugin};
8
9/// Plugin that identifies accounts that are opened but never used.
10///
11/// Reports a warning for each account that has an Open directive but is never
12/// referenced in any transaction, balance, pad, or other directive.
13pub struct NoUnusedPlugin;
14
15impl NativePlugin for NoUnusedPlugin {
16    fn name(&self) -> &'static str {
17        "nounused"
18    }
19
20    fn description(&self) -> &'static str {
21        "Warn about unused accounts"
22    }
23
24    fn process(&self, input: PluginInput) -> PluginOutput {
25        use std::collections::HashSet;
26
27        let mut opened_accounts: HashSet<String> = HashSet::new();
28        let mut used_accounts: HashSet<String> = HashSet::new();
29
30        // Collect all opened accounts and used accounts in one pass
31        for wrapper in &input.directives {
32            match &wrapper.data {
33                DirectiveData::Open(data) => {
34                    opened_accounts.insert(data.account.clone());
35                }
36                DirectiveData::Close(data) => {
37                    // Closing an account counts as using it
38                    used_accounts.insert(data.account.clone());
39                }
40                DirectiveData::Transaction(txn) => {
41                    for posting in &txn.postings {
42                        used_accounts.insert(posting.account.clone());
43                    }
44                }
45                DirectiveData::Balance(data) => {
46                    used_accounts.insert(data.account.clone());
47                }
48                DirectiveData::Pad(data) => {
49                    used_accounts.insert(data.account.clone());
50                    used_accounts.insert(data.source_account.clone());
51                }
52                DirectiveData::Note(data) => {
53                    used_accounts.insert(data.account.clone());
54                }
55                DirectiveData::Document(data) => {
56                    used_accounts.insert(data.account.clone());
57                }
58                DirectiveData::Custom(data) => {
59                    // Check custom directive values for account references
60                    for value in &data.values {
61                        if let MetaValueData::Account(account) = value {
62                            used_accounts.insert(account.clone());
63                        }
64                    }
65                }
66                _ => {}
67            }
68        }
69
70        // Find unused accounts (opened but never used)
71        let mut errors = Vec::new();
72        let mut unused: Vec<_> = opened_accounts
73            .difference(&used_accounts)
74            .cloned()
75            .collect();
76        unused.sort(); // Consistent ordering for output
77
78        for account in unused {
79            errors.push(PluginError::warning(format!(
80                "Account '{account}' is opened but never used"
81            )));
82        }
83
84        PluginOutput {
85            ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
86            errors,
87        }
88    }
89}
90
91impl RegularPlugin for NoUnusedPlugin {}
92
93#[cfg(test)]
94mod nounused_tests {
95    use super::*;
96    use crate::types::*;
97
98    #[test]
99    fn test_nounused_reports_unused_account() {
100        let plugin = NoUnusedPlugin;
101
102        let input = PluginInput {
103            directives: vec![
104                DirectiveWrapper {
105                    directive_type: "open".to_string(),
106                    date: "2024-01-01".to_string(),
107                    filename: None,
108                    lineno: None,
109                    data: DirectiveData::Open(OpenData {
110                        account: "Assets:Bank".to_string(),
111                        currencies: vec![],
112                        booking: None,
113                        metadata: vec![],
114                    }),
115                },
116                DirectiveWrapper {
117                    directive_type: "open".to_string(),
118                    date: "2024-01-01".to_string(),
119                    filename: None,
120                    lineno: None,
121                    data: DirectiveData::Open(OpenData {
122                        account: "Assets:Unused".to_string(),
123                        currencies: vec![],
124                        booking: None,
125                        metadata: vec![],
126                    }),
127                },
128                DirectiveWrapper {
129                    directive_type: "transaction".to_string(),
130                    date: "2024-01-15".to_string(),
131                    filename: None,
132                    lineno: None,
133                    data: DirectiveData::Transaction(TransactionData {
134                        flag: "*".to_string(),
135                        payee: None,
136                        narration: "Test".to_string(),
137                        tags: vec![],
138                        links: vec![],
139                        metadata: vec![],
140                        postings: vec![PostingData {
141                            account: "Assets:Bank".to_string(),
142                            units: Some(AmountData {
143                                number: "100".to_string(),
144                                currency: "USD".to_string(),
145                            }),
146                            cost: None,
147                            price: None,
148                            flag: None,
149                            metadata: vec![],
150                            span: None,
151                        }],
152                    }),
153                },
154            ],
155            options: PluginOptions {
156                operating_currencies: vec!["USD".to_string()],
157                title: None,
158            },
159            config: None,
160        };
161
162        let output = plugin.process(input);
163        assert_eq!(output.errors.len(), 1);
164        assert!(output.errors[0].message.contains("Assets:Unused"));
165        assert!(output.errors[0].message.contains("never used"));
166    }
167
168    #[test]
169    fn test_nounused_no_warning_for_used_accounts() {
170        let plugin = NoUnusedPlugin;
171
172        let input = PluginInput {
173            directives: vec![
174                DirectiveWrapper {
175                    directive_type: "open".to_string(),
176                    date: "2024-01-01".to_string(),
177                    filename: None,
178                    lineno: None,
179                    data: DirectiveData::Open(OpenData {
180                        account: "Assets:Bank".to_string(),
181                        currencies: vec![],
182                        booking: None,
183                        metadata: vec![],
184                    }),
185                },
186                DirectiveWrapper {
187                    directive_type: "transaction".to_string(),
188                    date: "2024-01-15".to_string(),
189                    filename: None,
190                    lineno: None,
191                    data: DirectiveData::Transaction(TransactionData {
192                        flag: "*".to_string(),
193                        payee: None,
194                        narration: "Test".to_string(),
195                        tags: vec![],
196                        links: vec![],
197                        metadata: vec![],
198                        postings: vec![PostingData {
199                            account: "Assets:Bank".to_string(),
200                            units: Some(AmountData {
201                                number: "100".to_string(),
202                                currency: "USD".to_string(),
203                            }),
204                            cost: None,
205                            price: None,
206                            flag: None,
207                            metadata: vec![],
208                            span: None,
209                        }],
210                    }),
211                },
212            ],
213            options: PluginOptions {
214                operating_currencies: vec!["USD".to_string()],
215                title: None,
216            },
217            config: None,
218        };
219
220        let output = plugin.process(input);
221        assert_eq!(output.errors.len(), 0);
222    }
223
224    #[test]
225    fn test_nounused_close_counts_as_used() {
226        let plugin = NoUnusedPlugin;
227
228        let input = PluginInput {
229            directives: vec![
230                DirectiveWrapper {
231                    directive_type: "open".to_string(),
232                    date: "2024-01-01".to_string(),
233                    filename: None,
234                    lineno: None,
235                    data: DirectiveData::Open(OpenData {
236                        account: "Assets:OldAccount".to_string(),
237                        currencies: vec![],
238                        booking: None,
239                        metadata: vec![],
240                    }),
241                },
242                DirectiveWrapper {
243                    directive_type: "close".to_string(),
244                    date: "2024-12-31".to_string(),
245                    filename: None,
246                    lineno: None,
247                    data: DirectiveData::Close(CloseData {
248                        account: "Assets:OldAccount".to_string(),
249                        metadata: vec![],
250                    }),
251                },
252            ],
253            options: PluginOptions {
254                operating_currencies: vec!["USD".to_string()],
255                title: None,
256            },
257            config: None,
258        };
259
260        let output = plugin.process(input);
261        // Close counts as usage, so no warning
262        assert_eq!(output.errors.len(), 0);
263    }
264}