Skip to main content

rustledger_loader/
options.rs

1//! Beancount options parsing and storage.
2
3use rust_decimal::Decimal;
4use std::collections::{HashMap, HashSet};
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    "booking_method",
31    "render_commas",
32    "display_precision",
33    "allow_pipe_separator",
34    "long_string_maxlines",
35    "documents",
36    "insert_pythonpath",
37    "plugin_processing_mode",
38    "plugin", // Deprecated, but still known
39];
40
41/// Options that can be specified multiple times.
42const REPEATABLE_OPTIONS: &[&str] = &[
43    "operating_currency",
44    "insert_pythonpath",
45    "documents",
46    "inferred_tolerance_default",
47    "display_precision",
48];
49
50/// Options that are read-only and cannot be set by users.
51const READONLY_OPTIONS: &[&str] = &["filename"];
52
53/// Option validation warning.
54#[derive(Debug, Clone)]
55pub struct OptionWarning {
56    /// Warning code (E7001, E7002, E7003).
57    pub code: &'static str,
58    /// Warning message.
59    pub message: String,
60    /// Option name.
61    pub option: String,
62    /// Option value.
63    pub value: String,
64}
65
66/// Beancount file options.
67///
68/// These correspond to the `option` directives in beancount files.
69#[derive(Debug, Clone)]
70pub struct Options {
71    /// Title for the ledger.
72    pub title: Option<String>,
73
74    /// Source filename (auto-set).
75    pub filename: Option<String>,
76
77    /// Operating currencies (for reporting).
78    pub operating_currency: Vec<String>,
79
80    /// Name prefix for Assets accounts.
81    pub name_assets: String,
82
83    /// Name prefix for Liabilities accounts.
84    pub name_liabilities: String,
85
86    /// Name prefix for Equity accounts.
87    pub name_equity: String,
88
89    /// Name prefix for Income accounts.
90    pub name_income: String,
91
92    /// Name prefix for Expenses accounts.
93    pub name_expenses: String,
94
95    /// Account for rounding errors.
96    pub account_rounding: Option<String>,
97
98    /// Account for previous balances (opening balances).
99    pub account_previous_balances: String,
100
101    /// Account for previous earnings.
102    pub account_previous_earnings: String,
103
104    /// Account for previous conversions.
105    pub account_previous_conversions: String,
106
107    /// Account for current earnings.
108    pub account_current_earnings: String,
109
110    /// Account for current conversion differences.
111    pub account_current_conversions: Option<String>,
112
113    /// Account for unrealized gains.
114    pub account_unrealized_gains: Option<String>,
115
116    /// Currency for conversion (if specified).
117    pub conversion_currency: Option<String>,
118
119    /// Default tolerances per currency (e.g., "USD:0.005" or "*:0.001").
120    pub inferred_tolerance_default: HashMap<String, Decimal>,
121
122    /// Tolerance multiplier for balance assertions.
123    pub inferred_tolerance_multiplier: Decimal,
124
125    /// Whether to infer tolerance from cost.
126    pub infer_tolerance_from_cost: bool,
127
128    /// Whether to use legacy fixed tolerances.
129    pub use_legacy_fixed_tolerances: bool,
130
131    /// Enable experimental explicit tolerances in balance assertions.
132    pub experiment_explicit_tolerances: bool,
133
134    /// Default booking method.
135    pub booking_method: String,
136
137    /// Whether to render commas in numbers.
138    pub render_commas: bool,
139
140    /// Display precision per currency (e.g., "USD:2" means format USD with 2 decimal places).
141    /// Format: CURRENCY:PRECISION where PRECISION is the number of decimal places.
142    pub display_precision: HashMap<String, u32>,
143
144    /// Whether to allow pipe separator in numbers.
145    pub allow_pipe_separator: bool,
146
147    /// Maximum lines in multi-line strings.
148    pub long_string_maxlines: u32,
149
150    /// Directories to scan for document files.
151    pub documents: Vec<String>,
152
153    /// Plugin processing mode: "default" or "raw".
154    pub plugin_processing_mode: String,
155
156    /// Any other custom options.
157    pub custom: HashMap<String, String>,
158
159    /// Options that have been set (for duplicate detection).
160    #[doc(hidden)]
161    pub set_options: HashSet<String>,
162
163    /// Validation warnings collected during parsing.
164    pub warnings: Vec<OptionWarning>,
165}
166
167impl Default for Options {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173impl Options {
174    /// Create new options with defaults.
175    #[must_use]
176    pub fn new() -> Self {
177        Self {
178            title: None,
179            filename: None,
180            operating_currency: Vec::new(),
181            name_assets: "Assets".to_string(),
182            name_liabilities: "Liabilities".to_string(),
183            name_equity: "Equity".to_string(),
184            name_income: "Income".to_string(),
185            name_expenses: "Expenses".to_string(),
186            account_rounding: None,
187            account_previous_balances: "Equity:Opening-Balances".to_string(),
188            account_previous_earnings: "Equity:Earnings:Previous".to_string(),
189            account_previous_conversions: "Equity:Conversions:Previous".to_string(),
190            account_current_earnings: "Equity:Earnings:Current".to_string(),
191            account_current_conversions: None,
192            account_unrealized_gains: None,
193            conversion_currency: None,
194            inferred_tolerance_default: HashMap::new(),
195            inferred_tolerance_multiplier: Decimal::new(5, 1), // 0.5
196            infer_tolerance_from_cost: true,
197            use_legacy_fixed_tolerances: false,
198            experiment_explicit_tolerances: false,
199            booking_method: "STRICT".to_string(),
200            render_commas: false, // Python beancount default is FALSE
201            display_precision: HashMap::new(),
202            allow_pipe_separator: false,
203            long_string_maxlines: 64,
204            documents: Vec::new(),
205            plugin_processing_mode: "default".to_string(),
206            custom: HashMap::new(),
207            set_options: HashSet::new(),
208            warnings: Vec::new(),
209        }
210    }
211
212    /// Set an option by name.
213    ///
214    /// Validates the option and collects any warnings in `self.warnings`.
215    pub fn set(&mut self, key: &str, value: &str) {
216        // Check for unknown options (E7001)
217        let is_known = KNOWN_OPTIONS.contains(&key);
218        if !is_known {
219            self.warnings.push(OptionWarning {
220                code: "E7001",
221                message: format!("Unknown option \"{key}\""),
222                option: key.to_string(),
223                value: value.to_string(),
224            });
225        }
226
227        // Check for read-only options (E7005)
228        if READONLY_OPTIONS.contains(&key) {
229            self.warnings.push(OptionWarning {
230                code: "E7005",
231                message: format!("Option '{key}' may not be set"),
232                option: key.to_string(),
233                value: value.to_string(),
234            });
235            return; // Don't apply the value
236        }
237
238        // Check for duplicate non-repeatable options (E7003)
239        let is_repeatable = REPEATABLE_OPTIONS.contains(&key);
240        if is_known && !is_repeatable && self.set_options.contains(key) {
241            self.warnings.push(OptionWarning {
242                code: "E7003",
243                message: format!("Option \"{key}\" can only be specified once"),
244                option: key.to_string(),
245                value: value.to_string(),
246            });
247        }
248
249        // Track that this option was set
250        self.set_options.insert(key.to_string());
251
252        // Apply the option value
253        match key {
254            "title" => self.title = Some(value.to_string()),
255            "operating_currency" => self.operating_currency.push(value.to_string()),
256            "name_assets" => self.name_assets = value.to_string(),
257            "name_liabilities" => self.name_liabilities = value.to_string(),
258            "name_equity" => self.name_equity = value.to_string(),
259            "name_income" => self.name_income = value.to_string(),
260            "name_expenses" => self.name_expenses = value.to_string(),
261            "account_rounding" => {
262                if !Self::is_valid_account(value) {
263                    self.warnings.push(OptionWarning {
264                        code: "E7002",
265                        message: format!("Invalid leaf account name: '{value}'"),
266                        option: key.to_string(),
267                        value: value.to_string(),
268                    });
269                }
270                self.account_rounding = Some(value.to_string());
271            }
272            "account_current_conversions" => {
273                if !Self::is_valid_account(value) {
274                    self.warnings.push(OptionWarning {
275                        code: "E7002",
276                        message: format!("Invalid leaf account name: '{value}'"),
277                        option: key.to_string(),
278                        value: value.to_string(),
279                    });
280                }
281                self.account_current_conversions = Some(value.to_string());
282            }
283            "account_unrealized_gains" => {
284                if !Self::is_valid_account(value) {
285                    self.warnings.push(OptionWarning {
286                        code: "E7002",
287                        message: format!("Invalid leaf account name: '{value}'"),
288                        option: key.to_string(),
289                        value: value.to_string(),
290                    });
291                }
292                self.account_unrealized_gains = Some(value.to_string());
293            }
294            "inferred_tolerance_multiplier" => {
295                if let Ok(d) = Decimal::from_str(value) {
296                    self.inferred_tolerance_multiplier = d;
297                } else {
298                    // E7002: Invalid option value
299                    self.warnings.push(OptionWarning {
300                        code: "E7002",
301                        message: format!(
302                            "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
303                        ),
304                        option: key.to_string(),
305                        value: value.to_string(),
306                    });
307                }
308            }
309            "infer_tolerance_from_cost" => {
310                if !value.eq_ignore_ascii_case("true") && !value.eq_ignore_ascii_case("false") {
311                    self.warnings.push(OptionWarning {
312                        code: "E7002",
313                        message: format!(
314                            "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
315                        ),
316                        option: key.to_string(),
317                        value: value.to_string(),
318                    });
319                }
320                self.infer_tolerance_from_cost = value.eq_ignore_ascii_case("true");
321            }
322            "booking_method" => {
323                let valid_methods = [
324                    "STRICT",
325                    "STRICT_WITH_SIZE",
326                    "FIFO",
327                    "LIFO",
328                    "HIFO",
329                    "AVERAGE",
330                    "NONE",
331                ];
332                if !valid_methods.contains(&value.to_uppercase().as_str()) {
333                    self.warnings.push(OptionWarning {
334                        code: "E7002",
335                        message: format!(
336                            "Invalid value \"{}\" for option \"{}\": expected one of {}",
337                            value,
338                            key,
339                            valid_methods.join(", ")
340                        ),
341                        option: key.to_string(),
342                        value: value.to_string(),
343                    });
344                }
345                self.booking_method = value.to_string();
346            }
347            "render_commas" => {
348                // Accept TRUE/FALSE, true/false, 1/0 (Python beancount compatibility)
349                let is_true = value.eq_ignore_ascii_case("true") || value == "1";
350                let is_false = value.eq_ignore_ascii_case("false") || value == "0";
351                if !is_true && !is_false {
352                    self.warnings.push(OptionWarning {
353                        code: "E7002",
354                        message: format!(
355                            "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
356                        ),
357                        option: key.to_string(),
358                        value: value.to_string(),
359                    });
360                }
361                self.render_commas = is_true;
362            }
363            "display_precision" => {
364                // Parse "CURRENCY:EXAMPLE" where EXAMPLE's decimal places define the precision.
365                // E.g., "CHF:0.01" means 2 decimal places for CHF.
366                // E.g., "USD:0.001" means 3 decimal places for USD.
367                if let Some((curr, example)) = value.split_once(':') {
368                    if let Ok(d) = Decimal::from_str(example) {
369                        // Get the precision from the example number's decimal places
370                        let precision = d.scale();
371                        self.display_precision.insert(curr.to_string(), precision);
372                    } else {
373                        self.warnings.push(OptionWarning {
374                            code: "E7002",
375                            message: format!(
376                                "Invalid precision value \"{example}\" in option \"{key}\""
377                            ),
378                            option: key.to_string(),
379                            value: value.to_string(),
380                        });
381                    }
382                } else {
383                    self.warnings.push(OptionWarning {
384                        code: "E7002",
385                        message: format!(
386                            "Invalid format for option \"{key}\": expected CURRENCY:EXAMPLE (e.g., CHF:0.01)"
387                        ),
388                        option: key.to_string(),
389                        value: value.to_string(),
390                    });
391                }
392            }
393            "filename" => self.filename = Some(value.to_string()),
394            "account_previous_balances" => {
395                if !Self::is_valid_account(value) {
396                    self.warnings.push(OptionWarning {
397                        code: "E7002",
398                        message: format!("Invalid leaf account name: '{value}'"),
399                        option: key.to_string(),
400                        value: value.to_string(),
401                    });
402                }
403                self.account_previous_balances = value.to_string();
404            }
405            "account_previous_earnings" => {
406                if !Self::is_valid_account(value) {
407                    self.warnings.push(OptionWarning {
408                        code: "E7002",
409                        message: format!("Invalid leaf account name: '{value}'"),
410                        option: key.to_string(),
411                        value: value.to_string(),
412                    });
413                }
414                self.account_previous_earnings = value.to_string();
415            }
416            "account_previous_conversions" => {
417                if !Self::is_valid_account(value) {
418                    self.warnings.push(OptionWarning {
419                        code: "E7002",
420                        message: format!("Invalid leaf account name: '{value}'"),
421                        option: key.to_string(),
422                        value: value.to_string(),
423                    });
424                }
425                self.account_previous_conversions = value.to_string();
426            }
427            "account_current_earnings" => {
428                if !Self::is_valid_account(value) {
429                    self.warnings.push(OptionWarning {
430                        code: "E7002",
431                        message: format!("Invalid leaf account name: '{value}'"),
432                        option: key.to_string(),
433                        value: value.to_string(),
434                    });
435                }
436                self.account_current_earnings = value.to_string();
437            }
438            "conversion_currency" => self.conversion_currency = Some(value.to_string()),
439            "inferred_tolerance_default" => {
440                // Parse "CURRENCY:TOLERANCE" or "*:TOLERANCE"
441                if let Some((curr, tol)) = value.split_once(':') {
442                    if let Ok(d) = Decimal::from_str(tol) {
443                        self.inferred_tolerance_default.insert(curr.to_string(), d);
444                    } else {
445                        self.warnings.push(OptionWarning {
446                            code: "E7002",
447                            message: format!(
448                                "Invalid tolerance value \"{tol}\" in option \"{key}\""
449                            ),
450                            option: key.to_string(),
451                            value: value.to_string(),
452                        });
453                    }
454                } else {
455                    self.warnings.push(OptionWarning {
456                        code: "E7002",
457                        message: format!(
458                            "Invalid format for option \"{key}\": expected CURRENCY:TOLERANCE"
459                        ),
460                        option: key.to_string(),
461                        value: value.to_string(),
462                    });
463                }
464            }
465            "use_legacy_fixed_tolerances" => {
466                self.use_legacy_fixed_tolerances = value.eq_ignore_ascii_case("true");
467            }
468            "experiment_explicit_tolerances" => {
469                self.experiment_explicit_tolerances = value.eq_ignore_ascii_case("true");
470            }
471            "allow_pipe_separator" => {
472                // This option is deprecated in Python beancount
473                self.warnings.push(OptionWarning {
474                    code: "E7004",
475                    message: "Option 'allow_pipe_separator' is deprecated".to_string(),
476                    option: key.to_string(),
477                    value: value.to_string(),
478                });
479                self.allow_pipe_separator = value.eq_ignore_ascii_case("true");
480            }
481            "long_string_maxlines" => {
482                if let Ok(n) = value.parse::<u32>() {
483                    self.long_string_maxlines = n;
484                } else {
485                    self.warnings.push(OptionWarning {
486                        code: "E7002",
487                        message: format!(
488                            "Invalid value \"{value}\" for option \"{key}\": expected integer"
489                        ),
490                        option: key.to_string(),
491                        value: value.to_string(),
492                    });
493                }
494            }
495            "documents" => {
496                // Validate that document root exists
497                if !std::path::Path::new(value).exists() {
498                    self.warnings.push(OptionWarning {
499                        code: "E7006",
500                        message: format!("Document root '{value}' does not exist"),
501                        option: key.to_string(),
502                        value: value.to_string(),
503                    });
504                }
505                self.documents.push(value.to_string());
506            }
507            "plugin_processing_mode" => {
508                // Valid values are "default" and "raw" (case-sensitive, like Python)
509                if value != "default" && value != "raw" {
510                    self.warnings.push(OptionWarning {
511                        code: "E7002",
512                        message: format!("Invalid value '{value}'"),
513                        option: key.to_string(),
514                        value: value.to_string(),
515                    });
516                }
517                self.plugin_processing_mode = value.to_string();
518            }
519            "plugin" => {
520                // Deprecated: should use `plugin` directive instead of `option "plugin"`
521                self.warnings.push(OptionWarning {
522                    code: "E7004",
523                    message: "Option 'plugin' is deprecated; use the 'plugin' directive instead"
524                        .to_string(),
525                    option: key.to_string(),
526                    value: value.to_string(),
527                });
528            }
529            _ => {
530                // Unknown options go to custom map
531                self.custom.insert(key.to_string(), value.to_string());
532            }
533        }
534    }
535
536    /// Get a custom option value.
537    #[must_use]
538    pub fn get(&self, key: &str) -> Option<&str> {
539        self.custom.get(key).map(String::as_str)
540    }
541
542    /// Get all account type prefixes.
543    #[must_use]
544    pub fn account_types(&self) -> [&str; 5] {
545        [
546            &self.name_assets,
547            &self.name_liabilities,
548            &self.name_equity,
549            &self.name_income,
550            &self.name_expenses,
551        ]
552    }
553
554    /// Check if a value looks like a valid account name.
555    ///
556    /// Valid accounts have format "Type:Subaccount:..." where Type starts with
557    /// uppercase letter and subaccounts are colon-separated.
558    fn is_valid_account(value: &str) -> bool {
559        // Must contain at least one colon
560        if !value.contains(':') {
561            return false;
562        }
563
564        // Check each component
565        for part in value.split(':') {
566            // First char of each component should be uppercase letter
567            if let Some(first) = part.chars().next() {
568                if !first.is_ascii_uppercase() {
569                    return false;
570                }
571            } else {
572                // Empty component
573                return false;
574            }
575        }
576
577        true
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn test_default_options() {
587        let opts = Options::new();
588        assert_eq!(opts.name_assets, "Assets");
589        assert_eq!(opts.booking_method, "STRICT");
590        assert!(opts.infer_tolerance_from_cost);
591    }
592
593    #[test]
594    fn test_set_options() {
595        let mut opts = Options::new();
596        opts.set("title", "My Ledger");
597        opts.set("operating_currency", "USD");
598        opts.set("operating_currency", "EUR");
599        opts.set("booking_method", "FIFO");
600
601        assert_eq!(opts.title, Some("My Ledger".to_string()));
602        assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
603        assert_eq!(opts.booking_method, "FIFO");
604    }
605
606    #[test]
607    fn test_custom_options() {
608        let mut opts = Options::new();
609        opts.set("my_custom_option", "my_value");
610
611        assert_eq!(opts.get("my_custom_option"), Some("my_value"));
612        assert_eq!(opts.get("nonexistent"), None);
613    }
614
615    #[test]
616    fn test_unknown_option_warning() {
617        let mut opts = Options::new();
618        opts.set("unknown_option", "value");
619
620        assert_eq!(opts.warnings.len(), 1);
621        assert_eq!(opts.warnings[0].code, "E7001");
622        assert!(opts.warnings[0].message.contains("Unknown option"));
623    }
624
625    #[test]
626    fn test_duplicate_option_warning() {
627        let mut opts = Options::new();
628        opts.set("title", "First Title");
629        opts.set("title", "Second Title");
630
631        assert_eq!(opts.warnings.len(), 1);
632        assert_eq!(opts.warnings[0].code, "E7003");
633        assert!(opts.warnings[0].message.contains("only be specified once"));
634    }
635
636    #[test]
637    fn test_repeatable_option_no_warning() {
638        let mut opts = Options::new();
639        opts.set("operating_currency", "USD");
640        opts.set("operating_currency", "EUR");
641
642        // No warnings for repeatable options
643        assert!(
644            opts.warnings.is_empty(),
645            "Should not warn for repeatable options: {:?}",
646            opts.warnings
647        );
648        assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
649    }
650
651    #[test]
652    fn test_invalid_tolerance_value() {
653        let mut opts = Options::new();
654        opts.set("inferred_tolerance_multiplier", "not_a_number");
655
656        assert_eq!(opts.warnings.len(), 1);
657        assert_eq!(opts.warnings[0].code, "E7002");
658        assert!(opts.warnings[0].message.contains("expected decimal"));
659    }
660
661    #[test]
662    fn test_invalid_boolean_value() {
663        let mut opts = Options::new();
664        opts.set("infer_tolerance_from_cost", "maybe");
665
666        assert_eq!(opts.warnings.len(), 1);
667        assert_eq!(opts.warnings[0].code, "E7002");
668        assert!(opts.warnings[0].message.contains("TRUE or FALSE"));
669    }
670
671    #[test]
672    fn test_invalid_booking_method() {
673        let mut opts = Options::new();
674        opts.set("booking_method", "RANDOM");
675
676        assert_eq!(opts.warnings.len(), 1);
677        assert_eq!(opts.warnings[0].code, "E7002");
678        assert!(opts.warnings[0].message.contains("STRICT"));
679    }
680
681    #[test]
682    fn test_valid_booking_methods() {
683        for method in &["STRICT", "FIFO", "LIFO", "AVERAGE", "NONE"] {
684            let mut opts = Options::new();
685            opts.set("booking_method", method);
686            assert!(
687                opts.warnings.is_empty(),
688                "Should accept {method} as valid booking method"
689            );
690        }
691    }
692
693    #[test]
694    fn test_readonly_option_warning() {
695        let mut opts = Options::new();
696        opts.set("filename", "/some/path.beancount");
697
698        assert_eq!(opts.warnings.len(), 1);
699        assert_eq!(opts.warnings[0].code, "E7005");
700        assert!(opts.warnings[0].message.contains("may not be set"));
701    }
702
703    #[test]
704    fn test_invalid_account_name_validation() {
705        // Test account_rounding with invalid value
706        let mut opts = Options::new();
707        opts.set("account_rounding", "invalid");
708
709        assert_eq!(opts.warnings.len(), 1);
710        assert_eq!(opts.warnings[0].code, "E7002");
711        assert!(opts.warnings[0].message.contains("Invalid leaf account"));
712    }
713
714    #[test]
715    fn test_valid_account_name() {
716        let mut opts = Options::new();
717        opts.set("account_rounding", "Equity:Rounding");
718
719        assert!(
720            opts.warnings.is_empty(),
721            "Valid account name should not produce warnings: {:?}",
722            opts.warnings
723        );
724        assert_eq!(opts.account_rounding, Some("Equity:Rounding".to_string()));
725    }
726
727    #[test]
728    fn test_render_commas_with_numeric_values() {
729        let mut opts = Options::new();
730        opts.set("render_commas", "1");
731        assert!(opts.render_commas);
732        assert!(opts.warnings.is_empty());
733
734        let mut opts2 = Options::new();
735        opts2.set("render_commas", "0");
736        assert!(!opts2.render_commas);
737        assert!(opts2.warnings.is_empty());
738    }
739
740    #[test]
741    fn test_plugin_processing_mode_validation() {
742        // Valid values
743        let mut opts = Options::new();
744        opts.set("plugin_processing_mode", "default");
745        assert!(opts.warnings.is_empty());
746        assert_eq!(opts.plugin_processing_mode, "default");
747
748        let mut opts2 = Options::new();
749        opts2.set("plugin_processing_mode", "raw");
750        assert!(opts2.warnings.is_empty());
751        assert_eq!(opts2.plugin_processing_mode, "raw");
752
753        // Invalid value
754        let mut opts3 = Options::new();
755        opts3.set("plugin_processing_mode", "invalid");
756        assert_eq!(opts3.warnings.len(), 1);
757        assert_eq!(opts3.warnings[0].code, "E7002");
758    }
759
760    #[test]
761    fn test_deprecated_plugin_option() {
762        let mut opts = Options::new();
763        opts.set("plugin", "some.plugin");
764
765        assert_eq!(opts.warnings.len(), 1);
766        assert_eq!(opts.warnings[0].code, "E7004");
767        assert!(opts.warnings[0].message.contains("deprecated"));
768    }
769
770    #[test]
771    fn test_deprecated_allow_pipe_separator() {
772        let mut opts = Options::new();
773        opts.set("allow_pipe_separator", "true");
774
775        assert_eq!(opts.warnings.len(), 1);
776        assert_eq!(opts.warnings[0].code, "E7004");
777        assert!(opts.warnings[0].message.contains("deprecated"));
778    }
779
780    #[test]
781    fn test_is_valid_account() {
782        // Valid accounts
783        assert!(Options::is_valid_account("Assets:Bank"));
784        assert!(Options::is_valid_account("Equity:Rounding:Precision"));
785
786        // Invalid accounts
787        assert!(!Options::is_valid_account("invalid")); // No colon
788        assert!(!Options::is_valid_account("assets:bank")); // Lowercase
789        assert!(!Options::is_valid_account("Assets:")); // Empty component
790        assert!(!Options::is_valid_account(":Bank")); // Empty first component
791    }
792
793    #[test]
794    fn test_account_validation_options() {
795        // Test all account options that require validation
796        let account_options = [
797            "account_rounding",
798            "account_current_conversions",
799            "account_unrealized_gains",
800            "account_previous_balances",
801            "account_previous_earnings",
802            "account_previous_conversions",
803            "account_current_earnings",
804        ];
805
806        for opt in account_options {
807            let mut opts = Options::new();
808            opts.set(opt, "lowercase:invalid");
809
810            assert!(
811                !opts.warnings.is_empty(),
812                "Option '{opt}' should warn on invalid account name"
813            );
814            assert_eq!(opts.warnings[0].code, "E7002");
815        }
816    }
817
818    #[test]
819    fn test_inferred_tolerance_default() {
820        let mut opts = Options::new();
821        opts.set("inferred_tolerance_default", "USD:0.005");
822
823        assert!(opts.warnings.is_empty());
824        assert_eq!(
825            opts.inferred_tolerance_default.get("USD"),
826            Some(&rust_decimal_macros::dec!(0.005))
827        );
828
829        // Test wildcard
830        let mut opts2 = Options::new();
831        opts2.set("inferred_tolerance_default", "*:0.01");
832        assert!(opts2.warnings.is_empty());
833        assert_eq!(
834            opts2.inferred_tolerance_default.get("*"),
835            Some(&rust_decimal_macros::dec!(0.01))
836        );
837
838        // Test invalid format
839        let mut opts3 = Options::new();
840        opts3.set("inferred_tolerance_default", "INVALID");
841        assert_eq!(opts3.warnings.len(), 1);
842        assert_eq!(opts3.warnings[0].code, "E7002");
843    }
844}