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;
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
91#[cfg(test)]
92mod nounused_tests {
93    use super::*;
94    use crate::types::*;
95
96    #[test]
97    fn test_nounused_reports_unused_account() {
98        let plugin = NoUnusedPlugin;
99
100        let input = PluginInput {
101            directives: vec![
102                DirectiveWrapper {
103                    directive_type: "open".to_string(),
104                    date: "2024-01-01".to_string(),
105                    filename: None,
106                    lineno: None,
107                    data: DirectiveData::Open(OpenData {
108                        account: "Assets:Bank".to_string(),
109                        currencies: vec![],
110                        booking: None,
111                        metadata: vec![],
112                    }),
113                },
114                DirectiveWrapper {
115                    directive_type: "open".to_string(),
116                    date: "2024-01-01".to_string(),
117                    filename: None,
118                    lineno: None,
119                    data: DirectiveData::Open(OpenData {
120                        account: "Assets:Unused".to_string(),
121                        currencies: vec![],
122                        booking: None,
123                        metadata: vec![],
124                    }),
125                },
126                DirectiveWrapper {
127                    directive_type: "transaction".to_string(),
128                    date: "2024-01-15".to_string(),
129                    filename: None,
130                    lineno: None,
131                    data: DirectiveData::Transaction(TransactionData {
132                        flag: "*".to_string(),
133                        payee: None,
134                        narration: "Test".to_string(),
135                        tags: vec![],
136                        links: vec![],
137                        metadata: vec![],
138                        postings: vec![PostingData {
139                            account: "Assets:Bank".to_string(),
140                            units: Some(AmountData {
141                                number: "100".to_string(),
142                                currency: "USD".to_string(),
143                            }),
144                            cost: None,
145                            price: None,
146                            flag: None,
147                            metadata: vec![],
148                        }],
149                    }),
150                },
151            ],
152            options: PluginOptions {
153                operating_currencies: vec!["USD".to_string()],
154                title: None,
155            },
156            config: None,
157        };
158
159        let output = plugin.process(input);
160        assert_eq!(output.errors.len(), 1);
161        assert!(output.errors[0].message.contains("Assets:Unused"));
162        assert!(output.errors[0].message.contains("never used"));
163    }
164
165    #[test]
166    fn test_nounused_no_warning_for_used_accounts() {
167        let plugin = NoUnusedPlugin;
168
169        let input = PluginInput {
170            directives: vec![
171                DirectiveWrapper {
172                    directive_type: "open".to_string(),
173                    date: "2024-01-01".to_string(),
174                    filename: None,
175                    lineno: None,
176                    data: DirectiveData::Open(OpenData {
177                        account: "Assets:Bank".to_string(),
178                        currencies: vec![],
179                        booking: None,
180                        metadata: vec![],
181                    }),
182                },
183                DirectiveWrapper {
184                    directive_type: "transaction".to_string(),
185                    date: "2024-01-15".to_string(),
186                    filename: None,
187                    lineno: None,
188                    data: DirectiveData::Transaction(TransactionData {
189                        flag: "*".to_string(),
190                        payee: None,
191                        narration: "Test".to_string(),
192                        tags: vec![],
193                        links: vec![],
194                        metadata: vec![],
195                        postings: vec![PostingData {
196                            account: "Assets:Bank".to_string(),
197                            units: Some(AmountData {
198                                number: "100".to_string(),
199                                currency: "USD".to_string(),
200                            }),
201                            cost: None,
202                            price: None,
203                            flag: None,
204                            metadata: vec![],
205                        }],
206                    }),
207                },
208            ],
209            options: PluginOptions {
210                operating_currencies: vec!["USD".to_string()],
211                title: None,
212            },
213            config: None,
214        };
215
216        let output = plugin.process(input);
217        assert_eq!(output.errors.len(), 0);
218    }
219
220    #[test]
221    fn test_nounused_close_counts_as_used() {
222        let plugin = NoUnusedPlugin;
223
224        let input = PluginInput {
225            directives: vec![
226                DirectiveWrapper {
227                    directive_type: "open".to_string(),
228                    date: "2024-01-01".to_string(),
229                    filename: None,
230                    lineno: None,
231                    data: DirectiveData::Open(OpenData {
232                        account: "Assets:OldAccount".to_string(),
233                        currencies: vec![],
234                        booking: None,
235                        metadata: vec![],
236                    }),
237                },
238                DirectiveWrapper {
239                    directive_type: "close".to_string(),
240                    date: "2024-12-31".to_string(),
241                    filename: None,
242                    lineno: None,
243                    data: DirectiveData::Close(CloseData {
244                        account: "Assets:OldAccount".to_string(),
245                        metadata: vec![],
246                    }),
247                },
248            ],
249            options: PluginOptions {
250                operating_currencies: vec!["USD".to_string()],
251                title: None,
252            },
253            config: None,
254        };
255
256        let output = plugin.process(input);
257        // Close counts as usage, so no warning
258        assert_eq!(output.errors.len(), 0);
259    }
260}