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    "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: FxHashMap<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: FxHashMap<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: FxHashMap<String, String>,
159
160    /// Options that have been set (for duplicate detection).
161    #[doc(hidden)]
162    pub set_options: FxHashSet<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: FxHashMap::default(),
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: FxHashMap::default(),
203            allow_pipe_separator: false,
204            long_string_maxlines: 64,
205            documents: Vec::new(),
206            plugin_processing_mode: "default".to_string(),
207            custom: FxHashMap::default(),
208            set_options: FxHashSet::default(),
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!("Invalid 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 each component
579    /// starts with an uppercase letter (any script) or a non-ASCII letter
580    /// without case (CJK, etc.), and components are colon-separated.
581    fn is_valid_account(value: &str) -> bool {
582        // Must contain at least one colon
583        if !value.contains(':') {
584            return false;
585        }
586
587        // Check each component
588        for part in value.split(':') {
589            if let Some(first) = part.chars().next() {
590                // Accept: uppercase (any script), non-ASCII alphabetic (CJK, etc.)
591                let valid = first.is_uppercase() || (!first.is_ascii() && first.is_alphabetic());
592                if !valid {
593                    return false;
594                }
595            } else {
596                // Empty component
597                return false;
598            }
599        }
600
601        true
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn test_default_options() {
611        let opts = Options::new();
612        assert_eq!(opts.name_assets, "Assets");
613        assert_eq!(opts.booking_method, "STRICT");
614        assert!(!opts.infer_tolerance_from_cost);
615    }
616
617    #[test]
618    fn test_set_options() {
619        let mut opts = Options::new();
620        opts.set("title", "My Ledger");
621        opts.set("operating_currency", "USD");
622        opts.set("operating_currency", "EUR");
623        opts.set("booking_method", "FIFO");
624
625        assert_eq!(opts.title, Some("My Ledger".to_string()));
626        assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
627        assert_eq!(opts.booking_method, "FIFO");
628    }
629
630    #[test]
631    fn test_custom_options() {
632        let mut opts = Options::new();
633        opts.set("my_custom_option", "my_value");
634
635        assert_eq!(opts.get("my_custom_option"), Some("my_value"));
636        assert_eq!(opts.get("nonexistent"), None);
637    }
638
639    #[test]
640    fn test_unknown_option_warning() {
641        let mut opts = Options::new();
642        opts.set("unknown_option", "value");
643
644        assert_eq!(opts.warnings.len(), 1);
645        assert_eq!(opts.warnings[0].code, "E7001");
646        assert!(opts.warnings[0].message.contains("Invalid option"));
647    }
648
649    #[test]
650    fn test_duplicate_option_warning() {
651        let mut opts = Options::new();
652        opts.set("title", "First Title");
653        opts.set("title", "Second Title");
654
655        assert_eq!(opts.warnings.len(), 1);
656        assert_eq!(opts.warnings[0].code, "E7003");
657        assert!(opts.warnings[0].message.contains("only be specified once"));
658    }
659
660    #[test]
661    fn test_repeatable_option_no_warning() {
662        let mut opts = Options::new();
663        opts.set("operating_currency", "USD");
664        opts.set("operating_currency", "EUR");
665
666        // No warnings for repeatable options
667        assert!(
668            opts.warnings.is_empty(),
669            "Should not warn for repeatable options: {:?}",
670            opts.warnings
671        );
672        assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
673    }
674
675    #[test]
676    fn test_invalid_tolerance_value() {
677        let mut opts = Options::new();
678        opts.set("inferred_tolerance_multiplier", "not_a_number");
679
680        // E7004 (deprecated name) + E7002 (invalid value)
681        assert_eq!(opts.warnings.len(), 2);
682        assert_eq!(opts.warnings[0].code, "E7004");
683        assert!(opts.warnings[0].message.contains("Renamed"));
684        assert_eq!(opts.warnings[1].code, "E7002");
685        assert!(opts.warnings[1].message.contains("expected decimal"));
686    }
687
688    #[test]
689    fn test_tolerance_multiplier_new_name() {
690        let mut opts = Options::new();
691        opts.set("tolerance_multiplier", "1.5");
692
693        assert!(opts.warnings.is_empty());
694        assert_eq!(opts.inferred_tolerance_multiplier, Decimal::new(15, 1));
695    }
696
697    #[test]
698    fn test_inferred_tolerance_multiplier_deprecated() {
699        let mut opts = Options::new();
700        opts.set("inferred_tolerance_multiplier", "1.01");
701
702        assert_eq!(opts.warnings.len(), 1);
703        assert_eq!(opts.warnings[0].code, "E7004");
704        assert!(
705            opts.warnings[0]
706                .message
707                .contains("Renamed to 'tolerance_multiplier'")
708        );
709        assert_eq!(
710            opts.inferred_tolerance_multiplier,
711            Decimal::from_str("1.01").unwrap()
712        );
713    }
714
715    #[test]
716    fn test_invalid_boolean_value() {
717        let mut opts = Options::new();
718        opts.set("infer_tolerance_from_cost", "maybe");
719
720        assert_eq!(opts.warnings.len(), 1);
721        assert_eq!(opts.warnings[0].code, "E7002");
722        assert!(opts.warnings[0].message.contains("TRUE or FALSE"));
723    }
724
725    #[test]
726    fn test_invalid_booking_method() {
727        let mut opts = Options::new();
728        opts.set("booking_method", "RANDOM");
729
730        assert_eq!(opts.warnings.len(), 1);
731        assert_eq!(opts.warnings[0].code, "E7002");
732        assert!(opts.warnings[0].message.contains("STRICT"));
733    }
734
735    #[test]
736    fn test_valid_booking_methods() {
737        for method in &["STRICT", "FIFO", "LIFO", "AVERAGE", "NONE"] {
738            let mut opts = Options::new();
739            opts.set("booking_method", method);
740            assert!(
741                opts.warnings.is_empty(),
742                "Should accept {method} as valid booking method"
743            );
744        }
745    }
746
747    #[test]
748    fn test_readonly_option_warning() {
749        let mut opts = Options::new();
750        opts.set("filename", "/some/path.beancount");
751
752        assert_eq!(opts.warnings.len(), 1);
753        assert_eq!(opts.warnings[0].code, "E7005");
754        assert!(opts.warnings[0].message.contains("may not be set"));
755    }
756
757    #[test]
758    fn test_invalid_account_name_validation() {
759        // Test account_rounding with invalid value
760        let mut opts = Options::new();
761        opts.set("account_rounding", "invalid");
762
763        assert_eq!(opts.warnings.len(), 1);
764        assert_eq!(opts.warnings[0].code, "E7002");
765        assert!(opts.warnings[0].message.contains("Invalid leaf account"));
766    }
767
768    #[test]
769    fn test_valid_account_name() {
770        let mut opts = Options::new();
771        opts.set("account_rounding", "Equity:Rounding");
772
773        assert!(
774            opts.warnings.is_empty(),
775            "Valid account name should not produce warnings: {:?}",
776            opts.warnings
777        );
778        assert_eq!(opts.account_rounding, Some("Equity:Rounding".to_string()));
779    }
780
781    #[test]
782    fn test_render_commas_with_numeric_values() {
783        let mut opts = Options::new();
784        opts.set("render_commas", "1");
785        assert!(opts.render_commas);
786        assert!(opts.warnings.is_empty());
787
788        let mut opts2 = Options::new();
789        opts2.set("render_commas", "0");
790        assert!(!opts2.render_commas);
791        assert!(opts2.warnings.is_empty());
792    }
793
794    #[test]
795    fn test_plugin_processing_mode_validation() {
796        // Valid values
797        let mut opts = Options::new();
798        opts.set("plugin_processing_mode", "default");
799        assert!(opts.warnings.is_empty());
800        assert_eq!(opts.plugin_processing_mode, "default");
801
802        let mut opts2 = Options::new();
803        opts2.set("plugin_processing_mode", "raw");
804        assert!(opts2.warnings.is_empty());
805        assert_eq!(opts2.plugin_processing_mode, "raw");
806
807        // Invalid value
808        let mut opts3 = Options::new();
809        opts3.set("plugin_processing_mode", "invalid");
810        assert_eq!(opts3.warnings.len(), 1);
811        assert_eq!(opts3.warnings[0].code, "E7002");
812    }
813
814    #[test]
815    fn test_deprecated_plugin_option() {
816        let mut opts = Options::new();
817        opts.set("plugin", "some.plugin");
818
819        assert_eq!(opts.warnings.len(), 1);
820        assert_eq!(opts.warnings[0].code, "E7004");
821        assert!(opts.warnings[0].message.contains("deprecated"));
822    }
823
824    #[test]
825    fn test_deprecated_allow_pipe_separator() {
826        let mut opts = Options::new();
827        opts.set("allow_pipe_separator", "true");
828
829        assert_eq!(opts.warnings.len(), 1);
830        assert_eq!(opts.warnings[0].code, "E7004");
831        assert!(opts.warnings[0].message.contains("deprecated"));
832    }
833
834    #[test]
835    fn test_is_valid_account() {
836        // Valid accounts — ASCII
837        assert!(Options::is_valid_account("Assets:Bank"));
838        assert!(Options::is_valid_account("Equity:Rounding:Precision"));
839
840        // Valid accounts — Unicode
841        assert!(Options::is_valid_account("Капитал:Retained"));
842        assert!(Options::is_valid_account("资产:银行:支票"));
843
844        // Invalid accounts
845        assert!(!Options::is_valid_account("invalid")); // No colon
846        assert!(!Options::is_valid_account("assets:bank")); // Lowercase ASCII
847        assert!(!Options::is_valid_account("Assets:")); // Empty component
848        assert!(!Options::is_valid_account(":Bank")); // Empty first component
849    }
850
851    #[test]
852    fn test_account_validation_options() {
853        // Test all account options that require validation
854        let account_options = [
855            "account_rounding",
856            "account_current_conversions",
857            "account_unrealized_gains",
858            "account_previous_balances",
859            "account_previous_earnings",
860            "account_previous_conversions",
861            "account_current_earnings",
862        ];
863
864        for opt in account_options {
865            let mut opts = Options::new();
866            opts.set(opt, "lowercase:invalid");
867
868            assert!(
869                !opts.warnings.is_empty(),
870                "Option '{opt}' should warn on invalid account name"
871            );
872            assert_eq!(opts.warnings[0].code, "E7002");
873        }
874    }
875
876    #[test]
877    fn test_inferred_tolerance_default() {
878        let mut opts = Options::new();
879        opts.set("inferred_tolerance_default", "USD:0.005");
880
881        assert!(opts.warnings.is_empty());
882        assert_eq!(
883            opts.inferred_tolerance_default.get("USD"),
884            Some(&rust_decimal_macros::dec!(0.005))
885        );
886
887        // Test wildcard
888        let mut opts2 = Options::new();
889        opts2.set("inferred_tolerance_default", "*:0.01");
890        assert!(opts2.warnings.is_empty());
891        assert_eq!(
892            opts2.inferred_tolerance_default.get("*"),
893            Some(&rust_decimal_macros::dec!(0.01))
894        );
895
896        // Test invalid format
897        let mut opts3 = Options::new();
898        opts3.set("inferred_tolerance_default", "INVALID");
899        assert_eq!(opts3.warnings.len(), 1);
900        assert_eq!(opts3.warnings[0].code, "E7002");
901    }
902}