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 E7007).
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" => self.name_assets = value.to_string(),
273            "name_liabilities" => self.name_liabilities = value.to_string(),
274            "name_equity" => self.name_equity = value.to_string(),
275            "name_income" => self.name_income = value.to_string(),
276            "name_expenses" => self.name_expenses = value.to_string(),
277            "account_rounding" => {
278                if !Self::is_valid_account(value) {
279                    self.warnings.push(OptionWarning {
280                        code: "E7002",
281                        message: format!("Invalid leaf account name: '{value}'"),
282                        option: key.to_string(),
283                        value: value.to_string(),
284                    });
285                }
286                // Accepted for Beancount compatibility but intentionally a no-op.
287                // Beancount uses `account_rounding` to absorb the residual created
288                // when an interpolated leg is *rounded* and the rounding breaks the
289                // sum. rustledger never produces such a residual: `round_interpolated`
290                // (rustledger-booking) preserves full precision instead of rounding a
291                // non-zero residual to zero, so there is nothing for a rounding
292                // account to catch. Warn so the option isn't silently swallowed.
293                self.warnings.push(OptionWarning {
294                    code: "E7007",
295                    message: "Option 'account_rounding' is accepted for compatibility \
296                              but has no effect: rustledger preserves full precision \
297                              during interpolation rather than rounding into a rounding \
298                              account, so no rounding residual is produced."
299                        .to_string(),
300                    option: key.to_string(),
301                    value: value.to_string(),
302                });
303                self.account_rounding = Some(value.to_string());
304            }
305            "account_current_conversions" => {
306                if !Self::is_valid_account(value) {
307                    self.warnings.push(OptionWarning {
308                        code: "E7002",
309                        message: format!("Invalid leaf account name: '{value}'"),
310                        option: key.to_string(),
311                        value: value.to_string(),
312                    });
313                }
314                self.account_current_conversions = Some(value.to_string());
315            }
316            "account_unrealized_gains" => {
317                if !Self::is_valid_account(value) {
318                    self.warnings.push(OptionWarning {
319                        code: "E7002",
320                        message: format!("Invalid leaf account name: '{value}'"),
321                        option: key.to_string(),
322                        value: value.to_string(),
323                    });
324                }
325                self.account_unrealized_gains = Some(value.to_string());
326            }
327            "inferred_tolerance_multiplier" => {
328                // Deprecated: renamed to tolerance_multiplier in Python beancount
329                self.warnings.push(OptionWarning {
330                    code: "E7004",
331                    message: "Renamed to 'tolerance_multiplier'.".to_string(),
332                    option: key.to_string(),
333                    value: value.to_string(),
334                });
335                if let Ok(d) = Decimal::from_str(value) {
336                    self.inferred_tolerance_multiplier = d;
337                } else {
338                    // E7002: Invalid option value
339                    self.warnings.push(OptionWarning {
340                        code: "E7002",
341                        message: format!(
342                            "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
343                        ),
344                        option: key.to_string(),
345                        value: value.to_string(),
346                    });
347                }
348            }
349            "tolerance_multiplier" => {
350                if let Ok(d) = Decimal::from_str(value) {
351                    self.inferred_tolerance_multiplier = d;
352                } else {
353                    self.warnings.push(OptionWarning {
354                        code: "E7002",
355                        message: format!(
356                            "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
357                        ),
358                        option: key.to_string(),
359                        value: value.to_string(),
360                    });
361                }
362            }
363            "infer_tolerance_from_cost" => {
364                if !value.eq_ignore_ascii_case("true") && !value.eq_ignore_ascii_case("false") {
365                    self.warnings.push(OptionWarning {
366                        code: "E7002",
367                        message: format!(
368                            "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
369                        ),
370                        option: key.to_string(),
371                        value: value.to_string(),
372                    });
373                }
374                self.infer_tolerance_from_cost = value.eq_ignore_ascii_case("true");
375            }
376            "booking_method" => {
377                let valid_methods = [
378                    "STRICT",
379                    "STRICT_WITH_SIZE",
380                    "FIFO",
381                    "LIFO",
382                    "HIFO",
383                    "AVERAGE",
384                    "NONE",
385                ];
386                if !valid_methods.contains(&value.to_uppercase().as_str()) {
387                    self.warnings.push(OptionWarning {
388                        code: "E7002",
389                        message: format!(
390                            "Invalid value \"{}\" for option \"{}\": expected one of {}",
391                            value,
392                            key,
393                            valid_methods.join(", ")
394                        ),
395                        option: key.to_string(),
396                        value: value.to_string(),
397                    });
398                }
399                self.booking_method = value.to_string();
400            }
401            "render_commas" => {
402                // Accept TRUE/FALSE, true/false, 1/0 (Python beancount compatibility)
403                let is_true = value.eq_ignore_ascii_case("true") || value == "1";
404                let is_false = value.eq_ignore_ascii_case("false") || value == "0";
405                if !is_true && !is_false {
406                    self.warnings.push(OptionWarning {
407                        code: "E7002",
408                        message: format!(
409                            "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
410                        ),
411                        option: key.to_string(),
412                        value: value.to_string(),
413                    });
414                }
415                self.render_commas = is_true;
416            }
417            "display_precision" => {
418                // Parse "CURRENCY:EXAMPLE" where EXAMPLE's decimal places define the precision.
419                // E.g., "CHF:0.01" means 2 decimal places for CHF.
420                // E.g., "USD:0.001" means 3 decimal places for USD.
421                if let Some((curr, example)) = value.split_once(':') {
422                    if let Ok(d) = Decimal::from_str(example) {
423                        // Get the precision from the example number's decimal places
424                        let precision = d.scale();
425                        self.display_precision.insert(curr.to_string(), precision);
426                    } else {
427                        self.warnings.push(OptionWarning {
428                            code: "E7002",
429                            message: format!(
430                                "Invalid precision value \"{example}\" in option \"{key}\""
431                            ),
432                            option: key.to_string(),
433                            value: value.to_string(),
434                        });
435                    }
436                } else {
437                    self.warnings.push(OptionWarning {
438                        code: "E7002",
439                        message: format!(
440                            "Invalid format for option \"{key}\": expected CURRENCY:EXAMPLE (e.g., CHF:0.01)"
441                        ),
442                        option: key.to_string(),
443                        value: value.to_string(),
444                    });
445                }
446            }
447            "filename" => self.filename = Some(value.to_string()),
448            "account_previous_balances" => {
449                if !Self::is_valid_account(value) {
450                    self.warnings.push(OptionWarning {
451                        code: "E7002",
452                        message: format!("Invalid leaf account name: '{value}'"),
453                        option: key.to_string(),
454                        value: value.to_string(),
455                    });
456                }
457                self.account_previous_balances = value.to_string();
458            }
459            "account_previous_earnings" => {
460                if !Self::is_valid_account(value) {
461                    self.warnings.push(OptionWarning {
462                        code: "E7002",
463                        message: format!("Invalid leaf account name: '{value}'"),
464                        option: key.to_string(),
465                        value: value.to_string(),
466                    });
467                }
468                self.account_previous_earnings = value.to_string();
469            }
470            "account_previous_conversions" => {
471                if !Self::is_valid_account(value) {
472                    self.warnings.push(OptionWarning {
473                        code: "E7002",
474                        message: format!("Invalid leaf account name: '{value}'"),
475                        option: key.to_string(),
476                        value: value.to_string(),
477                    });
478                }
479                self.account_previous_conversions = value.to_string();
480            }
481            "account_current_earnings" => {
482                if !Self::is_valid_account(value) {
483                    self.warnings.push(OptionWarning {
484                        code: "E7002",
485                        message: format!("Invalid leaf account name: '{value}'"),
486                        option: key.to_string(),
487                        value: value.to_string(),
488                    });
489                }
490                self.account_current_earnings = value.to_string();
491            }
492            "conversion_currency" => self.conversion_currency = Some(value.to_string()),
493            "inferred_tolerance_default" => {
494                // Parse "CURRENCY:TOLERANCE" or "*:TOLERANCE"
495                if let Some((curr, tol)) = value.split_once(':') {
496                    if let Ok(d) = Decimal::from_str(tol) {
497                        self.inferred_tolerance_default.insert(curr.to_string(), d);
498                    } else {
499                        self.warnings.push(OptionWarning {
500                            code: "E7002",
501                            message: format!(
502                                "Invalid tolerance value \"{tol}\" in option \"{key}\""
503                            ),
504                            option: key.to_string(),
505                            value: value.to_string(),
506                        });
507                    }
508                } else {
509                    self.warnings.push(OptionWarning {
510                        code: "E7002",
511                        message: format!(
512                            "Invalid format for option \"{key}\": expected CURRENCY:TOLERANCE"
513                        ),
514                        option: key.to_string(),
515                        value: value.to_string(),
516                    });
517                }
518            }
519            "use_legacy_fixed_tolerances" => {
520                self.use_legacy_fixed_tolerances = value.eq_ignore_ascii_case("true");
521            }
522            "experiment_explicit_tolerances" => {
523                self.experiment_explicit_tolerances = value.eq_ignore_ascii_case("true");
524            }
525            "use_precise_interpolation" => {
526                // Accepted for beancount 3.x compatibility. rustledger already
527                // interpolates with exact decimals, so this is a no-op on
528                // results — recorded only to reflect the user's declaration.
529                self.use_precise_interpolation = value.eq_ignore_ascii_case("true");
530            }
531            "allow_pipe_separator" => {
532                // This option is deprecated in Python beancount
533                self.warnings.push(OptionWarning {
534                    code: "E7004",
535                    message: "Option 'allow_pipe_separator' is deprecated".to_string(),
536                    option: key.to_string(),
537                    value: value.to_string(),
538                });
539                self.allow_pipe_separator = value.eq_ignore_ascii_case("true");
540            }
541            "long_string_maxlines" => {
542                if let Ok(n) = value.parse::<u32>() {
543                    self.long_string_maxlines = n;
544                } else {
545                    self.warnings.push(OptionWarning {
546                        code: "E7002",
547                        message: format!(
548                            "Invalid value \"{value}\" for option \"{key}\": expected integer"
549                        ),
550                        option: key.to_string(),
551                        value: value.to_string(),
552                    });
553                }
554            }
555            "documents" => {
556                // Validate that document root exists
557                if !std::path::Path::new(value).exists() {
558                    self.warnings.push(OptionWarning {
559                        code: "E7006",
560                        message: format!("Document root '{value}' does not exist"),
561                        option: key.to_string(),
562                        value: value.to_string(),
563                    });
564                }
565                self.documents.push(value.to_string());
566            }
567            "plugin_processing_mode" => {
568                // Valid values are "default" and "raw" (case-sensitive, like Python)
569                if value != "default" && value != "raw" {
570                    self.warnings.push(OptionWarning {
571                        code: "E7002",
572                        message: format!("Invalid value '{value}'"),
573                        option: key.to_string(),
574                        value: value.to_string(),
575                    });
576                }
577                self.plugin_processing_mode = value.to_string();
578            }
579            "plugin" => {
580                // Deprecated: should use `plugin` directive instead of `option "plugin"`
581                self.warnings.push(OptionWarning {
582                    code: "E7004",
583                    message: "Option 'plugin' is deprecated; use the 'plugin' directive instead"
584                        .to_string(),
585                    option: key.to_string(),
586                    value: value.to_string(),
587                });
588            }
589            _ => {
590                // Unknown options go to custom map
591                self.custom.insert(key.to_string(), value.to_string());
592            }
593        }
594    }
595
596    /// Get a custom option value.
597    #[must_use]
598    pub fn get(&self, key: &str) -> Option<&str> {
599        self.custom.get(key).map(String::as_str)
600    }
601
602    /// Get all account type prefixes.
603    #[must_use]
604    pub fn account_types(&self) -> [&str; 5] {
605        [
606            &self.name_assets,
607            &self.name_liabilities,
608            &self.name_equity,
609            &self.name_income,
610            &self.name_expenses,
611        ]
612    }
613
614    /// Check if a value looks like a valid account name.
615    ///
616    /// Valid accounts have format "Type:Subaccount:..." where each component
617    /// starts with an uppercase letter (any script) or a non-ASCII letter
618    /// without case (CJK, etc.), and components are colon-separated.
619    fn is_valid_account(value: &str) -> bool {
620        // Must contain at least one colon
621        if !value.contains(':') {
622            return false;
623        }
624
625        // Check each component
626        for part in value.split(':') {
627            if let Some(first) = part.chars().next() {
628                // Accept: uppercase (any script), non-ASCII alphabetic (CJK, etc.)
629                let valid = first.is_uppercase() || (!first.is_ascii() && first.is_alphabetic());
630                if !valid {
631                    return false;
632                }
633            } else {
634                // Empty component
635                return false;
636            }
637        }
638
639        true
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    #[test]
648    fn test_default_options() {
649        let opts = Options::new();
650        assert_eq!(opts.name_assets, "Assets");
651        assert_eq!(opts.booking_method, "STRICT");
652        assert!(!opts.infer_tolerance_from_cost);
653    }
654
655    #[test]
656    fn test_set_options() {
657        let mut opts = Options::new();
658        opts.set("title", "My Ledger");
659        opts.set("operating_currency", "USD");
660        opts.set("operating_currency", "EUR");
661        opts.set("booking_method", "FIFO");
662
663        assert_eq!(opts.title, Some("My Ledger".to_string()));
664        assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
665        assert_eq!(opts.booking_method, "FIFO");
666    }
667
668    #[test]
669    fn test_custom_options() {
670        let mut opts = Options::new();
671        opts.set("my_custom_option", "my_value");
672
673        assert_eq!(opts.get("my_custom_option"), Some("my_value"));
674        assert_eq!(opts.get("nonexistent"), None);
675    }
676
677    #[test]
678    fn test_unknown_option_warning() {
679        let mut opts = Options::new();
680        opts.set("unknown_option", "value");
681
682        assert_eq!(opts.warnings.len(), 1);
683        assert_eq!(opts.warnings[0].code, "E7001");
684        assert!(opts.warnings[0].message.contains("Invalid option"));
685    }
686
687    /// #1416: the beancount 3.x `use_precise_interpolation` option must be
688    /// accepted (no E7001) — rustledger already interpolates precisely.
689    #[test]
690    fn test_use_precise_interpolation_accepted() {
691        let mut opts = Options::new();
692        opts.set("use_precise_interpolation", "TRUE");
693
694        assert!(
695            opts.warnings.is_empty(),
696            "should not warn on a known option: {:?}",
697            opts.warnings
698        );
699        assert!(opts.use_precise_interpolation);
700    }
701
702    #[test]
703    fn test_duplicate_option_warning() {
704        let mut opts = Options::new();
705        opts.set("title", "First Title");
706        opts.set("title", "Second Title");
707
708        assert_eq!(opts.warnings.len(), 1);
709        assert_eq!(opts.warnings[0].code, "E7003");
710        assert!(opts.warnings[0].message.contains("only be specified once"));
711    }
712
713    #[test]
714    fn test_repeatable_option_no_warning() {
715        let mut opts = Options::new();
716        opts.set("operating_currency", "USD");
717        opts.set("operating_currency", "EUR");
718
719        // No warnings for repeatable options
720        assert!(
721            opts.warnings.is_empty(),
722            "Should not warn for repeatable options: {:?}",
723            opts.warnings
724        );
725        assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
726    }
727
728    #[test]
729    fn test_invalid_tolerance_value() {
730        let mut opts = Options::new();
731        opts.set("inferred_tolerance_multiplier", "not_a_number");
732
733        // E7004 (deprecated name) + E7002 (invalid value)
734        assert_eq!(opts.warnings.len(), 2);
735        assert_eq!(opts.warnings[0].code, "E7004");
736        assert!(opts.warnings[0].message.contains("Renamed"));
737        assert_eq!(opts.warnings[1].code, "E7002");
738        assert!(opts.warnings[1].message.contains("expected decimal"));
739    }
740
741    #[test]
742    fn test_tolerance_multiplier_new_name() {
743        let mut opts = Options::new();
744        opts.set("tolerance_multiplier", "1.5");
745
746        assert!(opts.warnings.is_empty());
747        assert_eq!(opts.inferred_tolerance_multiplier, Decimal::new(15, 1));
748    }
749
750    #[test]
751    fn test_inferred_tolerance_multiplier_deprecated() {
752        let mut opts = Options::new();
753        opts.set("inferred_tolerance_multiplier", "1.01");
754
755        assert_eq!(opts.warnings.len(), 1);
756        assert_eq!(opts.warnings[0].code, "E7004");
757        assert!(
758            opts.warnings[0]
759                .message
760                .contains("Renamed to 'tolerance_multiplier'")
761        );
762        assert_eq!(
763            opts.inferred_tolerance_multiplier,
764            Decimal::from_str("1.01").unwrap()
765        );
766    }
767
768    #[test]
769    fn test_invalid_boolean_value() {
770        let mut opts = Options::new();
771        opts.set("infer_tolerance_from_cost", "maybe");
772
773        assert_eq!(opts.warnings.len(), 1);
774        assert_eq!(opts.warnings[0].code, "E7002");
775        assert!(opts.warnings[0].message.contains("TRUE or FALSE"));
776    }
777
778    #[test]
779    fn test_invalid_booking_method() {
780        let mut opts = Options::new();
781        opts.set("booking_method", "RANDOM");
782
783        assert_eq!(opts.warnings.len(), 1);
784        assert_eq!(opts.warnings[0].code, "E7002");
785        assert!(opts.warnings[0].message.contains("STRICT"));
786    }
787
788    #[test]
789    fn test_valid_booking_methods() {
790        for method in &["STRICT", "FIFO", "LIFO", "AVERAGE", "NONE"] {
791            let mut opts = Options::new();
792            opts.set("booking_method", method);
793            assert!(
794                opts.warnings.is_empty(),
795                "Should accept {method} as valid booking method"
796            );
797        }
798    }
799
800    #[test]
801    fn test_readonly_option_warning() {
802        let mut opts = Options::new();
803        opts.set("filename", "/some/path.beancount");
804
805        assert_eq!(opts.warnings.len(), 1);
806        assert_eq!(opts.warnings[0].code, "E7005");
807        assert!(opts.warnings[0].message.contains("may not be set"));
808    }
809
810    #[test]
811    fn test_account_rounding_accepted_but_warns_noop() {
812        let mut opts = Options::new();
813        opts.set("account_rounding", "Equity:Rounding");
814
815        // Still stored for Beancount compatibility...
816        assert_eq!(opts.account_rounding.as_deref(), Some("Equity:Rounding"));
817        // ...but a no-op warning is emitted so the option isn't silently swallowed.
818        let w = opts
819            .warnings
820            .iter()
821            .find(|w| w.code == "E7007")
822            .expect("expected an E7007 no-op warning for account_rounding");
823        assert!(w.message.contains("no effect"));
824        assert_eq!(w.option, "account_rounding");
825        // A valid account name must NOT also trip E7002 (invalid value).
826        assert!(!opts.warnings.iter().any(|w| w.code == "E7002"));
827    }
828
829    #[test]
830    fn test_invalid_account_name_validation() {
831        // account_rounding with an invalid value: both the invalid-account
832        // warning (E7002) and the accepted-but-no-op warning (E7007) fire.
833        let mut opts = Options::new();
834        opts.set("account_rounding", "invalid");
835
836        assert!(
837            opts.warnings
838                .iter()
839                .any(|w| w.code == "E7002" && w.message.contains("Invalid leaf account"))
840        );
841        assert!(opts.warnings.iter().any(|w| w.code == "E7007"));
842    }
843
844    #[test]
845    fn test_valid_account_name() {
846        let mut opts = Options::new();
847        opts.set("account_rounding", "Equity:Rounding");
848
849        // A valid account name does not trip E7002; the value is stored, but
850        // account_rounding is a no-op in rustledger so an E7007 warning fires.
851        assert!(!opts.warnings.iter().any(|w| w.code == "E7002"));
852        assert!(opts.warnings.iter().any(|w| w.code == "E7007"));
853        assert_eq!(opts.account_rounding, Some("Equity:Rounding".to_string()));
854    }
855
856    #[test]
857    fn test_render_commas_with_numeric_values() {
858        let mut opts = Options::new();
859        opts.set("render_commas", "1");
860        assert!(opts.render_commas);
861        assert!(opts.warnings.is_empty());
862
863        let mut opts2 = Options::new();
864        opts2.set("render_commas", "0");
865        assert!(!opts2.render_commas);
866        assert!(opts2.warnings.is_empty());
867    }
868
869    #[test]
870    fn test_plugin_processing_mode_validation() {
871        // Valid values
872        let mut opts = Options::new();
873        opts.set("plugin_processing_mode", "default");
874        assert!(opts.warnings.is_empty());
875        assert_eq!(opts.plugin_processing_mode, "default");
876
877        let mut opts2 = Options::new();
878        opts2.set("plugin_processing_mode", "raw");
879        assert!(opts2.warnings.is_empty());
880        assert_eq!(opts2.plugin_processing_mode, "raw");
881
882        // Invalid value
883        let mut opts3 = Options::new();
884        opts3.set("plugin_processing_mode", "invalid");
885        assert_eq!(opts3.warnings.len(), 1);
886        assert_eq!(opts3.warnings[0].code, "E7002");
887    }
888
889    #[test]
890    fn test_deprecated_plugin_option() {
891        let mut opts = Options::new();
892        opts.set("plugin", "some.plugin");
893
894        assert_eq!(opts.warnings.len(), 1);
895        assert_eq!(opts.warnings[0].code, "E7004");
896        assert!(opts.warnings[0].message.contains("deprecated"));
897    }
898
899    #[test]
900    fn test_deprecated_allow_pipe_separator() {
901        let mut opts = Options::new();
902        opts.set("allow_pipe_separator", "true");
903
904        assert_eq!(opts.warnings.len(), 1);
905        assert_eq!(opts.warnings[0].code, "E7004");
906        assert!(opts.warnings[0].message.contains("deprecated"));
907    }
908
909    #[test]
910    fn test_is_valid_account() {
911        // Valid accounts — ASCII
912        assert!(Options::is_valid_account("Assets:Bank"));
913        assert!(Options::is_valid_account("Equity:Rounding:Precision"));
914
915        // Valid accounts — Unicode
916        assert!(Options::is_valid_account("Капитал:Retained"));
917        assert!(Options::is_valid_account("资产:银行:支票"));
918
919        // Invalid accounts
920        assert!(!Options::is_valid_account("invalid")); // No colon
921        assert!(!Options::is_valid_account("assets:bank")); // Lowercase ASCII
922        assert!(!Options::is_valid_account("Assets:")); // Empty component
923        assert!(!Options::is_valid_account(":Bank")); // Empty first component
924    }
925
926    #[test]
927    fn test_account_validation_options() {
928        // Test all account options that require validation
929        let account_options = [
930            "account_rounding",
931            "account_current_conversions",
932            "account_unrealized_gains",
933            "account_previous_balances",
934            "account_previous_earnings",
935            "account_previous_conversions",
936            "account_current_earnings",
937        ];
938
939        for opt in account_options {
940            let mut opts = Options::new();
941            opts.set(opt, "lowercase:invalid");
942
943            assert!(
944                !opts.warnings.is_empty(),
945                "Option '{opt}' should warn on invalid account name"
946            );
947            assert_eq!(opts.warnings[0].code, "E7002");
948        }
949    }
950
951    #[test]
952    fn test_inferred_tolerance_default() {
953        let mut opts = Options::new();
954        opts.set("inferred_tolerance_default", "USD:0.005");
955
956        assert!(opts.warnings.is_empty());
957        assert_eq!(
958            opts.inferred_tolerance_default.get("USD"),
959            Some(&rust_decimal_macros::dec!(0.005))
960        );
961
962        // Test wildcard
963        let mut opts2 = Options::new();
964        opts2.set("inferred_tolerance_default", "*:0.01");
965        assert!(opts2.warnings.is_empty());
966        assert_eq!(
967            opts2.inferred_tolerance_default.get("*"),
968            Some(&rust_decimal_macros::dec!(0.01))
969        );
970
971        // Test invalid format
972        let mut opts3 = Options::new();
973        opts3.set("inferred_tolerance_default", "INVALID");
974        assert_eq!(opts3.warnings.len(), 1);
975        assert_eq!(opts3.warnings[0].code, "E7002");
976    }
977
978    #[test]
979    fn test_display_precision_basic() {
980        let mut opts = Options::new();
981        opts.set("display_precision", "USD:0.01");
982        assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
983        assert_eq!(opts.display_precision.get("USD"), Some(&2));
984    }
985
986    #[test]
987    fn test_display_precision_high_precision() {
988        let mut opts = Options::new();
989        opts.set("display_precision", "BTC:0.00000001");
990        assert!(opts.warnings.is_empty());
991        assert_eq!(opts.display_precision.get("BTC"), Some(&8));
992    }
993
994    #[test]
995    fn test_display_precision_zero_decimals() {
996        // "JPY:1" → no fractional digits → precision 0.
997        let mut opts = Options::new();
998        opts.set("display_precision", "JPY:1");
999        assert!(opts.warnings.is_empty());
1000        assert_eq!(opts.display_precision.get("JPY"), Some(&0));
1001    }
1002
1003    #[test]
1004    fn test_display_precision_repeatable_per_currency() {
1005        let mut opts = Options::new();
1006        opts.set("display_precision", "USD:0.01");
1007        opts.set("display_precision", "EUR:0.001");
1008        assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
1009        assert_eq!(opts.display_precision.get("USD"), Some(&2));
1010        assert_eq!(opts.display_precision.get("EUR"), Some(&3));
1011    }
1012
1013    #[test]
1014    fn test_display_precision_missing_colon_warns() {
1015        let mut opts = Options::new();
1016        opts.set("display_precision", "USD0.01");
1017        assert_eq!(opts.warnings.len(), 1);
1018        assert_eq!(opts.warnings[0].code, "E7002");
1019        assert!(opts.warnings[0].message.contains("CURRENCY:EXAMPLE"));
1020        assert!(opts.display_precision.is_empty());
1021    }
1022
1023    #[test]
1024    fn test_display_precision_invalid_example_warns() {
1025        let mut opts = Options::new();
1026        opts.set("display_precision", "USD:abc");
1027        assert_eq!(opts.warnings.len(), 1);
1028        assert_eq!(opts.warnings[0].code, "E7002");
1029        assert!(opts.warnings[0].message.contains("Invalid precision"));
1030        assert!(opts.display_precision.is_empty());
1031    }
1032}