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                if !value.eq_ignore_ascii_case("true") && !value.eq_ignore_ascii_case("false") {
380                    self.warnings.push(OptionWarning {
381                        code: "E7002",
382                        message: format!(
383                            "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
384                        ),
385                        option: key.to_string(),
386                        value: value.to_string(),
387                    });
388                }
389                self.infer_tolerance_from_cost = value.eq_ignore_ascii_case("true");
390            }
391            "booking_method" => {
392                let valid_methods = [
393                    "STRICT",
394                    "STRICT_WITH_SIZE",
395                    "FIFO",
396                    "LIFO",
397                    "HIFO",
398                    "AVERAGE",
399                    "NONE",
400                ];
401                if !valid_methods.contains(&value.to_uppercase().as_str()) {
402                    self.warnings.push(OptionWarning {
403                        code: "E7002",
404                        message: format!(
405                            "Invalid value \"{}\" for option \"{}\": expected one of {}",
406                            value,
407                            key,
408                            valid_methods.join(", ")
409                        ),
410                        option: key.to_string(),
411                        value: value.to_string(),
412                    });
413                }
414                self.booking_method = value.to_string();
415            }
416            "render_commas" => {
417                // Accept TRUE/FALSE, true/false, 1/0 (Python beancount compatibility)
418                let is_true = value.eq_ignore_ascii_case("true") || value == "1";
419                let is_false = value.eq_ignore_ascii_case("false") || value == "0";
420                if !is_true && !is_false {
421                    self.warnings.push(OptionWarning {
422                        code: "E7002",
423                        message: format!(
424                            "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
425                        ),
426                        option: key.to_string(),
427                        value: value.to_string(),
428                    });
429                }
430                self.render_commas = is_true;
431            }
432            "display_precision" => {
433                // Parse "CURRENCY:EXAMPLE" where EXAMPLE's decimal places define the precision.
434                // E.g., "CHF:0.01" means 2 decimal places for CHF.
435                // E.g., "USD:0.001" means 3 decimal places for USD.
436                if let Some((curr, example)) = value.split_once(':') {
437                    if let Ok(d) = Decimal::from_str(example) {
438                        // Get the precision from the example number's decimal places
439                        let precision = d.scale();
440                        self.display_precision.insert(curr.to_string(), precision);
441                    } else {
442                        self.warnings.push(OptionWarning {
443                            code: "E7002",
444                            message: format!(
445                                "Invalid precision value \"{example}\" in option \"{key}\""
446                            ),
447                            option: key.to_string(),
448                            value: value.to_string(),
449                        });
450                    }
451                } else {
452                    self.warnings.push(OptionWarning {
453                        code: "E7002",
454                        message: format!(
455                            "Invalid format for option \"{key}\": expected CURRENCY:EXAMPLE (e.g., CHF:0.01)"
456                        ),
457                        option: key.to_string(),
458                        value: value.to_string(),
459                    });
460                }
461            }
462            "filename" => self.filename = Some(value.to_string()),
463            "account_previous_balances" => {
464                if !Self::is_valid_account(value) {
465                    self.warnings.push(OptionWarning {
466                        code: "E7002",
467                        message: format!("Invalid leaf account name: '{value}'"),
468                        option: key.to_string(),
469                        value: value.to_string(),
470                    });
471                }
472                self.account_previous_balances = value.to_string();
473            }
474            "account_previous_earnings" => {
475                if !Self::is_valid_account(value) {
476                    self.warnings.push(OptionWarning {
477                        code: "E7002",
478                        message: format!("Invalid leaf account name: '{value}'"),
479                        option: key.to_string(),
480                        value: value.to_string(),
481                    });
482                }
483                self.account_previous_earnings = value.to_string();
484            }
485            "account_previous_conversions" => {
486                if !Self::is_valid_account(value) {
487                    self.warnings.push(OptionWarning {
488                        code: "E7002",
489                        message: format!("Invalid leaf account name: '{value}'"),
490                        option: key.to_string(),
491                        value: value.to_string(),
492                    });
493                }
494                self.account_previous_conversions = value.to_string();
495            }
496            "account_current_earnings" => {
497                if !Self::is_valid_account(value) {
498                    self.warnings.push(OptionWarning {
499                        code: "E7002",
500                        message: format!("Invalid leaf account name: '{value}'"),
501                        option: key.to_string(),
502                        value: value.to_string(),
503                    });
504                }
505                self.account_current_earnings = value.to_string();
506            }
507            "conversion_currency" => self.conversion_currency = Some(value.to_string()),
508            "inferred_tolerance_default" => {
509                // Parse "CURRENCY:TOLERANCE" or "*:TOLERANCE"
510                if let Some((curr, tol)) = value.split_once(':') {
511                    if let Ok(d) = Decimal::from_str(tol) {
512                        self.inferred_tolerance_default.insert(curr.to_string(), d);
513                    } else {
514                        self.warnings.push(OptionWarning {
515                            code: "E7002",
516                            message: format!(
517                                "Invalid tolerance value \"{tol}\" in option \"{key}\""
518                            ),
519                            option: key.to_string(),
520                            value: value.to_string(),
521                        });
522                    }
523                } else {
524                    self.warnings.push(OptionWarning {
525                        code: "E7002",
526                        message: format!(
527                            "Invalid format for option \"{key}\": expected CURRENCY:TOLERANCE"
528                        ),
529                        option: key.to_string(),
530                        value: value.to_string(),
531                    });
532                }
533            }
534            "use_legacy_fixed_tolerances" => {
535                self.use_legacy_fixed_tolerances = value.eq_ignore_ascii_case("true");
536            }
537            "experiment_explicit_tolerances" => {
538                self.experiment_explicit_tolerances = value.eq_ignore_ascii_case("true");
539            }
540            "use_precise_interpolation" => {
541                // Accepted for beancount 3.x compatibility. rustledger already
542                // interpolates with exact decimals, so this is a no-op on
543                // results — recorded only to reflect the user's declaration.
544                self.use_precise_interpolation = value.eq_ignore_ascii_case("true");
545            }
546            "allow_pipe_separator" => {
547                // This option is deprecated in Python beancount
548                self.warnings.push(OptionWarning {
549                    code: "E7004",
550                    message: "Option 'allow_pipe_separator' is deprecated".to_string(),
551                    option: key.to_string(),
552                    value: value.to_string(),
553                });
554                self.allow_pipe_separator = value.eq_ignore_ascii_case("true");
555            }
556            "long_string_maxlines" => {
557                if let Ok(n) = value.parse::<u32>() {
558                    self.long_string_maxlines = n;
559                } else {
560                    self.warnings.push(OptionWarning {
561                        code: "E7002",
562                        message: format!(
563                            "Invalid value \"{value}\" for option \"{key}\": expected integer"
564                        ),
565                        option: key.to_string(),
566                        value: value.to_string(),
567                    });
568                }
569            }
570            "documents" => {
571                // Validate that document root exists
572                if !std::path::Path::new(value).exists() {
573                    self.warnings.push(OptionWarning {
574                        code: "E7006",
575                        message: format!("Document root '{value}' does not exist"),
576                        option: key.to_string(),
577                        value: value.to_string(),
578                    });
579                }
580                self.documents.push(value.to_string());
581            }
582            "plugin_processing_mode" => {
583                // Valid values are "default" and "raw" (case-sensitive, like Python)
584                if value != "default" && value != "raw" {
585                    self.warnings.push(OptionWarning {
586                        code: "E7002",
587                        message: format!("Invalid value '{value}'"),
588                        option: key.to_string(),
589                        value: value.to_string(),
590                    });
591                }
592                self.plugin_processing_mode = value.to_string();
593            }
594            "plugin" => {
595                // Deprecated: should use `plugin` directive instead of `option "plugin"`
596                self.warnings.push(OptionWarning {
597                    code: "E7004",
598                    message: "Option 'plugin' is deprecated; use the 'plugin' directive instead"
599                        .to_string(),
600                    option: key.to_string(),
601                    value: value.to_string(),
602                });
603            }
604            _ => {
605                // Unknown options go to custom map
606                self.custom.insert(key.to_string(), value.to_string());
607            }
608        }
609    }
610
611    /// Get a custom option value.
612    #[must_use]
613    pub fn get(&self, key: &str) -> Option<&str> {
614        self.custom.get(key).map(String::as_str)
615    }
616
617    /// Config-aware [`rustledger_core::AccountTypes`] classifier honoring the
618    /// `name_*` renames. Consumers that route or sign accounts by root type
619    /// must use this, not the rename-blind `ACCOUNT_TYPES` defaults.
620    #[must_use]
621    pub fn to_account_types(&self) -> rustledger_core::AccountTypes {
622        rustledger_core::AccountTypes {
623            assets: self.name_assets.clone(),
624            liabilities: self.name_liabilities.clone(),
625            equity: self.name_equity.clone(),
626            income: self.name_income.clone(),
627            expenses: self.name_expenses.clone(),
628        }
629    }
630
631    /// Get all account type prefixes.
632    #[must_use]
633    pub fn account_types(&self) -> [&str; 5] {
634        [
635            &self.name_assets,
636            &self.name_liabilities,
637            &self.name_equity,
638            &self.name_income,
639            &self.name_expenses,
640        ]
641    }
642
643    /// Warn (E7008) when a `name_*` account-type rename is not a lexable
644    /// account root: every account under such a root is unparsable (both
645    /// rledger and Python beancount fail at parse time with "unexpected
646    /// NUMBER"-style errors), so the rename can only produce a broken
647    /// ledger. Accepted anyway for option-handling parity — the warning
648    /// makes the failure mode visible at the option site instead of at
649    /// every account mention. The `account_*` options get the analogous
650    /// E7002 guard; `name_*` used to be the unguarded exception.
651    fn warn_if_invalid_root(&mut self, key: &str, value: &str) {
652        if !Self::is_valid_account_root(value) {
653            self.warnings.push(OptionWarning {
654                code: "E7008",
655                message: format!(
656                    "Invalid account type name: '{value}' cannot begin an \
657                     account name (accounts under it will never parse)"
658                ),
659                option: key.to_string(),
660                value: value.to_string(),
661            });
662        }
663    }
664
665    /// Check if a value looks like a valid account name.
666    ///
667    /// Delegates to the canonical [`rustledger_parser::is_valid_account_name`]
668    /// (the lexer itself), so option values are held to exactly the rule the
669    /// parser applies to account tokens. The old hand-written check here was a
670    /// third, divergent variant (it accepted lowercase-adjacent first chars the
671    /// lexer rejects and had no per-character rule at all).
672    fn is_valid_account(value: &str) -> bool {
673        rustledger_parser::is_valid_account_name(value)
674    }
675
676    /// Check if a value is usable as an account TYPE root (a `name_*` option
677    /// value): a single component (no `:`) such that accounts under it are
678    /// lexable. Checked by running the canonical account predicate on
679    /// `value:X` — a root is valid exactly when it can head a real account.
680    fn is_valid_account_root(value: &str) -> bool {
681        !value.contains(':') && rustledger_parser::is_valid_account_name(&format!("{value}:X"))
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    #[test]
690    fn test_default_options() {
691        let opts = Options::new();
692        assert_eq!(opts.name_assets, "Assets");
693        assert_eq!(opts.booking_method, "STRICT");
694        assert!(!opts.infer_tolerance_from_cost);
695    }
696
697    #[test]
698    fn test_set_options() {
699        let mut opts = Options::new();
700        opts.set("title", "My Ledger");
701        opts.set("operating_currency", "USD");
702        opts.set("operating_currency", "EUR");
703        opts.set("booking_method", "FIFO");
704
705        assert_eq!(opts.title, Some("My Ledger".to_string()));
706        assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
707        assert_eq!(opts.booking_method, "FIFO");
708    }
709
710    #[test]
711    fn test_custom_options() {
712        let mut opts = Options::new();
713        opts.set("my_custom_option", "my_value");
714
715        assert_eq!(opts.get("my_custom_option"), Some("my_value"));
716        assert_eq!(opts.get("nonexistent"), None);
717    }
718
719    #[test]
720    fn test_unknown_option_warning() {
721        let mut opts = Options::new();
722        opts.set("unknown_option", "value");
723
724        assert_eq!(opts.warnings.len(), 1);
725        assert_eq!(opts.warnings[0].code, "E7001");
726        assert!(opts.warnings[0].message.contains("Invalid option"));
727    }
728
729    /// #1416: the beancount 3.x `use_precise_interpolation` option must be
730    /// accepted (no E7001) — rustledger already interpolates precisely.
731    #[test]
732    fn test_use_precise_interpolation_accepted() {
733        let mut opts = Options::new();
734        opts.set("use_precise_interpolation", "TRUE");
735
736        assert!(
737            opts.warnings.is_empty(),
738            "should not warn on a known option: {:?}",
739            opts.warnings
740        );
741        assert!(opts.use_precise_interpolation);
742    }
743
744    #[test]
745    fn test_duplicate_option_warning() {
746        let mut opts = Options::new();
747        opts.set("title", "First Title");
748        opts.set("title", "Second Title");
749
750        assert_eq!(opts.warnings.len(), 1);
751        assert_eq!(opts.warnings[0].code, "E7003");
752        assert!(opts.warnings[0].message.contains("only be specified once"));
753    }
754
755    #[test]
756    fn test_repeatable_option_no_warning() {
757        let mut opts = Options::new();
758        opts.set("operating_currency", "USD");
759        opts.set("operating_currency", "EUR");
760
761        // No warnings for repeatable options
762        assert!(
763            opts.warnings.is_empty(),
764            "Should not warn for repeatable options: {:?}",
765            opts.warnings
766        );
767        assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
768    }
769
770    #[test]
771    fn test_invalid_tolerance_value() {
772        let mut opts = Options::new();
773        opts.set("inferred_tolerance_multiplier", "not_a_number");
774
775        // E7004 (deprecated name) + E7002 (invalid value)
776        assert_eq!(opts.warnings.len(), 2);
777        assert_eq!(opts.warnings[0].code, "E7004");
778        assert!(opts.warnings[0].message.contains("Renamed"));
779        assert_eq!(opts.warnings[1].code, "E7002");
780        assert!(opts.warnings[1].message.contains("expected decimal"));
781    }
782
783    #[test]
784    fn test_tolerance_multiplier_new_name() {
785        let mut opts = Options::new();
786        opts.set("tolerance_multiplier", "1.5");
787
788        assert!(opts.warnings.is_empty());
789        assert_eq!(opts.inferred_tolerance_multiplier, Decimal::new(15, 1));
790    }
791
792    #[test]
793    fn test_inferred_tolerance_multiplier_deprecated() {
794        let mut opts = Options::new();
795        opts.set("inferred_tolerance_multiplier", "1.01");
796
797        assert_eq!(opts.warnings.len(), 1);
798        assert_eq!(opts.warnings[0].code, "E7004");
799        assert!(
800            opts.warnings[0]
801                .message
802                .contains("Renamed to 'tolerance_multiplier'")
803        );
804        assert_eq!(
805            opts.inferred_tolerance_multiplier,
806            Decimal::from_str("1.01").unwrap()
807        );
808    }
809
810    #[test]
811    fn test_invalid_boolean_value() {
812        let mut opts = Options::new();
813        opts.set("infer_tolerance_from_cost", "maybe");
814
815        assert_eq!(opts.warnings.len(), 1);
816        assert_eq!(opts.warnings[0].code, "E7002");
817        assert!(opts.warnings[0].message.contains("TRUE or FALSE"));
818    }
819
820    #[test]
821    fn test_invalid_booking_method() {
822        let mut opts = Options::new();
823        opts.set("booking_method", "RANDOM");
824
825        assert_eq!(opts.warnings.len(), 1);
826        assert_eq!(opts.warnings[0].code, "E7002");
827        assert!(opts.warnings[0].message.contains("STRICT"));
828    }
829
830    #[test]
831    fn test_valid_booking_methods() {
832        for method in &["STRICT", "FIFO", "LIFO", "AVERAGE", "NONE"] {
833            let mut opts = Options::new();
834            opts.set("booking_method", method);
835            assert!(
836                opts.warnings.is_empty(),
837                "Should accept {method} as valid booking method"
838            );
839        }
840    }
841
842    #[test]
843    fn test_readonly_option_warning() {
844        let mut opts = Options::new();
845        opts.set("filename", "/some/path.beancount");
846
847        assert_eq!(opts.warnings.len(), 1);
848        assert_eq!(opts.warnings[0].code, "E7005");
849        assert!(opts.warnings[0].message.contains("may not be set"));
850    }
851
852    #[test]
853    fn test_account_rounding_accepted_but_warns_noop() {
854        let mut opts = Options::new();
855        opts.set("account_rounding", "Equity:Rounding");
856
857        // Still stored for Beancount compatibility...
858        assert_eq!(opts.account_rounding.as_deref(), Some("Equity:Rounding"));
859        // ...but a no-op warning is emitted so the option isn't silently swallowed.
860        let w = opts
861            .warnings
862            .iter()
863            .find(|w| w.code == "E7007")
864            .expect("expected an E7007 no-op warning for account_rounding");
865        assert!(w.message.contains("no effect"));
866        assert_eq!(w.option, "account_rounding");
867        // A valid account name must NOT also trip E7002 (invalid value).
868        assert!(!opts.warnings.iter().any(|w| w.code == "E7002"));
869    }
870
871    #[test]
872    fn test_invalid_account_name_validation() {
873        // account_rounding with an invalid value: both the invalid-account
874        // warning (E7002) and the accepted-but-no-op warning (E7007) fire.
875        let mut opts = Options::new();
876        opts.set("account_rounding", "invalid");
877
878        assert!(
879            opts.warnings
880                .iter()
881                .any(|w| w.code == "E7002" && w.message.contains("Invalid leaf account"))
882        );
883        assert!(opts.warnings.iter().any(|w| w.code == "E7007"));
884    }
885
886    #[test]
887    fn test_valid_account_name() {
888        let mut opts = Options::new();
889        opts.set("account_rounding", "Equity:Rounding");
890
891        // A valid account name does not trip E7002; the value is stored, but
892        // account_rounding is a no-op in rustledger so an E7007 warning fires.
893        assert!(!opts.warnings.iter().any(|w| w.code == "E7002"));
894        assert!(opts.warnings.iter().any(|w| w.code == "E7007"));
895        assert_eq!(opts.account_rounding, Some("Equity:Rounding".to_string()));
896    }
897
898    #[test]
899    fn test_render_commas_with_numeric_values() {
900        let mut opts = Options::new();
901        opts.set("render_commas", "1");
902        assert!(opts.render_commas);
903        assert!(opts.warnings.is_empty());
904
905        let mut opts2 = Options::new();
906        opts2.set("render_commas", "0");
907        assert!(!opts2.render_commas);
908        assert!(opts2.warnings.is_empty());
909    }
910
911    #[test]
912    fn test_plugin_processing_mode_validation() {
913        // Valid values
914        let mut opts = Options::new();
915        opts.set("plugin_processing_mode", "default");
916        assert!(opts.warnings.is_empty());
917        assert_eq!(opts.plugin_processing_mode, "default");
918
919        let mut opts2 = Options::new();
920        opts2.set("plugin_processing_mode", "raw");
921        assert!(opts2.warnings.is_empty());
922        assert_eq!(opts2.plugin_processing_mode, "raw");
923
924        // Invalid value
925        let mut opts3 = Options::new();
926        opts3.set("plugin_processing_mode", "invalid");
927        assert_eq!(opts3.warnings.len(), 1);
928        assert_eq!(opts3.warnings[0].code, "E7002");
929    }
930
931    #[test]
932    fn test_deprecated_plugin_option() {
933        let mut opts = Options::new();
934        opts.set("plugin", "some.plugin");
935
936        assert_eq!(opts.warnings.len(), 1);
937        assert_eq!(opts.warnings[0].code, "E7004");
938        assert!(opts.warnings[0].message.contains("deprecated"));
939    }
940
941    #[test]
942    fn test_deprecated_allow_pipe_separator() {
943        let mut opts = Options::new();
944        opts.set("allow_pipe_separator", "true");
945
946        assert_eq!(opts.warnings.len(), 1);
947        assert_eq!(opts.warnings[0].code, "E7004");
948        assert!(opts.warnings[0].message.contains("deprecated"));
949    }
950
951    #[test]
952    fn test_is_valid_account() {
953        // Valid accounts — ASCII
954        assert!(Options::is_valid_account("Assets:Bank"));
955        assert!(Options::is_valid_account("Equity:Rounding:Precision"));
956
957        // Valid accounts — Unicode
958        assert!(Options::is_valid_account("Капитал:Retained"));
959        assert!(Options::is_valid_account("资产:银行:支票"));
960
961        // Invalid accounts
962        assert!(!Options::is_valid_account("invalid")); // No colon
963        assert!(!Options::is_valid_account("assets:bank")); // Lowercase ASCII
964        assert!(!Options::is_valid_account("Assets:")); // Empty component
965        assert!(!Options::is_valid_account(":Bank")); // Empty first component
966    }
967
968    #[test]
969    fn test_account_validation_options() {
970        // Test all account options that require validation
971        let account_options = [
972            "account_rounding",
973            "account_current_conversions",
974            "account_unrealized_gains",
975            "account_previous_balances",
976            "account_previous_earnings",
977            "account_previous_conversions",
978            "account_current_earnings",
979        ];
980
981        for opt in account_options {
982            let mut opts = Options::new();
983            opts.set(opt, "lowercase:invalid");
984
985            assert!(
986                !opts.warnings.is_empty(),
987                "Option '{opt}' should warn on invalid account name"
988            );
989            assert_eq!(opts.warnings[0].code, "E7002");
990        }
991    }
992
993    #[test]
994    fn test_inferred_tolerance_default() {
995        let mut opts = Options::new();
996        opts.set("inferred_tolerance_default", "USD:0.005");
997
998        assert!(opts.warnings.is_empty());
999        assert_eq!(
1000            opts.inferred_tolerance_default.get("USD"),
1001            Some(&rust_decimal_macros::dec!(0.005))
1002        );
1003
1004        // Test wildcard
1005        let mut opts2 = Options::new();
1006        opts2.set("inferred_tolerance_default", "*:0.01");
1007        assert!(opts2.warnings.is_empty());
1008        assert_eq!(
1009            opts2.inferred_tolerance_default.get("*"),
1010            Some(&rust_decimal_macros::dec!(0.01))
1011        );
1012
1013        // Test invalid format
1014        let mut opts3 = Options::new();
1015        opts3.set("inferred_tolerance_default", "INVALID");
1016        assert_eq!(opts3.warnings.len(), 1);
1017        assert_eq!(opts3.warnings[0].code, "E7002");
1018    }
1019
1020    #[test]
1021    fn test_display_precision_basic() {
1022        let mut opts = Options::new();
1023        opts.set("display_precision", "USD:0.01");
1024        assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
1025        assert_eq!(opts.display_precision.get("USD"), Some(&2));
1026    }
1027
1028    #[test]
1029    fn test_display_precision_high_precision() {
1030        let mut opts = Options::new();
1031        opts.set("display_precision", "BTC:0.00000001");
1032        assert!(opts.warnings.is_empty());
1033        assert_eq!(opts.display_precision.get("BTC"), Some(&8));
1034    }
1035
1036    #[test]
1037    fn test_display_precision_zero_decimals() {
1038        // "JPY:1" → no fractional digits → precision 0.
1039        let mut opts = Options::new();
1040        opts.set("display_precision", "JPY:1");
1041        assert!(opts.warnings.is_empty());
1042        assert_eq!(opts.display_precision.get("JPY"), Some(&0));
1043    }
1044
1045    #[test]
1046    fn test_display_precision_repeatable_per_currency() {
1047        let mut opts = Options::new();
1048        opts.set("display_precision", "USD:0.01");
1049        opts.set("display_precision", "EUR:0.001");
1050        assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
1051        assert_eq!(opts.display_precision.get("USD"), Some(&2));
1052        assert_eq!(opts.display_precision.get("EUR"), Some(&3));
1053    }
1054
1055    #[test]
1056    fn test_display_precision_missing_colon_warns() {
1057        let mut opts = Options::new();
1058        opts.set("display_precision", "USD0.01");
1059        assert_eq!(opts.warnings.len(), 1);
1060        assert_eq!(opts.warnings[0].code, "E7002");
1061        assert!(opts.warnings[0].message.contains("CURRENCY:EXAMPLE"));
1062        assert!(opts.display_precision.is_empty());
1063    }
1064
1065    #[test]
1066    fn test_name_option_invalid_root_warns_e7008() {
1067        // Digit-start root: every account under it is unparsable (both
1068        // rledger and Python fail at parse time) — the option site now
1069        // says so up front instead of the ledger erroring at every mention.
1070        let mut opts = Options::new();
1071        opts.set("name_assets", "1Assets");
1072        assert_eq!(opts.warnings.len(), 1);
1073        assert_eq!(opts.warnings[0].code, "E7008");
1074        assert!(opts.warnings[0].message.contains("1Assets"));
1075        // Accepted anyway (option-handling parity).
1076        assert_eq!(opts.name_assets, "1Assets");
1077
1078        // Colon inside a root can never match a root component.
1079        let mut opts = Options::new();
1080        opts.set("name_income", "In:Come");
1081        assert_eq!(opts.warnings.len(), 1);
1082        assert_eq!(opts.warnings[0].code, "E7008");
1083    }
1084
1085    #[test]
1086    fn test_name_option_valid_roots_no_warning() {
1087        let mut opts = Options::new();
1088        opts.set("name_income", "Revenue");
1089        opts.set("name_assets", "Activa");
1090        opts.set("name_expenses", "Ausgaben");
1091        opts.set("name_liabilities", "負債"); // caseless (\p{Lo}) root
1092        assert!(
1093            opts.warnings.is_empty(),
1094            "lexable renames must not warn: {:?}",
1095            opts.warnings
1096        );
1097    }
1098
1099    #[test]
1100    fn test_account_option_uses_canonical_rule() {
1101        // The old hand-written is_valid_account had no per-character rule:
1102        // 'Equity:Ro unding' style values with invalid chars slipped through
1103        // as long as first chars looked right. The canonical predicate
1104        // rejects what the lexer rejects.
1105        let mut opts = Options::new();
1106        opts.set("account_current_conversions", "Equity:Conv ersions");
1107        assert!(opts.warnings.iter().any(|w| w.code == "E7002"));
1108
1109        let mut opts = Options::new();
1110        opts.set("account_current_conversions", "Equity:Conversions:Current");
1111        assert!(opts.warnings.is_empty(), "{:?}", opts.warnings);
1112    }
1113
1114    #[test]
1115    fn test_display_precision_invalid_example_warns() {
1116        let mut opts = Options::new();
1117        opts.set("display_precision", "USD:abc");
1118        assert_eq!(opts.warnings.len(), 1);
1119        assert_eq!(opts.warnings[0].code, "E7002");
1120        assert!(opts.warnings[0].message.contains("Invalid precision"));
1121        assert!(opts.display_precision.is_empty());
1122    }
1123}