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, PartialEq, Eq)]
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, PartialEq, Eq)]
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);
256 if is_known && !is_repeatable && self.set_options.contains(key) {
257 self.warnings.push(OptionWarning {
258 code: "E7003",
259 message: format!("Option \"{key}\" can only be specified once"),
260 option: key.to_string(),
261 value: value.to_string(),
262 });
263 }
264
265 self.set_options.insert(key.to_string());
267
268 match key {
270 "title" => self.title = Some(value.to_string()),
271 "operating_currency" => self.operating_currency.push(value.to_string()),
272 "name_assets" => self.name_assets = value.to_string(),
273 "name_liabilities" => self.name_liabilities = value.to_string(),
274 "name_equity" => self.name_equity = value.to_string(),
275 "name_income" => self.name_income = value.to_string(),
276 "name_expenses" => self.name_expenses = value.to_string(),
277 "account_rounding" => {
278 if !Self::is_valid_account(value) {
279 self.warnings.push(OptionWarning {
280 code: "E7002",
281 message: format!("Invalid leaf account name: '{value}'"),
282 option: key.to_string(),
283 value: value.to_string(),
284 });
285 }
286 self.warnings.push(OptionWarning {
294 code: "E7007",
295 message: "Option 'account_rounding' is accepted for compatibility \
296 but has no effect: rustledger preserves full precision \
297 during interpolation rather than rounding into a rounding \
298 account, so no rounding residual is produced."
299 .to_string(),
300 option: key.to_string(),
301 value: value.to_string(),
302 });
303 self.account_rounding = Some(value.to_string());
304 }
305 "account_current_conversions" => {
306 if !Self::is_valid_account(value) {
307 self.warnings.push(OptionWarning {
308 code: "E7002",
309 message: format!("Invalid leaf account name: '{value}'"),
310 option: key.to_string(),
311 value: value.to_string(),
312 });
313 }
314 self.account_current_conversions = Some(value.to_string());
315 }
316 "account_unrealized_gains" => {
317 if !Self::is_valid_account(value) {
318 self.warnings.push(OptionWarning {
319 code: "E7002",
320 message: format!("Invalid leaf account name: '{value}'"),
321 option: key.to_string(),
322 value: value.to_string(),
323 });
324 }
325 self.account_unrealized_gains = Some(value.to_string());
326 }
327 "inferred_tolerance_multiplier" => {
328 self.warnings.push(OptionWarning {
330 code: "E7004",
331 message: "Renamed to 'tolerance_multiplier'.".to_string(),
332 option: key.to_string(),
333 value: value.to_string(),
334 });
335 if let Ok(d) = Decimal::from_str(value) {
336 self.inferred_tolerance_multiplier = d;
337 } else {
338 self.warnings.push(OptionWarning {
340 code: "E7002",
341 message: format!(
342 "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
343 ),
344 option: key.to_string(),
345 value: value.to_string(),
346 });
347 }
348 }
349 "tolerance_multiplier" => {
350 if let Ok(d) = Decimal::from_str(value) {
351 self.inferred_tolerance_multiplier = d;
352 } else {
353 self.warnings.push(OptionWarning {
354 code: "E7002",
355 message: format!(
356 "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
357 ),
358 option: key.to_string(),
359 value: value.to_string(),
360 });
361 }
362 }
363 "infer_tolerance_from_cost" => {
364 if !value.eq_ignore_ascii_case("true") && !value.eq_ignore_ascii_case("false") {
365 self.warnings.push(OptionWarning {
366 code: "E7002",
367 message: format!(
368 "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
369 ),
370 option: key.to_string(),
371 value: value.to_string(),
372 });
373 }
374 self.infer_tolerance_from_cost = value.eq_ignore_ascii_case("true");
375 }
376 "booking_method" => {
377 let valid_methods = [
378 "STRICT",
379 "STRICT_WITH_SIZE",
380 "FIFO",
381 "LIFO",
382 "HIFO",
383 "AVERAGE",
384 "NONE",
385 ];
386 if !valid_methods.contains(&value.to_uppercase().as_str()) {
387 self.warnings.push(OptionWarning {
388 code: "E7002",
389 message: format!(
390 "Invalid value \"{}\" for option \"{}\": expected one of {}",
391 value,
392 key,
393 valid_methods.join(", ")
394 ),
395 option: key.to_string(),
396 value: value.to_string(),
397 });
398 }
399 self.booking_method = value.to_string();
400 }
401 "render_commas" => {
402 let is_true = value.eq_ignore_ascii_case("true") || value == "1";
404 let is_false = value.eq_ignore_ascii_case("false") || value == "0";
405 if !is_true && !is_false {
406 self.warnings.push(OptionWarning {
407 code: "E7002",
408 message: format!(
409 "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
410 ),
411 option: key.to_string(),
412 value: value.to_string(),
413 });
414 }
415 self.render_commas = is_true;
416 }
417 "display_precision" => {
418 if let Some((curr, example)) = value.split_once(':') {
422 if let Ok(d) = Decimal::from_str(example) {
423 let precision = d.scale();
425 self.display_precision.insert(curr.to_string(), precision);
426 } else {
427 self.warnings.push(OptionWarning {
428 code: "E7002",
429 message: format!(
430 "Invalid precision value \"{example}\" in option \"{key}\""
431 ),
432 option: key.to_string(),
433 value: value.to_string(),
434 });
435 }
436 } else {
437 self.warnings.push(OptionWarning {
438 code: "E7002",
439 message: format!(
440 "Invalid format for option \"{key}\": expected CURRENCY:EXAMPLE (e.g., CHF:0.01)"
441 ),
442 option: key.to_string(),
443 value: value.to_string(),
444 });
445 }
446 }
447 "filename" => self.filename = Some(value.to_string()),
448 "account_previous_balances" => {
449 if !Self::is_valid_account(value) {
450 self.warnings.push(OptionWarning {
451 code: "E7002",
452 message: format!("Invalid leaf account name: '{value}'"),
453 option: key.to_string(),
454 value: value.to_string(),
455 });
456 }
457 self.account_previous_balances = value.to_string();
458 }
459 "account_previous_earnings" => {
460 if !Self::is_valid_account(value) {
461 self.warnings.push(OptionWarning {
462 code: "E7002",
463 message: format!("Invalid leaf account name: '{value}'"),
464 option: key.to_string(),
465 value: value.to_string(),
466 });
467 }
468 self.account_previous_earnings = value.to_string();
469 }
470 "account_previous_conversions" => {
471 if !Self::is_valid_account(value) {
472 self.warnings.push(OptionWarning {
473 code: "E7002",
474 message: format!("Invalid leaf account name: '{value}'"),
475 option: key.to_string(),
476 value: value.to_string(),
477 });
478 }
479 self.account_previous_conversions = value.to_string();
480 }
481 "account_current_earnings" => {
482 if !Self::is_valid_account(value) {
483 self.warnings.push(OptionWarning {
484 code: "E7002",
485 message: format!("Invalid leaf account name: '{value}'"),
486 option: key.to_string(),
487 value: value.to_string(),
488 });
489 }
490 self.account_current_earnings = value.to_string();
491 }
492 "conversion_currency" => self.conversion_currency = Some(value.to_string()),
493 "inferred_tolerance_default" => {
494 if let Some((curr, tol)) = value.split_once(':') {
496 if let Ok(d) = Decimal::from_str(tol) {
497 self.inferred_tolerance_default.insert(curr.to_string(), d);
498 } else {
499 self.warnings.push(OptionWarning {
500 code: "E7002",
501 message: format!(
502 "Invalid tolerance value \"{tol}\" in option \"{key}\""
503 ),
504 option: key.to_string(),
505 value: value.to_string(),
506 });
507 }
508 } else {
509 self.warnings.push(OptionWarning {
510 code: "E7002",
511 message: format!(
512 "Invalid format for option \"{key}\": expected CURRENCY:TOLERANCE"
513 ),
514 option: key.to_string(),
515 value: value.to_string(),
516 });
517 }
518 }
519 "use_legacy_fixed_tolerances" => {
520 self.use_legacy_fixed_tolerances = value.eq_ignore_ascii_case("true");
521 }
522 "experiment_explicit_tolerances" => {
523 self.experiment_explicit_tolerances = value.eq_ignore_ascii_case("true");
524 }
525 "use_precise_interpolation" => {
526 self.use_precise_interpolation = value.eq_ignore_ascii_case("true");
530 }
531 "allow_pipe_separator" => {
532 self.warnings.push(OptionWarning {
534 code: "E7004",
535 message: "Option 'allow_pipe_separator' is deprecated".to_string(),
536 option: key.to_string(),
537 value: value.to_string(),
538 });
539 self.allow_pipe_separator = value.eq_ignore_ascii_case("true");
540 }
541 "long_string_maxlines" => {
542 if let Ok(n) = value.parse::<u32>() {
543 self.long_string_maxlines = n;
544 } else {
545 self.warnings.push(OptionWarning {
546 code: "E7002",
547 message: format!(
548 "Invalid value \"{value}\" for option \"{key}\": expected integer"
549 ),
550 option: key.to_string(),
551 value: value.to_string(),
552 });
553 }
554 }
555 "documents" => {
556 if !std::path::Path::new(value).exists() {
558 self.warnings.push(OptionWarning {
559 code: "E7006",
560 message: format!("Document root '{value}' does not exist"),
561 option: key.to_string(),
562 value: value.to_string(),
563 });
564 }
565 self.documents.push(value.to_string());
566 }
567 "plugin_processing_mode" => {
568 if value != "default" && value != "raw" {
570 self.warnings.push(OptionWarning {
571 code: "E7002",
572 message: format!("Invalid value '{value}'"),
573 option: key.to_string(),
574 value: value.to_string(),
575 });
576 }
577 self.plugin_processing_mode = value.to_string();
578 }
579 "plugin" => {
580 self.warnings.push(OptionWarning {
582 code: "E7004",
583 message: "Option 'plugin' is deprecated; use the 'plugin' directive instead"
584 .to_string(),
585 option: key.to_string(),
586 value: value.to_string(),
587 });
588 }
589 _ => {
590 self.custom.insert(key.to_string(), value.to_string());
592 }
593 }
594 }
595
596 #[must_use]
598 pub fn get(&self, key: &str) -> Option<&str> {
599 self.custom.get(key).map(String::as_str)
600 }
601
602 #[must_use]
604 pub fn account_types(&self) -> [&str; 5] {
605 [
606 &self.name_assets,
607 &self.name_liabilities,
608 &self.name_equity,
609 &self.name_income,
610 &self.name_expenses,
611 ]
612 }
613
614 fn is_valid_account(value: &str) -> bool {
620 if !value.contains(':') {
622 return false;
623 }
624
625 for part in value.split(':') {
627 if let Some(first) = part.chars().next() {
628 let valid = first.is_uppercase() || (!first.is_ascii() && first.is_alphabetic());
630 if !valid {
631 return false;
632 }
633 } else {
634 return false;
636 }
637 }
638
639 true
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 #[test]
648 fn test_default_options() {
649 let opts = Options::new();
650 assert_eq!(opts.name_assets, "Assets");
651 assert_eq!(opts.booking_method, "STRICT");
652 assert!(!opts.infer_tolerance_from_cost);
653 }
654
655 #[test]
656 fn test_set_options() {
657 let mut opts = Options::new();
658 opts.set("title", "My Ledger");
659 opts.set("operating_currency", "USD");
660 opts.set("operating_currency", "EUR");
661 opts.set("booking_method", "FIFO");
662
663 assert_eq!(opts.title, Some("My Ledger".to_string()));
664 assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
665 assert_eq!(opts.booking_method, "FIFO");
666 }
667
668 #[test]
669 fn test_custom_options() {
670 let mut opts = Options::new();
671 opts.set("my_custom_option", "my_value");
672
673 assert_eq!(opts.get("my_custom_option"), Some("my_value"));
674 assert_eq!(opts.get("nonexistent"), None);
675 }
676
677 #[test]
678 fn test_unknown_option_warning() {
679 let mut opts = Options::new();
680 opts.set("unknown_option", "value");
681
682 assert_eq!(opts.warnings.len(), 1);
683 assert_eq!(opts.warnings[0].code, "E7001");
684 assert!(opts.warnings[0].message.contains("Invalid option"));
685 }
686
687 #[test]
690 fn test_use_precise_interpolation_accepted() {
691 let mut opts = Options::new();
692 opts.set("use_precise_interpolation", "TRUE");
693
694 assert!(
695 opts.warnings.is_empty(),
696 "should not warn on a known option: {:?}",
697 opts.warnings
698 );
699 assert!(opts.use_precise_interpolation);
700 }
701
702 #[test]
703 fn test_duplicate_option_warning() {
704 let mut opts = Options::new();
705 opts.set("title", "First Title");
706 opts.set("title", "Second Title");
707
708 assert_eq!(opts.warnings.len(), 1);
709 assert_eq!(opts.warnings[0].code, "E7003");
710 assert!(opts.warnings[0].message.contains("only be specified once"));
711 }
712
713 #[test]
714 fn test_repeatable_option_no_warning() {
715 let mut opts = Options::new();
716 opts.set("operating_currency", "USD");
717 opts.set("operating_currency", "EUR");
718
719 assert!(
721 opts.warnings.is_empty(),
722 "Should not warn for repeatable options: {:?}",
723 opts.warnings
724 );
725 assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
726 }
727
728 #[test]
729 fn test_invalid_tolerance_value() {
730 let mut opts = Options::new();
731 opts.set("inferred_tolerance_multiplier", "not_a_number");
732
733 assert_eq!(opts.warnings.len(), 2);
735 assert_eq!(opts.warnings[0].code, "E7004");
736 assert!(opts.warnings[0].message.contains("Renamed"));
737 assert_eq!(opts.warnings[1].code, "E7002");
738 assert!(opts.warnings[1].message.contains("expected decimal"));
739 }
740
741 #[test]
742 fn test_tolerance_multiplier_new_name() {
743 let mut opts = Options::new();
744 opts.set("tolerance_multiplier", "1.5");
745
746 assert!(opts.warnings.is_empty());
747 assert_eq!(opts.inferred_tolerance_multiplier, Decimal::new(15, 1));
748 }
749
750 #[test]
751 fn test_inferred_tolerance_multiplier_deprecated() {
752 let mut opts = Options::new();
753 opts.set("inferred_tolerance_multiplier", "1.01");
754
755 assert_eq!(opts.warnings.len(), 1);
756 assert_eq!(opts.warnings[0].code, "E7004");
757 assert!(
758 opts.warnings[0]
759 .message
760 .contains("Renamed to 'tolerance_multiplier'")
761 );
762 assert_eq!(
763 opts.inferred_tolerance_multiplier,
764 Decimal::from_str("1.01").unwrap()
765 );
766 }
767
768 #[test]
769 fn test_invalid_boolean_value() {
770 let mut opts = Options::new();
771 opts.set("infer_tolerance_from_cost", "maybe");
772
773 assert_eq!(opts.warnings.len(), 1);
774 assert_eq!(opts.warnings[0].code, "E7002");
775 assert!(opts.warnings[0].message.contains("TRUE or FALSE"));
776 }
777
778 #[test]
779 fn test_invalid_booking_method() {
780 let mut opts = Options::new();
781 opts.set("booking_method", "RANDOM");
782
783 assert_eq!(opts.warnings.len(), 1);
784 assert_eq!(opts.warnings[0].code, "E7002");
785 assert!(opts.warnings[0].message.contains("STRICT"));
786 }
787
788 #[test]
789 fn test_valid_booking_methods() {
790 for method in &["STRICT", "FIFO", "LIFO", "AVERAGE", "NONE"] {
791 let mut opts = Options::new();
792 opts.set("booking_method", method);
793 assert!(
794 opts.warnings.is_empty(),
795 "Should accept {method} as valid booking method"
796 );
797 }
798 }
799
800 #[test]
801 fn test_readonly_option_warning() {
802 let mut opts = Options::new();
803 opts.set("filename", "/some/path.beancount");
804
805 assert_eq!(opts.warnings.len(), 1);
806 assert_eq!(opts.warnings[0].code, "E7005");
807 assert!(opts.warnings[0].message.contains("may not be set"));
808 }
809
810 #[test]
811 fn test_account_rounding_accepted_but_warns_noop() {
812 let mut opts = Options::new();
813 opts.set("account_rounding", "Equity:Rounding");
814
815 assert_eq!(opts.account_rounding.as_deref(), Some("Equity:Rounding"));
817 let w = opts
819 .warnings
820 .iter()
821 .find(|w| w.code == "E7007")
822 .expect("expected an E7007 no-op warning for account_rounding");
823 assert!(w.message.contains("no effect"));
824 assert_eq!(w.option, "account_rounding");
825 assert!(!opts.warnings.iter().any(|w| w.code == "E7002"));
827 }
828
829 #[test]
830 fn test_invalid_account_name_validation() {
831 let mut opts = Options::new();
834 opts.set("account_rounding", "invalid");
835
836 assert!(
837 opts.warnings
838 .iter()
839 .any(|w| w.code == "E7002" && w.message.contains("Invalid leaf account"))
840 );
841 assert!(opts.warnings.iter().any(|w| w.code == "E7007"));
842 }
843
844 #[test]
845 fn test_valid_account_name() {
846 let mut opts = Options::new();
847 opts.set("account_rounding", "Equity:Rounding");
848
849 assert!(!opts.warnings.iter().any(|w| w.code == "E7002"));
852 assert!(opts.warnings.iter().any(|w| w.code == "E7007"));
853 assert_eq!(opts.account_rounding, Some("Equity:Rounding".to_string()));
854 }
855
856 #[test]
857 fn test_render_commas_with_numeric_values() {
858 let mut opts = Options::new();
859 opts.set("render_commas", "1");
860 assert!(opts.render_commas);
861 assert!(opts.warnings.is_empty());
862
863 let mut opts2 = Options::new();
864 opts2.set("render_commas", "0");
865 assert!(!opts2.render_commas);
866 assert!(opts2.warnings.is_empty());
867 }
868
869 #[test]
870 fn test_plugin_processing_mode_validation() {
871 let mut opts = Options::new();
873 opts.set("plugin_processing_mode", "default");
874 assert!(opts.warnings.is_empty());
875 assert_eq!(opts.plugin_processing_mode, "default");
876
877 let mut opts2 = Options::new();
878 opts2.set("plugin_processing_mode", "raw");
879 assert!(opts2.warnings.is_empty());
880 assert_eq!(opts2.plugin_processing_mode, "raw");
881
882 let mut opts3 = Options::new();
884 opts3.set("plugin_processing_mode", "invalid");
885 assert_eq!(opts3.warnings.len(), 1);
886 assert_eq!(opts3.warnings[0].code, "E7002");
887 }
888
889 #[test]
890 fn test_deprecated_plugin_option() {
891 let mut opts = Options::new();
892 opts.set("plugin", "some.plugin");
893
894 assert_eq!(opts.warnings.len(), 1);
895 assert_eq!(opts.warnings[0].code, "E7004");
896 assert!(opts.warnings[0].message.contains("deprecated"));
897 }
898
899 #[test]
900 fn test_deprecated_allow_pipe_separator() {
901 let mut opts = Options::new();
902 opts.set("allow_pipe_separator", "true");
903
904 assert_eq!(opts.warnings.len(), 1);
905 assert_eq!(opts.warnings[0].code, "E7004");
906 assert!(opts.warnings[0].message.contains("deprecated"));
907 }
908
909 #[test]
910 fn test_is_valid_account() {
911 assert!(Options::is_valid_account("Assets:Bank"));
913 assert!(Options::is_valid_account("Equity:Rounding:Precision"));
914
915 assert!(Options::is_valid_account("Капитал:Retained"));
917 assert!(Options::is_valid_account("资产:银行:支票"));
918
919 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")); }
925
926 #[test]
927 fn test_account_validation_options() {
928 let account_options = [
930 "account_rounding",
931 "account_current_conversions",
932 "account_unrealized_gains",
933 "account_previous_balances",
934 "account_previous_earnings",
935 "account_previous_conversions",
936 "account_current_earnings",
937 ];
938
939 for opt in account_options {
940 let mut opts = Options::new();
941 opts.set(opt, "lowercase:invalid");
942
943 assert!(
944 !opts.warnings.is_empty(),
945 "Option '{opt}' should warn on invalid account name"
946 );
947 assert_eq!(opts.warnings[0].code, "E7002");
948 }
949 }
950
951 #[test]
952 fn test_inferred_tolerance_default() {
953 let mut opts = Options::new();
954 opts.set("inferred_tolerance_default", "USD:0.005");
955
956 assert!(opts.warnings.is_empty());
957 assert_eq!(
958 opts.inferred_tolerance_default.get("USD"),
959 Some(&rust_decimal_macros::dec!(0.005))
960 );
961
962 let mut opts2 = Options::new();
964 opts2.set("inferred_tolerance_default", "*:0.01");
965 assert!(opts2.warnings.is_empty());
966 assert_eq!(
967 opts2.inferred_tolerance_default.get("*"),
968 Some(&rust_decimal_macros::dec!(0.01))
969 );
970
971 let mut opts3 = Options::new();
973 opts3.set("inferred_tolerance_default", "INVALID");
974 assert_eq!(opts3.warnings.len(), 1);
975 assert_eq!(opts3.warnings[0].code, "E7002");
976 }
977
978 #[test]
979 fn test_display_precision_basic() {
980 let mut opts = Options::new();
981 opts.set("display_precision", "USD:0.01");
982 assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
983 assert_eq!(opts.display_precision.get("USD"), Some(&2));
984 }
985
986 #[test]
987 fn test_display_precision_high_precision() {
988 let mut opts = Options::new();
989 opts.set("display_precision", "BTC:0.00000001");
990 assert!(opts.warnings.is_empty());
991 assert_eq!(opts.display_precision.get("BTC"), Some(&8));
992 }
993
994 #[test]
995 fn test_display_precision_zero_decimals() {
996 let mut opts = Options::new();
998 opts.set("display_precision", "JPY:1");
999 assert!(opts.warnings.is_empty());
1000 assert_eq!(opts.display_precision.get("JPY"), Some(&0));
1001 }
1002
1003 #[test]
1004 fn test_display_precision_repeatable_per_currency() {
1005 let mut opts = Options::new();
1006 opts.set("display_precision", "USD:0.01");
1007 opts.set("display_precision", "EUR:0.001");
1008 assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
1009 assert_eq!(opts.display_precision.get("USD"), Some(&2));
1010 assert_eq!(opts.display_precision.get("EUR"), Some(&3));
1011 }
1012
1013 #[test]
1014 fn test_display_precision_missing_colon_warns() {
1015 let mut opts = Options::new();
1016 opts.set("display_precision", "USD0.01");
1017 assert_eq!(opts.warnings.len(), 1);
1018 assert_eq!(opts.warnings[0].code, "E7002");
1019 assert!(opts.warnings[0].message.contains("CURRENCY:EXAMPLE"));
1020 assert!(opts.display_precision.is_empty());
1021 }
1022
1023 #[test]
1024 fn test_display_precision_invalid_example_warns() {
1025 let mut opts = Options::new();
1026 opts.set("display_precision", "USD:abc");
1027 assert_eq!(opts.warnings.len(), 1);
1028 assert_eq!(opts.warnings[0].code, "E7002");
1029 assert!(opts.warnings[0].message.contains("Invalid precision"));
1030 assert!(opts.display_precision.is_empty());
1031 }
1032}