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 if !value.eq_ignore_ascii_case("true") && !value.eq_ignore_ascii_case("false") {
380 self.warnings.push(OptionWarning {
381 code: "E7002",
382 message: format!(
383 "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
384 ),
385 option: key.to_string(),
386 value: value.to_string(),
387 });
388 }
389 self.infer_tolerance_from_cost = value.eq_ignore_ascii_case("true");
390 }
391 "booking_method" => {
392 let valid_methods = [
393 "STRICT",
394 "STRICT_WITH_SIZE",
395 "FIFO",
396 "LIFO",
397 "HIFO",
398 "AVERAGE",
399 "NONE",
400 ];
401 if !valid_methods.contains(&value.to_uppercase().as_str()) {
402 self.warnings.push(OptionWarning {
403 code: "E7002",
404 message: format!(
405 "Invalid value \"{}\" for option \"{}\": expected one of {}",
406 value,
407 key,
408 valid_methods.join(", ")
409 ),
410 option: key.to_string(),
411 value: value.to_string(),
412 });
413 }
414 self.booking_method = value.to_string();
415 }
416 "render_commas" => {
417 let is_true = value.eq_ignore_ascii_case("true") || value == "1";
419 let is_false = value.eq_ignore_ascii_case("false") || value == "0";
420 if !is_true && !is_false {
421 self.warnings.push(OptionWarning {
422 code: "E7002",
423 message: format!(
424 "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
425 ),
426 option: key.to_string(),
427 value: value.to_string(),
428 });
429 }
430 self.render_commas = is_true;
431 }
432 "display_precision" => {
433 if let Some((curr, example)) = value.split_once(':') {
437 if let Ok(d) = Decimal::from_str(example) {
438 let precision = d.scale();
440 self.display_precision.insert(curr.to_string(), precision);
441 } else {
442 self.warnings.push(OptionWarning {
443 code: "E7002",
444 message: format!(
445 "Invalid precision value \"{example}\" in option \"{key}\""
446 ),
447 option: key.to_string(),
448 value: value.to_string(),
449 });
450 }
451 } else {
452 self.warnings.push(OptionWarning {
453 code: "E7002",
454 message: format!(
455 "Invalid format for option \"{key}\": expected CURRENCY:EXAMPLE (e.g., CHF:0.01)"
456 ),
457 option: key.to_string(),
458 value: value.to_string(),
459 });
460 }
461 }
462 "filename" => self.filename = Some(value.to_string()),
463 "account_previous_balances" => {
464 if !Self::is_valid_account(value) {
465 self.warnings.push(OptionWarning {
466 code: "E7002",
467 message: format!("Invalid leaf account name: '{value}'"),
468 option: key.to_string(),
469 value: value.to_string(),
470 });
471 }
472 self.account_previous_balances = value.to_string();
473 }
474 "account_previous_earnings" => {
475 if !Self::is_valid_account(value) {
476 self.warnings.push(OptionWarning {
477 code: "E7002",
478 message: format!("Invalid leaf account name: '{value}'"),
479 option: key.to_string(),
480 value: value.to_string(),
481 });
482 }
483 self.account_previous_earnings = value.to_string();
484 }
485 "account_previous_conversions" => {
486 if !Self::is_valid_account(value) {
487 self.warnings.push(OptionWarning {
488 code: "E7002",
489 message: format!("Invalid leaf account name: '{value}'"),
490 option: key.to_string(),
491 value: value.to_string(),
492 });
493 }
494 self.account_previous_conversions = value.to_string();
495 }
496 "account_current_earnings" => {
497 if !Self::is_valid_account(value) {
498 self.warnings.push(OptionWarning {
499 code: "E7002",
500 message: format!("Invalid leaf account name: '{value}'"),
501 option: key.to_string(),
502 value: value.to_string(),
503 });
504 }
505 self.account_current_earnings = value.to_string();
506 }
507 "conversion_currency" => self.conversion_currency = Some(value.to_string()),
508 "inferred_tolerance_default" => {
509 if let Some((curr, tol)) = value.split_once(':') {
511 if let Ok(d) = Decimal::from_str(tol) {
512 self.inferred_tolerance_default.insert(curr.to_string(), d);
513 } else {
514 self.warnings.push(OptionWarning {
515 code: "E7002",
516 message: format!(
517 "Invalid tolerance value \"{tol}\" in option \"{key}\""
518 ),
519 option: key.to_string(),
520 value: value.to_string(),
521 });
522 }
523 } else {
524 self.warnings.push(OptionWarning {
525 code: "E7002",
526 message: format!(
527 "Invalid format for option \"{key}\": expected CURRENCY:TOLERANCE"
528 ),
529 option: key.to_string(),
530 value: value.to_string(),
531 });
532 }
533 }
534 "use_legacy_fixed_tolerances" => {
535 self.use_legacy_fixed_tolerances = value.eq_ignore_ascii_case("true");
536 }
537 "experiment_explicit_tolerances" => {
538 self.experiment_explicit_tolerances = value.eq_ignore_ascii_case("true");
539 }
540 "use_precise_interpolation" => {
541 self.use_precise_interpolation = value.eq_ignore_ascii_case("true");
545 }
546 "allow_pipe_separator" => {
547 self.warnings.push(OptionWarning {
549 code: "E7004",
550 message: "Option 'allow_pipe_separator' is deprecated".to_string(),
551 option: key.to_string(),
552 value: value.to_string(),
553 });
554 self.allow_pipe_separator = value.eq_ignore_ascii_case("true");
555 }
556 "long_string_maxlines" => {
557 if let Ok(n) = value.parse::<u32>() {
558 self.long_string_maxlines = n;
559 } else {
560 self.warnings.push(OptionWarning {
561 code: "E7002",
562 message: format!(
563 "Invalid value \"{value}\" for option \"{key}\": expected integer"
564 ),
565 option: key.to_string(),
566 value: value.to_string(),
567 });
568 }
569 }
570 "documents" => {
571 if !std::path::Path::new(value).exists() {
573 self.warnings.push(OptionWarning {
574 code: "E7006",
575 message: format!("Document root '{value}' does not exist"),
576 option: key.to_string(),
577 value: value.to_string(),
578 });
579 }
580 self.documents.push(value.to_string());
581 }
582 "plugin_processing_mode" => {
583 if value != "default" && value != "raw" {
585 self.warnings.push(OptionWarning {
586 code: "E7002",
587 message: format!("Invalid value '{value}'"),
588 option: key.to_string(),
589 value: value.to_string(),
590 });
591 }
592 self.plugin_processing_mode = value.to_string();
593 }
594 "plugin" => {
595 self.warnings.push(OptionWarning {
597 code: "E7004",
598 message: "Option 'plugin' is deprecated; use the 'plugin' directive instead"
599 .to_string(),
600 option: key.to_string(),
601 value: value.to_string(),
602 });
603 }
604 _ => {
605 self.custom.insert(key.to_string(), value.to_string());
607 }
608 }
609 }
610
611 #[must_use]
613 pub fn get(&self, key: &str) -> Option<&str> {
614 self.custom.get(key).map(String::as_str)
615 }
616
617 #[must_use]
621 pub fn to_account_types(&self) -> rustledger_core::AccountTypes {
622 rustledger_core::AccountTypes {
623 assets: self.name_assets.clone(),
624 liabilities: self.name_liabilities.clone(),
625 equity: self.name_equity.clone(),
626 income: self.name_income.clone(),
627 expenses: self.name_expenses.clone(),
628 }
629 }
630
631 #[must_use]
633 pub fn account_types(&self) -> [&str; 5] {
634 [
635 &self.name_assets,
636 &self.name_liabilities,
637 &self.name_equity,
638 &self.name_income,
639 &self.name_expenses,
640 ]
641 }
642
643 fn warn_if_invalid_root(&mut self, key: &str, value: &str) {
652 if !Self::is_valid_account_root(value) {
653 self.warnings.push(OptionWarning {
654 code: "E7008",
655 message: format!(
656 "Invalid account type name: '{value}' cannot begin an \
657 account name (accounts under it will never parse)"
658 ),
659 option: key.to_string(),
660 value: value.to_string(),
661 });
662 }
663 }
664
665 fn is_valid_account(value: &str) -> bool {
673 rustledger_parser::is_valid_account_name(value)
674 }
675
676 fn is_valid_account_root(value: &str) -> bool {
681 !value.contains(':') && rustledger_parser::is_valid_account_name(&format!("{value}:X"))
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688
689 #[test]
690 fn test_default_options() {
691 let opts = Options::new();
692 assert_eq!(opts.name_assets, "Assets");
693 assert_eq!(opts.booking_method, "STRICT");
694 assert!(!opts.infer_tolerance_from_cost);
695 }
696
697 #[test]
698 fn test_set_options() {
699 let mut opts = Options::new();
700 opts.set("title", "My Ledger");
701 opts.set("operating_currency", "USD");
702 opts.set("operating_currency", "EUR");
703 opts.set("booking_method", "FIFO");
704
705 assert_eq!(opts.title, Some("My Ledger".to_string()));
706 assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
707 assert_eq!(opts.booking_method, "FIFO");
708 }
709
710 #[test]
711 fn test_custom_options() {
712 let mut opts = Options::new();
713 opts.set("my_custom_option", "my_value");
714
715 assert_eq!(opts.get("my_custom_option"), Some("my_value"));
716 assert_eq!(opts.get("nonexistent"), None);
717 }
718
719 #[test]
720 fn test_unknown_option_warning() {
721 let mut opts = Options::new();
722 opts.set("unknown_option", "value");
723
724 assert_eq!(opts.warnings.len(), 1);
725 assert_eq!(opts.warnings[0].code, "E7001");
726 assert!(opts.warnings[0].message.contains("Invalid option"));
727 }
728
729 #[test]
732 fn test_use_precise_interpolation_accepted() {
733 let mut opts = Options::new();
734 opts.set("use_precise_interpolation", "TRUE");
735
736 assert!(
737 opts.warnings.is_empty(),
738 "should not warn on a known option: {:?}",
739 opts.warnings
740 );
741 assert!(opts.use_precise_interpolation);
742 }
743
744 #[test]
745 fn test_duplicate_option_warning() {
746 let mut opts = Options::new();
747 opts.set("title", "First Title");
748 opts.set("title", "Second Title");
749
750 assert_eq!(opts.warnings.len(), 1);
751 assert_eq!(opts.warnings[0].code, "E7003");
752 assert!(opts.warnings[0].message.contains("only be specified once"));
753 }
754
755 #[test]
756 fn test_repeatable_option_no_warning() {
757 let mut opts = Options::new();
758 opts.set("operating_currency", "USD");
759 opts.set("operating_currency", "EUR");
760
761 assert!(
763 opts.warnings.is_empty(),
764 "Should not warn for repeatable options: {:?}",
765 opts.warnings
766 );
767 assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
768 }
769
770 #[test]
771 fn test_invalid_tolerance_value() {
772 let mut opts = Options::new();
773 opts.set("inferred_tolerance_multiplier", "not_a_number");
774
775 assert_eq!(opts.warnings.len(), 2);
777 assert_eq!(opts.warnings[0].code, "E7004");
778 assert!(opts.warnings[0].message.contains("Renamed"));
779 assert_eq!(opts.warnings[1].code, "E7002");
780 assert!(opts.warnings[1].message.contains("expected decimal"));
781 }
782
783 #[test]
784 fn test_tolerance_multiplier_new_name() {
785 let mut opts = Options::new();
786 opts.set("tolerance_multiplier", "1.5");
787
788 assert!(opts.warnings.is_empty());
789 assert_eq!(opts.inferred_tolerance_multiplier, Decimal::new(15, 1));
790 }
791
792 #[test]
793 fn test_inferred_tolerance_multiplier_deprecated() {
794 let mut opts = Options::new();
795 opts.set("inferred_tolerance_multiplier", "1.01");
796
797 assert_eq!(opts.warnings.len(), 1);
798 assert_eq!(opts.warnings[0].code, "E7004");
799 assert!(
800 opts.warnings[0]
801 .message
802 .contains("Renamed to 'tolerance_multiplier'")
803 );
804 assert_eq!(
805 opts.inferred_tolerance_multiplier,
806 Decimal::from_str("1.01").unwrap()
807 );
808 }
809
810 #[test]
811 fn test_invalid_boolean_value() {
812 let mut opts = Options::new();
813 opts.set("infer_tolerance_from_cost", "maybe");
814
815 assert_eq!(opts.warnings.len(), 1);
816 assert_eq!(opts.warnings[0].code, "E7002");
817 assert!(opts.warnings[0].message.contains("TRUE or FALSE"));
818 }
819
820 #[test]
821 fn test_invalid_booking_method() {
822 let mut opts = Options::new();
823 opts.set("booking_method", "RANDOM");
824
825 assert_eq!(opts.warnings.len(), 1);
826 assert_eq!(opts.warnings[0].code, "E7002");
827 assert!(opts.warnings[0].message.contains("STRICT"));
828 }
829
830 #[test]
831 fn test_valid_booking_methods() {
832 for method in &["STRICT", "FIFO", "LIFO", "AVERAGE", "NONE"] {
833 let mut opts = Options::new();
834 opts.set("booking_method", method);
835 assert!(
836 opts.warnings.is_empty(),
837 "Should accept {method} as valid booking method"
838 );
839 }
840 }
841
842 #[test]
843 fn test_readonly_option_warning() {
844 let mut opts = Options::new();
845 opts.set("filename", "/some/path.beancount");
846
847 assert_eq!(opts.warnings.len(), 1);
848 assert_eq!(opts.warnings[0].code, "E7005");
849 assert!(opts.warnings[0].message.contains("may not be set"));
850 }
851
852 #[test]
853 fn test_account_rounding_accepted_but_warns_noop() {
854 let mut opts = Options::new();
855 opts.set("account_rounding", "Equity:Rounding");
856
857 assert_eq!(opts.account_rounding.as_deref(), Some("Equity:Rounding"));
859 let w = opts
861 .warnings
862 .iter()
863 .find(|w| w.code == "E7007")
864 .expect("expected an E7007 no-op warning for account_rounding");
865 assert!(w.message.contains("no effect"));
866 assert_eq!(w.option, "account_rounding");
867 assert!(!opts.warnings.iter().any(|w| w.code == "E7002"));
869 }
870
871 #[test]
872 fn test_invalid_account_name_validation() {
873 let mut opts = Options::new();
876 opts.set("account_rounding", "invalid");
877
878 assert!(
879 opts.warnings
880 .iter()
881 .any(|w| w.code == "E7002" && w.message.contains("Invalid leaf account"))
882 );
883 assert!(opts.warnings.iter().any(|w| w.code == "E7007"));
884 }
885
886 #[test]
887 fn test_valid_account_name() {
888 let mut opts = Options::new();
889 opts.set("account_rounding", "Equity:Rounding");
890
891 assert!(!opts.warnings.iter().any(|w| w.code == "E7002"));
894 assert!(opts.warnings.iter().any(|w| w.code == "E7007"));
895 assert_eq!(opts.account_rounding, Some("Equity:Rounding".to_string()));
896 }
897
898 #[test]
899 fn test_render_commas_with_numeric_values() {
900 let mut opts = Options::new();
901 opts.set("render_commas", "1");
902 assert!(opts.render_commas);
903 assert!(opts.warnings.is_empty());
904
905 let mut opts2 = Options::new();
906 opts2.set("render_commas", "0");
907 assert!(!opts2.render_commas);
908 assert!(opts2.warnings.is_empty());
909 }
910
911 #[test]
912 fn test_plugin_processing_mode_validation() {
913 let mut opts = Options::new();
915 opts.set("plugin_processing_mode", "default");
916 assert!(opts.warnings.is_empty());
917 assert_eq!(opts.plugin_processing_mode, "default");
918
919 let mut opts2 = Options::new();
920 opts2.set("plugin_processing_mode", "raw");
921 assert!(opts2.warnings.is_empty());
922 assert_eq!(opts2.plugin_processing_mode, "raw");
923
924 let mut opts3 = Options::new();
926 opts3.set("plugin_processing_mode", "invalid");
927 assert_eq!(opts3.warnings.len(), 1);
928 assert_eq!(opts3.warnings[0].code, "E7002");
929 }
930
931 #[test]
932 fn test_deprecated_plugin_option() {
933 let mut opts = Options::new();
934 opts.set("plugin", "some.plugin");
935
936 assert_eq!(opts.warnings.len(), 1);
937 assert_eq!(opts.warnings[0].code, "E7004");
938 assert!(opts.warnings[0].message.contains("deprecated"));
939 }
940
941 #[test]
942 fn test_deprecated_allow_pipe_separator() {
943 let mut opts = Options::new();
944 opts.set("allow_pipe_separator", "true");
945
946 assert_eq!(opts.warnings.len(), 1);
947 assert_eq!(opts.warnings[0].code, "E7004");
948 assert!(opts.warnings[0].message.contains("deprecated"));
949 }
950
951 #[test]
952 fn test_is_valid_account() {
953 assert!(Options::is_valid_account("Assets:Bank"));
955 assert!(Options::is_valid_account("Equity:Rounding:Precision"));
956
957 assert!(Options::is_valid_account("Капитал:Retained"));
959 assert!(Options::is_valid_account("资产:银行:支票"));
960
961 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")); }
967
968 #[test]
969 fn test_account_validation_options() {
970 let account_options = [
972 "account_rounding",
973 "account_current_conversions",
974 "account_unrealized_gains",
975 "account_previous_balances",
976 "account_previous_earnings",
977 "account_previous_conversions",
978 "account_current_earnings",
979 ];
980
981 for opt in account_options {
982 let mut opts = Options::new();
983 opts.set(opt, "lowercase:invalid");
984
985 assert!(
986 !opts.warnings.is_empty(),
987 "Option '{opt}' should warn on invalid account name"
988 );
989 assert_eq!(opts.warnings[0].code, "E7002");
990 }
991 }
992
993 #[test]
994 fn test_inferred_tolerance_default() {
995 let mut opts = Options::new();
996 opts.set("inferred_tolerance_default", "USD:0.005");
997
998 assert!(opts.warnings.is_empty());
999 assert_eq!(
1000 opts.inferred_tolerance_default.get("USD"),
1001 Some(&rust_decimal_macros::dec!(0.005))
1002 );
1003
1004 let mut opts2 = Options::new();
1006 opts2.set("inferred_tolerance_default", "*:0.01");
1007 assert!(opts2.warnings.is_empty());
1008 assert_eq!(
1009 opts2.inferred_tolerance_default.get("*"),
1010 Some(&rust_decimal_macros::dec!(0.01))
1011 );
1012
1013 let mut opts3 = Options::new();
1015 opts3.set("inferred_tolerance_default", "INVALID");
1016 assert_eq!(opts3.warnings.len(), 1);
1017 assert_eq!(opts3.warnings[0].code, "E7002");
1018 }
1019
1020 #[test]
1021 fn test_display_precision_basic() {
1022 let mut opts = Options::new();
1023 opts.set("display_precision", "USD:0.01");
1024 assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
1025 assert_eq!(opts.display_precision.get("USD"), Some(&2));
1026 }
1027
1028 #[test]
1029 fn test_display_precision_high_precision() {
1030 let mut opts = Options::new();
1031 opts.set("display_precision", "BTC:0.00000001");
1032 assert!(opts.warnings.is_empty());
1033 assert_eq!(opts.display_precision.get("BTC"), Some(&8));
1034 }
1035
1036 #[test]
1037 fn test_display_precision_zero_decimals() {
1038 let mut opts = Options::new();
1040 opts.set("display_precision", "JPY:1");
1041 assert!(opts.warnings.is_empty());
1042 assert_eq!(opts.display_precision.get("JPY"), Some(&0));
1043 }
1044
1045 #[test]
1046 fn test_display_precision_repeatable_per_currency() {
1047 let mut opts = Options::new();
1048 opts.set("display_precision", "USD:0.01");
1049 opts.set("display_precision", "EUR:0.001");
1050 assert!(opts.warnings.is_empty(), "warnings: {:?}", opts.warnings);
1051 assert_eq!(opts.display_precision.get("USD"), Some(&2));
1052 assert_eq!(opts.display_precision.get("EUR"), Some(&3));
1053 }
1054
1055 #[test]
1056 fn test_display_precision_missing_colon_warns() {
1057 let mut opts = Options::new();
1058 opts.set("display_precision", "USD0.01");
1059 assert_eq!(opts.warnings.len(), 1);
1060 assert_eq!(opts.warnings[0].code, "E7002");
1061 assert!(opts.warnings[0].message.contains("CURRENCY:EXAMPLE"));
1062 assert!(opts.display_precision.is_empty());
1063 }
1064
1065 #[test]
1066 fn test_name_option_invalid_root_warns_e7008() {
1067 let mut opts = Options::new();
1071 opts.set("name_assets", "1Assets");
1072 assert_eq!(opts.warnings.len(), 1);
1073 assert_eq!(opts.warnings[0].code, "E7008");
1074 assert!(opts.warnings[0].message.contains("1Assets"));
1075 assert_eq!(opts.name_assets, "1Assets");
1077
1078 let mut opts = Options::new();
1080 opts.set("name_income", "In:Come");
1081 assert_eq!(opts.warnings.len(), 1);
1082 assert_eq!(opts.warnings[0].code, "E7008");
1083 }
1084
1085 #[test]
1086 fn test_name_option_valid_roots_no_warning() {
1087 let mut opts = Options::new();
1088 opts.set("name_income", "Revenue");
1089 opts.set("name_assets", "Activa");
1090 opts.set("name_expenses", "Ausgaben");
1091 opts.set("name_liabilities", "負債"); assert!(
1093 opts.warnings.is_empty(),
1094 "lexable renames must not warn: {:?}",
1095 opts.warnings
1096 );
1097 }
1098
1099 #[test]
1100 fn test_account_option_uses_canonical_rule() {
1101 let mut opts = Options::new();
1106 opts.set("account_current_conversions", "Equity:Conv ersions");
1107 assert!(opts.warnings.iter().any(|w| w.code == "E7002"));
1108
1109 let mut opts = Options::new();
1110 opts.set("account_current_conversions", "Equity:Conversions:Current");
1111 assert!(opts.warnings.is_empty(), "{:?}", opts.warnings);
1112 }
1113
1114 #[test]
1115 fn test_display_precision_invalid_example_warns() {
1116 let mut opts = Options::new();
1117 opts.set("display_precision", "USD:abc");
1118 assert_eq!(opts.warnings.len(), 1);
1119 assert_eq!(opts.warnings[0].code, "E7002");
1120 assert!(opts.warnings[0].message.contains("Invalid precision"));
1121 assert!(opts.display_precision.is_empty());
1122 }
1123}