1use rust_decimal::Decimal;
4use rustc_hash::{FxHashMap, FxHashSet};
5use std::str::FromStr;
6
7const 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", "tolerance_multiplier", ];
41
42const REPEATABLE_OPTIONS: &[&str] = &[
44 "operating_currency",
45 "insert_pythonpath",
46 "documents",
47 "inferred_tolerance_default",
48 "display_precision",
49];
50
51const READONLY_OPTIONS: &[&str] = &["filename"];
53
54#[derive(Debug, Clone)]
56pub struct OptionWarning {
57 pub code: &'static str,
59 pub message: String,
61 pub option: String,
63 pub value: String,
65}
66
67#[derive(Debug, Clone)]
71pub struct Options {
72 pub title: Option<String>,
74
75 pub filename: Option<String>,
77
78 pub operating_currency: Vec<String>,
80
81 pub name_assets: String,
83
84 pub name_liabilities: String,
86
87 pub name_equity: String,
89
90 pub name_income: String,
92
93 pub name_expenses: String,
95
96 pub account_rounding: Option<String>,
98
99 pub account_previous_balances: String,
101
102 pub account_previous_earnings: String,
104
105 pub account_previous_conversions: String,
107
108 pub account_current_earnings: String,
110
111 pub account_current_conversions: Option<String>,
113
114 pub account_unrealized_gains: Option<String>,
116
117 pub conversion_currency: Option<String>,
119
120 pub inferred_tolerance_default: FxHashMap<String, Decimal>,
122
123 pub inferred_tolerance_multiplier: Decimal,
125
126 pub infer_tolerance_from_cost: bool,
128
129 pub use_legacy_fixed_tolerances: bool,
131
132 pub experiment_explicit_tolerances: bool,
134
135 pub booking_method: String,
137
138 pub render_commas: bool,
140
141 pub display_precision: FxHashMap<String, u32>,
144
145 pub allow_pipe_separator: bool,
147
148 pub long_string_maxlines: u32,
150
151 pub documents: Vec<String>,
153
154 pub plugin_processing_mode: String,
156
157 pub custom: FxHashMap<String, String>,
159
160 #[doc(hidden)]
162 pub set_options: FxHashSet<String>,
163
164 pub warnings: Vec<OptionWarning>,
166}
167
168impl Default for Options {
169 fn default() -> Self {
170 Self::new()
171 }
172}
173
174impl Options {
175 #[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), 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, 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 pub fn set(&mut self, key: &str, value: &str) {
217 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 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; }
238
239 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 self.set_options.insert(key.to_string());
252
253 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 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 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 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 if let Some((curr, example)) = value.split_once(':') {
390 if let Ok(d) = Decimal::from_str(example) {
391 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 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 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 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 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 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 self.custom.insert(key.to_string(), value.to_string());
554 }
555 }
556 }
557
558 #[must_use]
560 pub fn get(&self, key: &str) -> Option<&str> {
561 self.custom.get(key).map(String::as_str)
562 }
563
564 #[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 fn is_valid_account(value: &str) -> bool {
582 if !value.contains(':') {
584 return false;
585 }
586
587 for part in value.split(':') {
589 if let Some(first) = part.chars().next() {
590 let valid = first.is_uppercase() || (!first.is_ascii() && first.is_alphabetic());
592 if !valid {
593 return false;
594 }
595 } else {
596 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 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 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 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 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 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 assert!(Options::is_valid_account("Assets:Bank"));
838 assert!(Options::is_valid_account("Equity:Rounding:Precision"));
839
840 assert!(Options::is_valid_account("Капитал:Retained"));
842 assert!(Options::is_valid_account("资产:银行:支票"));
843
844 assert!(!Options::is_valid_account("invalid")); assert!(!Options::is_valid_account("assets:bank")); assert!(!Options::is_valid_account("Assets:")); assert!(!Options::is_valid_account(":Bank")); }
850
851 #[test]
852 fn test_account_validation_options() {
853 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 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 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}