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 "allow_pipe_separator",
33 "long_string_maxlines",
34 "documents",
35 "insert_pythonpath",
36 "plugin_processing_mode",
37];
38
39const REPEATABLE_OPTIONS: &[&str] = &["operating_currency", "insert_pythonpath", "documents"];
41
42#[derive(Debug, Clone)]
44pub struct OptionWarning {
45 pub code: &'static str,
47 pub message: String,
49 pub option: String,
51 pub value: String,
53}
54
55#[derive(Debug, Clone, Default)]
59pub struct Options {
60 pub title: Option<String>,
62
63 pub filename: Option<String>,
65
66 pub operating_currency: Vec<String>,
68
69 pub name_assets: String,
71
72 pub name_liabilities: String,
74
75 pub name_equity: String,
77
78 pub name_income: String,
80
81 pub name_expenses: String,
83
84 pub account_rounding: Option<String>,
86
87 pub account_previous_balances: String,
89
90 pub account_previous_earnings: String,
92
93 pub account_previous_conversions: String,
95
96 pub account_current_earnings: String,
98
99 pub account_current_conversions: Option<String>,
101
102 pub account_unrealized_gains: Option<String>,
104
105 pub conversion_currency: Option<String>,
107
108 pub inferred_tolerance_default: HashMap<String, Decimal>,
110
111 pub inferred_tolerance_multiplier: Decimal,
113
114 pub infer_tolerance_from_cost: bool,
116
117 pub use_legacy_fixed_tolerances: bool,
119
120 pub experiment_explicit_tolerances: bool,
122
123 pub booking_method: String,
125
126 pub render_commas: bool,
128
129 pub allow_pipe_separator: bool,
131
132 pub long_string_maxlines: u32,
134
135 pub documents: Vec<String>,
137
138 pub custom: HashMap<String, String>,
140
141 #[doc(hidden)]
143 pub set_options: HashSet<String>,
144
145 pub warnings: Vec<OptionWarning>,
147}
148
149impl Options {
150 #[must_use]
152 pub fn new() -> Self {
153 Self {
154 title: None,
155 filename: None,
156 operating_currency: Vec::new(),
157 name_assets: "Assets".to_string(),
158 name_liabilities: "Liabilities".to_string(),
159 name_equity: "Equity".to_string(),
160 name_income: "Income".to_string(),
161 name_expenses: "Expenses".to_string(),
162 account_rounding: None,
163 account_previous_balances: "Equity:Opening-Balances".to_string(),
164 account_previous_earnings: "Equity:Earnings:Previous".to_string(),
165 account_previous_conversions: "Equity:Conversions:Previous".to_string(),
166 account_current_earnings: "Equity:Earnings:Current".to_string(),
167 account_current_conversions: None,
168 account_unrealized_gains: None,
169 conversion_currency: None,
170 inferred_tolerance_default: HashMap::new(),
171 inferred_tolerance_multiplier: Decimal::new(5, 1), infer_tolerance_from_cost: true,
173 use_legacy_fixed_tolerances: false,
174 experiment_explicit_tolerances: false,
175 booking_method: "STRICT".to_string(),
176 render_commas: true,
177 allow_pipe_separator: false,
178 long_string_maxlines: 64,
179 documents: Vec::new(),
180 custom: HashMap::new(),
181 set_options: HashSet::new(),
182 warnings: Vec::new(),
183 }
184 }
185
186 pub fn set(&mut self, key: &str, value: &str) {
190 let is_known = KNOWN_OPTIONS.contains(&key);
192 if !is_known {
193 self.warnings.push(OptionWarning {
194 code: "E7001",
195 message: format!("Unknown option \"{key}\""),
196 option: key.to_string(),
197 value: value.to_string(),
198 });
199 }
200
201 let is_repeatable = REPEATABLE_OPTIONS.contains(&key);
203 if is_known && !is_repeatable && self.set_options.contains(key) {
204 self.warnings.push(OptionWarning {
205 code: "E7003",
206 message: format!("Option \"{key}\" can only be specified once"),
207 option: key.to_string(),
208 value: value.to_string(),
209 });
210 }
211
212 self.set_options.insert(key.to_string());
214
215 match key {
217 "title" => self.title = Some(value.to_string()),
218 "operating_currency" => self.operating_currency.push(value.to_string()),
219 "name_assets" => self.name_assets = value.to_string(),
220 "name_liabilities" => self.name_liabilities = value.to_string(),
221 "name_equity" => self.name_equity = value.to_string(),
222 "name_income" => self.name_income = value.to_string(),
223 "name_expenses" => self.name_expenses = value.to_string(),
224 "account_rounding" => self.account_rounding = Some(value.to_string()),
225 "account_current_conversions" => {
226 self.account_current_conversions = Some(value.to_string());
227 }
228 "account_unrealized_gains" => {
229 self.account_unrealized_gains = Some(value.to_string());
230 }
231 "inferred_tolerance_multiplier" => {
232 if let Ok(d) = Decimal::from_str(value) {
233 self.inferred_tolerance_multiplier = d;
234 } else {
235 self.warnings.push(OptionWarning {
237 code: "E7002",
238 message: format!(
239 "Invalid value \"{value}\" for option \"{key}\": expected decimal number"
240 ),
241 option: key.to_string(),
242 value: value.to_string(),
243 });
244 }
245 }
246 "infer_tolerance_from_cost" => {
247 if !value.eq_ignore_ascii_case("true") && !value.eq_ignore_ascii_case("false") {
248 self.warnings.push(OptionWarning {
249 code: "E7002",
250 message: format!(
251 "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
252 ),
253 option: key.to_string(),
254 value: value.to_string(),
255 });
256 }
257 self.infer_tolerance_from_cost = value.eq_ignore_ascii_case("true");
258 }
259 "booking_method" => {
260 let valid_methods = [
261 "STRICT",
262 "STRICT_WITH_SIZE",
263 "FIFO",
264 "LIFO",
265 "HIFO",
266 "AVERAGE",
267 "NONE",
268 ];
269 if !valid_methods.contains(&value.to_uppercase().as_str()) {
270 self.warnings.push(OptionWarning {
271 code: "E7002",
272 message: format!(
273 "Invalid value \"{}\" for option \"{}\": expected one of {}",
274 value,
275 key,
276 valid_methods.join(", ")
277 ),
278 option: key.to_string(),
279 value: value.to_string(),
280 });
281 }
282 self.booking_method = value.to_string();
283 }
284 "render_commas" => {
285 if !value.eq_ignore_ascii_case("true") && !value.eq_ignore_ascii_case("false") {
286 self.warnings.push(OptionWarning {
287 code: "E7002",
288 message: format!(
289 "Invalid value \"{value}\" for option \"{key}\": expected TRUE or FALSE"
290 ),
291 option: key.to_string(),
292 value: value.to_string(),
293 });
294 }
295 self.render_commas = value.eq_ignore_ascii_case("true");
296 }
297 "filename" => self.filename = Some(value.to_string()),
298 "account_previous_balances" => self.account_previous_balances = value.to_string(),
299 "account_previous_earnings" => self.account_previous_earnings = value.to_string(),
300 "account_previous_conversions" => self.account_previous_conversions = value.to_string(),
301 "account_current_earnings" => self.account_current_earnings = value.to_string(),
302 "conversion_currency" => self.conversion_currency = Some(value.to_string()),
303 "inferred_tolerance_default" => {
304 if let Some((curr, tol)) = value.split_once(':') {
306 if let Ok(d) = Decimal::from_str(tol) {
307 self.inferred_tolerance_default.insert(curr.to_string(), d);
308 } else {
309 self.warnings.push(OptionWarning {
310 code: "E7002",
311 message: format!(
312 "Invalid tolerance value \"{tol}\" in option \"{key}\""
313 ),
314 option: key.to_string(),
315 value: value.to_string(),
316 });
317 }
318 } else {
319 self.warnings.push(OptionWarning {
320 code: "E7002",
321 message: format!(
322 "Invalid format for option \"{key}\": expected CURRENCY:TOLERANCE"
323 ),
324 option: key.to_string(),
325 value: value.to_string(),
326 });
327 }
328 }
329 "use_legacy_fixed_tolerances" => {
330 self.use_legacy_fixed_tolerances = value.eq_ignore_ascii_case("true");
331 }
332 "experiment_explicit_tolerances" => {
333 self.experiment_explicit_tolerances = value.eq_ignore_ascii_case("true");
334 }
335 "allow_pipe_separator" => {
336 self.allow_pipe_separator = value.eq_ignore_ascii_case("true");
337 }
338 "long_string_maxlines" => {
339 if let Ok(n) = value.parse::<u32>() {
340 self.long_string_maxlines = n;
341 } else {
342 self.warnings.push(OptionWarning {
343 code: "E7002",
344 message: format!(
345 "Invalid value \"{value}\" for option \"{key}\": expected integer"
346 ),
347 option: key.to_string(),
348 value: value.to_string(),
349 });
350 }
351 }
352 "documents" => self.documents.push(value.to_string()),
353 _ => {
354 self.custom.insert(key.to_string(), value.to_string());
356 }
357 }
358 }
359
360 #[must_use]
362 pub fn get(&self, key: &str) -> Option<&str> {
363 self.custom.get(key).map(String::as_str)
364 }
365
366 #[must_use]
368 pub fn account_types(&self) -> [&str; 5] {
369 [
370 &self.name_assets,
371 &self.name_liabilities,
372 &self.name_equity,
373 &self.name_income,
374 &self.name_expenses,
375 ]
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn test_default_options() {
385 let opts = Options::new();
386 assert_eq!(opts.name_assets, "Assets");
387 assert_eq!(opts.booking_method, "STRICT");
388 assert!(opts.infer_tolerance_from_cost);
389 }
390
391 #[test]
392 fn test_set_options() {
393 let mut opts = Options::new();
394 opts.set("title", "My Ledger");
395 opts.set("operating_currency", "USD");
396 opts.set("operating_currency", "EUR");
397 opts.set("booking_method", "FIFO");
398
399 assert_eq!(opts.title, Some("My Ledger".to_string()));
400 assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
401 assert_eq!(opts.booking_method, "FIFO");
402 }
403
404 #[test]
405 fn test_custom_options() {
406 let mut opts = Options::new();
407 opts.set("my_custom_option", "my_value");
408
409 assert_eq!(opts.get("my_custom_option"), Some("my_value"));
410 assert_eq!(opts.get("nonexistent"), None);
411 }
412
413 #[test]
414 fn test_unknown_option_warning() {
415 let mut opts = Options::new();
416 opts.set("unknown_option", "value");
417
418 assert_eq!(opts.warnings.len(), 1);
419 assert_eq!(opts.warnings[0].code, "E7001");
420 assert!(opts.warnings[0].message.contains("Unknown option"));
421 }
422
423 #[test]
424 fn test_duplicate_option_warning() {
425 let mut opts = Options::new();
426 opts.set("title", "First Title");
427 opts.set("title", "Second Title");
428
429 assert_eq!(opts.warnings.len(), 1);
430 assert_eq!(opts.warnings[0].code, "E7003");
431 assert!(opts.warnings[0].message.contains("only be specified once"));
432 }
433
434 #[test]
435 fn test_repeatable_option_no_warning() {
436 let mut opts = Options::new();
437 opts.set("operating_currency", "USD");
438 opts.set("operating_currency", "EUR");
439
440 assert!(
442 opts.warnings.is_empty(),
443 "Should not warn for repeatable options: {:?}",
444 opts.warnings
445 );
446 assert_eq!(opts.operating_currency, vec!["USD", "EUR"]);
447 }
448
449 #[test]
450 fn test_invalid_tolerance_value() {
451 let mut opts = Options::new();
452 opts.set("inferred_tolerance_multiplier", "not_a_number");
453
454 assert_eq!(opts.warnings.len(), 1);
455 assert_eq!(opts.warnings[0].code, "E7002");
456 assert!(opts.warnings[0].message.contains("expected decimal"));
457 }
458
459 #[test]
460 fn test_invalid_boolean_value() {
461 let mut opts = Options::new();
462 opts.set("infer_tolerance_from_cost", "maybe");
463
464 assert_eq!(opts.warnings.len(), 1);
465 assert_eq!(opts.warnings[0].code, "E7002");
466 assert!(opts.warnings[0].message.contains("TRUE or FALSE"));
467 }
468
469 #[test]
470 fn test_invalid_booking_method() {
471 let mut opts = Options::new();
472 opts.set("booking_method", "RANDOM");
473
474 assert_eq!(opts.warnings.len(), 1);
475 assert_eq!(opts.warnings[0].code, "E7002");
476 assert!(opts.warnings[0].message.contains("STRICT"));
477 }
478
479 #[test]
480 fn test_valid_booking_methods() {
481 for method in &["STRICT", "FIFO", "LIFO", "AVERAGE", "NONE"] {
482 let mut opts = Options::new();
483 opts.set("booking_method", method);
484 assert!(
485 opts.warnings.is_empty(),
486 "Should accept {method} as valid booking method"
487 );
488 }
489 }
490}