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