rustledger_loader/
options.rs

1//! Beancount options parsing and storage.
2
3use rust_decimal::Decimal;
4use std::collections::{HashMap, HashSet};
5use std::str::FromStr;
6
7/// Known beancount option names.
8const KNOWN_OPTIONS: &[&str] = &[
9    "title",
10    "filename",
11    "operating_currency",
12    "name_assets",
13    "name_liabilities",
14    "name_equity",
15    "name_income",
16    "name_expenses",
17    "account_rounding",
18    "account_previous_balances",
19    "account_previous_earnings",
20    "account_previous_conversions",
21    "account_current_earnings",
22    "account_current_conversions",
23    "account_unrealized_gains",
24    "conversion_currency",
25    "inferred_tolerance_default",
26    "inferred_tolerance_multiplier",
27    "infer_tolerance_from_cost",
28    "use_legacy_fixed_tolerances",
29    "experiment_explicit_tolerances",
30    "booking_method",
31    "render_commas",
32    "allow_pipe_separator",
33    "long_string_maxlines",
34    "documents",
35    "insert_pythonpath",
36    "plugin_processing_mode",
37];
38
39/// Options that can be specified multiple times.
40const REPEATABLE_OPTIONS: &[&str] = &["operating_currency", "insert_pythonpath", "documents"];
41
42/// Option validation warning.
43#[derive(Debug, Clone)]
44pub struct OptionWarning {
45    /// Warning code (E7001, E7002, E7003).
46    pub code: &'static str,
47    /// Warning message.
48    pub message: String,
49    /// Option name.
50    pub option: String,
51    /// Option value.
52    pub value: String,
53}
54
55/// Beancount file options.
56///
57/// These correspond to the `option` directives in beancount files.
58#[derive(Debug, Clone, Default)]
59pub struct Options {
60    /// Title for the ledger.
61    pub title: Option<String>,
62
63    /// Source filename (auto-set).
64    pub filename: Option<String>,
65
66    /// Operating currencies (for reporting).
67    pub operating_currency: Vec<String>,
68
69    /// Name prefix for Assets accounts.
70    pub name_assets: String,
71
72    /// Name prefix for Liabilities accounts.
73    pub name_liabilities: String,
74
75    /// Name prefix for Equity accounts.
76    pub name_equity: String,
77
78    /// Name prefix for Income accounts.
79    pub name_income: String,
80
81    /// Name prefix for Expenses accounts.
82    pub name_expenses: String,
83
84    /// Account for rounding errors.
85    pub account_rounding: Option<String>,
86
87    /// Account for previous balances (opening balances).
88    pub account_previous_balances: String,
89
90    /// Account for previous earnings.
91    pub account_previous_earnings: String,
92
93    /// Account for previous conversions.
94    pub account_previous_conversions: String,
95
96    /// Account for current earnings.
97    pub account_current_earnings: String,
98
99    /// Account for current conversion differences.
100    pub account_current_conversions: Option<String>,
101
102    /// Account for unrealized gains.
103    pub account_unrealized_gains: Option<String>,
104
105    /// Currency for conversion (if specified).
106    pub conversion_currency: Option<String>,
107
108    /// Default tolerances per currency (e.g., "USD:0.005" or "*:0.001").
109    pub inferred_tolerance_default: HashMap<String, Decimal>,
110
111    /// Tolerance multiplier for balance assertions.
112    pub inferred_tolerance_multiplier: Decimal,
113
114    /// Whether to infer tolerance from cost.
115    pub infer_tolerance_from_cost: bool,
116
117    /// Whether to use legacy fixed tolerances.
118    pub use_legacy_fixed_tolerances: bool,
119
120    /// Enable experimental explicit tolerances in balance assertions.
121    pub experiment_explicit_tolerances: bool,
122
123    /// Default booking method.
124    pub booking_method: String,
125
126    /// Whether to render commas in numbers.
127    pub render_commas: bool,
128
129    /// Whether to allow pipe separator in numbers.
130    pub allow_pipe_separator: bool,
131
132    /// Maximum lines in multi-line strings.
133    pub long_string_maxlines: u32,
134
135    /// Directories to scan for document files.
136    pub documents: Vec<String>,
137
138    /// Any other custom options.
139    pub custom: HashMap<String, String>,
140
141    /// Options that have been set (for duplicate detection).
142    #[doc(hidden)]
143    pub set_options: HashSet<String>,
144
145    /// Validation warnings collected during parsing.
146    pub warnings: Vec<OptionWarning>,
147}
148
149impl Options {
150    /// Create new options with defaults.
151    #[must_use]
152    pub fn new() -> Self {
153        Self {
154            title: None,
155            filename: None,
156            operating_currency: Vec::new(),
157            name_assets: "Assets".to_string(),
158            name_liabilities: "Liabilities".to_string(),
159            name_equity: "Equity".to_string(),
160            name_income: "Income".to_string(),
161            name_expenses: "Expenses".to_string(),
162            account_rounding: None,
163            account_previous_balances: "Equity:Opening-Balances".to_string(),
164            account_previous_earnings: "Equity:Earnings:Previous".to_string(),
165            account_previous_conversions: "Equity:Conversions:Previous".to_string(),
166            account_current_earnings: "Equity:Earnings:Current".to_string(),
167            account_current_conversions: None,
168            account_unrealized_gains: None,
169            conversion_currency: None,
170            inferred_tolerance_default: HashMap::new(),
171            inferred_tolerance_multiplier: Decimal::new(5, 1), // 0.5
172            infer_tolerance_from_cost: true,
173            use_legacy_fixed_tolerances: false,
174            experiment_explicit_tolerances: false,
175            booking_method: "STRICT".to_string(),
176            render_commas: true,
177            allow_pipe_separator: false,
178            long_string_maxlines: 64,
179            documents: Vec::new(),
180            custom: HashMap::new(),
181            set_options: HashSet::new(),
182            warnings: Vec::new(),
183        }
184    }
185
186    /// Set an option by name.
187    ///
188    /// Validates the option and collects any warnings in `self.warnings`.
189    pub fn set(&mut self, key: &str, value: &str) {
190        // Check for unknown options (E7001)
191        let is_known = KNOWN_OPTIONS.contains(&key);
192        if !is_known {
193            self.warnings.push(OptionWarning {
194                code: "E7001",
195                message: format!("Unknown option \"{key}\""),
196                option: key.to_string(),
197                value: value.to_string(),
198            });
199        }
200
201        // Check for duplicate non-repeatable options (E7003)
202        let is_repeatable = REPEATABLE_OPTIONS.contains(&key);
203        if is_known && !is_repeatable && self.set_options.contains(key) {
204            self.warnings.push(OptionWarning {
205                code: "E7003",
206                message: format!("Option \"{key}\" can only be specified once"),
207                option: key.to_string(),
208                value: value.to_string(),
209            });
210        }
211
212        // Track that this option was set
213        self.set_options.insert(key.to_string());
214
215        // Apply the option value
216        match key {
217            "title" => self.title = Some(value.to_string()),
218            "operating_currency" => self.operating_currency.push(value.to_string()),
219            "name_assets" => self.name_assets = value.to_string(),
220            "name_liabilities" => self.name_liabilities = value.to_string(),
221            "name_equity" => self.name_equity = value.to_string(),
222            "name_income" => self.name_income = value.to_string(),
223            "name_expenses" => self.name_expenses = value.to_string(),
224            "account_rounding" => self.account_rounding = Some(value.to_string()),
225            "account_current_conversions" => {
226                self.account_current_conversions = Some(value.to_string());
227            }
228            "account_unrealized_gains" => {
229                self.account_unrealized_gains = Some(value.to_string());
230            }
231            "inferred_tolerance_multiplier" => {
232                if let Ok(d) = Decimal::from_str(value) {
233                    self.inferred_tolerance_multiplier = d;
234                } else {
235                    // E7002: Invalid option value
236                    self.warnings.push(OptionWarning {
237                        code: "E7002",
238                        message: format!(
239                            "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
240                        ),
241                        option: key.to_string(),
242                        value: value.to_string(),
243                    });
244                }
245            }
246            "infer_tolerance_from_cost" => {
247                if !value.eq_ignore_ascii_case("true") && !value.eq_ignore_ascii_case("false") {
248                    self.warnings.push(OptionWarning {
249                        code: "E7002",
250                        message: format!(
251                            "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
252                        ),
253                        option: key.to_string(),
254                        value: value.to_string(),
255                    });
256                }
257                self.infer_tolerance_from_cost = value.eq_ignore_ascii_case("true");
258            }
259            "booking_method" => {
260                let valid_methods = [
261                    "STRICT",
262                    "STRICT_WITH_SIZE",
263                    "FIFO",
264                    "LIFO",
265                    "HIFO",
266                    "AVERAGE",
267                    "NONE",
268                ];
269                if !valid_methods.contains(&value.to_uppercase().as_str()) {
270                    self.warnings.push(OptionWarning {
271                        code: "E7002",
272                        message: format!(
273                            "Invalid value \"{}\" for option \"{}\": expected one of {}",
274                            value,
275                            key,
276                            valid_methods.join(", ")
277                        ),
278                        option: key.to_string(),
279                        value: value.to_string(),
280                    });
281                }
282                self.booking_method = value.to_string();
283            }
284            "render_commas" => {
285                if !value.eq_ignore_ascii_case("true") && !value.eq_ignore_ascii_case("false") {
286                    self.warnings.push(OptionWarning {
287                        code: "E7002",
288                        message: format!(
289                            "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
290                        ),
291                        option: key.to_string(),
292                        value: value.to_string(),
293                    });
294                }
295                self.render_commas = value.eq_ignore_ascii_case("true");
296            }
297            "filename" => self.filename = Some(value.to_string()),
298            "account_previous_balances" => self.account_previous_balances = value.to_string(),
299            "account_previous_earnings" => self.account_previous_earnings = value.to_string(),
300            "account_previous_conversions" => self.account_previous_conversions = value.to_string(),
301            "account_current_earnings" => self.account_current_earnings = value.to_string(),
302            "conversion_currency" => self.conversion_currency = Some(value.to_string()),
303            "inferred_tolerance_default" => {
304                // Parse "CURRENCY:TOLERANCE" or "*:TOLERANCE"
305                if let Some((curr, tol)) = value.split_once(':') {
306                    if let Ok(d) = Decimal::from_str(tol) {
307                        self.inferred_tolerance_default.insert(curr.to_string(), d);
308                    } else {
309                        self.warnings.push(OptionWarning {
310                            code: "E7002",
311                            message: format!(
312                                "Invalid tolerance value \"{tol}\" in option \"{key}\""
313                            ),
314                            option: key.to_string(),
315                            value: value.to_string(),
316                        });
317                    }
318                } else {
319                    self.warnings.push(OptionWarning {
320                        code: "E7002",
321                        message: format!(
322                            "Invalid format for option \"{key}\": expected CURRENCY:TOLERANCE"
323                        ),
324                        option: key.to_string(),
325                        value: value.to_string(),
326                    });
327                }
328            }
329            "use_legacy_fixed_tolerances" => {
330                self.use_legacy_fixed_tolerances = value.eq_ignore_ascii_case("true");
331            }
332            "experiment_explicit_tolerances" => {
333                self.experiment_explicit_tolerances = value.eq_ignore_ascii_case("true");
334            }
335            "allow_pipe_separator" => {
336                self.allow_pipe_separator = value.eq_ignore_ascii_case("true");
337            }
338            "long_string_maxlines" => {
339                if let Ok(n) = value.parse::<u32>() {
340                    self.long_string_maxlines = n;
341                } else {
342                    self.warnings.push(OptionWarning {
343                        code: "E7002",
344                        message: format!(
345                            "Invalid value \"{value}\" for option \"{key}\": expected integer"
346                        ),
347                        option: key.to_string(),
348                        value: value.to_string(),
349                    });
350                }
351            }
352            "documents" => self.documents.push(value.to_string()),
353            _ => {
354                // Unknown options go to custom map
355                self.custom.insert(key.to_string(), value.to_string());
356            }
357        }
358    }
359
360    /// Get a custom option value.
361    #[must_use]
362    pub fn get(&self, key: &str) -> Option<&str> {
363        self.custom.get(key).map(String::as_str)
364    }
365
366    /// Get all account type prefixes.
367    #[must_use]
368    pub fn account_types(&self) -> [&str; 5] {
369        [
370            &self.name_assets,
371            &self.name_liabilities,
372            &self.name_equity,
373            &self.name_income,
374            &self.name_expenses,
375        ]
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn test_default_options() {
385        let opts = Options::new();
386        assert_eq!(opts.name_assets, "Assets");
387        assert_eq!(opts.booking_method, "STRICT");
388        assert!(opts.infer_tolerance_from_cost);
389    }
390
391    #[test]
392    fn test_set_options() {
393        let mut opts = Options::new();
394        opts.set("title", "My Ledger");
395        opts.set("operating_currency", "USD");
396        opts.set("operating_currency", "EUR");
397        opts.set("booking_method", "FIFO");
398
399        assert_eq!(opts.title, Some("My Ledger".to_string()));
400        assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
401        assert_eq!(opts.booking_method, "FIFO");
402    }
403
404    #[test]
405    fn test_custom_options() {
406        let mut opts = Options::new();
407        opts.set("my_custom_option", "my_value");
408
409        assert_eq!(opts.get("my_custom_option"), Some("my_value"));
410        assert_eq!(opts.get("nonexistent"), None);
411    }
412
413    #[test]
414    fn test_unknown_option_warning() {
415        let mut opts = Options::new();
416        opts.set("unknown_option", "value");
417
418        assert_eq!(opts.warnings.len(), 1);
419        assert_eq!(opts.warnings[0].code, "E7001");
420        assert!(opts.warnings[0].message.contains("Unknown option"));
421    }
422
423    #[test]
424    fn test_duplicate_option_warning() {
425        let mut opts = Options::new();
426        opts.set("title", "First Title");
427        opts.set("title", "Second Title");
428
429        assert_eq!(opts.warnings.len(), 1);
430        assert_eq!(opts.warnings[0].code, "E7003");
431        assert!(opts.warnings[0].message.contains("only be specified once"));
432    }
433
434    #[test]
435    fn test_repeatable_option_no_warning() {
436        let mut opts = Options::new();
437        opts.set("operating_currency", "USD");
438        opts.set("operating_currency", "EUR");
439
440        // No warnings for repeatable options
441        assert!(
442            opts.warnings.is_empty(),
443            "Should not warn for repeatable options: {:?}",
444            opts.warnings
445        );
446        assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
447    }
448
449    #[test]
450    fn test_invalid_tolerance_value() {
451        let mut opts = Options::new();
452        opts.set("inferred_tolerance_multiplier", "not_a_number");
453
454        assert_eq!(opts.warnings.len(), 1);
455        assert_eq!(opts.warnings[0].code, "E7002");
456        assert!(opts.warnings[0].message.contains("expected decimal"));
457    }
458
459    #[test]
460    fn test_invalid_boolean_value() {
461        let mut opts = Options::new();
462        opts.set("infer_tolerance_from_cost", "maybe");
463
464        assert_eq!(opts.warnings.len(), 1);
465        assert_eq!(opts.warnings[0].code, "E7002");
466        assert!(opts.warnings[0].message.contains("TRUE or FALSE"));
467    }
468
469    #[test]
470    fn test_invalid_booking_method() {
471        let mut opts = Options::new();
472        opts.set("booking_method", "RANDOM");
473
474        assert_eq!(opts.warnings.len(), 1);
475        assert_eq!(opts.warnings[0].code, "E7002");
476        assert!(opts.warnings[0].message.contains("STRICT"));
477    }
478
479    #[test]
480    fn test_valid_booking_methods() {
481        for method in &["STRICT", "FIFO", "LIFO", "AVERAGE", "NONE"] {
482            let mut opts = Options::new();
483            opts.set("booking_method", method);
484            assert!(
485                opts.warnings.is_empty(),
486                "Should accept {method} as valid booking method"
487            );
488        }
489    }
490}