use rust_decimal::Decimal;
use rustc_hash::{FxHashMap, FxHashSet};
use std::str::FromStr;
const KNOWN_OPTIONS: &[&str] = &[
"title",
"filename",
"operating_currency",
"name_assets",
"name_liabilities",
"name_equity",
"name_income",
"name_expenses",
"account_rounding",
"account_previous_balances",
"account_previous_earnings",
"account_previous_conversions",
"account_current_earnings",
"account_current_conversions",
"account_unrealized_gains",
"conversion_currency",
"inferred_tolerance_default",
"inferred_tolerance_multiplier",
"infer_tolerance_from_cost",
"use_legacy_fixed_tolerances",
"experiment_explicit_tolerances",
"booking_method",
"render_commas",
"display_precision",
"allow_pipe_separator",
"long_string_maxlines",
"documents",
"insert_pythonpath",
"plugin_processing_mode",
"plugin", "tolerance_multiplier", ];
const REPEATABLE_OPTIONS: &[&str] = &[
"operating_currency",
"insert_pythonpath",
"documents",
"inferred_tolerance_default",
"display_precision",
];
const READONLY_OPTIONS: &[&str] = &["filename"];
#[derive(Debug, Clone)]
pub struct OptionWarning {
pub code: &'static str,
pub message: String,
pub option: String,
pub value: String,
}
#[derive(Debug, Clone)]
pub struct Options {
pub title: Option<String>,
pub filename: Option<String>,
pub operating_currency: Vec<String>,
pub name_assets: String,
pub name_liabilities: String,
pub name_equity: String,
pub name_income: String,
pub name_expenses: String,
pub account_rounding: Option<String>,
pub account_previous_balances: String,
pub account_previous_earnings: String,
pub account_previous_conversions: String,
pub account_current_earnings: String,
pub account_current_conversions: Option<String>,
pub account_unrealized_gains: Option<String>,
pub conversion_currency: Option<String>,
pub inferred_tolerance_default: FxHashMap<String, Decimal>,
pub inferred_tolerance_multiplier: Decimal,
pub infer_tolerance_from_cost: bool,
pub use_legacy_fixed_tolerances: bool,
pub experiment_explicit_tolerances: bool,
pub booking_method: String,
pub render_commas: bool,
pub display_precision: FxHashMap<String, u32>,
pub allow_pipe_separator: bool,
pub long_string_maxlines: u32,
pub documents: Vec<String>,
pub plugin_processing_mode: String,
pub custom: FxHashMap<String, String>,
#[doc(hidden)]
pub set_options: FxHashSet<String>,
pub warnings: Vec<OptionWarning>,
}
impl Default for Options {
fn default() -> Self {
Self::new()
}
}
impl Options {
#[must_use]
pub fn new() -> Self {
Self {
title: None,
filename: None,
operating_currency: Vec::new(),
name_assets: "Assets".to_string(),
name_liabilities: "Liabilities".to_string(),
name_equity: "Equity".to_string(),
name_income: "Income".to_string(),
name_expenses: "Expenses".to_string(),
account_rounding: None,
account_previous_balances: "Equity:Opening-Balances".to_string(),
account_previous_earnings: "Equity:Earnings:Previous".to_string(),
account_previous_conversions: "Equity:Conversions:Previous".to_string(),
account_current_earnings: "Equity:Earnings:Current".to_string(),
account_current_conversions: None,
account_unrealized_gains: None,
conversion_currency: None,
inferred_tolerance_default: FxHashMap::default(),
inferred_tolerance_multiplier: Decimal::new(5, 1), infer_tolerance_from_cost: false,
use_legacy_fixed_tolerances: false,
experiment_explicit_tolerances: false,
booking_method: "STRICT".to_string(),
render_commas: false, display_precision: FxHashMap::default(),
allow_pipe_separator: false,
long_string_maxlines: 64,
documents: Vec::new(),
plugin_processing_mode: "default".to_string(),
custom: FxHashMap::default(),
set_options: FxHashSet::default(),
warnings: Vec::new(),
}
}
pub fn set(&mut self, key: &str, value: &str) {
let is_known = KNOWN_OPTIONS.contains(&key);
if !is_known {
self.warnings.push(OptionWarning {
code: "E7001",
message: format!("Invalid option \"{key}\""),
option: key.to_string(),
value: value.to_string(),
});
}
if READONLY_OPTIONS.contains(&key) {
self.warnings.push(OptionWarning {
code: "E7005",
message: format!("Option '{key}' may not be set"),
option: key.to_string(),
value: value.to_string(),
});
return; }
let is_repeatable = REPEATABLE_OPTIONS.contains(&key);
if is_known && !is_repeatable && self.set_options.contains(key) {
self.warnings.push(OptionWarning {
code: "E7003",
message: format!("Option \"{key}\" can only be specified once"),
option: key.to_string(),
value: value.to_string(),
});
}
self.set_options.insert(key.to_string());
match key {
"title" => self.title = Some(value.to_string()),
"operating_currency" => self.operating_currency.push(value.to_string()),
"name_assets" => self.name_assets = value.to_string(),
"name_liabilities" => self.name_liabilities = value.to_string(),
"name_equity" => self.name_equity = value.to_string(),
"name_income" => self.name_income = value.to_string(),
"name_expenses" => self.name_expenses = value.to_string(),
"account_rounding" => {
if !Self::is_valid_account(value) {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!("Invalid leaf account name: '{value}'"),
option: key.to_string(),
value: value.to_string(),
});
}
self.account_rounding = Some(value.to_string());
}
"account_current_conversions" => {
if !Self::is_valid_account(value) {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!("Invalid leaf account name: '{value}'"),
option: key.to_string(),
value: value.to_string(),
});
}
self.account_current_conversions = Some(value.to_string());
}
"account_unrealized_gains" => {
if !Self::is_valid_account(value) {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!("Invalid leaf account name: '{value}'"),
option: key.to_string(),
value: value.to_string(),
});
}
self.account_unrealized_gains = Some(value.to_string());
}
"inferred_tolerance_multiplier" => {
self.warnings.push(OptionWarning {
code: "E7004",
message: "Renamed to 'tolerance_multiplier'.".to_string(),
option: key.to_string(),
value: value.to_string(),
});
if let Ok(d) = Decimal::from_str(value) {
self.inferred_tolerance_multiplier = d;
} else {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!(
"Invalid value \"{value}\" for option \"{key}\": expected decimal number"
),
option: key.to_string(),
value: value.to_string(),
});
}
}
"tolerance_multiplier" => {
if let Ok(d) = Decimal::from_str(value) {
self.inferred_tolerance_multiplier = d;
} else {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!(
"Invalid value \"{value}\" for option \"{key}\": expected decimal number"
),
option: key.to_string(),
value: value.to_string(),
});
}
}
"infer_tolerance_from_cost" => {
if !value.eq_ignore_ascii_case("true") && !value.eq_ignore_ascii_case("false") {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!(
"Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
),
option: key.to_string(),
value: value.to_string(),
});
}
self.infer_tolerance_from_cost = value.eq_ignore_ascii_case("true");
}
"booking_method" => {
let valid_methods = [
"STRICT",
"STRICT_WITH_SIZE",
"FIFO",
"LIFO",
"HIFO",
"AVERAGE",
"NONE",
];
if !valid_methods.contains(&value.to_uppercase().as_str()) {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!(
"Invalid value \"{}\" for option \"{}\": expected one of {}",
value,
key,
valid_methods.join(", ")
),
option: key.to_string(),
value: value.to_string(),
});
}
self.booking_method = value.to_string();
}
"render_commas" => {
let is_true = value.eq_ignore_ascii_case("true") || value == "1";
let is_false = value.eq_ignore_ascii_case("false") || value == "0";
if !is_true && !is_false {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!(
"Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
),
option: key.to_string(),
value: value.to_string(),
});
}
self.render_commas = is_true;
}
"display_precision" => {
if let Some((curr, example)) = value.split_once(':') {
if let Ok(d) = Decimal::from_str(example) {
let precision = d.scale();
self.display_precision.insert(curr.to_string(), precision);
} else {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!(
"Invalid precision value \"{example}\" in option \"{key}\""
),
option: key.to_string(),
value: value.to_string(),
});
}
} else {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!(
"Invalid format for option \"{key}\": expected CURRENCY:EXAMPLE (e.g., CHF:0.01)"
),
option: key.to_string(),
value: value.to_string(),
});
}
}
"filename" => self.filename = Some(value.to_string()),
"account_previous_balances" => {
if !Self::is_valid_account(value) {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!("Invalid leaf account name: '{value}'"),
option: key.to_string(),
value: value.to_string(),
});
}
self.account_previous_balances = value.to_string();
}
"account_previous_earnings" => {
if !Self::is_valid_account(value) {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!("Invalid leaf account name: '{value}'"),
option: key.to_string(),
value: value.to_string(),
});
}
self.account_previous_earnings = value.to_string();
}
"account_previous_conversions" => {
if !Self::is_valid_account(value) {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!("Invalid leaf account name: '{value}'"),
option: key.to_string(),
value: value.to_string(),
});
}
self.account_previous_conversions = value.to_string();
}
"account_current_earnings" => {
if !Self::is_valid_account(value) {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!("Invalid leaf account name: '{value}'"),
option: key.to_string(),
value: value.to_string(),
});
}
self.account_current_earnings = value.to_string();
}
"conversion_currency" => self.conversion_currency = Some(value.to_string()),
"inferred_tolerance_default" => {
if let Some((curr, tol)) = value.split_once(':') {
if let Ok(d) = Decimal::from_str(tol) {
self.inferred_tolerance_default.insert(curr.to_string(), d);
} else {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!(
"Invalid tolerance value \"{tol}\" in option \"{key}\""
),
option: key.to_string(),
value: value.to_string(),
});
}
} else {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!(
"Invalid format for option \"{key}\": expected CURRENCY:TOLERANCE"
),
option: key.to_string(),
value: value.to_string(),
});
}
}
"use_legacy_fixed_tolerances" => {
self.use_legacy_fixed_tolerances = value.eq_ignore_ascii_case("true");
}
"experiment_explicit_tolerances" => {
self.experiment_explicit_tolerances = value.eq_ignore_ascii_case("true");
}
"allow_pipe_separator" => {
self.warnings.push(OptionWarning {
code: "E7004",
message: "Option 'allow_pipe_separator' is deprecated".to_string(),
option: key.to_string(),
value: value.to_string(),
});
self.allow_pipe_separator = value.eq_ignore_ascii_case("true");
}
"long_string_maxlines" => {
if let Ok(n) = value.parse::<u32>() {
self.long_string_maxlines = n;
} else {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!(
"Invalid value \"{value}\" for option \"{key}\": expected integer"
),
option: key.to_string(),
value: value.to_string(),
});
}
}
"documents" => {
if !std::path::Path::new(value).exists() {
self.warnings.push(OptionWarning {
code: "E7006",
message: format!("Document root '{value}' does not exist"),
option: key.to_string(),
value: value.to_string(),
});
}
self.documents.push(value.to_string());
}
"plugin_processing_mode" => {
if value != "default" && value != "raw" {
self.warnings.push(OptionWarning {
code: "E7002",
message: format!("Invalid value '{value}'"),
option: key.to_string(),
value: value.to_string(),
});
}
self.plugin_processing_mode = value.to_string();
}
"plugin" => {
self.warnings.push(OptionWarning {
code: "E7004",
message: "Option 'plugin' is deprecated; use the 'plugin' directive instead"
.to_string(),
option: key.to_string(),
value: value.to_string(),
});
}
_ => {
self.custom.insert(key.to_string(), value.to_string());
}
}
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
self.custom.get(key).map(String::as_str)
}
#[must_use]
pub fn account_types(&self) -> [&str; 5] {
[
&self.name_assets,
&self.name_liabilities,
&self.name_equity,
&self.name_income,
&self.name_expenses,
]
}
fn is_valid_account(value: &str) -> bool {
if !value.contains(':') {
return false;
}
for part in value.split(':') {
if let Some(first) = part.chars().next() {
let valid = first.is_uppercase() || (!first.is_ascii() && first.is_alphabetic());
if !valid {
return false;
}
} else {
return false;
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_options() {
let opts = Options::new();
assert_eq!(opts.name_assets, "Assets");
assert_eq!(opts.booking_method, "STRICT");
assert!(!opts.infer_tolerance_from_cost);
}
#[test]
fn test_set_options() {
let mut opts = Options::new();
opts.set("title", "My Ledger");
opts.set("operating_currency", "USD");
opts.set("operating_currency", "EUR");
opts.set("booking_method", "FIFO");
assert_eq!(opts.title, Some("My Ledger".to_string()));
assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
assert_eq!(opts.booking_method, "FIFO");
}
#[test]
fn test_custom_options() {
let mut opts = Options::new();
opts.set("my_custom_option", "my_value");
assert_eq!(opts.get("my_custom_option"), Some("my_value"));
assert_eq!(opts.get("nonexistent"), None);
}
#[test]
fn test_unknown_option_warning() {
let mut opts = Options::new();
opts.set("unknown_option", "value");
assert_eq!(opts.warnings.len(), 1);
assert_eq!(opts.warnings[0].code, "E7001");
assert!(opts.warnings[0].message.contains("Invalid option"));
}
#[test]
fn test_duplicate_option_warning() {
let mut opts = Options::new();
opts.set("title", "First Title");
opts.set("title", "Second Title");
assert_eq!(opts.warnings.len(), 1);
assert_eq!(opts.warnings[0].code, "E7003");
assert!(opts.warnings[0].message.contains("only be specified once"));
}
#[test]
fn test_repeatable_option_no_warning() {
let mut opts = Options::new();
opts.set("operating_currency", "USD");
opts.set("operating_currency", "EUR");
assert!(
opts.warnings.is_empty(),
"Should not warn for repeatable options: {:?}",
opts.warnings
);
assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
}
#[test]
fn test_invalid_tolerance_value() {
let mut opts = Options::new();
opts.set("inferred_tolerance_multiplier", "not_a_number");
assert_eq!(opts.warnings.len(), 2);
assert_eq!(opts.warnings[0].code, "E7004");
assert!(opts.warnings[0].message.contains("Renamed"));
assert_eq!(opts.warnings[1].code, "E7002");
assert!(opts.warnings[1].message.contains("expected decimal"));
}
#[test]
fn test_tolerance_multiplier_new_name() {
let mut opts = Options::new();
opts.set("tolerance_multiplier", "1.5");
assert!(opts.warnings.is_empty());
assert_eq!(opts.inferred_tolerance_multiplier, Decimal::new(15, 1));
}
#[test]
fn test_inferred_tolerance_multiplier_deprecated() {
let mut opts = Options::new();
opts.set("inferred_tolerance_multiplier", "1.01");
assert_eq!(opts.warnings.len(), 1);
assert_eq!(opts.warnings[0].code, "E7004");
assert!(
opts.warnings[0]
.message
.contains("Renamed to 'tolerance_multiplier'")
);
assert_eq!(
opts.inferred_tolerance_multiplier,
Decimal::from_str("1.01").unwrap()
);
}
#[test]
fn test_invalid_boolean_value() {
let mut opts = Options::new();
opts.set("infer_tolerance_from_cost", "maybe");
assert_eq!(opts.warnings.len(), 1);
assert_eq!(opts.warnings[0].code, "E7002");
assert!(opts.warnings[0].message.contains("TRUE or FALSE"));
}
#[test]
fn test_invalid_booking_method() {
let mut opts = Options::new();
opts.set("booking_method", "RANDOM");
assert_eq!(opts.warnings.len(), 1);
assert_eq!(opts.warnings[0].code, "E7002");
assert!(opts.warnings[0].message.contains("STRICT"));
}
#[test]
fn test_valid_booking_methods() {
for method in &["STRICT", "FIFO", "LIFO", "AVERAGE", "NONE"] {
let mut opts = Options::new();
opts.set("booking_method", method);
assert!(
opts.warnings.is_empty(),
"Should accept {method} as valid booking method"
);
}
}
#[test]
fn test_readonly_option_warning() {
let mut opts = Options::new();
opts.set("filename", "/some/path.beancount");
assert_eq!(opts.warnings.len(), 1);
assert_eq!(opts.warnings[0].code, "E7005");
assert!(opts.warnings[0].message.contains("may not be set"));
}
#[test]
fn test_invalid_account_name_validation() {
let mut opts = Options::new();
opts.set("account_rounding", "invalid");
assert_eq!(opts.warnings.len(), 1);
assert_eq!(opts.warnings[0].code, "E7002");
assert!(opts.warnings[0].message.contains("Invalid leaf account"));
}
#[test]
fn test_valid_account_name() {
let mut opts = Options::new();
opts.set("account_rounding", "Equity:Rounding");
assert!(
opts.warnings.is_empty(),
"Valid account name should not produce warnings: {:?}",
opts.warnings
);
assert_eq!(opts.account_rounding, Some("Equity:Rounding".to_string()));
}
#[test]
fn test_render_commas_with_numeric_values() {
let mut opts = Options::new();
opts.set("render_commas", "1");
assert!(opts.render_commas);
assert!(opts.warnings.is_empty());
let mut opts2 = Options::new();
opts2.set("render_commas", "0");
assert!(!opts2.render_commas);
assert!(opts2.warnings.is_empty());
}
#[test]
fn test_plugin_processing_mode_validation() {
let mut opts = Options::new();
opts.set("plugin_processing_mode", "default");
assert!(opts.warnings.is_empty());
assert_eq!(opts.plugin_processing_mode, "default");
let mut opts2 = Options::new();
opts2.set("plugin_processing_mode", "raw");
assert!(opts2.warnings.is_empty());
assert_eq!(opts2.plugin_processing_mode, "raw");
let mut opts3 = Options::new();
opts3.set("plugin_processing_mode", "invalid");
assert_eq!(opts3.warnings.len(), 1);
assert_eq!(opts3.warnings[0].code, "E7002");
}
#[test]
fn test_deprecated_plugin_option() {
let mut opts = Options::new();
opts.set("plugin", "some.plugin");
assert_eq!(opts.warnings.len(), 1);
assert_eq!(opts.warnings[0].code, "E7004");
assert!(opts.warnings[0].message.contains("deprecated"));
}
#[test]
fn test_deprecated_allow_pipe_separator() {
let mut opts = Options::new();
opts.set("allow_pipe_separator", "true");
assert_eq!(opts.warnings.len(), 1);
assert_eq!(opts.warnings[0].code, "E7004");
assert!(opts.warnings[0].message.contains("deprecated"));
}
#[test]
fn test_is_valid_account() {
assert!(Options::is_valid_account("Assets:Bank"));
assert!(Options::is_valid_account("Equity:Rounding:Precision"));
assert!(Options::is_valid_account("Капитал:Retained"));
assert!(Options::is_valid_account("资产:银行:支票"));
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")); }
#[test]
fn test_account_validation_options() {
let account_options = [
"account_rounding",
"account_current_conversions",
"account_unrealized_gains",
"account_previous_balances",
"account_previous_earnings",
"account_previous_conversions",
"account_current_earnings",
];
for opt in account_options {
let mut opts = Options::new();
opts.set(opt, "lowercase:invalid");
assert!(
!opts.warnings.is_empty(),
"Option '{opt}' should warn on invalid account name"
);
assert_eq!(opts.warnings[0].code, "E7002");
}
}
#[test]
fn test_inferred_tolerance_default() {
let mut opts = Options::new();
opts.set("inferred_tolerance_default", "USD:0.005");
assert!(opts.warnings.is_empty());
assert_eq!(
opts.inferred_tolerance_default.get("USD"),
Some(&rust_decimal_macros::dec!(0.005))
);
let mut opts2 = Options::new();
opts2.set("inferred_tolerance_default", "*:0.01");
assert!(opts2.warnings.is_empty());
assert_eq!(
opts2.inferred_tolerance_default.get("*"),
Some(&rust_decimal_macros::dec!(0.01))
);
let mut opts3 = Options::new();
opts3.set("inferred_tolerance_default", "INVALID");
assert_eq!(opts3.warnings.len(), 1);
assert_eq!(opts3.warnings[0].code, "E7002");
}
}