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
43fn deprecation_message(key: &str) -> Option<&'static str> {
49 match key {
50 "inferred_tolerance_multiplier" => Some("Renamed to 'tolerance_multiplier'."),
51 "allow_pipe_separator" => Some("Option 'allow_pipe_separator' is deprecated"),
52 "plugin" => Some("Option 'plugin' is deprecated; use the 'plugin' directive instead"),
53 _ => None,
54 }
55}
56
57const ACCUMULATE_ACROSS_INCLUDES: &[&str] = &[
89 "operating_currency",
90 "documents",
91 "insert_pythonpath",
92 "display_precision",
93];
94
95const REPEATABLE_OPTIONS: &[&str] = &[
97 "operating_currency",
98 "insert_pythonpath",
99 "documents",
100 "inferred_tolerance_default",
101 "display_precision",
102];
103
104const READONLY_OPTIONS: &[&str] = &["filename"];
106
107#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct OptionWarning {
110 pub code: &'static str,
112 pub message: String,
114 pub option: String,
116 pub value: String,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct Options {
125 pub title: Option<String>,
127
128 pub filename: Option<String>,
130
131 pub operating_currency: Vec<String>,
133
134 pub name_assets: String,
136
137 pub name_liabilities: String,
139
140 pub name_equity: String,
142
143 pub name_income: String,
145
146 pub name_expenses: String,
148
149 pub account_rounding: Option<String>,
151
152 pub account_previous_balances: String,
154
155 pub account_previous_earnings: String,
157
158 pub account_previous_conversions: String,
160
161 pub account_current_earnings: String,
163
164 pub account_current_conversions: Option<String>,
166
167 pub account_unrealized_gains: Option<String>,
169
170 pub conversion_currency: Option<String>,
172
173 pub inferred_tolerance_default: FxHashMap<String, Decimal>,
175
176 pub inferred_tolerance_multiplier: Decimal,
178
179 pub infer_tolerance_from_cost: bool,
181
182 pub use_legacy_fixed_tolerances: bool,
184
185 pub experiment_explicit_tolerances: bool,
187
188 pub use_precise_interpolation: bool,
193
194 pub booking_method: String,
196
197 pub render_commas: bool,
199
200 pub display_precision: FxHashMap<String, u32>,
203
204 pub allow_pipe_separator: bool,
206
207 pub long_string_maxlines: u32,
209
210 pub documents: Vec<String>,
212
213 pub plugin_processing_mode: String,
215
216 pub custom: FxHashMap<String, String>,
218
219 #[doc(hidden)]
221 pub set_options: FxHashSet<String>,
222
223 pub warnings: Vec<OptionWarning>,
225}
226
227impl Default for Options {
228 fn default() -> Self {
229 Self::new()
230 }
231}
232
233impl Options {
234 #[must_use]
236 pub fn new() -> Self {
237 Self {
238 title: None,
239 filename: None,
240 operating_currency: Vec::new(),
241 name_assets: "Assets".to_string(),
242 name_liabilities: "Liabilities".to_string(),
243 name_equity: "Equity".to_string(),
244 name_income: "Income".to_string(),
245 name_expenses: "Expenses".to_string(),
246 account_rounding: None,
247 account_previous_balances: "Equity:Opening-Balances".to_string(),
248 account_previous_earnings: "Equity:Earnings:Previous".to_string(),
249 account_previous_conversions: "Equity:Conversions:Previous".to_string(),
250 account_current_earnings: "Equity:Earnings:Current".to_string(),
251 account_current_conversions: None,
252 account_unrealized_gains: None,
253 conversion_currency: None,
254 inferred_tolerance_default: FxHashMap::default(),
255 inferred_tolerance_multiplier: Decimal::new(5, 1), infer_tolerance_from_cost: false,
257 use_legacy_fixed_tolerances: false,
258 experiment_explicit_tolerances: false,
259 use_precise_interpolation: false,
260 booking_method: "STRICT".to_string(),
261 render_commas: false, display_precision: FxHashMap::default(),
263 allow_pipe_separator: false,
264 long_string_maxlines: 64,
265 documents: Vec::new(),
266 plugin_processing_mode: "default".to_string(),
267 custom: FxHashMap::default(),
268 set_options: FxHashSet::default(),
269 warnings: Vec::new(),
270 }
271 }
272
273 pub fn set(&mut self, key: &str, value: &str) {
277 self.set_scoped(key, value, true);
278 }
279
280 fn warn_if_deprecated(&mut self, key: &str, value: &str) {
288 let Some(message) = deprecation_message(key) else {
289 return;
290 };
291 self.warnings.push(OptionWarning {
292 code: "E7004",
293 message: message.to_string(),
294 option: key.to_string(),
295 value: value.to_string(),
296 });
297 }
298
299 pub fn set_scoped(&mut self, key: &str, value: &str, top_level: bool) {
306 if !top_level && KNOWN_OPTIONS.contains(&key) && !ACCUMULATE_ACROSS_INCLUDES.contains(&key)
307 {
308 self.warn_if_deprecated(key, value);
313 self.warnings.push(OptionWarning {
317 code: "E7009",
318 message: format!(
319 "Option \"{key}\" set in an included file is ignored; \
320 the top-level ledger's value governs"
321 ),
322 option: key.to_string(),
323 value: value.to_string(),
324 });
325 return;
326 }
327 self.set_inner(key, value);
328 }
329
330 fn set_inner(&mut self, key: &str, value: &str) {
331 let is_known = KNOWN_OPTIONS.contains(&key);
333 if !is_known {
334 self.warnings.push(OptionWarning {
335 code: "E7001",
336 message: format!("Invalid option \"{key}\""),
337 option: key.to_string(),
338 value: value.to_string(),
339 });
340 }
341
342 if READONLY_OPTIONS.contains(&key) {
344 self.warnings.push(OptionWarning {
345 code: "E7005",
346 message: format!("Option '{key}' may not be set"),
347 option: key.to_string(),
348 value: value.to_string(),
349 });
350 return; }
352
353 let is_repeatable = REPEATABLE_OPTIONS.contains(&key);
362 if is_known && !is_repeatable && self.set_options.contains(key) {
363 self.warnings.push(OptionWarning {
364 code: "E7003",
365 message: format!("Option \"{key}\" is set more than once; the last value wins"),
366 option: key.to_string(),
367 value: value.to_string(),
368 });
369 }
370
371 self.set_options.insert(key.to_string());
373
374 match key {
376 "title" => self.title = Some(value.to_string()),
377 "operating_currency" => self.operating_currency.push(value.to_string()),
378 "name_assets" => {
379 self.warn_if_invalid_root("name_assets", value);
380 self.name_assets = value.to_string();
381 }
382 "name_liabilities" => {
383 self.warn_if_invalid_root("name_liabilities", value);
384 self.name_liabilities = value.to_string();
385 }
386 "name_equity" => {
387 self.warn_if_invalid_root("name_equity", value);
388 self.name_equity = value.to_string();
389 }
390 "name_income" => {
391 self.warn_if_invalid_root("name_income", value);
392 self.name_income = value.to_string();
393 }
394 "name_expenses" => {
395 self.warn_if_invalid_root("name_expenses", value);
396 self.name_expenses = value.to_string();
397 }
398 "account_rounding" => {
399 if !Self::is_valid_account(value) {
400 self.warnings.push(OptionWarning {
401 code: "E7002",
402 message: format!("Invalid leaf account name: '{value}'"),
403 option: key.to_string(),
404 value: value.to_string(),
405 });
406 }
407 self.warnings.push(OptionWarning {
415 code: "E7007",
416 message: "Option 'account_rounding' is accepted for compatibility \
417 but has no effect: rustledger preserves full precision \
418 during interpolation rather than rounding into a rounding \
419 account, so no rounding residual is produced."
420 .to_string(),
421 option: key.to_string(),
422 value: value.to_string(),
423 });
424 self.account_rounding = Some(value.to_string());
425 }
426 "account_current_conversions" => {
427 if !Self::is_valid_account(value) {
428 self.warnings.push(OptionWarning {
429 code: "E7002",
430 message: format!("Invalid leaf account name: '{value}'"),
431 option: key.to_string(),
432 value: value.to_string(),
433 });
434 }
435 self.account_current_conversions = Some(value.to_string());
436 }
437 "account_unrealized_gains" => {
438 if !Self::is_valid_account(value) {
439 self.warnings.push(OptionWarning {
440 code: "E7002",
441 message: format!("Invalid leaf account name: '{value}'"),
442 option: key.to_string(),
443 value: value.to_string(),
444 });
445 }
446 self.account_unrealized_gains = Some(value.to_string());
447 }
448 "inferred_tolerance_multiplier" => {
449 self.warn_if_deprecated(key, value);
451 if let Ok(d) = Decimal::from_str(value) {
452 self.inferred_tolerance_multiplier = d;
453 } else {
454 self.warnings.push(OptionWarning {
456 code: "E7002",
457 message: format!(
458 "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
459 ),
460 option: key.to_string(),
461 value: value.to_string(),
462 });
463 }
464 }
465 "tolerance_multiplier" => {
466 if let Ok(d) = Decimal::from_str(value) {
467 self.inferred_tolerance_multiplier = d;
468 } else {
469 self.warnings.push(OptionWarning {
470 code: "E7002",
471 message: format!(
472 "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
473 ),
474 option: key.to_string(),
475 value: value.to_string(),
476 });
477 }
478 }
479 "infer_tolerance_from_cost" => {
480 let parsed = rustledger_core::parse_bool_word(value);
485 if parsed.is_none() {
486 self.warnings.push(OptionWarning {
487 code: "E7002",
488 message: format!(
489 "Invalid value \"{value}\" for option \"{key}\": expected TRUE, FALSE, 1 or 0"
490 ),
491 option: key.to_string(),
492 value: value.to_string(),
493 });
494 }
495 self.infer_tolerance_from_cost = parsed == Some(true);
496 }
497 "booking_method" => {
498 let valid_methods = [
499 "STRICT",
500 "STRICT_WITH_SIZE",
501 "FIFO",
502 "LIFO",
503 "HIFO",
504 "AVERAGE",
505 "NONE",
506 ];
507 if !valid_methods.contains(&value.to_uppercase().as_str()) {
508 self.warnings.push(OptionWarning {
509 code: "E7002",
510 message: format!(
511 "Invalid value \"{}\" for option \"{}\": expected one of {}",
512 value,
513 key,
514 valid_methods.join(", ")
515 ),
516 option: key.to_string(),
517 value: value.to_string(),
518 });
519 }
520 self.booking_method = value.to_string();
521 }
522 "render_commas" => {
523 let parsed = rustledger_core::parse_bool_word(value);
527 let is_true = parsed == Some(true);
528 if parsed.is_none() {
529 self.warnings.push(OptionWarning {
530 code: "E7002",
531 message: format!(
532 "Invalid value \"{value}\" for option \"{key}\": expected TRUE, FALSE, 1 or 0"
533 ),
534 option: key.to_string(),
535 value: value.to_string(),
536 });
537 }
538 self.render_commas = is_true;
539 }
540 "display_precision" => {
541 if let Some((curr, example)) = value.split_once(':') {
545 if let Ok(d) = Decimal::from_str(example) {
546 let precision = d.scale();
548 self.display_precision.insert(curr.to_string(), precision);
549 } else {
550 self.warnings.push(OptionWarning {
551 code: "E7002",
552 message: format!(
553 "Invalid precision value \"{example}\" in option \"{key}\""
554 ),
555 option: key.to_string(),
556 value: value.to_string(),
557 });
558 }
559 } else {
560 self.warnings.push(OptionWarning {
561 code: "E7002",
562 message: format!(
563 "Invalid format for option \"{key}\": expected CURRENCY:EXAMPLE (e.g., CHF:0.01)"
564 ),
565 option: key.to_string(),
566 value: value.to_string(),
567 });
568 }
569 }
570 "filename" => self.filename = Some(value.to_string()),
571 "account_previous_balances" => {
572 if !Self::is_valid_account(value) {
573 self.warnings.push(OptionWarning {
574 code: "E7002",
575 message: format!("Invalid leaf account name: '{value}'"),
576 option: key.to_string(),
577 value: value.to_string(),
578 });
579 }
580 self.account_previous_balances = value.to_string();
581 }
582 "account_previous_earnings" => {
583 if !Self::is_valid_account(value) {
584 self.warnings.push(OptionWarning {
585 code: "E7002",
586 message: format!("Invalid leaf account name: '{value}'"),
587 option: key.to_string(),
588 value: value.to_string(),
589 });
590 }
591 self.account_previous_earnings = value.to_string();
592 }
593 "account_previous_conversions" => {
594 if !Self::is_valid_account(value) {
595 self.warnings.push(OptionWarning {
596 code: "E7002",
597 message: format!("Invalid leaf account name: '{value}'"),
598 option: key.to_string(),
599 value: value.to_string(),
600 });
601 }
602 self.account_previous_conversions = value.to_string();
603 }
604 "account_current_earnings" => {
605 if !Self::is_valid_account(value) {
606 self.warnings.push(OptionWarning {
607 code: "E7002",
608 message: format!("Invalid leaf account name: '{value}'"),
609 option: key.to_string(),
610 value: value.to_string(),
611 });
612 }
613 self.account_current_earnings = value.to_string();
614 }
615 "conversion_currency" => self.conversion_currency = Some(value.to_string()),
616 "inferred_tolerance_default" => {
617 if let Some((curr, tol)) = value.split_once(':') {
619 if let Ok(d) = Decimal::from_str(tol) {
620 self.inferred_tolerance_default.insert(curr.to_string(), d);
621 } else {
622 self.warnings.push(OptionWarning {
623 code: "E7002",
624 message: format!(
625 "Invalid tolerance value \"{tol}\" in option \"{key}\""
626 ),
627 option: key.to_string(),
628 value: value.to_string(),
629 });
630 }
631 } else {
632 self.warnings.push(OptionWarning {
633 code: "E7002",
634 message: format!(
635 "Invalid format for option \"{key}\": expected CURRENCY:TOLERANCE"
636 ),
637 option: key.to_string(),
638 value: value.to_string(),
639 });
640 }
641 }
642 "use_legacy_fixed_tolerances" => {
643 self.use_legacy_fixed_tolerances = value.eq_ignore_ascii_case("true");
644 }
645 "experiment_explicit_tolerances" => {
646 self.experiment_explicit_tolerances = value.eq_ignore_ascii_case("true");
647 }
648 "use_precise_interpolation" => {
649 self.use_precise_interpolation = value.eq_ignore_ascii_case("true");
653 }
654 "allow_pipe_separator" => {
655 self.warn_if_deprecated(key, value);
657 self.allow_pipe_separator = value.eq_ignore_ascii_case("true");
658 }
659 "long_string_maxlines" => {
660 if let Ok(n) = value.parse::<u32>() {
661 self.long_string_maxlines = n;
662 } else {
663 self.warnings.push(OptionWarning {
664 code: "E7002",
665 message: format!(
666 "Invalid value \"{value}\" for option \"{key}\": expected integer"
667 ),
668 option: key.to_string(),
669 value: value.to_string(),
670 });
671 }
672 }
673 "documents" => {
674 self.documents.push(value.to_string());
686 }
687 "plugin_processing_mode" => {
688 if value != "default" && value != "raw" {
690 self.warnings.push(OptionWarning {
691 code: "E7002",
692 message: format!("Invalid value '{value}'"),
693 option: key.to_string(),
694 value: value.to_string(),
695 });
696 }
697 self.plugin_processing_mode = value.to_string();
698 }
699 "plugin" => {
700 self.warn_if_deprecated(key, value);
702 }
703 _ => {
704 self.custom.insert(key.to_string(), value.to_string());
706 }
707 }
708 }
709
710 #[must_use]
712 pub fn get(&self, key: &str) -> Option<&str> {
713 self.custom.get(key).map(String::as_str)
714 }
715
716 #[must_use]
720 pub fn to_account_types(&self) -> rustledger_core::AccountTypes {
721 rustledger_core::AccountTypes {
722 assets: self.name_assets.clone(),
723 liabilities: self.name_liabilities.clone(),
724 equity: self.name_equity.clone(),
725 income: self.name_income.clone(),
726 expenses: self.name_expenses.clone(),
727 }
728 }
729
730 #[must_use]
732 pub fn account_types(&self) -> [&str; 5] {
733 [
734 &self.name_assets,
735 &self.name_liabilities,
736 &self.name_equity,
737 &self.name_income,
738 &self.name_expenses,
739 ]
740 }
741
742 fn warn_if_invalid_root(&mut self, key: &str, value: &str) {
751 if !Self::is_valid_account_root(value) {
752 self.warnings.push(OptionWarning {
753 code: "E7008",
754 message: format!(
755 "Invalid account type name: '{value}' cannot begin an \
756 account name (accounts under it will never parse)"
757 ),
758 option: key.to_string(),
759 value: value.to_string(),
760 });
761 }
762 }
763
764 fn is_valid_account(value: &str) -> bool {
772 rustledger_parser::is_valid_account_name(value)
773 }
774
775 fn is_valid_account_root(value: &str) -> bool {
780 !value.contains(':') && rustledger_parser::is_valid_account_name(&format!("{value}:X"))
781 }
782}
783
784#[cfg(test)]
785mod tests {
786 use super::*;
787
788 #[test]
789 fn test_default_options() {
790 let opts = Options::new();
791 assert_eq!(opts.name_assets, "Assets");
792 assert_eq!(opts.booking_method, "STRICT");
793 assert!(!opts.infer_tolerance_from_cost);
794 }
795
796 #[test]
797 fn test_set_options() {
798 let mut opts = Options::new();
799 opts.set("title", "My Ledger");
800 opts.set("operating_currency", "USD");
801 opts.set("operating_currency", "EUR");
802 opts.set("booking_method", "FIFO");
803
804 assert_eq!(opts.title, Some("My Ledger".to_string()));
805 assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
806 assert_eq!(opts.booking_method, "FIFO");
807 }
808
809 #[test]
810 fn test_custom_options() {
811 let mut opts = Options::new();
812 opts.set("my_custom_option", "my_value");
813
814 assert_eq!(opts.get("my_custom_option"), Some("my_value"));
815 assert_eq!(opts.get("nonexistent"), None);
816 }
817
818 #[test]
819 fn test_unknown_option_warning() {
820 let mut opts = Options::new();
821 opts.set("unknown_option", "value");
822
823 assert_eq!(opts.warnings.len(), 1);
824 assert_eq!(opts.warnings[0].code, "E7001");
825 assert!(opts.warnings[0].message.contains("Invalid option"));
826 }
827
828 #[test]
831 fn test_use_precise_interpolation_accepted() {
832 let mut opts = Options::new();
833 opts.set("use_precise_interpolation", "TRUE");
834
835 assert!(
836 opts.warnings.is_empty(),
837 "should not warn on a known option: {:?}",
838 opts.warnings
839 );
840 assert!(opts.use_precise_interpolation);
841 }
842
843 #[test]
844 fn test_duplicate_option_warning() {
845 let mut opts = Options::new();
846 opts.set("title", "First Title");
847 opts.set("title", "Second Title");
848
849 assert_eq!(opts.warnings.len(), 1);
850 assert_eq!(opts.warnings[0].code, "E7003");
851 assert!(
857 opts.warnings[0].message.contains("the last value wins"),
858 "got: {}",
859 opts.warnings[0].message,
860 );
861 assert_eq!(opts.title.as_deref(), Some("Second Title"));
863 }
864
865 #[test]
873 fn included_files_do_not_govern_scoped_options() {
874 let mut opts = Options::new();
875 opts.set_scoped("title", "Master", true);
876 opts.set_scoped("booking_method", "LIFO", false);
877 opts.set_scoped("title", "Sub-ledger", false);
878
879 assert_eq!(
880 opts.title.as_deref(),
881 Some("Master"),
882 "the top-level ledger names the combined result, not whichever \
883 sub-ledger was included last",
884 );
885 assert!(
886 !opts.set_options.contains("booking_method"),
887 "an included booking_method must not reach the booker",
888 );
889 assert_eq!(opts.warnings.len(), 2, "each ignored option is reported");
890 assert!(
891 opts.warnings.iter().all(|w| w.code == "E7009"),
892 "must not reuse E7003: that one means specified-twice-last-wins and \
893 maps downstream to DuplicateOption, but an ignored option may be \
894 the only one of its name in the tree",
895 );
896 assert!(
897 opts.warnings
898 .iter()
899 .all(|w| w.message.contains("is ignored")),
900 "the warning has to say the value was dropped, or the user cannot \
901 tell why their setting had no effect",
902 );
903 }
904
905 #[test]
912 fn scoping_out_an_option_still_reports_its_deprecation() {
913 let mut opts = Options::new();
914 opts.set_scoped("plugin", "some.module", false);
915
916 let codes: Vec<&str> = opts.warnings.iter().map(|w| w.code).collect();
917 assert!(
918 codes.contains(&"E7004"),
919 "deprecation survives scoping: {codes:?}"
920 );
921 assert!(
922 codes.contains(&"E7009"),
923 "and the ignore is still reported: {codes:?}"
924 );
925 assert!(
926 opts.warnings
927 .iter()
928 .any(|w| w.code == "E7004" && w.message.contains("deprecated")),
929 "the message must come from the shared table, not an empty default",
930 );
931 }
932
933 #[test]
939 fn included_files_still_contribute_accumulating_options() {
940 let mut opts = Options::new();
941 opts.set_scoped("operating_currency", "USD", true);
942 opts.set_scoped("operating_currency", "EUR", false);
943 opts.set_scoped("documents", "docs-from-include", false);
944
945 assert!(
946 opts.operating_currency.iter().any(|c| c == "EUR"),
947 "an included operating_currency must still be collected",
948 );
949 assert!(
950 opts.documents.iter().any(|d| d == "docs-from-include"),
951 "an included documents root must still be collected",
952 );
953 }
954
955 #[test]
956 fn test_repeatable_option_no_warning() {
957 let mut opts = Options::new();
958 opts.set("operating_currency", "USD");
959 opts.set("operating_currency", "EUR");
960
961 assert!(
963 opts.warnings.is_empty(),
964 "Should not warn for repeatable options: {:?}",
965 opts.warnings
966 );
967 assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
968 }
969
970 #[test]
971 fn test_invalid_tolerance_value() {
972 let mut opts = Options::new();
973 opts.set("inferred_tolerance_multiplier", "not_a_number");
974
975 assert_eq!(opts.warnings.len(), 2);
977 assert_eq!(opts.warnings[0].code, "E7004");
978 assert!(opts.warnings[0].message.contains("Renamed"));
979 assert_eq!(opts.warnings[1].code, "E7002");
980 assert!(opts.warnings[1].message.contains("expected decimal"));
981 }
982
983 #[test]
984 fn test_tolerance_multiplier_new_name() {
985 let mut opts = Options::new();
986 opts.set("tolerance_multiplier", "1.5");
987
988 assert!(opts.warnings.is_empty());
989 assert_eq!(opts.inferred_tolerance_multiplier, Decimal::new(15, 1));
990 }
991
992 #[test]
993 fn test_inferred_tolerance_multiplier_deprecated() {
994 let mut opts = Options::new();
995 opts.set("inferred_tolerance_multiplier", "1.01");
996
997 assert_eq!(opts.warnings.len(), 1);
998 assert_eq!(opts.warnings[0].code, "E7004");
999 assert!(
1000 opts.warnings[0]
1001 .message
1002 .contains("Renamed to 'tolerance_multiplier'")
1003 );
1004 assert_eq!(
1005 opts.inferred_tolerance_multiplier,
1006 Decimal::from_str("1.01").unwrap()
1007 );
1008 }
1009
1010 #[test]
1011 fn test_invalid_boolean_value() {
1012 let mut opts = Options::new();
1013 opts.set("infer_tolerance_from_cost", "maybe");
1014
1015 assert_eq!(opts.warnings.len(), 1);
1016 assert_eq!(opts.warnings[0].code, "E7002");
1017 assert!(
1018 opts.warnings[0].message.contains("TRUE, FALSE, 1 or 0"),
1019 "the message must name the vocabulary actually accepted: {}",
1020 opts.warnings[0].message
1021 );
1022 }
1023
1024 #[test]
1033 fn boolean_options_share_one_vocabulary() {
1034 for key in ["infer_tolerance_from_cost", "render_commas"] {
1035 for (value, expected) in [
1036 ("TRUE", true),
1037 ("true", true),
1038 ("1", true),
1039 ("FALSE", false),
1040 ("false", false),
1041 ("0", false),
1042 ] {
1043 let mut opts = Options::new();
1044 opts.set(key, value);
1045 assert!(
1046 opts.warnings.is_empty(),
1047 "{key} = {value:?} must be accepted without a warning: {:?}",
1048 opts.warnings
1049 );
1050 let actual = if key == "render_commas" {
1051 opts.render_commas
1052 } else {
1053 opts.infer_tolerance_from_cost
1054 };
1055 assert_eq!(actual, expected, "{key} = {value:?}");
1056 }
1057
1058 let mut opts = Options::new();
1059 opts.set(key, "yes");
1060 assert_eq!(
1061 opts.warnings.len(),
1062 1,
1063 "{key}: `yes` is outside the shared vocabulary and must warn"
1064 );
1065 }
1066 }
1067
1068 #[test]
1069 fn test_invalid_booking_method() {
1070 let mut opts = Options::new();
1071 opts.set("booking_method", "RANDOM");
1072
1073 assert_eq!(opts.warnings.len(), 1);
1074 assert_eq!(opts.warnings[0].code, "E7002");
1075 assert!(opts.warnings[0].message.contains("STRICT"));
1076 }
1077
1078 #[test]
1079 fn test_valid_booking_methods() {
1080 for method in &["STRICT", "FIFO", "LIFO", "AVERAGE", "NONE"] {
1081 let mut opts = Options::new();
1082 opts.set("booking_method", method);
1083 assert!(
1084 opts.warnings.is_empty(),
1085 "Should accept {method} as valid booking method"
1086 );
1087 }
1088 }
1089
1090 #[test]
1091 fn test_readonly_option_warning() {
1092 let mut opts = Options::new();
1093 opts.set("filename", "/some/path.beancount");
1094
1095 assert_eq!(opts.warnings.len(), 1);
1096 assert_eq!(opts.warnings[0].code, "E7005");
1097 assert!(opts.warnings[0].message.contains("may not be set"));
1098 }
1099
1100 #[test]
1101 fn test_account_rounding_accepted_but_warns_noop() {
1102 let mut opts = Options::new();
1103 opts.set("account_rounding", "Equity:Rounding");
1104
1105 assert_eq!(opts.account_rounding.as_deref(), Some("Equity:Rounding"));
1107 let w = opts
1109 .warnings
1110 .iter()
1111 .find(|w| w.code == "E7007")
1112 .expect("expected an E7007 no-op warning for account_rounding");
1113 assert!(w.message.contains("no effect"));
1114 assert_eq!(w.option, "account_rounding");
1115 assert!(!opts.warnings.iter().any(|w| w.code == "E7002"));
1117 }
1118
1119 #[test]
1120 fn test_invalid_account_name_validation() {
1121 let mut opts = Options::new();
1124 opts.set("account_rounding", "invalid");
1125
1126 assert!(
1127 opts.warnings
1128 .iter()
1129 .any(|w| w.code == "E7002" && w.message.contains("Invalid leaf account"))
1130 );
1131 assert!(opts.warnings.iter().any(|w| w.code == "E7007"));
1132 }
1133
1134 #[test]
1135 fn test_valid_account_name() {
1136 let mut opts = Options::new();
1137 opts.set("account_rounding", "Equity:Rounding");
1138
1139 assert!(!opts.warnings.iter().any(|w| w.code == "E7002"));
1142 assert!(opts.warnings.iter().any(|w| w.code == "E7007"));
1143 assert_eq!(opts.account_rounding, Some("Equity:Rounding".to_string()));
1144 }
1145
1146 #[test]
1147 fn test_render_commas_with_numeric_values() {
1148 let mut opts = Options::new();
1149 opts.set("render_commas", "1");
1150 assert!(opts.render_commas);
1151 assert!(opts.warnings.is_empty());
1152
1153 let mut opts2 = Options::new();
1154 opts2.set("render_commas", "0");
1155 assert!(!opts2.render_commas);
1156 assert!(opts2.warnings.is_empty());
1157 }
1158
1159 #[test]
1160 fn test_plugin_processing_mode_validation() {
1161 let mut opts = Options::new();
1163 opts.set("plugin_processing_mode", "default");
1164 assert!(opts.warnings.is_empty());
1165 assert_eq!(opts.plugin_processing_mode, "default");
1166
1167 let mut opts2 = Options::new();
1168 opts2.set("plugin_processing_mode", "raw");
1169 assert!(opts2.warnings.is_empty());
1170 assert_eq!(opts2.plugin_processing_mode, "raw");
1171
1172 let mut opts3 = Options::new();
1174 opts3.set("plugin_processing_mode", "invalid");
1175 assert_eq!(opts3.warnings.len(), 1);
1176 assert_eq!(opts3.warnings[0].code, "E7002");
1177 }
1178
1179 #[test]
1180 fn test_deprecated_plugin_option() {
1181 let mut opts = Options::new();
1182 opts.set("plugin", "some.plugin");
1183
1184 assert_eq!(opts.warnings.len(), 1);
1185 assert_eq!(opts.warnings[0].code, "E7004");
1186 assert!(opts.warnings[0].message.contains("deprecated"));
1187 }
1188
1189 #[test]
1190 fn test_deprecated_allow_pipe_separator() {
1191 let mut opts = Options::new();
1192 opts.set("allow_pipe_separator", "true");
1193
1194 assert_eq!(opts.warnings.len(), 1);
1195 assert_eq!(opts.warnings[0].code, "E7004");
1196 assert!(opts.warnings[0].message.contains("deprecated"));
1197 }
1198
1199 #[test]
1200 fn test_is_valid_account() {
1201 assert!(Options::is_valid_account("Assets:Bank"));
1203 assert!(Options::is_valid_account("Equity:Rounding:Precision"));
1204
1205 assert!(Options::is_valid_account("Капитал:Retained"));
1207 assert!(Options::is_valid_account("资产:银行:支票"));
1208
1209 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")); }
1215
1216 #[test]
1217 fn test_account_validation_options() {
1218 let account_options = [
1220 "account_rounding",
1221 "account_current_conversions",
1222 "account_unrealized_gains",
1223 "account_previous_balances",
1224 "account_previous_earnings",
1225 "account_previous_conversions",
1226 "account_current_earnings",
1227 ];
1228
1229 for opt in account_options {
1230 let mut opts = Options::new();
1231 opts.set(opt, "lowercase:invalid");
1232
1233 assert!(
1234 !opts.warnings.is_empty(),
1235 "Option '{opt}' should warn on invalid account name"
1236 );
1237 assert_eq!(opts.warnings[0].code, "E7002");
1238 }
1239 }
1240
1241 #[test]
1242 fn test_inferred_tolerance_default() {
1243 let mut opts = Options::new();
1244 opts.set("inferred_tolerance_default", "USD:0.005");
1245
1246 assert!(opts.warnings.is_empty());
1247 assert_eq!(
1248 opts.inferred_tolerance_default.get("USD"),
1249 Some(&rust_decimal_macros::dec!(0.005))
1250 );
1251
1252 let mut opts2 = Options::new();
1254 opts2.set("inferred_tolerance_default", "*:0.01");
1255 assert!(opts2.warnings.is_empty());
1256 assert_eq!(
1257 opts2.inferred_tolerance_default.get("*"),
1258 Some(&rust_decimal_macros::dec!(0.01))
1259 );
1260
1261 let mut opts3 = Options::new();
1263 opts3.set("inferred_tolerance_default", "INVALID");
1264 assert_eq!(opts3.warnings.len(), 1);
1265 assert_eq!(opts3.warnings[0].code, "E7002");
1266 }
1267
1268 #[test]
1269 fn test_display_precision_basic() {
1270 let mut opts = Options::new();
1271 opts.set("display_precision", "USD:0.01");
1272 assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
1273 assert_eq!(opts.display_precision.get("USD"), Some(&2));
1274 }
1275
1276 #[test]
1277 fn test_display_precision_high_precision() {
1278 let mut opts = Options::new();
1279 opts.set("display_precision", "BTC:0.00000001");
1280 assert!(opts.warnings.is_empty());
1281 assert_eq!(opts.display_precision.get("BTC"), Some(&8));
1282 }
1283
1284 #[test]
1285 fn test_display_precision_zero_decimals() {
1286 let mut opts = Options::new();
1288 opts.set("display_precision", "JPY:1");
1289 assert!(opts.warnings.is_empty());
1290 assert_eq!(opts.display_precision.get("JPY"), Some(&0));
1291 }
1292
1293 #[test]
1294 fn test_display_precision_repeatable_per_currency() {
1295 let mut opts = Options::new();
1296 opts.set("display_precision", "USD:0.01");
1297 opts.set("display_precision", "EUR:0.001");
1298 assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
1299 assert_eq!(opts.display_precision.get("USD"), Some(&2));
1300 assert_eq!(opts.display_precision.get("EUR"), Some(&3));
1301 }
1302
1303 #[test]
1304 fn test_display_precision_missing_colon_warns() {
1305 let mut opts = Options::new();
1306 opts.set("display_precision", "USD0.01");
1307 assert_eq!(opts.warnings.len(), 1);
1308 assert_eq!(opts.warnings[0].code, "E7002");
1309 assert!(opts.warnings[0].message.contains("CURRENCY:EXAMPLE"));
1310 assert!(opts.display_precision.is_empty());
1311 }
1312
1313 #[test]
1314 fn test_name_option_invalid_root_warns_e7008() {
1315 let mut opts = Options::new();
1319 opts.set("name_assets", "1Assets");
1320 assert_eq!(opts.warnings.len(), 1);
1321 assert_eq!(opts.warnings[0].code, "E7008");
1322 assert!(opts.warnings[0].message.contains("1Assets"));
1323 assert_eq!(opts.name_assets, "1Assets");
1325
1326 let mut opts = Options::new();
1328 opts.set("name_income", "In:Come");
1329 assert_eq!(opts.warnings.len(), 1);
1330 assert_eq!(opts.warnings[0].code, "E7008");
1331 }
1332
1333 #[test]
1334 fn test_name_option_valid_roots_no_warning() {
1335 let mut opts = Options::new();
1336 opts.set("name_income", "Revenue");
1337 opts.set("name_assets", "Activa");
1338 opts.set("name_expenses", "Ausgaben");
1339 opts.set("name_liabilities", "負債"); assert!(
1341 opts.warnings.is_empty(),
1342 "lexable renames must not warn: {:?}",
1343 opts.warnings
1344 );
1345 }
1346
1347 #[test]
1348 fn test_account_option_uses_canonical_rule() {
1349 let mut opts = Options::new();
1354 opts.set("account_current_conversions", "Equity:Conv ersions");
1355 assert!(opts.warnings.iter().any(|w| w.code == "E7002"));
1356
1357 let mut opts = Options::new();
1358 opts.set("account_current_conversions", "Equity:Conversions:Current");
1359 assert!(opts.warnings.is_empty(), "{:?}", opts.warnings);
1360 }
1361
1362 #[test]
1363 fn test_display_precision_invalid_example_warns() {
1364 let mut opts = Options::new();
1365 opts.set("display_precision", "USD:abc");
1366 assert_eq!(opts.warnings.len(), 1);
1367 assert_eq!(opts.warnings[0].code, "E7002");
1368 assert!(opts.warnings[0].message.contains("Invalid precision"));
1369 assert!(opts.display_precision.is_empty());
1370 }
1371}