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