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