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" => {
273 self.warn_if_invalid_root("name_assets", value);
274 self.name_assets = value.to_string();
275 }
276 "name_liabilities" => {
277 self.warn_if_invalid_root("name_liabilities", value);
278 self.name_liabilities = value.to_string();
279 }
280 "name_equity" => {
281 self.warn_if_invalid_root("name_equity", value);
282 self.name_equity = value.to_string();
283 }
284 "name_income" => {
285 self.warn_if_invalid_root("name_income", value);
286 self.name_income = value.to_string();
287 }
288 "name_expenses" => {
289 self.warn_if_invalid_root("name_expenses", value);
290 self.name_expenses = value.to_string();
291 }
292 "account_rounding" => {
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.warnings.push(OptionWarning {
309 code: "E7007",
310 message: "Option 'account_rounding' is accepted for compatibility \
311 but has no effect: rustledger preserves full precision \
312 during interpolation rather than rounding into a rounding \
313 account, so no rounding residual is produced."
314 .to_string(),
315 option: key.to_string(),
316 value: value.to_string(),
317 });
318 self.account_rounding = Some(value.to_string());
319 }
320 "account_current_conversions" => {
321 if !Self::is_valid_account(value) {
322 self.warnings.push(OptionWarning {
323 code: "E7002",
324 message: format!("Invalid leaf account name: '{value}'"),
325 option: key.to_string(),
326 value: value.to_string(),
327 });
328 }
329 self.account_current_conversions = Some(value.to_string());
330 }
331 "account_unrealized_gains" => {
332 if !Self::is_valid_account(value) {
333 self.warnings.push(OptionWarning {
334 code: "E7002",
335 message: format!("Invalid leaf account name: '{value}'"),
336 option: key.to_string(),
337 value: value.to_string(),
338 });
339 }
340 self.account_unrealized_gains = Some(value.to_string());
341 }
342 "inferred_tolerance_multiplier" => {
343 self.warnings.push(OptionWarning {
345 code: "E7004",
346 message: "Renamed to 'tolerance_multiplier'.".to_string(),
347 option: key.to_string(),
348 value: value.to_string(),
349 });
350 if let Ok(d) = Decimal::from_str(value) {
351 self.inferred_tolerance_multiplier = d;
352 } else {
353 self.warnings.push(OptionWarning {
355 code: "E7002",
356 message: format!(
357 "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
358 ),
359 option: key.to_string(),
360 value: value.to_string(),
361 });
362 }
363 }
364 "tolerance_multiplier" => {
365 if let Ok(d) = Decimal::from_str(value) {
366 self.inferred_tolerance_multiplier = d;
367 } else {
368 self.warnings.push(OptionWarning {
369 code: "E7002",
370 message: format!(
371 "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
372 ),
373 option: key.to_string(),
374 value: value.to_string(),
375 });
376 }
377 }
378 "infer_tolerance_from_cost" => {
379 let parsed = rustledger_core::parse_bool_word(value);
384 if parsed.is_none() {
385 self.warnings.push(OptionWarning {
386 code: "E7002",
387 message: format!(
388 "Invalid value \"{value}\" for option \"{key}\": expected TRUE, FALSE, 1 or 0"
389 ),
390 option: key.to_string(),
391 value: value.to_string(),
392 });
393 }
394 self.infer_tolerance_from_cost = parsed == Some(true);
395 }
396 "booking_method" => {
397 let valid_methods = [
398 "STRICT",
399 "STRICT_WITH_SIZE",
400 "FIFO",
401 "LIFO",
402 "HIFO",
403 "AVERAGE",
404 "NONE",
405 ];
406 if !valid_methods.contains(&value.to_uppercase().as_str()) {
407 self.warnings.push(OptionWarning {
408 code: "E7002",
409 message: format!(
410 "Invalid value \"{}\" for option \"{}\": expected one of {}",
411 value,
412 key,
413 valid_methods.join(", ")
414 ),
415 option: key.to_string(),
416 value: value.to_string(),
417 });
418 }
419 self.booking_method = value.to_string();
420 }
421 "render_commas" => {
422 let parsed = rustledger_core::parse_bool_word(value);
426 let is_true = parsed == Some(true);
427 if parsed.is_none() {
428 self.warnings.push(OptionWarning {
429 code: "E7002",
430 message: format!(
431 "Invalid value \"{value}\" for option \"{key}\": expected TRUE, FALSE, 1 or 0"
432 ),
433 option: key.to_string(),
434 value: value.to_string(),
435 });
436 }
437 self.render_commas = is_true;
438 }
439 "display_precision" => {
440 if let Some((curr, example)) = value.split_once(':') {
444 if let Ok(d) = Decimal::from_str(example) {
445 let precision = d.scale();
447 self.display_precision.insert(curr.to_string(), precision);
448 } else {
449 self.warnings.push(OptionWarning {
450 code: "E7002",
451 message: format!(
452 "Invalid precision value \"{example}\" in option \"{key}\""
453 ),
454 option: key.to_string(),
455 value: value.to_string(),
456 });
457 }
458 } else {
459 self.warnings.push(OptionWarning {
460 code: "E7002",
461 message: format!(
462 "Invalid format for option \"{key}\": expected CURRENCY:EXAMPLE (e.g., CHF:0.01)"
463 ),
464 option: key.to_string(),
465 value: value.to_string(),
466 });
467 }
468 }
469 "filename" => self.filename = Some(value.to_string()),
470 "account_previous_balances" => {
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_balances = value.to_string();
480 }
481 "account_previous_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_previous_earnings = value.to_string();
491 }
492 "account_previous_conversions" => {
493 if !Self::is_valid_account(value) {
494 self.warnings.push(OptionWarning {
495 code: "E7002",
496 message: format!("Invalid leaf account name: '{value}'"),
497 option: key.to_string(),
498 value: value.to_string(),
499 });
500 }
501 self.account_previous_conversions = value.to_string();
502 }
503 "account_current_earnings" => {
504 if !Self::is_valid_account(value) {
505 self.warnings.push(OptionWarning {
506 code: "E7002",
507 message: format!("Invalid leaf account name: '{value}'"),
508 option: key.to_string(),
509 value: value.to_string(),
510 });
511 }
512 self.account_current_earnings = value.to_string();
513 }
514 "conversion_currency" => self.conversion_currency = Some(value.to_string()),
515 "inferred_tolerance_default" => {
516 if let Some((curr, tol)) = value.split_once(':') {
518 if let Ok(d) = Decimal::from_str(tol) {
519 self.inferred_tolerance_default.insert(curr.to_string(), d);
520 } else {
521 self.warnings.push(OptionWarning {
522 code: "E7002",
523 message: format!(
524 "Invalid tolerance value \"{tol}\" in option \"{key}\""
525 ),
526 option: key.to_string(),
527 value: value.to_string(),
528 });
529 }
530 } else {
531 self.warnings.push(OptionWarning {
532 code: "E7002",
533 message: format!(
534 "Invalid format for option \"{key}\": expected CURRENCY:TOLERANCE"
535 ),
536 option: key.to_string(),
537 value: value.to_string(),
538 });
539 }
540 }
541 "use_legacy_fixed_tolerances" => {
542 self.use_legacy_fixed_tolerances = value.eq_ignore_ascii_case("true");
543 }
544 "experiment_explicit_tolerances" => {
545 self.experiment_explicit_tolerances = value.eq_ignore_ascii_case("true");
546 }
547 "use_precise_interpolation" => {
548 self.use_precise_interpolation = value.eq_ignore_ascii_case("true");
552 }
553 "allow_pipe_separator" => {
554 self.warnings.push(OptionWarning {
556 code: "E7004",
557 message: "Option 'allow_pipe_separator' is deprecated".to_string(),
558 option: key.to_string(),
559 value: value.to_string(),
560 });
561 self.allow_pipe_separator = value.eq_ignore_ascii_case("true");
562 }
563 "long_string_maxlines" => {
564 if let Ok(n) = value.parse::<u32>() {
565 self.long_string_maxlines = n;
566 } else {
567 self.warnings.push(OptionWarning {
568 code: "E7002",
569 message: format!(
570 "Invalid value \"{value}\" for option \"{key}\": expected integer"
571 ),
572 option: key.to_string(),
573 value: value.to_string(),
574 });
575 }
576 }
577 "documents" => {
578 self.documents.push(value.to_string());
590 }
591 "plugin_processing_mode" => {
592 if value != "default" && value != "raw" {
594 self.warnings.push(OptionWarning {
595 code: "E7002",
596 message: format!("Invalid value '{value}'"),
597 option: key.to_string(),
598 value: value.to_string(),
599 });
600 }
601 self.plugin_processing_mode = value.to_string();
602 }
603 "plugin" => {
604 self.warnings.push(OptionWarning {
606 code: "E7004",
607 message: "Option 'plugin' is deprecated; use the 'plugin' directive instead"
608 .to_string(),
609 option: key.to_string(),
610 value: value.to_string(),
611 });
612 }
613 _ => {
614 self.custom.insert(key.to_string(), value.to_string());
616 }
617 }
618 }
619
620 #[must_use]
622 pub fn get(&self, key: &str) -> Option<&str> {
623 self.custom.get(key).map(String::as_str)
624 }
625
626 #[must_use]
630 pub fn to_account_types(&self) -> rustledger_core::AccountTypes {
631 rustledger_core::AccountTypes {
632 assets: self.name_assets.clone(),
633 liabilities: self.name_liabilities.clone(),
634 equity: self.name_equity.clone(),
635 income: self.name_income.clone(),
636 expenses: self.name_expenses.clone(),
637 }
638 }
639
640 #[must_use]
642 pub fn account_types(&self) -> [&str; 5] {
643 [
644 &self.name_assets,
645 &self.name_liabilities,
646 &self.name_equity,
647 &self.name_income,
648 &self.name_expenses,
649 ]
650 }
651
652 fn warn_if_invalid_root(&mut self, key: &str, value: &str) {
661 if !Self::is_valid_account_root(value) {
662 self.warnings.push(OptionWarning {
663 code: "E7008",
664 message: format!(
665 "Invalid account type name: '{value}' cannot begin an \
666 account name (accounts under it will never parse)"
667 ),
668 option: key.to_string(),
669 value: value.to_string(),
670 });
671 }
672 }
673
674 fn is_valid_account(value: &str) -> bool {
682 rustledger_parser::is_valid_account_name(value)
683 }
684
685 fn is_valid_account_root(value: &str) -> bool {
690 !value.contains(':') && rustledger_parser::is_valid_account_name(&format!("{value}:X"))
691 }
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697
698 #[test]
699 fn test_default_options() {
700 let opts = Options::new();
701 assert_eq!(opts.name_assets, "Assets");
702 assert_eq!(opts.booking_method, "STRICT");
703 assert!(!opts.infer_tolerance_from_cost);
704 }
705
706 #[test]
707 fn test_set_options() {
708 let mut opts = Options::new();
709 opts.set("title", "My Ledger");
710 opts.set("operating_currency", "USD");
711 opts.set("operating_currency", "EUR");
712 opts.set("booking_method", "FIFO");
713
714 assert_eq!(opts.title, Some("My Ledger".to_string()));
715 assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
716 assert_eq!(opts.booking_method, "FIFO");
717 }
718
719 #[test]
720 fn test_custom_options() {
721 let mut opts = Options::new();
722 opts.set("my_custom_option", "my_value");
723
724 assert_eq!(opts.get("my_custom_option"), Some("my_value"));
725 assert_eq!(opts.get("nonexistent"), None);
726 }
727
728 #[test]
729 fn test_unknown_option_warning() {
730 let mut opts = Options::new();
731 opts.set("unknown_option", "value");
732
733 assert_eq!(opts.warnings.len(), 1);
734 assert_eq!(opts.warnings[0].code, "E7001");
735 assert!(opts.warnings[0].message.contains("Invalid option"));
736 }
737
738 #[test]
741 fn test_use_precise_interpolation_accepted() {
742 let mut opts = Options::new();
743 opts.set("use_precise_interpolation", "TRUE");
744
745 assert!(
746 opts.warnings.is_empty(),
747 "should not warn on a known option: {:?}",
748 opts.warnings
749 );
750 assert!(opts.use_precise_interpolation);
751 }
752
753 #[test]
754 fn test_duplicate_option_warning() {
755 let mut opts = Options::new();
756 opts.set("title", "First Title");
757 opts.set("title", "Second Title");
758
759 assert_eq!(opts.warnings.len(), 1);
760 assert_eq!(opts.warnings[0].code, "E7003");
761 assert!(opts.warnings[0].message.contains("only be specified once"));
762 }
763
764 #[test]
765 fn test_repeatable_option_no_warning() {
766 let mut opts = Options::new();
767 opts.set("operating_currency", "USD");
768 opts.set("operating_currency", "EUR");
769
770 assert!(
772 opts.warnings.is_empty(),
773 "Should not warn for repeatable options: {:?}",
774 opts.warnings
775 );
776 assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
777 }
778
779 #[test]
780 fn test_invalid_tolerance_value() {
781 let mut opts = Options::new();
782 opts.set("inferred_tolerance_multiplier", "not_a_number");
783
784 assert_eq!(opts.warnings.len(), 2);
786 assert_eq!(opts.warnings[0].code, "E7004");
787 assert!(opts.warnings[0].message.contains("Renamed"));
788 assert_eq!(opts.warnings[1].code, "E7002");
789 assert!(opts.warnings[1].message.contains("expected decimal"));
790 }
791
792 #[test]
793 fn test_tolerance_multiplier_new_name() {
794 let mut opts = Options::new();
795 opts.set("tolerance_multiplier", "1.5");
796
797 assert!(opts.warnings.is_empty());
798 assert_eq!(opts.inferred_tolerance_multiplier, Decimal::new(15, 1));
799 }
800
801 #[test]
802 fn test_inferred_tolerance_multiplier_deprecated() {
803 let mut opts = Options::new();
804 opts.set("inferred_tolerance_multiplier", "1.01");
805
806 assert_eq!(opts.warnings.len(), 1);
807 assert_eq!(opts.warnings[0].code, "E7004");
808 assert!(
809 opts.warnings[0]
810 .message
811 .contains("Renamed to 'tolerance_multiplier'")
812 );
813 assert_eq!(
814 opts.inferred_tolerance_multiplier,
815 Decimal::from_str("1.01").unwrap()
816 );
817 }
818
819 #[test]
820 fn test_invalid_boolean_value() {
821 let mut opts = Options::new();
822 opts.set("infer_tolerance_from_cost", "maybe");
823
824 assert_eq!(opts.warnings.len(), 1);
825 assert_eq!(opts.warnings[0].code, "E7002");
826 assert!(
827 opts.warnings[0].message.contains("TRUE, FALSE, 1 or 0"),
828 "the message must name the vocabulary actually accepted: {}",
829 opts.warnings[0].message
830 );
831 }
832
833 #[test]
842 fn boolean_options_share_one_vocabulary() {
843 for key in ["infer_tolerance_from_cost", "render_commas"] {
844 for (value, expected) in [
845 ("TRUE", true),
846 ("true", true),
847 ("1", true),
848 ("FALSE", false),
849 ("false", false),
850 ("0", false),
851 ] {
852 let mut opts = Options::new();
853 opts.set(key, value);
854 assert!(
855 opts.warnings.is_empty(),
856 "{key} = {value:?} must be accepted without a warning: {:?}",
857 opts.warnings
858 );
859 let actual = if key == "render_commas" {
860 opts.render_commas
861 } else {
862 opts.infer_tolerance_from_cost
863 };
864 assert_eq!(actual, expected, "{key} = {value:?}");
865 }
866
867 let mut opts = Options::new();
868 opts.set(key, "yes");
869 assert_eq!(
870 opts.warnings.len(),
871 1,
872 "{key}: `yes` is outside the shared vocabulary and must warn"
873 );
874 }
875 }
876
877 #[test]
878 fn test_invalid_booking_method() {
879 let mut opts = Options::new();
880 opts.set("booking_method", "RANDOM");
881
882 assert_eq!(opts.warnings.len(), 1);
883 assert_eq!(opts.warnings[0].code, "E7002");
884 assert!(opts.warnings[0].message.contains("STRICT"));
885 }
886
887 #[test]
888 fn test_valid_booking_methods() {
889 for method in &["STRICT", "FIFO", "LIFO", "AVERAGE", "NONE"] {
890 let mut opts = Options::new();
891 opts.set("booking_method", method);
892 assert!(
893 opts.warnings.is_empty(),
894 "Should accept {method} as valid booking method"
895 );
896 }
897 }
898
899 #[test]
900 fn test_readonly_option_warning() {
901 let mut opts = Options::new();
902 opts.set("filename", "/some/path.beancount");
903
904 assert_eq!(opts.warnings.len(), 1);
905 assert_eq!(opts.warnings[0].code, "E7005");
906 assert!(opts.warnings[0].message.contains("may not be set"));
907 }
908
909 #[test]
910 fn test_account_rounding_accepted_but_warns_noop() {
911 let mut opts = Options::new();
912 opts.set("account_rounding", "Equity:Rounding");
913
914 assert_eq!(opts.account_rounding.as_deref(), Some("Equity:Rounding"));
916 let w = opts
918 .warnings
919 .iter()
920 .find(|w| w.code == "E7007")
921 .expect("expected an E7007 no-op warning for account_rounding");
922 assert!(w.message.contains("no effect"));
923 assert_eq!(w.option, "account_rounding");
924 assert!(!opts.warnings.iter().any(|w| w.code == "E7002"));
926 }
927
928 #[test]
929 fn test_invalid_account_name_validation() {
930 let mut opts = Options::new();
933 opts.set("account_rounding", "invalid");
934
935 assert!(
936 opts.warnings
937 .iter()
938 .any(|w| w.code == "E7002" && w.message.contains("Invalid leaf account"))
939 );
940 assert!(opts.warnings.iter().any(|w| w.code == "E7007"));
941 }
942
943 #[test]
944 fn test_valid_account_name() {
945 let mut opts = Options::new();
946 opts.set("account_rounding", "Equity:Rounding");
947
948 assert!(!opts.warnings.iter().any(|w| w.code == "E7002"));
951 assert!(opts.warnings.iter().any(|w| w.code == "E7007"));
952 assert_eq!(opts.account_rounding, Some("Equity:Rounding".to_string()));
953 }
954
955 #[test]
956 fn test_render_commas_with_numeric_values() {
957 let mut opts = Options::new();
958 opts.set("render_commas", "1");
959 assert!(opts.render_commas);
960 assert!(opts.warnings.is_empty());
961
962 let mut opts2 = Options::new();
963 opts2.set("render_commas", "0");
964 assert!(!opts2.render_commas);
965 assert!(opts2.warnings.is_empty());
966 }
967
968 #[test]
969 fn test_plugin_processing_mode_validation() {
970 let mut opts = Options::new();
972 opts.set("plugin_processing_mode", "default");
973 assert!(opts.warnings.is_empty());
974 assert_eq!(opts.plugin_processing_mode, "default");
975
976 let mut opts2 = Options::new();
977 opts2.set("plugin_processing_mode", "raw");
978 assert!(opts2.warnings.is_empty());
979 assert_eq!(opts2.plugin_processing_mode, "raw");
980
981 let mut opts3 = Options::new();
983 opts3.set("plugin_processing_mode", "invalid");
984 assert_eq!(opts3.warnings.len(), 1);
985 assert_eq!(opts3.warnings[0].code, "E7002");
986 }
987
988 #[test]
989 fn test_deprecated_plugin_option() {
990 let mut opts = Options::new();
991 opts.set("plugin", "some.plugin");
992
993 assert_eq!(opts.warnings.len(), 1);
994 assert_eq!(opts.warnings[0].code, "E7004");
995 assert!(opts.warnings[0].message.contains("deprecated"));
996 }
997
998 #[test]
999 fn test_deprecated_allow_pipe_separator() {
1000 let mut opts = Options::new();
1001 opts.set("allow_pipe_separator", "true");
1002
1003 assert_eq!(opts.warnings.len(), 1);
1004 assert_eq!(opts.warnings[0].code, "E7004");
1005 assert!(opts.warnings[0].message.contains("deprecated"));
1006 }
1007
1008 #[test]
1009 fn test_is_valid_account() {
1010 assert!(Options::is_valid_account("Assets:Bank"));
1012 assert!(Options::is_valid_account("Equity:Rounding:Precision"));
1013
1014 assert!(Options::is_valid_account("Капитал:Retained"));
1016 assert!(Options::is_valid_account("资产:银行:支票"));
1017
1018 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")); }
1024
1025 #[test]
1026 fn test_account_validation_options() {
1027 let account_options = [
1029 "account_rounding",
1030 "account_current_conversions",
1031 "account_unrealized_gains",
1032 "account_previous_balances",
1033 "account_previous_earnings",
1034 "account_previous_conversions",
1035 "account_current_earnings",
1036 ];
1037
1038 for opt in account_options {
1039 let mut opts = Options::new();
1040 opts.set(opt, "lowercase:invalid");
1041
1042 assert!(
1043 !opts.warnings.is_empty(),
1044 "Option '{opt}' should warn on invalid account name"
1045 );
1046 assert_eq!(opts.warnings[0].code, "E7002");
1047 }
1048 }
1049
1050 #[test]
1051 fn test_inferred_tolerance_default() {
1052 let mut opts = Options::new();
1053 opts.set("inferred_tolerance_default", "USD:0.005");
1054
1055 assert!(opts.warnings.is_empty());
1056 assert_eq!(
1057 opts.inferred_tolerance_default.get("USD"),
1058 Some(&rust_decimal_macros::dec!(0.005))
1059 );
1060
1061 let mut opts2 = Options::new();
1063 opts2.set("inferred_tolerance_default", "*:0.01");
1064 assert!(opts2.warnings.is_empty());
1065 assert_eq!(
1066 opts2.inferred_tolerance_default.get("*"),
1067 Some(&rust_decimal_macros::dec!(0.01))
1068 );
1069
1070 let mut opts3 = Options::new();
1072 opts3.set("inferred_tolerance_default", "INVALID");
1073 assert_eq!(opts3.warnings.len(), 1);
1074 assert_eq!(opts3.warnings[0].code, "E7002");
1075 }
1076
1077 #[test]
1078 fn test_display_precision_basic() {
1079 let mut opts = Options::new();
1080 opts.set("display_precision", "USD:0.01");
1081 assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
1082 assert_eq!(opts.display_precision.get("USD"), Some(&2));
1083 }
1084
1085 #[test]
1086 fn test_display_precision_high_precision() {
1087 let mut opts = Options::new();
1088 opts.set("display_precision", "BTC:0.00000001");
1089 assert!(opts.warnings.is_empty());
1090 assert_eq!(opts.display_precision.get("BTC"), Some(&8));
1091 }
1092
1093 #[test]
1094 fn test_display_precision_zero_decimals() {
1095 let mut opts = Options::new();
1097 opts.set("display_precision", "JPY:1");
1098 assert!(opts.warnings.is_empty());
1099 assert_eq!(opts.display_precision.get("JPY"), Some(&0));
1100 }
1101
1102 #[test]
1103 fn test_display_precision_repeatable_per_currency() {
1104 let mut opts = Options::new();
1105 opts.set("display_precision", "USD:0.01");
1106 opts.set("display_precision", "EUR:0.001");
1107 assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
1108 assert_eq!(opts.display_precision.get("USD"), Some(&2));
1109 assert_eq!(opts.display_precision.get("EUR"), Some(&3));
1110 }
1111
1112 #[test]
1113 fn test_display_precision_missing_colon_warns() {
1114 let mut opts = Options::new();
1115 opts.set("display_precision", "USD0.01");
1116 assert_eq!(opts.warnings.len(), 1);
1117 assert_eq!(opts.warnings[0].code, "E7002");
1118 assert!(opts.warnings[0].message.contains("CURRENCY:EXAMPLE"));
1119 assert!(opts.display_precision.is_empty());
1120 }
1121
1122 #[test]
1123 fn test_name_option_invalid_root_warns_e7008() {
1124 let mut opts = Options::new();
1128 opts.set("name_assets", "1Assets");
1129 assert_eq!(opts.warnings.len(), 1);
1130 assert_eq!(opts.warnings[0].code, "E7008");
1131 assert!(opts.warnings[0].message.contains("1Assets"));
1132 assert_eq!(opts.name_assets, "1Assets");
1134
1135 let mut opts = Options::new();
1137 opts.set("name_income", "In:Come");
1138 assert_eq!(opts.warnings.len(), 1);
1139 assert_eq!(opts.warnings[0].code, "E7008");
1140 }
1141
1142 #[test]
1143 fn test_name_option_valid_roots_no_warning() {
1144 let mut opts = Options::new();
1145 opts.set("name_income", "Revenue");
1146 opts.set("name_assets", "Activa");
1147 opts.set("name_expenses", "Ausgaben");
1148 opts.set("name_liabilities", "負債"); assert!(
1150 opts.warnings.is_empty(),
1151 "lexable renames must not warn: {:?}",
1152 opts.warnings
1153 );
1154 }
1155
1156 #[test]
1157 fn test_account_option_uses_canonical_rule() {
1158 let mut opts = Options::new();
1163 opts.set("account_current_conversions", "Equity:Conv ersions");
1164 assert!(opts.warnings.iter().any(|w| w.code == "E7002"));
1165
1166 let mut opts = Options::new();
1167 opts.set("account_current_conversions", "Equity:Conversions:Current");
1168 assert!(opts.warnings.is_empty(), "{:?}", opts.warnings);
1169 }
1170
1171 #[test]
1172 fn test_display_precision_invalid_example_warns() {
1173 let mut opts = Options::new();
1174 opts.set("display_precision", "USD:abc");
1175 assert_eq!(opts.warnings.len(), 1);
1176 assert_eq!(opts.warnings[0].code, "E7002");
1177 assert!(opts.warnings[0].message.contains("Invalid precision"));
1178 assert!(opts.display_precision.is_empty());
1179 }
1180}