1use rust_decimal::Decimal;
4use std::collections::{HashMap, HashSet};
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 "booking_method",
31 "render_commas",
32 "display_precision",
33 "allow_pipe_separator",
34 "long_string_maxlines",
35 "documents",
36 "insert_pythonpath",
37 "plugin_processing_mode",
38 "plugin", ];
40
41const REPEATABLE_OPTIONS: &[&str] = &[
43 "operating_currency",
44 "insert_pythonpath",
45 "documents",
46 "inferred_tolerance_default",
47 "display_precision",
48];
49
50const READONLY_OPTIONS: &[&str] = &["filename"];
52
53#[derive(Debug, Clone)]
55pub struct OptionWarning {
56 pub code: &'static str,
58 pub message: String,
60 pub option: String,
62 pub value: String,
64}
65
66#[derive(Debug, Clone)]
70pub struct Options {
71 pub title: Option<String>,
73
74 pub filename: Option<String>,
76
77 pub operating_currency: Vec<String>,
79
80 pub name_assets: String,
82
83 pub name_liabilities: String,
85
86 pub name_equity: String,
88
89 pub name_income: String,
91
92 pub name_expenses: String,
94
95 pub account_rounding: Option<String>,
97
98 pub account_previous_balances: String,
100
101 pub account_previous_earnings: String,
103
104 pub account_previous_conversions: String,
106
107 pub account_current_earnings: String,
109
110 pub account_current_conversions: Option<String>,
112
113 pub account_unrealized_gains: Option<String>,
115
116 pub conversion_currency: Option<String>,
118
119 pub inferred_tolerance_default: HashMap<String, Decimal>,
121
122 pub inferred_tolerance_multiplier: Decimal,
124
125 pub infer_tolerance_from_cost: bool,
127
128 pub use_legacy_fixed_tolerances: bool,
130
131 pub experiment_explicit_tolerances: bool,
133
134 pub booking_method: String,
136
137 pub render_commas: bool,
139
140 pub display_precision: HashMap<String, u32>,
143
144 pub allow_pipe_separator: bool,
146
147 pub long_string_maxlines: u32,
149
150 pub documents: Vec<String>,
152
153 pub plugin_processing_mode: String,
155
156 pub custom: HashMap<String, String>,
158
159 #[doc(hidden)]
161 pub set_options: HashSet<String>,
162
163 pub warnings: Vec<OptionWarning>,
165}
166
167impl Default for Options {
168 fn default() -> Self {
169 Self::new()
170 }
171}
172
173impl Options {
174 #[must_use]
176 pub fn new() -> Self {
177 Self {
178 title: None,
179 filename: None,
180 operating_currency: Vec::new(),
181 name_assets: "Assets".to_string(),
182 name_liabilities: "Liabilities".to_string(),
183 name_equity: "Equity".to_string(),
184 name_income: "Income".to_string(),
185 name_expenses: "Expenses".to_string(),
186 account_rounding: None,
187 account_previous_balances: "Equity:Opening-Balances".to_string(),
188 account_previous_earnings: "Equity:Earnings:Previous".to_string(),
189 account_previous_conversions: "Equity:Conversions:Previous".to_string(),
190 account_current_earnings: "Equity:Earnings:Current".to_string(),
191 account_current_conversions: None,
192 account_unrealized_gains: None,
193 conversion_currency: None,
194 inferred_tolerance_default: HashMap::new(),
195 inferred_tolerance_multiplier: Decimal::new(5, 1), infer_tolerance_from_cost: true,
197 use_legacy_fixed_tolerances: false,
198 experiment_explicit_tolerances: false,
199 booking_method: "STRICT".to_string(),
200 render_commas: false, display_precision: HashMap::new(),
202 allow_pipe_separator: false,
203 long_string_maxlines: 64,
204 documents: Vec::new(),
205 plugin_processing_mode: "default".to_string(),
206 custom: HashMap::new(),
207 set_options: HashSet::new(),
208 warnings: Vec::new(),
209 }
210 }
211
212 pub fn set(&mut self, key: &str, value: &str) {
216 let is_known = KNOWN_OPTIONS.contains(&key);
218 if !is_known {
219 self.warnings.push(OptionWarning {
220 code: "E7001",
221 message: format!("Unknown option \"{key}\""),
222 option: key.to_string(),
223 value: value.to_string(),
224 });
225 }
226
227 if READONLY_OPTIONS.contains(&key) {
229 self.warnings.push(OptionWarning {
230 code: "E7005",
231 message: format!("Option '{key}' may not be set"),
232 option: key.to_string(),
233 value: value.to_string(),
234 });
235 return; }
237
238 let is_repeatable = REPEATABLE_OPTIONS.contains(&key);
240 if is_known && !is_repeatable && self.set_options.contains(key) {
241 self.warnings.push(OptionWarning {
242 code: "E7003",
243 message: format!("Option \"{key}\" can only be specified once"),
244 option: key.to_string(),
245 value: value.to_string(),
246 });
247 }
248
249 self.set_options.insert(key.to_string());
251
252 match key {
254 "title" => self.title = Some(value.to_string()),
255 "operating_currency" => self.operating_currency.push(value.to_string()),
256 "name_assets" => self.name_assets = value.to_string(),
257 "name_liabilities" => self.name_liabilities = value.to_string(),
258 "name_equity" => self.name_equity = value.to_string(),
259 "name_income" => self.name_income = value.to_string(),
260 "name_expenses" => self.name_expenses = value.to_string(),
261 "account_rounding" => {
262 if !Self::is_valid_account(value) {
263 self.warnings.push(OptionWarning {
264 code: "E7002",
265 message: format!("Invalid leaf account name: '{value}'"),
266 option: key.to_string(),
267 value: value.to_string(),
268 });
269 }
270 self.account_rounding = Some(value.to_string());
271 }
272 "account_current_conversions" => {
273 if !Self::is_valid_account(value) {
274 self.warnings.push(OptionWarning {
275 code: "E7002",
276 message: format!("Invalid leaf account name: '{value}'"),
277 option: key.to_string(),
278 value: value.to_string(),
279 });
280 }
281 self.account_current_conversions = Some(value.to_string());
282 }
283 "account_unrealized_gains" => {
284 if !Self::is_valid_account(value) {
285 self.warnings.push(OptionWarning {
286 code: "E7002",
287 message: format!("Invalid leaf account name: '{value}'"),
288 option: key.to_string(),
289 value: value.to_string(),
290 });
291 }
292 self.account_unrealized_gains = Some(value.to_string());
293 }
294 "inferred_tolerance_multiplier" => {
295 if let Ok(d) = Decimal::from_str(value) {
296 self.inferred_tolerance_multiplier = d;
297 } else {
298 self.warnings.push(OptionWarning {
300 code: "E7002",
301 message: format!(
302 "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
303 ),
304 option: key.to_string(),
305 value: value.to_string(),
306 });
307 }
308 }
309 "infer_tolerance_from_cost" => {
310 if !value.eq_ignore_ascii_case("true") && !value.eq_ignore_ascii_case("false") {
311 self.warnings.push(OptionWarning {
312 code: "E7002",
313 message: format!(
314 "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
315 ),
316 option: key.to_string(),
317 value: value.to_string(),
318 });
319 }
320 self.infer_tolerance_from_cost = value.eq_ignore_ascii_case("true");
321 }
322 "booking_method" => {
323 let valid_methods = [
324 "STRICT",
325 "STRICT_WITH_SIZE",
326 "FIFO",
327 "LIFO",
328 "HIFO",
329 "AVERAGE",
330 "NONE",
331 ];
332 if !valid_methods.contains(&value.to_uppercase().as_str()) {
333 self.warnings.push(OptionWarning {
334 code: "E7002",
335 message: format!(
336 "Invalid value \"{}\" for option \"{}\": expected one of {}",
337 value,
338 key,
339 valid_methods.join(", ")
340 ),
341 option: key.to_string(),
342 value: value.to_string(),
343 });
344 }
345 self.booking_method = value.to_string();
346 }
347 "render_commas" => {
348 let is_true = value.eq_ignore_ascii_case("true") || value == "1";
350 let is_false = value.eq_ignore_ascii_case("false") || value == "0";
351 if !is_true && !is_false {
352 self.warnings.push(OptionWarning {
353 code: "E7002",
354 message: format!(
355 "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
356 ),
357 option: key.to_string(),
358 value: value.to_string(),
359 });
360 }
361 self.render_commas = is_true;
362 }
363 "display_precision" => {
364 if let Some((curr, example)) = value.split_once(':') {
368 if let Ok(d) = Decimal::from_str(example) {
369 let precision = d.scale();
371 self.display_precision.insert(curr.to_string(), precision);
372 } else {
373 self.warnings.push(OptionWarning {
374 code: "E7002",
375 message: format!(
376 "Invalid precision value \"{example}\" in option \"{key}\""
377 ),
378 option: key.to_string(),
379 value: value.to_string(),
380 });
381 }
382 } else {
383 self.warnings.push(OptionWarning {
384 code: "E7002",
385 message: format!(
386 "Invalid format for option \"{key}\": expected CURRENCY:EXAMPLE (e.g., CHF:0.01)"
387 ),
388 option: key.to_string(),
389 value: value.to_string(),
390 });
391 }
392 }
393 "filename" => self.filename = Some(value.to_string()),
394 "account_previous_balances" => {
395 if !Self::is_valid_account(value) {
396 self.warnings.push(OptionWarning {
397 code: "E7002",
398 message: format!("Invalid leaf account name: '{value}'"),
399 option: key.to_string(),
400 value: value.to_string(),
401 });
402 }
403 self.account_previous_balances = value.to_string();
404 }
405 "account_previous_earnings" => {
406 if !Self::is_valid_account(value) {
407 self.warnings.push(OptionWarning {
408 code: "E7002",
409 message: format!("Invalid leaf account name: '{value}'"),
410 option: key.to_string(),
411 value: value.to_string(),
412 });
413 }
414 self.account_previous_earnings = value.to_string();
415 }
416 "account_previous_conversions" => {
417 if !Self::is_valid_account(value) {
418 self.warnings.push(OptionWarning {
419 code: "E7002",
420 message: format!("Invalid leaf account name: '{value}'"),
421 option: key.to_string(),
422 value: value.to_string(),
423 });
424 }
425 self.account_previous_conversions = value.to_string();
426 }
427 "account_current_earnings" => {
428 if !Self::is_valid_account(value) {
429 self.warnings.push(OptionWarning {
430 code: "E7002",
431 message: format!("Invalid leaf account name: '{value}'"),
432 option: key.to_string(),
433 value: value.to_string(),
434 });
435 }
436 self.account_current_earnings = value.to_string();
437 }
438 "conversion_currency" => self.conversion_currency = Some(value.to_string()),
439 "inferred_tolerance_default" => {
440 if let Some((curr, tol)) = value.split_once(':') {
442 if let Ok(d) = Decimal::from_str(tol) {
443 self.inferred_tolerance_default.insert(curr.to_string(), d);
444 } else {
445 self.warnings.push(OptionWarning {
446 code: "E7002",
447 message: format!(
448 "Invalid tolerance value \"{tol}\" in option \"{key}\""
449 ),
450 option: key.to_string(),
451 value: value.to_string(),
452 });
453 }
454 } else {
455 self.warnings.push(OptionWarning {
456 code: "E7002",
457 message: format!(
458 "Invalid format for option \"{key}\": expected CURRENCY:TOLERANCE"
459 ),
460 option: key.to_string(),
461 value: value.to_string(),
462 });
463 }
464 }
465 "use_legacy_fixed_tolerances" => {
466 self.use_legacy_fixed_tolerances = value.eq_ignore_ascii_case("true");
467 }
468 "experiment_explicit_tolerances" => {
469 self.experiment_explicit_tolerances = value.eq_ignore_ascii_case("true");
470 }
471 "allow_pipe_separator" => {
472 self.warnings.push(OptionWarning {
474 code: "E7004",
475 message: "Option 'allow_pipe_separator' is deprecated".to_string(),
476 option: key.to_string(),
477 value: value.to_string(),
478 });
479 self.allow_pipe_separator = value.eq_ignore_ascii_case("true");
480 }
481 "long_string_maxlines" => {
482 if let Ok(n) = value.parse::<u32>() {
483 self.long_string_maxlines = n;
484 } else {
485 self.warnings.push(OptionWarning {
486 code: "E7002",
487 message: format!(
488 "Invalid value \"{value}\" for option \"{key}\": expected integer"
489 ),
490 option: key.to_string(),
491 value: value.to_string(),
492 });
493 }
494 }
495 "documents" => {
496 if !std::path::Path::new(value).exists() {
498 self.warnings.push(OptionWarning {
499 code: "E7006",
500 message: format!("Document root '{value}' does not exist"),
501 option: key.to_string(),
502 value: value.to_string(),
503 });
504 }
505 self.documents.push(value.to_string());
506 }
507 "plugin_processing_mode" => {
508 if value != "default" && value != "raw" {
510 self.warnings.push(OptionWarning {
511 code: "E7002",
512 message: format!("Invalid value '{value}'"),
513 option: key.to_string(),
514 value: value.to_string(),
515 });
516 }
517 self.plugin_processing_mode = value.to_string();
518 }
519 "plugin" => {
520 self.warnings.push(OptionWarning {
522 code: "E7004",
523 message: "Option 'plugin' is deprecated; use the 'plugin' directive instead"
524 .to_string(),
525 option: key.to_string(),
526 value: value.to_string(),
527 });
528 }
529 _ => {
530 self.custom.insert(key.to_string(), value.to_string());
532 }
533 }
534 }
535
536 #[must_use]
538 pub fn get(&self, key: &str) -> Option<&str> {
539 self.custom.get(key).map(String::as_str)
540 }
541
542 #[must_use]
544 pub fn account_types(&self) -> [&str; 5] {
545 [
546 &self.name_assets,
547 &self.name_liabilities,
548 &self.name_equity,
549 &self.name_income,
550 &self.name_expenses,
551 ]
552 }
553
554 fn is_valid_account(value: &str) -> bool {
559 if !value.contains(':') {
561 return false;
562 }
563
564 for part in value.split(':') {
566 if let Some(first) = part.chars().next() {
568 if !first.is_ascii_uppercase() {
569 return false;
570 }
571 } else {
572 return false;
574 }
575 }
576
577 true
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn test_default_options() {
587 let opts = Options::new();
588 assert_eq!(opts.name_assets, "Assets");
589 assert_eq!(opts.booking_method, "STRICT");
590 assert!(opts.infer_tolerance_from_cost);
591 }
592
593 #[test]
594 fn test_set_options() {
595 let mut opts = Options::new();
596 opts.set("title", "My Ledger");
597 opts.set("operating_currency", "USD");
598 opts.set("operating_currency", "EUR");
599 opts.set("booking_method", "FIFO");
600
601 assert_eq!(opts.title, Some("My Ledger".to_string()));
602 assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
603 assert_eq!(opts.booking_method, "FIFO");
604 }
605
606 #[test]
607 fn test_custom_options() {
608 let mut opts = Options::new();
609 opts.set("my_custom_option", "my_value");
610
611 assert_eq!(opts.get("my_custom_option"), Some("my_value"));
612 assert_eq!(opts.get("nonexistent"), None);
613 }
614
615 #[test]
616 fn test_unknown_option_warning() {
617 let mut opts = Options::new();
618 opts.set("unknown_option", "value");
619
620 assert_eq!(opts.warnings.len(), 1);
621 assert_eq!(opts.warnings[0].code, "E7001");
622 assert!(opts.warnings[0].message.contains("Unknown option"));
623 }
624
625 #[test]
626 fn test_duplicate_option_warning() {
627 let mut opts = Options::new();
628 opts.set("title", "First Title");
629 opts.set("title", "Second Title");
630
631 assert_eq!(opts.warnings.len(), 1);
632 assert_eq!(opts.warnings[0].code, "E7003");
633 assert!(opts.warnings[0].message.contains("only be specified once"));
634 }
635
636 #[test]
637 fn test_repeatable_option_no_warning() {
638 let mut opts = Options::new();
639 opts.set("operating_currency", "USD");
640 opts.set("operating_currency", "EUR");
641
642 assert!(
644 opts.warnings.is_empty(),
645 "Should not warn for repeatable options: {:?}",
646 opts.warnings
647 );
648 assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
649 }
650
651 #[test]
652 fn test_invalid_tolerance_value() {
653 let mut opts = Options::new();
654 opts.set("inferred_tolerance_multiplier", "not_a_number");
655
656 assert_eq!(opts.warnings.len(), 1);
657 assert_eq!(opts.warnings[0].code, "E7002");
658 assert!(opts.warnings[0].message.contains("expected decimal"));
659 }
660
661 #[test]
662 fn test_invalid_boolean_value() {
663 let mut opts = Options::new();
664 opts.set("infer_tolerance_from_cost", "maybe");
665
666 assert_eq!(opts.warnings.len(), 1);
667 assert_eq!(opts.warnings[0].code, "E7002");
668 assert!(opts.warnings[0].message.contains("TRUE or FALSE"));
669 }
670
671 #[test]
672 fn test_invalid_booking_method() {
673 let mut opts = Options::new();
674 opts.set("booking_method", "RANDOM");
675
676 assert_eq!(opts.warnings.len(), 1);
677 assert_eq!(opts.warnings[0].code, "E7002");
678 assert!(opts.warnings[0].message.contains("STRICT"));
679 }
680
681 #[test]
682 fn test_valid_booking_methods() {
683 for method in &["STRICT", "FIFO", "LIFO", "AVERAGE", "NONE"] {
684 let mut opts = Options::new();
685 opts.set("booking_method", method);
686 assert!(
687 opts.warnings.is_empty(),
688 "Should accept {method} as valid booking method"
689 );
690 }
691 }
692
693 #[test]
694 fn test_readonly_option_warning() {
695 let mut opts = Options::new();
696 opts.set("filename", "/some/path.beancount");
697
698 assert_eq!(opts.warnings.len(), 1);
699 assert_eq!(opts.warnings[0].code, "E7005");
700 assert!(opts.warnings[0].message.contains("may not be set"));
701 }
702
703 #[test]
704 fn test_invalid_account_name_validation() {
705 let mut opts = Options::new();
707 opts.set("account_rounding", "invalid");
708
709 assert_eq!(opts.warnings.len(), 1);
710 assert_eq!(opts.warnings[0].code, "E7002");
711 assert!(opts.warnings[0].message.contains("Invalid leaf account"));
712 }
713
714 #[test]
715 fn test_valid_account_name() {
716 let mut opts = Options::new();
717 opts.set("account_rounding", "Equity:Rounding");
718
719 assert!(
720 opts.warnings.is_empty(),
721 "Valid account name should not produce warnings: {:?}",
722 opts.warnings
723 );
724 assert_eq!(opts.account_rounding, Some("Equity:Rounding".to_string()));
725 }
726
727 #[test]
728 fn test_render_commas_with_numeric_values() {
729 let mut opts = Options::new();
730 opts.set("render_commas", "1");
731 assert!(opts.render_commas);
732 assert!(opts.warnings.is_empty());
733
734 let mut opts2 = Options::new();
735 opts2.set("render_commas", "0");
736 assert!(!opts2.render_commas);
737 assert!(opts2.warnings.is_empty());
738 }
739
740 #[test]
741 fn test_plugin_processing_mode_validation() {
742 let mut opts = Options::new();
744 opts.set("plugin_processing_mode", "default");
745 assert!(opts.warnings.is_empty());
746 assert_eq!(opts.plugin_processing_mode, "default");
747
748 let mut opts2 = Options::new();
749 opts2.set("plugin_processing_mode", "raw");
750 assert!(opts2.warnings.is_empty());
751 assert_eq!(opts2.plugin_processing_mode, "raw");
752
753 let mut opts3 = Options::new();
755 opts3.set("plugin_processing_mode", "invalid");
756 assert_eq!(opts3.warnings.len(), 1);
757 assert_eq!(opts3.warnings[0].code, "E7002");
758 }
759
760 #[test]
761 fn test_deprecated_plugin_option() {
762 let mut opts = Options::new();
763 opts.set("plugin", "some.plugin");
764
765 assert_eq!(opts.warnings.len(), 1);
766 assert_eq!(opts.warnings[0].code, "E7004");
767 assert!(opts.warnings[0].message.contains("deprecated"));
768 }
769
770 #[test]
771 fn test_deprecated_allow_pipe_separator() {
772 let mut opts = Options::new();
773 opts.set("allow_pipe_separator", "true");
774
775 assert_eq!(opts.warnings.len(), 1);
776 assert_eq!(opts.warnings[0].code, "E7004");
777 assert!(opts.warnings[0].message.contains("deprecated"));
778 }
779
780 #[test]
781 fn test_is_valid_account() {
782 assert!(Options::is_valid_account("Assets:Bank"));
784 assert!(Options::is_valid_account("Equity:Rounding:Precision"));
785
786 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")); }
792
793 #[test]
794 fn test_account_validation_options() {
795 let account_options = [
797 "account_rounding",
798 "account_current_conversions",
799 "account_unrealized_gains",
800 "account_previous_balances",
801 "account_previous_earnings",
802 "account_previous_conversions",
803 "account_current_earnings",
804 ];
805
806 for opt in account_options {
807 let mut opts = Options::new();
808 opts.set(opt, "lowercase:invalid");
809
810 assert!(
811 !opts.warnings.is_empty(),
812 "Option '{opt}' should warn on invalid account name"
813 );
814 assert_eq!(opts.warnings[0].code, "E7002");
815 }
816 }
817
818 #[test]
819 fn test_inferred_tolerance_default() {
820 let mut opts = Options::new();
821 opts.set("inferred_tolerance_default", "USD:0.005");
822
823 assert!(opts.warnings.is_empty());
824 assert_eq!(
825 opts.inferred_tolerance_default.get("USD"),
826 Some(&rust_decimal_macros::dec!(0.005))
827 );
828
829 let mut opts2 = Options::new();
831 opts2.set("inferred_tolerance_default", "*:0.01");
832 assert!(opts2.warnings.is_empty());
833 assert_eq!(
834 opts2.inferred_tolerance_default.get("*"),
835 Some(&rust_decimal_macros::dec!(0.01))
836 );
837
838 let mut opts3 = Options::new();
840 opts3.set("inferred_tolerance_default", "INVALID");
841 assert_eq!(opts3.warnings.len(), 1);
842 assert_eq!(opts3.warnings[0].code, "E7002");
843 }
844}