Skip to main content

rustledger_loader/
options.rs

1//! Beancount options parsing and storage.
2
3use rust_decimal::Decimal;
4use rustc_hash::{FxHashMap, FxHashSet};
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    "use_precise_interpolation",
31    "booking_method",
32    "render_commas",
33    "display_precision",
34    "allow_pipe_separator",
35    "long_string_maxlines",
36    "documents",
37    "insert_pythonpath",
38    "plugin_processing_mode",
39    "plugin",               // Deprecated, but still known
40    "tolerance_multiplier", // Renamed from inferred_tolerance_multiplier
41];
42
43/// Options that can be specified multiple times.
44const REPEATABLE_OPTIONS: &[&str] = &[
45    "operating_currency",
46    "insert_pythonpath",
47    "documents",
48    "inferred_tolerance_default",
49    "display_precision",
50];
51
52/// Options that are read-only and cannot be set by users.
53const READONLY_OPTIONS: &[&str] = &["filename"];
54
55/// Option validation warning.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct OptionWarning {
58    /// Warning code (E7001 through E7008).
59    pub code: &'static str,
60    /// Warning message.
61    pub message: String,
62    /// Option name.
63    pub option: String,
64    /// Option value.
65    pub value: String,
66}
67
68/// Beancount file options.
69///
70/// These correspond to the `option` directives in beancount files.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct Options {
73    /// Title for the ledger.
74    pub title: Option<String>,
75
76    /// Source filename (auto-set).
77    pub filename: Option<String>,
78
79    /// Operating currencies (for reporting).
80    pub operating_currency: Vec<String>,
81
82    /// Name prefix for Assets accounts.
83    pub name_assets: String,
84
85    /// Name prefix for Liabilities accounts.
86    pub name_liabilities: String,
87
88    /// Name prefix for Equity accounts.
89    pub name_equity: String,
90
91    /// Name prefix for Income accounts.
92    pub name_income: String,
93
94    /// Name prefix for Expenses accounts.
95    pub name_expenses: String,
96
97    /// Account for rounding errors.
98    pub account_rounding: Option<String>,
99
100    /// Account for previous balances (opening balances).
101    pub account_previous_balances: String,
102
103    /// Account for previous earnings.
104    pub account_previous_earnings: String,
105
106    /// Account for previous conversions.
107    pub account_previous_conversions: String,
108
109    /// Account for current earnings.
110    pub account_current_earnings: String,
111
112    /// Account for current conversion differences.
113    pub account_current_conversions: Option<String>,
114
115    /// Account for unrealized gains.
116    pub account_unrealized_gains: Option<String>,
117
118    /// Currency for conversion (if specified).
119    pub conversion_currency: Option<String>,
120
121    /// Default tolerances per currency (e.g., "USD:0.005" or "*:0.001").
122    pub inferred_tolerance_default: FxHashMap<String, Decimal>,
123
124    /// Tolerance multiplier for balance assertions.
125    pub inferred_tolerance_multiplier: Decimal,
126
127    /// Whether to infer tolerance from cost.
128    pub infer_tolerance_from_cost: bool,
129
130    /// Whether to use legacy fixed tolerances.
131    pub use_legacy_fixed_tolerances: bool,
132
133    /// Enable experimental explicit tolerances in balance assertions.
134    pub experiment_explicit_tolerances: bool,
135
136    /// Beancount 3.x `use_precise_interpolation` flag, parsed for compatibility.
137    /// rustledger always interpolates with exact `rust_decimal` arithmetic, so
138    /// this is effectively always-on; the field records the user's declared
139    /// value but does not change booking results (issue #1416).
140    pub use_precise_interpolation: bool,
141
142    /// Default booking method.
143    pub booking_method: String,
144
145    /// Whether to render commas in numbers.
146    pub render_commas: bool,
147
148    /// Display precision per currency (e.g., "USD:2" means format USD with 2 decimal places).
149    /// Format: CURRENCY:PRECISION where PRECISION is the number of decimal places.
150    pub display_precision: FxHashMap<String, u32>,
151
152    /// Whether to allow pipe separator in numbers.
153    pub allow_pipe_separator: bool,
154
155    /// Maximum lines in multi-line strings.
156    pub long_string_maxlines: u32,
157
158    /// Directories to scan for document files.
159    pub documents: Vec<String>,
160
161    /// Plugin processing mode: "default" or "raw".
162    pub plugin_processing_mode: String,
163
164    /// Any other custom options.
165    pub custom: FxHashMap<String, String>,
166
167    /// Options that have been set (for duplicate detection).
168    #[doc(hidden)]
169    pub set_options: FxHashSet<String>,
170
171    /// Validation warnings collected during parsing.
172    pub warnings: Vec<OptionWarning>,
173}
174
175impl Default for Options {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181impl Options {
182    /// Create new options with defaults.
183    #[must_use]
184    pub fn new() -> Self {
185        Self {
186            title: None,
187            filename: None,
188            operating_currency: Vec::new(),
189            name_assets: "Assets".to_string(),
190            name_liabilities: "Liabilities".to_string(),
191            name_equity: "Equity".to_string(),
192            name_income: "Income".to_string(),
193            name_expenses: "Expenses".to_string(),
194            account_rounding: None,
195            account_previous_balances: "Equity:Opening-Balances".to_string(),
196            account_previous_earnings: "Equity:Earnings:Previous".to_string(),
197            account_previous_conversions: "Equity:Conversions:Previous".to_string(),
198            account_current_earnings: "Equity:Earnings:Current".to_string(),
199            account_current_conversions: None,
200            account_unrealized_gains: None,
201            conversion_currency: None,
202            inferred_tolerance_default: FxHashMap::default(),
203            inferred_tolerance_multiplier: Decimal::new(5, 1), // 0.5
204            infer_tolerance_from_cost: false,
205            use_legacy_fixed_tolerances: false,
206            experiment_explicit_tolerances: false,
207            use_precise_interpolation: false,
208            booking_method: "STRICT".to_string(),
209            render_commas: false, // Python beancount default is FALSE
210            display_precision: FxHashMap::default(),
211            allow_pipe_separator: false,
212            long_string_maxlines: 64,
213            documents: Vec::new(),
214            plugin_processing_mode: "default".to_string(),
215            custom: FxHashMap::default(),
216            set_options: FxHashSet::default(),
217            warnings: Vec::new(),
218        }
219    }
220
221    /// Set an option by name.
222    ///
223    /// Validates the option and collects any warnings in `self.warnings`.
224    pub fn set(&mut self, key: &str, value: &str) {
225        // Check for unknown options (E7001)
226        let is_known = KNOWN_OPTIONS.contains(&key);
227        if !is_known {
228            self.warnings.push(OptionWarning {
229                code: "E7001",
230                message: format!("Invalid option \"{key}\""),
231                option: key.to_string(),
232                value: value.to_string(),
233            });
234        }
235
236        // Check for read-only options (E7005)
237        if READONLY_OPTIONS.contains(&key) {
238            self.warnings.push(OptionWarning {
239                code: "E7005",
240                message: format!("Option '{key}' may not be set"),
241                option: key.to_string(),
242                value: value.to_string(),
243            });
244            return; // Don't apply the value
245        }
246
247        // Check for duplicate non-repeatable options (E7003).
248        //
249        // Emitted as a WARNING (not an error), matching `bean-check`, which
250        // silently lets the last value win (exit 0). A master ledger that
251        // `include`s self-contained sub-ledgers — each setting its own
252        // `option "title"` / `booking_method` / ... for standalone use — is a
253        // legitimate layout (issue #1546). The value below is applied last-wins
254        // to match. `cmd::check` and `validate` both surface this as a warning.
255        let is_repeatable = REPEATABLE_OPTIONS.contains(&key);
256        if is_known && !is_repeatable && self.set_options.contains(key) {
257            self.warnings.push(OptionWarning {
258                code: "E7003",
259                message: format!("Option \"{key}\" can only be specified once"),
260                option: key.to_string(),
261                value: value.to_string(),
262            });
263        }
264
265        // Track that this option was set
266        self.set_options.insert(key.to_string());
267
268        // Apply the option value
269        match key {
270            "title" => self.title = Some(value.to_string()),
271            "operating_currency" => self.operating_currency.push(value.to_string()),
272            "name_assets" => {
273                self.warn_if_invalid_root("name_assets", value);
274                self.name_assets = value.to_string();
275            }
276            "name_liabilities" => {
277                self.warn_if_invalid_root("name_liabilities", value);
278                self.name_liabilities = value.to_string();
279            }
280            "name_equity" => {
281                self.warn_if_invalid_root("name_equity", value);
282                self.name_equity = value.to_string();
283            }
284            "name_income" => {
285                self.warn_if_invalid_root("name_income", value);
286                self.name_income = value.to_string();
287            }
288            "name_expenses" => {
289                self.warn_if_invalid_root("name_expenses", value);
290                self.name_expenses = value.to_string();
291            }
292            "account_rounding" => {
293                if !Self::is_valid_account(value) {
294                    self.warnings.push(OptionWarning {
295                        code: "E7002",
296                        message: format!("Invalid leaf account name: '{value}'"),
297                        option: key.to_string(),
298                        value: value.to_string(),
299                    });
300                }
301                // Accepted for Beancount compatibility but intentionally a no-op.
302                // Beancount uses `account_rounding` to absorb the residual created
303                // when an interpolated leg is *rounded* and the rounding breaks the
304                // sum. rustledger never produces such a residual: `round_interpolated`
305                // (rustledger-booking) preserves full precision instead of rounding a
306                // non-zero residual to zero, so there is nothing for a rounding
307                // account to catch. Warn so the option isn't silently swallowed.
308                self.warnings.push(OptionWarning {
309                    code: "E7007",
310                    message: "Option 'account_rounding' is accepted for compatibility \
311                              but has no effect: rustledger preserves full precision \
312                              during interpolation rather than rounding into a rounding \
313                              account, so no rounding residual is produced."
314                        .to_string(),
315                    option: key.to_string(),
316                    value: value.to_string(),
317                });
318                self.account_rounding = Some(value.to_string());
319            }
320            "account_current_conversions" => {
321                if !Self::is_valid_account(value) {
322                    self.warnings.push(OptionWarning {
323                        code: "E7002",
324                        message: format!("Invalid leaf account name: '{value}'"),
325                        option: key.to_string(),
326                        value: value.to_string(),
327                    });
328                }
329                self.account_current_conversions = Some(value.to_string());
330            }
331            "account_unrealized_gains" => {
332                if !Self::is_valid_account(value) {
333                    self.warnings.push(OptionWarning {
334                        code: "E7002",
335                        message: format!("Invalid leaf account name: '{value}'"),
336                        option: key.to_string(),
337                        value: value.to_string(),
338                    });
339                }
340                self.account_unrealized_gains = Some(value.to_string());
341            }
342            "inferred_tolerance_multiplier" => {
343                // Deprecated: renamed to tolerance_multiplier in Python beancount
344                self.warnings.push(OptionWarning {
345                    code: "E7004",
346                    message: "Renamed to 'tolerance_multiplier'.".to_string(),
347                    option: key.to_string(),
348                    value: value.to_string(),
349                });
350                if let Ok(d) = Decimal::from_str(value) {
351                    self.inferred_tolerance_multiplier = d;
352                } else {
353                    // E7002: Invalid option value
354                    self.warnings.push(OptionWarning {
355                        code: "E7002",
356                        message: format!(
357                            "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
358                        ),
359                        option: key.to_string(),
360                        value: value.to_string(),
361                    });
362                }
363            }
364            "tolerance_multiplier" => {
365                if let Ok(d) = Decimal::from_str(value) {
366                    self.inferred_tolerance_multiplier = d;
367                } else {
368                    self.warnings.push(OptionWarning {
369                        code: "E7002",
370                        message: format!(
371                            "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
372                        ),
373                        option: key.to_string(),
374                        value: value.to_string(),
375                    });
376                }
377            }
378            "infer_tolerance_from_cost" => {
379                // Same vocabulary as every other boolean in ledger source
380                // (`parse_bool_word`). This arm used to take TRUE/FALSE only
381                // and warn on `1`, which Python beancount accepts as true for
382                // every boolean option.
383                let parsed = rustledger_core::parse_bool_word(value);
384                if parsed.is_none() {
385                    self.warnings.push(OptionWarning {
386                        code: "E7002",
387                        message: format!(
388                            "Invalid value \"{value}\" for option \"{key}\": expected TRUE, FALSE, 1 or 0"
389                        ),
390                        option: key.to_string(),
391                        value: value.to_string(),
392                    });
393                }
394                self.infer_tolerance_from_cost = parsed == Some(true);
395            }
396            "booking_method" => {
397                let valid_methods = [
398                    "STRICT",
399                    "STRICT_WITH_SIZE",
400                    "FIFO",
401                    "LIFO",
402                    "HIFO",
403                    "AVERAGE",
404                    "NONE",
405                ];
406                if !valid_methods.contains(&value.to_uppercase().as_str()) {
407                    self.warnings.push(OptionWarning {
408                        code: "E7002",
409                        message: format!(
410                            "Invalid value \"{}\" for option \"{}\": expected one of {}",
411                            value,
412                            key,
413                            valid_methods.join(", ")
414                        ),
415                        option: key.to_string(),
416                        value: value.to_string(),
417                    });
418                }
419                self.booking_method = value.to_string();
420            }
421            "render_commas" => {
422                // Accept TRUE/FALSE, true/false, 1/0 (Python beancount
423                // compatibility). Shared with `render_commas:` metadata so one
424                // concept has one vocabulary — see `parse_bool_word`.
425                let parsed = rustledger_core::parse_bool_word(value);
426                let is_true = parsed == Some(true);
427                if parsed.is_none() {
428                    self.warnings.push(OptionWarning {
429                        code: "E7002",
430                        message: format!(
431                            "Invalid value \"{value}\" for option \"{key}\": expected TRUE, FALSE, 1 or 0"
432                        ),
433                        option: key.to_string(),
434                        value: value.to_string(),
435                    });
436                }
437                self.render_commas = is_true;
438            }
439            "display_precision" => {
440                // Parse "CURRENCY:EXAMPLE" where EXAMPLE's decimal places define the precision.
441                // E.g., "CHF:0.01" means 2 decimal places for CHF.
442                // E.g., "USD:0.001" means 3 decimal places for USD.
443                if let Some((curr, example)) = value.split_once(':') {
444                    if let Ok(d) = Decimal::from_str(example) {
445                        // Get the precision from the example number's decimal places
446                        let precision = d.scale();
447                        self.display_precision.insert(curr.to_string(), precision);
448                    } else {
449                        self.warnings.push(OptionWarning {
450                            code: "E7002",
451                            message: format!(
452                                "Invalid precision value \"{example}\" in option \"{key}\""
453                            ),
454                            option: key.to_string(),
455                            value: value.to_string(),
456                        });
457                    }
458                } else {
459                    self.warnings.push(OptionWarning {
460                        code: "E7002",
461                        message: format!(
462                            "Invalid format for option \"{key}\": expected CURRENCY:EXAMPLE (e.g., CHF:0.01)"
463                        ),
464                        option: key.to_string(),
465                        value: value.to_string(),
466                    });
467                }
468            }
469            "filename" => self.filename = Some(value.to_string()),
470            "account_previous_balances" => {
471                if !Self::is_valid_account(value) {
472                    self.warnings.push(OptionWarning {
473                        code: "E7002",
474                        message: format!("Invalid leaf account name: '{value}'"),
475                        option: key.to_string(),
476                        value: value.to_string(),
477                    });
478                }
479                self.account_previous_balances = value.to_string();
480            }
481            "account_previous_earnings" => {
482                if !Self::is_valid_account(value) {
483                    self.warnings.push(OptionWarning {
484                        code: "E7002",
485                        message: format!("Invalid leaf account name: '{value}'"),
486                        option: key.to_string(),
487                        value: value.to_string(),
488                    });
489                }
490                self.account_previous_earnings = value.to_string();
491            }
492            "account_previous_conversions" => {
493                if !Self::is_valid_account(value) {
494                    self.warnings.push(OptionWarning {
495                        code: "E7002",
496                        message: format!("Invalid leaf account name: '{value}'"),
497                        option: key.to_string(),
498                        value: value.to_string(),
499                    });
500                }
501                self.account_previous_conversions = value.to_string();
502            }
503            "account_current_earnings" => {
504                if !Self::is_valid_account(value) {
505                    self.warnings.push(OptionWarning {
506                        code: "E7002",
507                        message: format!("Invalid leaf account name: '{value}'"),
508                        option: key.to_string(),
509                        value: value.to_string(),
510                    });
511                }
512                self.account_current_earnings = value.to_string();
513            }
514            "conversion_currency" => self.conversion_currency = Some(value.to_string()),
515            "inferred_tolerance_default" => {
516                // Parse "CURRENCY:TOLERANCE" or "*:TOLERANCE"
517                if let Some((curr, tol)) = value.split_once(':') {
518                    if let Ok(d) = Decimal::from_str(tol) {
519                        self.inferred_tolerance_default.insert(curr.to_string(), d);
520                    } else {
521                        self.warnings.push(OptionWarning {
522                            code: "E7002",
523                            message: format!(
524                                "Invalid tolerance value \"{tol}\" in option \"{key}\""
525                            ),
526                            option: key.to_string(),
527                            value: value.to_string(),
528                        });
529                    }
530                } else {
531                    self.warnings.push(OptionWarning {
532                        code: "E7002",
533                        message: format!(
534                            "Invalid format for option \"{key}\": expected CURRENCY:TOLERANCE"
535                        ),
536                        option: key.to_string(),
537                        value: value.to_string(),
538                    });
539                }
540            }
541            "use_legacy_fixed_tolerances" => {
542                self.use_legacy_fixed_tolerances = value.eq_ignore_ascii_case("true");
543            }
544            "experiment_explicit_tolerances" => {
545                self.experiment_explicit_tolerances = value.eq_ignore_ascii_case("true");
546            }
547            "use_precise_interpolation" => {
548                // Accepted for beancount 3.x compatibility. rustledger already
549                // interpolates with exact decimals, so this is a no-op on
550                // results — recorded only to reflect the user's declaration.
551                self.use_precise_interpolation = value.eq_ignore_ascii_case("true");
552            }
553            "allow_pipe_separator" => {
554                // This option is deprecated in Python beancount
555                self.warnings.push(OptionWarning {
556                    code: "E7004",
557                    message: "Option 'allow_pipe_separator' is deprecated".to_string(),
558                    option: key.to_string(),
559                    value: value.to_string(),
560                });
561                self.allow_pipe_separator = value.eq_ignore_ascii_case("true");
562            }
563            "long_string_maxlines" => {
564                if let Ok(n) = value.parse::<u32>() {
565                    self.long_string_maxlines = n;
566                } else {
567                    self.warnings.push(OptionWarning {
568                        code: "E7002",
569                        message: format!(
570                            "Invalid value \"{value}\" for option \"{key}\": expected integer"
571                        ),
572                        option: key.to_string(),
573                        value: value.to_string(),
574                    });
575                }
576            }
577            "documents" => {
578                // NO existence check here. A relative `documents` path is
579                // relative to the LEDGER FILE, as it is in beancount and as
580                // `include` is — and option parsing does not know where that
581                // file is. `Path::new(value).exists()` therefore asked about
582                // the process CWD, so `rledger check path/to/ledger` reported
583                // E7006 for a document root that was present (#1999), while
584                // `query` — which resolves through `resolve_document_dirs` —
585                // found it.
586                //
587                // The check now happens where the source map exists, in
588                // `Loader::load`, through that same canonical resolver.
589                self.documents.push(value.to_string());
590            }
591            "plugin_processing_mode" => {
592                // Valid values are "default" and "raw" (case-sensitive, like Python)
593                if value != "default" && value != "raw" {
594                    self.warnings.push(OptionWarning {
595                        code: "E7002",
596                        message: format!("Invalid value '{value}'"),
597                        option: key.to_string(),
598                        value: value.to_string(),
599                    });
600                }
601                self.plugin_processing_mode = value.to_string();
602            }
603            "plugin" => {
604                // Deprecated: should use `plugin` directive instead of `option "plugin"`
605                self.warnings.push(OptionWarning {
606                    code: "E7004",
607                    message: "Option 'plugin' is deprecated; use the 'plugin' directive instead"
608                        .to_string(),
609                    option: key.to_string(),
610                    value: value.to_string(),
611                });
612            }
613            _ => {
614                // Unknown options go to custom map
615                self.custom.insert(key.to_string(), value.to_string());
616            }
617        }
618    }
619
620    /// Get a custom option value.
621    #[must_use]
622    pub fn get(&self, key: &str) -> Option<&str> {
623        self.custom.get(key).map(String::as_str)
624    }
625
626    /// Config-aware [`rustledger_core::AccountTypes`] classifier honoring the
627    /// `name_*` renames. Consumers that route or sign accounts by root type
628    /// must use this, not the rename-blind `ACCOUNT_TYPES` defaults.
629    #[must_use]
630    pub fn to_account_types(&self) -> rustledger_core::AccountTypes {
631        rustledger_core::AccountTypes {
632            assets: self.name_assets.clone(),
633            liabilities: self.name_liabilities.clone(),
634            equity: self.name_equity.clone(),
635            income: self.name_income.clone(),
636            expenses: self.name_expenses.clone(),
637        }
638    }
639
640    /// Get all account type prefixes.
641    #[must_use]
642    pub fn account_types(&self) -> [&str; 5] {
643        [
644            &self.name_assets,
645            &self.name_liabilities,
646            &self.name_equity,
647            &self.name_income,
648            &self.name_expenses,
649        ]
650    }
651
652    /// Warn (E7008) when a `name_*` account-type rename is not a lexable
653    /// account root: every account under such a root is unparsable (both
654    /// rledger and Python beancount fail at parse time with "unexpected
655    /// NUMBER"-style errors), so the rename can only produce a broken
656    /// ledger. Accepted anyway for option-handling parity — the warning
657    /// makes the failure mode visible at the option site instead of at
658    /// every account mention. The `account_*` options get the analogous
659    /// E7002 guard; `name_*` used to be the unguarded exception.
660    fn warn_if_invalid_root(&mut self, key: &str, value: &str) {
661        if !Self::is_valid_account_root(value) {
662            self.warnings.push(OptionWarning {
663                code: "E7008",
664                message: format!(
665                    "Invalid account type name: '{value}' cannot begin an \
666                     account name (accounts under it will never parse)"
667                ),
668                option: key.to_string(),
669                value: value.to_string(),
670            });
671        }
672    }
673
674    /// Check if a value looks like a valid account name.
675    ///
676    /// Delegates to the canonical [`rustledger_parser::is_valid_account_name`]
677    /// (the lexer itself), so option values are held to exactly the rule the
678    /// parser applies to account tokens. The old hand-written check here was a
679    /// third, divergent variant (it accepted lowercase-adjacent first chars the
680    /// lexer rejects and had no per-character rule at all).
681    fn is_valid_account(value: &str) -> bool {
682        rustledger_parser::is_valid_account_name(value)
683    }
684
685    /// Check if a value is usable as an account TYPE root (a `name_*` option
686    /// value): a single component (no `:`) such that accounts under it are
687    /// lexable. Checked by running the canonical account predicate on
688    /// `value:X` — a root is valid exactly when it can head a real account.
689    fn is_valid_account_root(value: &str) -> bool {
690        !value.contains(':') && rustledger_parser::is_valid_account_name(&format!("{value}:X"))
691    }
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697
698    #[test]
699    fn test_default_options() {
700        let opts = Options::new();
701        assert_eq!(opts.name_assets, "Assets");
702        assert_eq!(opts.booking_method, "STRICT");
703        assert!(!opts.infer_tolerance_from_cost);
704    }
705
706    #[test]
707    fn test_set_options() {
708        let mut opts = Options::new();
709        opts.set("title", "My Ledger");
710        opts.set("operating_currency", "USD");
711        opts.set("operating_currency", "EUR");
712        opts.set("booking_method", "FIFO");
713
714        assert_eq!(opts.title, Some("My Ledger".to_string()));
715        assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
716        assert_eq!(opts.booking_method, "FIFO");
717    }
718
719    #[test]
720    fn test_custom_options() {
721        let mut opts = Options::new();
722        opts.set("my_custom_option", "my_value");
723
724        assert_eq!(opts.get("my_custom_option"), Some("my_value"));
725        assert_eq!(opts.get("nonexistent"), None);
726    }
727
728    #[test]
729    fn test_unknown_option_warning() {
730        let mut opts = Options::new();
731        opts.set("unknown_option", "value");
732
733        assert_eq!(opts.warnings.len(), 1);
734        assert_eq!(opts.warnings[0].code, "E7001");
735        assert!(opts.warnings[0].message.contains("Invalid option"));
736    }
737
738    /// #1416: the beancount 3.x `use_precise_interpolation` option must be
739    /// accepted (no E7001) — rustledger already interpolates precisely.
740    #[test]
741    fn test_use_precise_interpolation_accepted() {
742        let mut opts = Options::new();
743        opts.set("use_precise_interpolation", "TRUE");
744
745        assert!(
746            opts.warnings.is_empty(),
747            "should not warn on a known option: {:?}",
748            opts.warnings
749        );
750        assert!(opts.use_precise_interpolation);
751    }
752
753    #[test]
754    fn test_duplicate_option_warning() {
755        let mut opts = Options::new();
756        opts.set("title", "First Title");
757        opts.set("title", "Second Title");
758
759        assert_eq!(opts.warnings.len(), 1);
760        assert_eq!(opts.warnings[0].code, "E7003");
761        assert!(opts.warnings[0].message.contains("only be specified once"));
762    }
763
764    #[test]
765    fn test_repeatable_option_no_warning() {
766        let mut opts = Options::new();
767        opts.set("operating_currency", "USD");
768        opts.set("operating_currency", "EUR");
769
770        // No warnings for repeatable options
771        assert!(
772            opts.warnings.is_empty(),
773            "Should not warn for repeatable options: {:?}",
774            opts.warnings
775        );
776        assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
777    }
778
779    #[test]
780    fn test_invalid_tolerance_value() {
781        let mut opts = Options::new();
782        opts.set("inferred_tolerance_multiplier", "not_a_number");
783
784        // E7004 (deprecated name) + E7002 (invalid value)
785        assert_eq!(opts.warnings.len(), 2);
786        assert_eq!(opts.warnings[0].code, "E7004");
787        assert!(opts.warnings[0].message.contains("Renamed"));
788        assert_eq!(opts.warnings[1].code, "E7002");
789        assert!(opts.warnings[1].message.contains("expected decimal"));
790    }
791
792    #[test]
793    fn test_tolerance_multiplier_new_name() {
794        let mut opts = Options::new();
795        opts.set("tolerance_multiplier", "1.5");
796
797        assert!(opts.warnings.is_empty());
798        assert_eq!(opts.inferred_tolerance_multiplier, Decimal::new(15, 1));
799    }
800
801    #[test]
802    fn test_inferred_tolerance_multiplier_deprecated() {
803        let mut opts = Options::new();
804        opts.set("inferred_tolerance_multiplier", "1.01");
805
806        assert_eq!(opts.warnings.len(), 1);
807        assert_eq!(opts.warnings[0].code, "E7004");
808        assert!(
809            opts.warnings[0]
810                .message
811                .contains("Renamed to 'tolerance_multiplier'")
812        );
813        assert_eq!(
814            opts.inferred_tolerance_multiplier,
815            Decimal::from_str("1.01").unwrap()
816        );
817    }
818
819    #[test]
820    fn test_invalid_boolean_value() {
821        let mut opts = Options::new();
822        opts.set("infer_tolerance_from_cost", "maybe");
823
824        assert_eq!(opts.warnings.len(), 1);
825        assert_eq!(opts.warnings[0].code, "E7002");
826        assert!(
827            opts.warnings[0].message.contains("TRUE, FALSE, 1 or 0"),
828            "the message must name the vocabulary actually accepted: {}",
829            opts.warnings[0].message
830        );
831    }
832
833    /// Every boolean option in ledger source shares one vocabulary, and the
834    /// E7002 message names it accurately.
835    ///
836    /// `infer_tolerance_from_cost` used to take TRUE/FALSE only and warn on
837    /// `1`, which Python beancount accepts as true for every boolean option;
838    /// `render_commas` took `1`/`0` as well. The message said "TRUE or FALSE"
839    /// on both. A test that only checks the rejected value cannot catch a
840    /// message that under-promises, so this asserts the accepted ones too.
841    #[test]
842    fn boolean_options_share_one_vocabulary() {
843        for key in ["infer_tolerance_from_cost", "render_commas"] {
844            for (value, expected) in [
845                ("TRUE", true),
846                ("true", true),
847                ("1", true),
848                ("FALSE", false),
849                ("false", false),
850                ("0", false),
851            ] {
852                let mut opts = Options::new();
853                opts.set(key, value);
854                assert!(
855                    opts.warnings.is_empty(),
856                    "{key} = {value:?} must be accepted without a warning: {:?}",
857                    opts.warnings
858                );
859                let actual = if key == "render_commas" {
860                    opts.render_commas
861                } else {
862                    opts.infer_tolerance_from_cost
863                };
864                assert_eq!(actual, expected, "{key} = {value:?}");
865            }
866
867            let mut opts = Options::new();
868            opts.set(key, "yes");
869            assert_eq!(
870                opts.warnings.len(),
871                1,
872                "{key}: `yes` is outside the shared vocabulary and must warn"
873            );
874        }
875    }
876
877    #[test]
878    fn test_invalid_booking_method() {
879        let mut opts = Options::new();
880        opts.set("booking_method", "RANDOM");
881
882        assert_eq!(opts.warnings.len(), 1);
883        assert_eq!(opts.warnings[0].code, "E7002");
884        assert!(opts.warnings[0].message.contains("STRICT"));
885    }
886
887    #[test]
888    fn test_valid_booking_methods() {
889        for method in &["STRICT", "FIFO", "LIFO", "AVERAGE", "NONE"] {
890            let mut opts = Options::new();
891            opts.set("booking_method", method);
892            assert!(
893                opts.warnings.is_empty(),
894                "Should accept {method} as valid booking method"
895            );
896        }
897    }
898
899    #[test]
900    fn test_readonly_option_warning() {
901        let mut opts = Options::new();
902        opts.set("filename", "/some/path.beancount");
903
904        assert_eq!(opts.warnings.len(), 1);
905        assert_eq!(opts.warnings[0].code, "E7005");
906        assert!(opts.warnings[0].message.contains("may not be set"));
907    }
908
909    #[test]
910    fn test_account_rounding_accepted_but_warns_noop() {
911        let mut opts = Options::new();
912        opts.set("account_rounding", "Equity:Rounding");
913
914        // Still stored for Beancount compatibility...
915        assert_eq!(opts.account_rounding.as_deref(), Some("Equity:Rounding"));
916        // ...but a no-op warning is emitted so the option isn't silently swallowed.
917        let w = opts
918            .warnings
919            .iter()
920            .find(|w| w.code == "E7007")
921            .expect("expected an E7007 no-op warning for account_rounding");
922        assert!(w.message.contains("no effect"));
923        assert_eq!(w.option, "account_rounding");
924        // A valid account name must NOT also trip E7002 (invalid value).
925        assert!(!opts.warnings.iter().any(|w| w.code == "E7002"));
926    }
927
928    #[test]
929    fn test_invalid_account_name_validation() {
930        // account_rounding with an invalid value: both the invalid-account
931        // warning (E7002) and the accepted-but-no-op warning (E7007) fire.
932        let mut opts = Options::new();
933        opts.set("account_rounding", "invalid");
934
935        assert!(
936            opts.warnings
937                .iter()
938                .any(|w| w.code == "E7002" && w.message.contains("Invalid leaf account"))
939        );
940        assert!(opts.warnings.iter().any(|w| w.code == "E7007"));
941    }
942
943    #[test]
944    fn test_valid_account_name() {
945        let mut opts = Options::new();
946        opts.set("account_rounding", "Equity:Rounding");
947
948        // A valid account name does not trip E7002; the value is stored, but
949        // account_rounding is a no-op in rustledger so an E7007 warning fires.
950        assert!(!opts.warnings.iter().any(|w| w.code == "E7002"));
951        assert!(opts.warnings.iter().any(|w| w.code == "E7007"));
952        assert_eq!(opts.account_rounding, Some("Equity:Rounding".to_string()));
953    }
954
955    #[test]
956    fn test_render_commas_with_numeric_values() {
957        let mut opts = Options::new();
958        opts.set("render_commas", "1");
959        assert!(opts.render_commas);
960        assert!(opts.warnings.is_empty());
961
962        let mut opts2 = Options::new();
963        opts2.set("render_commas", "0");
964        assert!(!opts2.render_commas);
965        assert!(opts2.warnings.is_empty());
966    }
967
968    #[test]
969    fn test_plugin_processing_mode_validation() {
970        // Valid values
971        let mut opts = Options::new();
972        opts.set("plugin_processing_mode", "default");
973        assert!(opts.warnings.is_empty());
974        assert_eq!(opts.plugin_processing_mode, "default");
975
976        let mut opts2 = Options::new();
977        opts2.set("plugin_processing_mode", "raw");
978        assert!(opts2.warnings.is_empty());
979        assert_eq!(opts2.plugin_processing_mode, "raw");
980
981        // Invalid value
982        let mut opts3 = Options::new();
983        opts3.set("plugin_processing_mode", "invalid");
984        assert_eq!(opts3.warnings.len(), 1);
985        assert_eq!(opts3.warnings[0].code, "E7002");
986    }
987
988    #[test]
989    fn test_deprecated_plugin_option() {
990        let mut opts = Options::new();
991        opts.set("plugin", "some.plugin");
992
993        assert_eq!(opts.warnings.len(), 1);
994        assert_eq!(opts.warnings[0].code, "E7004");
995        assert!(opts.warnings[0].message.contains("deprecated"));
996    }
997
998    #[test]
999    fn test_deprecated_allow_pipe_separator() {
1000        let mut opts = Options::new();
1001        opts.set("allow_pipe_separator", "true");
1002
1003        assert_eq!(opts.warnings.len(), 1);
1004        assert_eq!(opts.warnings[0].code, "E7004");
1005        assert!(opts.warnings[0].message.contains("deprecated"));
1006    }
1007
1008    #[test]
1009    fn test_is_valid_account() {
1010        // Valid accounts — ASCII
1011        assert!(Options::is_valid_account("Assets:Bank"));
1012        assert!(Options::is_valid_account("Equity:Rounding:Precision"));
1013
1014        // Valid accounts — Unicode
1015        assert!(Options::is_valid_account("Капитал:Retained"));
1016        assert!(Options::is_valid_account("资产:银行:支票"));
1017
1018        // Invalid accounts
1019        assert!(!Options::is_valid_account("invalid")); // No colon
1020        assert!(!Options::is_valid_account("assets:bank")); // Lowercase ASCII
1021        assert!(!Options::is_valid_account("Assets:")); // Empty component
1022        assert!(!Options::is_valid_account(":Bank")); // Empty first component
1023    }
1024
1025    #[test]
1026    fn test_account_validation_options() {
1027        // Test all account options that require validation
1028        let account_options = [
1029            "account_rounding",
1030            "account_current_conversions",
1031            "account_unrealized_gains",
1032            "account_previous_balances",
1033            "account_previous_earnings",
1034            "account_previous_conversions",
1035            "account_current_earnings",
1036        ];
1037
1038        for opt in account_options {
1039            let mut opts = Options::new();
1040            opts.set(opt, "lowercase:invalid");
1041
1042            assert!(
1043                !opts.warnings.is_empty(),
1044                "Option '{opt}' should warn on invalid account name"
1045            );
1046            assert_eq!(opts.warnings[0].code, "E7002");
1047        }
1048    }
1049
1050    #[test]
1051    fn test_inferred_tolerance_default() {
1052        let mut opts = Options::new();
1053        opts.set("inferred_tolerance_default", "USD:0.005");
1054
1055        assert!(opts.warnings.is_empty());
1056        assert_eq!(
1057            opts.inferred_tolerance_default.get("USD"),
1058            Some(&rust_decimal_macros::dec!(0.005))
1059        );
1060
1061        // Test wildcard
1062        let mut opts2 = Options::new();
1063        opts2.set("inferred_tolerance_default", "*:0.01");
1064        assert!(opts2.warnings.is_empty());
1065        assert_eq!(
1066            opts2.inferred_tolerance_default.get("*"),
1067            Some(&rust_decimal_macros::dec!(0.01))
1068        );
1069
1070        // Test invalid format
1071        let mut opts3 = Options::new();
1072        opts3.set("inferred_tolerance_default", "INVALID");
1073        assert_eq!(opts3.warnings.len(), 1);
1074        assert_eq!(opts3.warnings[0].code, "E7002");
1075    }
1076
1077    #[test]
1078    fn test_display_precision_basic() {
1079        let mut opts = Options::new();
1080        opts.set("display_precision", "USD:0.01");
1081        assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
1082        assert_eq!(opts.display_precision.get("USD"), Some(&2));
1083    }
1084
1085    #[test]
1086    fn test_display_precision_high_precision() {
1087        let mut opts = Options::new();
1088        opts.set("display_precision", "BTC:0.00000001");
1089        assert!(opts.warnings.is_empty());
1090        assert_eq!(opts.display_precision.get("BTC"), Some(&8));
1091    }
1092
1093    #[test]
1094    fn test_display_precision_zero_decimals() {
1095        // "JPY:1" → no fractional digits → precision 0.
1096        let mut opts = Options::new();
1097        opts.set("display_precision", "JPY:1");
1098        assert!(opts.warnings.is_empty());
1099        assert_eq!(opts.display_precision.get("JPY"), Some(&0));
1100    }
1101
1102    #[test]
1103    fn test_display_precision_repeatable_per_currency() {
1104        let mut opts = Options::new();
1105        opts.set("display_precision", "USD:0.01");
1106        opts.set("display_precision", "EUR:0.001");
1107        assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
1108        assert_eq!(opts.display_precision.get("USD"), Some(&2));
1109        assert_eq!(opts.display_precision.get("EUR"), Some(&3));
1110    }
1111
1112    #[test]
1113    fn test_display_precision_missing_colon_warns() {
1114        let mut opts = Options::new();
1115        opts.set("display_precision", "USD0.01");
1116        assert_eq!(opts.warnings.len(), 1);
1117        assert_eq!(opts.warnings[0].code, "E7002");
1118        assert!(opts.warnings[0].message.contains("CURRENCY:EXAMPLE"));
1119        assert!(opts.display_precision.is_empty());
1120    }
1121
1122    #[test]
1123    fn test_name_option_invalid_root_warns_e7008() {
1124        // Digit-start root: every account under it is unparsable (both
1125        // rledger and Python fail at parse time) — the option site now
1126        // says so up front instead of the ledger erroring at every mention.
1127        let mut opts = Options::new();
1128        opts.set("name_assets", "1Assets");
1129        assert_eq!(opts.warnings.len(), 1);
1130        assert_eq!(opts.warnings[0].code, "E7008");
1131        assert!(opts.warnings[0].message.contains("1Assets"));
1132        // Accepted anyway (option-handling parity).
1133        assert_eq!(opts.name_assets, "1Assets");
1134
1135        // Colon inside a root can never match a root component.
1136        let mut opts = Options::new();
1137        opts.set("name_income", "In:Come");
1138        assert_eq!(opts.warnings.len(), 1);
1139        assert_eq!(opts.warnings[0].code, "E7008");
1140    }
1141
1142    #[test]
1143    fn test_name_option_valid_roots_no_warning() {
1144        let mut opts = Options::new();
1145        opts.set("name_income", "Revenue");
1146        opts.set("name_assets", "Activa");
1147        opts.set("name_expenses", "Ausgaben");
1148        opts.set("name_liabilities", "負債"); // caseless (\p{Lo}) root
1149        assert!(
1150            opts.warnings.is_empty(),
1151            "lexable renames must not warn: {:?}",
1152            opts.warnings
1153        );
1154    }
1155
1156    #[test]
1157    fn test_account_option_uses_canonical_rule() {
1158        // The old hand-written is_valid_account had no per-character rule:
1159        // 'Equity:Ro unding' style values with invalid chars slipped through
1160        // as long as first chars looked right. The canonical predicate
1161        // rejects what the lexer rejects.
1162        let mut opts = Options::new();
1163        opts.set("account_current_conversions", "Equity:Conv ersions");
1164        assert!(opts.warnings.iter().any(|w| w.code == "E7002"));
1165
1166        let mut opts = Options::new();
1167        opts.set("account_current_conversions", "Equity:Conversions:Current");
1168        assert!(opts.warnings.is_empty(), "{:?}", opts.warnings);
1169    }
1170
1171    #[test]
1172    fn test_display_precision_invalid_example_warns() {
1173        let mut opts = Options::new();
1174        opts.set("display_precision", "USD:abc");
1175        assert_eq!(opts.warnings.len(), 1);
1176        assert_eq!(opts.warnings[0].code, "E7002");
1177        assert!(opts.warnings[0].message.contains("Invalid precision"));
1178        assert!(opts.display_precision.is_empty());
1179    }
1180}