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 "use_precise_interpolation",
31 "booking_method",
32 "render_commas",
33 "display_precision",
34 "allow_pipe_separator",
35 "long_string_maxlines",
36 "documents",
37 "insert_pythonpath",
38 "plugin_processing_mode",
39 "plugin", "tolerance_multiplier", ];
42
43const REPEATABLE_OPTIONS: &[&str] = &[
45 "operating_currency",
46 "insert_pythonpath",
47 "documents",
48 "inferred_tolerance_default",
49 "display_precision",
50];
51
52const READONLY_OPTIONS: &[&str] = &["filename"];
54
55#[derive(Debug, Clone)]
57pub struct OptionWarning {
58 pub code: &'static str,
60 pub message: String,
62 pub option: String,
64 pub value: String,
66}
67
68#[derive(Debug, Clone)]
72pub struct Options {
73 pub title: Option<String>,
75
76 pub filename: Option<String>,
78
79 pub operating_currency: Vec<String>,
81
82 pub name_assets: String,
84
85 pub name_liabilities: String,
87
88 pub name_equity: String,
90
91 pub name_income: String,
93
94 pub name_expenses: String,
96
97 pub account_rounding: Option<String>,
99
100 pub account_previous_balances: String,
102
103 pub account_previous_earnings: String,
105
106 pub account_previous_conversions: String,
108
109 pub account_current_earnings: String,
111
112 pub account_current_conversions: Option<String>,
114
115 pub account_unrealized_gains: Option<String>,
117
118 pub conversion_currency: Option<String>,
120
121 pub inferred_tolerance_default: FxHashMap<String, Decimal>,
123
124 pub inferred_tolerance_multiplier: Decimal,
126
127 pub infer_tolerance_from_cost: bool,
129
130 pub use_legacy_fixed_tolerances: bool,
132
133 pub experiment_explicit_tolerances: bool,
135
136 pub use_precise_interpolation: bool,
141
142 pub booking_method: String,
144
145 pub render_commas: bool,
147
148 pub display_precision: FxHashMap<String, u32>,
151
152 pub allow_pipe_separator: bool,
154
155 pub long_string_maxlines: u32,
157
158 pub documents: Vec<String>,
160
161 pub plugin_processing_mode: String,
163
164 pub custom: FxHashMap<String, String>,
166
167 #[doc(hidden)]
169 pub set_options: FxHashSet<String>,
170
171 pub warnings: Vec<OptionWarning>,
173}
174
175impl Default for Options {
176 fn default() -> Self {
177 Self::new()
178 }
179}
180
181impl Options {
182 #[must_use]
184 pub fn new() -> Self {
185 Self {
186 title: None,
187 filename: None,
188 operating_currency: Vec::new(),
189 name_assets: "Assets".to_string(),
190 name_liabilities: "Liabilities".to_string(),
191 name_equity: "Equity".to_string(),
192 name_income: "Income".to_string(),
193 name_expenses: "Expenses".to_string(),
194 account_rounding: None,
195 account_previous_balances: "Equity:Opening-Balances".to_string(),
196 account_previous_earnings: "Equity:Earnings:Previous".to_string(),
197 account_previous_conversions: "Equity:Conversions:Previous".to_string(),
198 account_current_earnings: "Equity:Earnings:Current".to_string(),
199 account_current_conversions: None,
200 account_unrealized_gains: None,
201 conversion_currency: None,
202 inferred_tolerance_default: FxHashMap::default(),
203 inferred_tolerance_multiplier: Decimal::new(5, 1), infer_tolerance_from_cost: false,
205 use_legacy_fixed_tolerances: false,
206 experiment_explicit_tolerances: false,
207 use_precise_interpolation: false,
208 booking_method: "STRICT".to_string(),
209 render_commas: false, display_precision: FxHashMap::default(),
211 allow_pipe_separator: false,
212 long_string_maxlines: 64,
213 documents: Vec::new(),
214 plugin_processing_mode: "default".to_string(),
215 custom: FxHashMap::default(),
216 set_options: FxHashSet::default(),
217 warnings: Vec::new(),
218 }
219 }
220
221 pub fn set(&mut self, key: &str, value: &str) {
225 let is_known = KNOWN_OPTIONS.contains(&key);
227 if !is_known {
228 self.warnings.push(OptionWarning {
229 code: "E7001",
230 message: format!("Invalid option \"{key}\""),
231 option: key.to_string(),
232 value: value.to_string(),
233 });
234 }
235
236 if READONLY_OPTIONS.contains(&key) {
238 self.warnings.push(OptionWarning {
239 code: "E7005",
240 message: format!("Option '{key}' may not be set"),
241 option: key.to_string(),
242 value: value.to_string(),
243 });
244 return; }
246
247 let is_repeatable = REPEATABLE_OPTIONS.contains(&key);
249 if is_known && !is_repeatable && self.set_options.contains(key) {
250 self.warnings.push(OptionWarning {
251 code: "E7003",
252 message: format!("Option \"{key}\" can only be specified once"),
253 option: key.to_string(),
254 value: value.to_string(),
255 });
256 }
257
258 self.set_options.insert(key.to_string());
260
261 match key {
263 "title" => self.title = Some(value.to_string()),
264 "operating_currency" => self.operating_currency.push(value.to_string()),
265 "name_assets" => self.name_assets = value.to_string(),
266 "name_liabilities" => self.name_liabilities = value.to_string(),
267 "name_equity" => self.name_equity = value.to_string(),
268 "name_income" => self.name_income = value.to_string(),
269 "name_expenses" => self.name_expenses = value.to_string(),
270 "account_rounding" => {
271 if !Self::is_valid_account(value) {
272 self.warnings.push(OptionWarning {
273 code: "E7002",
274 message: format!("Invalid leaf account name: '{value}'"),
275 option: key.to_string(),
276 value: value.to_string(),
277 });
278 }
279 self.account_rounding = Some(value.to_string());
280 }
281 "account_current_conversions" => {
282 if !Self::is_valid_account(value) {
283 self.warnings.push(OptionWarning {
284 code: "E7002",
285 message: format!("Invalid leaf account name: '{value}'"),
286 option: key.to_string(),
287 value: value.to_string(),
288 });
289 }
290 self.account_current_conversions = Some(value.to_string());
291 }
292 "account_unrealized_gains" => {
293 if !Self::is_valid_account(value) {
294 self.warnings.push(OptionWarning {
295 code: "E7002",
296 message: format!("Invalid leaf account name: '{value}'"),
297 option: key.to_string(),
298 value: value.to_string(),
299 });
300 }
301 self.account_unrealized_gains = Some(value.to_string());
302 }
303 "inferred_tolerance_multiplier" => {
304 self.warnings.push(OptionWarning {
306 code: "E7004",
307 message: "Renamed to 'tolerance_multiplier'.".to_string(),
308 option: key.to_string(),
309 value: value.to_string(),
310 });
311 if let Ok(d) = Decimal::from_str(value) {
312 self.inferred_tolerance_multiplier = d;
313 } else {
314 self.warnings.push(OptionWarning {
316 code: "E7002",
317 message: format!(
318 "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
319 ),
320 option: key.to_string(),
321 value: value.to_string(),
322 });
323 }
324 }
325 "tolerance_multiplier" => {
326 if let Ok(d) = Decimal::from_str(value) {
327 self.inferred_tolerance_multiplier = d;
328 } else {
329 self.warnings.push(OptionWarning {
330 code: "E7002",
331 message: format!(
332 "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
333 ),
334 option: key.to_string(),
335 value: value.to_string(),
336 });
337 }
338 }
339 "infer_tolerance_from_cost" => {
340 if !value.eq_ignore_ascii_case("true") && !value.eq_ignore_ascii_case("false") {
341 self.warnings.push(OptionWarning {
342 code: "E7002",
343 message: format!(
344 "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
345 ),
346 option: key.to_string(),
347 value: value.to_string(),
348 });
349 }
350 self.infer_tolerance_from_cost = value.eq_ignore_ascii_case("true");
351 }
352 "booking_method" => {
353 let valid_methods = [
354 "STRICT",
355 "STRICT_WITH_SIZE",
356 "FIFO",
357 "LIFO",
358 "HIFO",
359 "AVERAGE",
360 "NONE",
361 ];
362 if !valid_methods.contains(&value.to_uppercase().as_str()) {
363 self.warnings.push(OptionWarning {
364 code: "E7002",
365 message: format!(
366 "Invalid value \"{}\" for option \"{}\": expected one of {}",
367 value,
368 key,
369 valid_methods.join(", ")
370 ),
371 option: key.to_string(),
372 value: value.to_string(),
373 });
374 }
375 self.booking_method = value.to_string();
376 }
377 "render_commas" => {
378 let is_true = value.eq_ignore_ascii_case("true") || value == "1";
380 let is_false = value.eq_ignore_ascii_case("false") || value == "0";
381 if !is_true && !is_false {
382 self.warnings.push(OptionWarning {
383 code: "E7002",
384 message: format!(
385 "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
386 ),
387 option: key.to_string(),
388 value: value.to_string(),
389 });
390 }
391 self.render_commas = is_true;
392 }
393 "display_precision" => {
394 if let Some((curr, example)) = value.split_once(':') {
398 if let Ok(d) = Decimal::from_str(example) {
399 let precision = d.scale();
401 self.display_precision.insert(curr.to_string(), precision);
402 } else {
403 self.warnings.push(OptionWarning {
404 code: "E7002",
405 message: format!(
406 "Invalid precision value \"{example}\" in option \"{key}\""
407 ),
408 option: key.to_string(),
409 value: value.to_string(),
410 });
411 }
412 } else {
413 self.warnings.push(OptionWarning {
414 code: "E7002",
415 message: format!(
416 "Invalid format for option \"{key}\": expected CURRENCY:EXAMPLE (e.g., CHF:0.01)"
417 ),
418 option: key.to_string(),
419 value: value.to_string(),
420 });
421 }
422 }
423 "filename" => self.filename = Some(value.to_string()),
424 "account_previous_balances" => {
425 if !Self::is_valid_account(value) {
426 self.warnings.push(OptionWarning {
427 code: "E7002",
428 message: format!("Invalid leaf account name: '{value}'"),
429 option: key.to_string(),
430 value: value.to_string(),
431 });
432 }
433 self.account_previous_balances = value.to_string();
434 }
435 "account_previous_earnings" => {
436 if !Self::is_valid_account(value) {
437 self.warnings.push(OptionWarning {
438 code: "E7002",
439 message: format!("Invalid leaf account name: '{value}'"),
440 option: key.to_string(),
441 value: value.to_string(),
442 });
443 }
444 self.account_previous_earnings = value.to_string();
445 }
446 "account_previous_conversions" => {
447 if !Self::is_valid_account(value) {
448 self.warnings.push(OptionWarning {
449 code: "E7002",
450 message: format!("Invalid leaf account name: '{value}'"),
451 option: key.to_string(),
452 value: value.to_string(),
453 });
454 }
455 self.account_previous_conversions = value.to_string();
456 }
457 "account_current_earnings" => {
458 if !Self::is_valid_account(value) {
459 self.warnings.push(OptionWarning {
460 code: "E7002",
461 message: format!("Invalid leaf account name: '{value}'"),
462 option: key.to_string(),
463 value: value.to_string(),
464 });
465 }
466 self.account_current_earnings = value.to_string();
467 }
468 "conversion_currency" => self.conversion_currency = Some(value.to_string()),
469 "inferred_tolerance_default" => {
470 if let Some((curr, tol)) = value.split_once(':') {
472 if let Ok(d) = Decimal::from_str(tol) {
473 self.inferred_tolerance_default.insert(curr.to_string(), d);
474 } else {
475 self.warnings.push(OptionWarning {
476 code: "E7002",
477 message: format!(
478 "Invalid tolerance value \"{tol}\" in option \"{key}\""
479 ),
480 option: key.to_string(),
481 value: value.to_string(),
482 });
483 }
484 } else {
485 self.warnings.push(OptionWarning {
486 code: "E7002",
487 message: format!(
488 "Invalid format for option \"{key}\": expected CURRENCY:TOLERANCE"
489 ),
490 option: key.to_string(),
491 value: value.to_string(),
492 });
493 }
494 }
495 "use_legacy_fixed_tolerances" => {
496 self.use_legacy_fixed_tolerances = value.eq_ignore_ascii_case("true");
497 }
498 "experiment_explicit_tolerances" => {
499 self.experiment_explicit_tolerances = value.eq_ignore_ascii_case("true");
500 }
501 "use_precise_interpolation" => {
502 self.use_precise_interpolation = value.eq_ignore_ascii_case("true");
506 }
507 "allow_pipe_separator" => {
508 self.warnings.push(OptionWarning {
510 code: "E7004",
511 message: "Option 'allow_pipe_separator' is deprecated".to_string(),
512 option: key.to_string(),
513 value: value.to_string(),
514 });
515 self.allow_pipe_separator = value.eq_ignore_ascii_case("true");
516 }
517 "long_string_maxlines" => {
518 if let Ok(n) = value.parse::<u32>() {
519 self.long_string_maxlines = n;
520 } else {
521 self.warnings.push(OptionWarning {
522 code: "E7002",
523 message: format!(
524 "Invalid value \"{value}\" for option \"{key}\": expected integer"
525 ),
526 option: key.to_string(),
527 value: value.to_string(),
528 });
529 }
530 }
531 "documents" => {
532 if !std::path::Path::new(value).exists() {
534 self.warnings.push(OptionWarning {
535 code: "E7006",
536 message: format!("Document root '{value}' does not exist"),
537 option: key.to_string(),
538 value: value.to_string(),
539 });
540 }
541 self.documents.push(value.to_string());
542 }
543 "plugin_processing_mode" => {
544 if value != "default" && value != "raw" {
546 self.warnings.push(OptionWarning {
547 code: "E7002",
548 message: format!("Invalid value '{value}'"),
549 option: key.to_string(),
550 value: value.to_string(),
551 });
552 }
553 self.plugin_processing_mode = value.to_string();
554 }
555 "plugin" => {
556 self.warnings.push(OptionWarning {
558 code: "E7004",
559 message: "Option 'plugin' is deprecated; use the 'plugin' directive instead"
560 .to_string(),
561 option: key.to_string(),
562 value: value.to_string(),
563 });
564 }
565 _ => {
566 self.custom.insert(key.to_string(), value.to_string());
568 }
569 }
570 }
571
572 #[must_use]
574 pub fn get(&self, key: &str) -> Option<&str> {
575 self.custom.get(key).map(String::as_str)
576 }
577
578 #[must_use]
580 pub fn account_types(&self) -> [&str; 5] {
581 [
582 &self.name_assets,
583 &self.name_liabilities,
584 &self.name_equity,
585 &self.name_income,
586 &self.name_expenses,
587 ]
588 }
589
590 fn is_valid_account(value: &str) -> bool {
596 if !value.contains(':') {
598 return false;
599 }
600
601 for part in value.split(':') {
603 if let Some(first) = part.chars().next() {
604 let valid = first.is_uppercase() || (!first.is_ascii() && first.is_alphabetic());
606 if !valid {
607 return false;
608 }
609 } else {
610 return false;
612 }
613 }
614
615 true
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622
623 #[test]
624 fn test_default_options() {
625 let opts = Options::new();
626 assert_eq!(opts.name_assets, "Assets");
627 assert_eq!(opts.booking_method, "STRICT");
628 assert!(!opts.infer_tolerance_from_cost);
629 }
630
631 #[test]
632 fn test_set_options() {
633 let mut opts = Options::new();
634 opts.set("title", "My Ledger");
635 opts.set("operating_currency", "USD");
636 opts.set("operating_currency", "EUR");
637 opts.set("booking_method", "FIFO");
638
639 assert_eq!(opts.title, Some("My Ledger".to_string()));
640 assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
641 assert_eq!(opts.booking_method, "FIFO");
642 }
643
644 #[test]
645 fn test_custom_options() {
646 let mut opts = Options::new();
647 opts.set("my_custom_option", "my_value");
648
649 assert_eq!(opts.get("my_custom_option"), Some("my_value"));
650 assert_eq!(opts.get("nonexistent"), None);
651 }
652
653 #[test]
654 fn test_unknown_option_warning() {
655 let mut opts = Options::new();
656 opts.set("unknown_option", "value");
657
658 assert_eq!(opts.warnings.len(), 1);
659 assert_eq!(opts.warnings[0].code, "E7001");
660 assert!(opts.warnings[0].message.contains("Invalid option"));
661 }
662
663 #[test]
666 fn test_use_precise_interpolation_accepted() {
667 let mut opts = Options::new();
668 opts.set("use_precise_interpolation", "TRUE");
669
670 assert!(
671 opts.warnings.is_empty(),
672 "should not warn on a known option: {:?}",
673 opts.warnings
674 );
675 assert!(opts.use_precise_interpolation);
676 }
677
678 #[test]
679 fn test_duplicate_option_warning() {
680 let mut opts = Options::new();
681 opts.set("title", "First Title");
682 opts.set("title", "Second Title");
683
684 assert_eq!(opts.warnings.len(), 1);
685 assert_eq!(opts.warnings[0].code, "E7003");
686 assert!(opts.warnings[0].message.contains("only be specified once"));
687 }
688
689 #[test]
690 fn test_repeatable_option_no_warning() {
691 let mut opts = Options::new();
692 opts.set("operating_currency", "USD");
693 opts.set("operating_currency", "EUR");
694
695 assert!(
697 opts.warnings.is_empty(),
698 "Should not warn for repeatable options: {:?}",
699 opts.warnings
700 );
701 assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
702 }
703
704 #[test]
705 fn test_invalid_tolerance_value() {
706 let mut opts = Options::new();
707 opts.set("inferred_tolerance_multiplier", "not_a_number");
708
709 assert_eq!(opts.warnings.len(), 2);
711 assert_eq!(opts.warnings[0].code, "E7004");
712 assert!(opts.warnings[0].message.contains("Renamed"));
713 assert_eq!(opts.warnings[1].code, "E7002");
714 assert!(opts.warnings[1].message.contains("expected decimal"));
715 }
716
717 #[test]
718 fn test_tolerance_multiplier_new_name() {
719 let mut opts = Options::new();
720 opts.set("tolerance_multiplier", "1.5");
721
722 assert!(opts.warnings.is_empty());
723 assert_eq!(opts.inferred_tolerance_multiplier, Decimal::new(15, 1));
724 }
725
726 #[test]
727 fn test_inferred_tolerance_multiplier_deprecated() {
728 let mut opts = Options::new();
729 opts.set("inferred_tolerance_multiplier", "1.01");
730
731 assert_eq!(opts.warnings.len(), 1);
732 assert_eq!(opts.warnings[0].code, "E7004");
733 assert!(
734 opts.warnings[0]
735 .message
736 .contains("Renamed to 'tolerance_multiplier'")
737 );
738 assert_eq!(
739 opts.inferred_tolerance_multiplier,
740 Decimal::from_str("1.01").unwrap()
741 );
742 }
743
744 #[test]
745 fn test_invalid_boolean_value() {
746 let mut opts = Options::new();
747 opts.set("infer_tolerance_from_cost", "maybe");
748
749 assert_eq!(opts.warnings.len(), 1);
750 assert_eq!(opts.warnings[0].code, "E7002");
751 assert!(opts.warnings[0].message.contains("TRUE or FALSE"));
752 }
753
754 #[test]
755 fn test_invalid_booking_method() {
756 let mut opts = Options::new();
757 opts.set("booking_method", "RANDOM");
758
759 assert_eq!(opts.warnings.len(), 1);
760 assert_eq!(opts.warnings[0].code, "E7002");
761 assert!(opts.warnings[0].message.contains("STRICT"));
762 }
763
764 #[test]
765 fn test_valid_booking_methods() {
766 for method in &["STRICT", "FIFO", "LIFO", "AVERAGE", "NONE"] {
767 let mut opts = Options::new();
768 opts.set("booking_method", method);
769 assert!(
770 opts.warnings.is_empty(),
771 "Should accept {method} as valid booking method"
772 );
773 }
774 }
775
776 #[test]
777 fn test_readonly_option_warning() {
778 let mut opts = Options::new();
779 opts.set("filename", "/some/path.beancount");
780
781 assert_eq!(opts.warnings.len(), 1);
782 assert_eq!(opts.warnings[0].code, "E7005");
783 assert!(opts.warnings[0].message.contains("may not be set"));
784 }
785
786 #[test]
787 fn test_invalid_account_name_validation() {
788 let mut opts = Options::new();
790 opts.set("account_rounding", "invalid");
791
792 assert_eq!(opts.warnings.len(), 1);
793 assert_eq!(opts.warnings[0].code, "E7002");
794 assert!(opts.warnings[0].message.contains("Invalid leaf account"));
795 }
796
797 #[test]
798 fn test_valid_account_name() {
799 let mut opts = Options::new();
800 opts.set("account_rounding", "Equity:Rounding");
801
802 assert!(
803 opts.warnings.is_empty(),
804 "Valid account name should not produce warnings: {:?}",
805 opts.warnings
806 );
807 assert_eq!(opts.account_rounding, Some("Equity:Rounding".to_string()));
808 }
809
810 #[test]
811 fn test_render_commas_with_numeric_values() {
812 let mut opts = Options::new();
813 opts.set("render_commas", "1");
814 assert!(opts.render_commas);
815 assert!(opts.warnings.is_empty());
816
817 let mut opts2 = Options::new();
818 opts2.set("render_commas", "0");
819 assert!(!opts2.render_commas);
820 assert!(opts2.warnings.is_empty());
821 }
822
823 #[test]
824 fn test_plugin_processing_mode_validation() {
825 let mut opts = Options::new();
827 opts.set("plugin_processing_mode", "default");
828 assert!(opts.warnings.is_empty());
829 assert_eq!(opts.plugin_processing_mode, "default");
830
831 let mut opts2 = Options::new();
832 opts2.set("plugin_processing_mode", "raw");
833 assert!(opts2.warnings.is_empty());
834 assert_eq!(opts2.plugin_processing_mode, "raw");
835
836 let mut opts3 = Options::new();
838 opts3.set("plugin_processing_mode", "invalid");
839 assert_eq!(opts3.warnings.len(), 1);
840 assert_eq!(opts3.warnings[0].code, "E7002");
841 }
842
843 #[test]
844 fn test_deprecated_plugin_option() {
845 let mut opts = Options::new();
846 opts.set("plugin", "some.plugin");
847
848 assert_eq!(opts.warnings.len(), 1);
849 assert_eq!(opts.warnings[0].code, "E7004");
850 assert!(opts.warnings[0].message.contains("deprecated"));
851 }
852
853 #[test]
854 fn test_deprecated_allow_pipe_separator() {
855 let mut opts = Options::new();
856 opts.set("allow_pipe_separator", "true");
857
858 assert_eq!(opts.warnings.len(), 1);
859 assert_eq!(opts.warnings[0].code, "E7004");
860 assert!(opts.warnings[0].message.contains("deprecated"));
861 }
862
863 #[test]
864 fn test_is_valid_account() {
865 assert!(Options::is_valid_account("Assets:Bank"));
867 assert!(Options::is_valid_account("Equity:Rounding:Precision"));
868
869 assert!(Options::is_valid_account("Капитал:Retained"));
871 assert!(Options::is_valid_account("资产:银行:支票"));
872
873 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")); }
879
880 #[test]
881 fn test_account_validation_options() {
882 let account_options = [
884 "account_rounding",
885 "account_current_conversions",
886 "account_unrealized_gains",
887 "account_previous_balances",
888 "account_previous_earnings",
889 "account_previous_conversions",
890 "account_current_earnings",
891 ];
892
893 for opt in account_options {
894 let mut opts = Options::new();
895 opts.set(opt, "lowercase:invalid");
896
897 assert!(
898 !opts.warnings.is_empty(),
899 "Option '{opt}' should warn on invalid account name"
900 );
901 assert_eq!(opts.warnings[0].code, "E7002");
902 }
903 }
904
905 #[test]
906 fn test_inferred_tolerance_default() {
907 let mut opts = Options::new();
908 opts.set("inferred_tolerance_default", "USD:0.005");
909
910 assert!(opts.warnings.is_empty());
911 assert_eq!(
912 opts.inferred_tolerance_default.get("USD"),
913 Some(&rust_decimal_macros::dec!(0.005))
914 );
915
916 let mut opts2 = Options::new();
918 opts2.set("inferred_tolerance_default", "*:0.01");
919 assert!(opts2.warnings.is_empty());
920 assert_eq!(
921 opts2.inferred_tolerance_default.get("*"),
922 Some(&rust_decimal_macros::dec!(0.01))
923 );
924
925 let mut opts3 = Options::new();
927 opts3.set("inferred_tolerance_default", "INVALID");
928 assert_eq!(opts3.warnings.len(), 1);
929 assert_eq!(opts3.warnings[0].code, "E7002");
930 }
931
932 #[test]
933 fn test_display_precision_basic() {
934 let mut opts = Options::new();
935 opts.set("display_precision", "USD:0.01");
936 assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
937 assert_eq!(opts.display_precision.get("USD"), Some(&2));
938 }
939
940 #[test]
941 fn test_display_precision_high_precision() {
942 let mut opts = Options::new();
943 opts.set("display_precision", "BTC:0.00000001");
944 assert!(opts.warnings.is_empty());
945 assert_eq!(opts.display_precision.get("BTC"), Some(&8));
946 }
947
948 #[test]
949 fn test_display_precision_zero_decimals() {
950 let mut opts = Options::new();
952 opts.set("display_precision", "JPY:1");
953 assert!(opts.warnings.is_empty());
954 assert_eq!(opts.display_precision.get("JPY"), Some(&0));
955 }
956
957 #[test]
958 fn test_display_precision_repeatable_per_currency() {
959 let mut opts = Options::new();
960 opts.set("display_precision", "USD:0.01");
961 opts.set("display_precision", "EUR:0.001");
962 assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
963 assert_eq!(opts.display_precision.get("USD"), Some(&2));
964 assert_eq!(opts.display_precision.get("EUR"), Some(&3));
965 }
966
967 #[test]
968 fn test_display_precision_missing_colon_warns() {
969 let mut opts = Options::new();
970 opts.set("display_precision", "USD0.01");
971 assert_eq!(opts.warnings.len(), 1);
972 assert_eq!(opts.warnings[0].code, "E7002");
973 assert!(opts.warnings[0].message.contains("CURRENCY:EXAMPLE"));
974 assert!(opts.display_precision.is_empty());
975 }
976
977 #[test]
978 fn test_display_precision_invalid_example_warns() {
979 let mut opts = Options::new();
980 opts.set("display_precision", "USD:abc");
981 assert_eq!(opts.warnings.len(), 1);
982 assert_eq!(opts.warnings[0].code, "E7002");
983 assert!(opts.warnings[0].message.contains("Invalid precision"));
984 assert!(opts.display_precision.is_empty());
985 }
986}