Skip to main content

rustledger_plugin/native/plugins/
no_unused.rs

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