tackler_core/kernel/
settings.rs

1/*
2 * Tackler-NG 2023-2025
3 * SPDX-License-Identifier: Apache-2.0
4 */
5use crate::config::overlaps::{InputOverlap, OverlapConfig, StorageOverlap};
6use crate::config::{
7    AccountSelectors, Config, Export, ExportType, Kernel, PriceLookupType, Report, ReportType,
8    StorageType,
9};
10use crate::kernel::hash::Hash;
11use crate::kernel::price_lookup::PriceLookup;
12use crate::model::TxnAccount;
13use crate::model::price_entry::PriceDb;
14use crate::model::{AccountTreeNode, Commodity};
15use crate::{config, parser, tackler};
16use jiff::Zoned;
17use std::collections::HashMap;
18use std::path::PathBuf;
19use std::sync::Arc;
20use tackler_api::txn_header::Tag;
21use tackler_api::txn_ts::GroupBy;
22use tackler_rs::normalize_extension;
23
24#[derive(Debug, Default, Clone)]
25pub struct FileInput {
26    pub path: PathBuf,
27}
28
29#[derive(Debug, Clone)]
30pub struct FsInput {
31    pub path: PathBuf,
32    pub dir: PathBuf,
33    pub ext: String,
34}
35
36#[derive(Debug, Clone)]
37pub enum GitInputSelector {
38    CommitId(String),
39    Reference(String),
40}
41
42#[derive(Debug, Clone)]
43pub struct GitInput {
44    pub repo: PathBuf,
45    pub dir: String,
46    pub git_ref: GitInputSelector,
47    pub ext: String,
48}
49
50#[derive(Debug, Clone)]
51pub enum InputSettings {
52    File(FileInput),
53    Fs(FsInput),
54    Git(GitInput),
55}
56impl Default for InputSettings {
57    fn default() -> Self {
58        Self::File(FileInput::default())
59    }
60}
61
62#[derive(Debug, Default)]
63struct Commodities {
64    names: HashMap<String, Arc<Commodity>>,
65    permit_empty_commodity: bool,
66}
67
68impl Commodities {
69    fn default_empty_ok() -> Self {
70        Commodities {
71            names: HashMap::new(),
72            permit_empty_commodity: true,
73        }
74    }
75
76    fn from(cfg: &Config) -> Result<Commodities, tackler::Error> {
77        let cfg_comm = &cfg.transaction.commodities;
78        let permit_empty_commodity = cfg_comm.permit_empty_commodity.unwrap_or(false);
79
80        let comms =
81            cfg_comm.names.iter().try_fold(
82                HashMap::new(),
83                |mut chm, comm| match Commodity::from(comm.to_string()) {
84                    Ok(c) => {
85                        chm.insert(comm.into(), Arc::new(c));
86                        Ok(chm)
87                    }
88                    Err(e) => {
89                        let msg = format!("Invalid Chart of Commodities: {e}");
90                        Err(msg)
91                    }
92                },
93            )?;
94        Ok(Commodities {
95            names: comms,
96            permit_empty_commodity,
97        })
98    }
99}
100
101#[derive(Debug, Default)]
102struct AccountTrees {
103    defined_accounts: HashMap<String, Arc<AccountTreeNode>>,
104    synthetic_parents: HashMap<String, Arc<AccountTreeNode>>,
105}
106
107impl AccountTrees {
108    fn build_account_tree(
109        target_account_tree: &mut HashMap<String, Arc<AccountTreeNode>>,
110        atn: &Arc<AccountTreeNode>,
111        other_account_tree: Option<&HashMap<String, Arc<AccountTreeNode>>>,
112    ) -> Result<(), tackler::Error> {
113        let parent = atn.parent.as_str();
114        let has_parent = other_account_tree.is_some_and(|a| a.contains_key(parent))
115            || target_account_tree.contains_key(parent);
116
117        if has_parent || atn.is_root() {
118            // this breaks recursion
119            Ok(())
120        } else {
121            let parent_atn =
122                Arc::new(AccountTreeNode::from(parent).expect("IE: synthetic parent is invalid"));
123            target_account_tree.insert(parent.to_string(), parent_atn.clone());
124
125            Self::build_account_tree(target_account_tree, &parent_atn, other_account_tree)
126        }
127    }
128
129    fn from(account_names: &[String], strict_mode: bool) -> Result<AccountTrees, tackler::Error> {
130        let defined_accounts =
131            account_names
132                .iter()
133                .try_fold(
134                    HashMap::new(),
135                    |mut accs, account| match AccountTreeNode::from(account) {
136                        Ok(atn) => {
137                            accs.insert(account.into(), Arc::new(atn));
138                            Ok(accs)
139                        }
140                        Err(e) => {
141                            let msg = format!("Invalid Chart of Accounts: {e}");
142                            Err(msg)
143                        }
144                    },
145                )?;
146
147        let synthetic_parents = if strict_mode {
148            // Synthetic Account Parents are only needed in strict mode
149            let mut sap = HashMap::new();
150            for atn_entry in &defined_accounts {
151                if !&defined_accounts.contains_key(atn_entry.1.parent.as_str()) {
152                    // Parent is missing -> Let's build synthetic tree
153                    let (_, atn) = atn_entry;
154                    Self::build_account_tree(&mut sap, atn, Some(&defined_accounts))?;
155                }
156            }
157            sap
158        } else {
159            HashMap::new()
160        };
161        Ok(AccountTrees {
162            defined_accounts,
163            synthetic_parents,
164        })
165    }
166}
167
168#[derive(Debug, Default)]
169pub struct Price {
170    // todo: fix visibility
171    pub price_db: PriceDb,
172    pub lookup_type: PriceLookupType,
173}
174
175#[derive(Debug)]
176pub struct Settings {
177    pub(crate) audit_mode: bool,
178    pub(crate) report: Report,
179    pub(crate) export: Export,
180    strict_mode: bool,
181    pub(crate) inverted: bool,
182    input_config: InputSettings,
183    kernel: Kernel,
184    pub price: Price,
185    price_lookup: PriceLookup,
186    global_acc_sel: Option<AccountSelectors>,
187    accounts: AccountTrees,
188    commodities: Commodities,
189    tags: HashMap<String, Arc<Tag>>,
190}
191
192impl Default for Settings {
193    fn default() -> Self {
194        Settings {
195            strict_mode: false,
196            audit_mode: false,
197            inverted: false,
198            input_config: InputSettings::default(),
199            report: Report::default(),
200            export: Export::default(),
201            kernel: Kernel::default(),
202            price: Price::default(),
203            price_lookup: PriceLookup::default(),
204            global_acc_sel: None,
205            accounts: AccountTrees::default(),
206            commodities: Commodities::default_empty_ok(),
207            tags: HashMap::new(),
208        }
209    }
210}
211
212impl Settings {
213    #[must_use]
214    pub fn default_audit() -> Self {
215        Settings {
216            audit_mode: true,
217            ..Self::default()
218        }
219    }
220}
221
222impl Settings {
223    /// # Errors
224    /// Return `Err` in case of semantically incorrect configuration
225    #[allow(clippy::too_many_lines)]
226    pub fn try_from(cfg: Config, overlaps: OverlapConfig) -> Result<Settings, tackler::Error> {
227        fn check_given_time_usage(
228            gt: Option<&String>,
229            plt: PriceLookupType,
230        ) -> Result<(), tackler::Error> {
231            if gt.is_some() {
232                let msg = format!(
233                    "Price \"before timestamp\" is not allowed when price lookup type is \"{plt}\""
234                );
235                return Err(msg.into());
236            }
237            Ok(())
238        }
239
240        let strict_mode = overlaps.strict.mode.unwrap_or(cfg.kernel.strict);
241        let audit_mode = overlaps.audit.mode.unwrap_or(cfg.kernel.audit.mode);
242
243        let input_settings = Self::input_settings(&cfg, &overlaps.storage)?;
244
245        let reports = match overlaps.target.reports {
246            Some(reports) => config::to_report_targets(&reports)?,
247            None => cfg.report.targets.clone(),
248        };
249        let exports = match overlaps.target.exports {
250            Some(exports) => config::to_export_targets(&exports)?,
251            None => cfg.export.targets.clone(),
252        };
253
254        let formats = match overlaps.target.formats {
255            Some(formats) => config::to_report_formats(Some(&formats))?,
256            None => cfg.report.formats.clone(),
257        };
258
259        let lookup_type = overlaps.price.lookup_type.unwrap_or(cfg.price.lookup_type);
260
261        let db_path = overlaps.price.db_path.unwrap_or(cfg.price.db_path.clone());
262
263        let account_trees = AccountTrees::from(&cfg.transaction.accounts.names, strict_mode)?;
264
265        let mut commodities = Commodities::from(&cfg)?;
266
267        let tags = cfg
268            .transaction
269            .tags
270            .names
271            .iter()
272            .fold(HashMap::new(), |mut tags, tag| {
273                let t = Tag::from(tag.to_string());
274                tags.insert(tag.into(), Arc::new(t));
275                tags
276            });
277
278        if strict_mode
279            && exports.contains(&ExportType::Equity)
280            && !account_trees
281                .defined_accounts
282                .contains_key(cfg.export.equity.equity_account.as_str())
283        {
284            let msg = "Unknown `equity.equity-account` and `strict` mode is on".to_string();
285            return Err(msg.into());
286        }
287
288        let cfg_rpt_commodity = cfg
289            .report
290            .commodity
291            .map(|c| {
292                Self::inner_get_or_create_commodity(
293                    &mut commodities,
294                    strict_mode,
295                    Some(c.name.as_str()),
296                )
297            })
298            .transpose()?;
299
300        let report_commodity = match overlaps.report.commodity {
301            Some(c) => Some(Self::inner_get_or_create_commodity(
302                &mut commodities,
303                strict_mode,
304                Some(c.as_str()),
305            )?),
306            None => cfg_rpt_commodity,
307        };
308
309        if report_commodity.is_none() && lookup_type != PriceLookupType::None {
310            let msg =
311                "Price conversion is activated, but there is no `report.commodity`".to_string();
312            return Err(msg.into());
313        }
314
315        let group_by = overlaps
316            .report
317            .group_by
318            .map(|g| GroupBy::from(g.as_str()))
319            .unwrap_or(Ok(cfg.report.balance_group.group_by))?;
320
321        let mut tmp_settings = Settings {
322            strict_mode,
323            audit_mode,
324            inverted: overlaps.report.inverted,
325            kernel: cfg.kernel,
326            input_config: input_settings,
327            price: Price::default(), // this is not real, see next one
328            price_lookup: PriceLookup::default(), // this is not real, see next one
329            global_acc_sel: overlaps.report.account_overlap,
330            report: Report {
331                commodity: report_commodity,
332                targets: reports,
333                formats,
334                ..cfg.report
335            },
336            export: Export {
337                targets: exports,
338                ..cfg.export
339            },
340            accounts: account_trees,
341            commodities,
342            tags,
343        };
344        tmp_settings.report.balance_group.group_by = group_by;
345
346        let given_time = overlaps.price.before_time;
347
348        let price_lookup = match lookup_type {
349            plt @ PriceLookupType::LastPrice => {
350                check_given_time_usage(given_time.as_ref(), plt)?;
351                PriceLookup::LastPriceDbEntry
352            }
353            plt @ PriceLookupType::TxnTime => {
354                check_given_time_usage(given_time.as_ref(), plt)?;
355                PriceLookup::AtTheTimeOfTxn
356            }
357            plt @ PriceLookupType::GivenTime => {
358                if let Some(ts) = given_time {
359                    tmp_settings
360                        .parse_timestamp(ts.as_str())
361                        .map(PriceLookup::GivenTime)?
362                } else {
363                    let msg =
364                        format!("Price lookup type is \"{plt}\" and there is no timestamp given");
365                    return Err(msg.into());
366                }
367            }
368            plt @ PriceLookupType::None => {
369                check_given_time_usage(given_time.as_ref(), plt)?;
370                PriceLookup::None
371            }
372        };
373
374        let price = match &lookup_type {
375            PriceLookupType::None => Price::default(),
376            _ => Price {
377                // we need half-baked settings here bc commodity and timestamp lookups
378                price_db: parser::pricedb_from_file(&db_path, &mut tmp_settings)?,
379                lookup_type,
380            },
381        };
382
383        Ok(Settings {
384            price,
385            price_lookup,
386            ..tmp_settings
387        })
388    }
389}
390impl Settings {
391    pub(crate) fn get_hash(&self) -> Option<Hash> {
392        if self.audit_mode {
393            Some(self.kernel.audit.hash.clone())
394        } else {
395            None
396        }
397    }
398
399    pub(crate) fn get_txn_account(
400        &self,
401        name: &str,
402        commodity: &Arc<Commodity>,
403    ) -> Result<TxnAccount, tackler::Error> {
404        let comm = self.get_commodity(commodity.name.as_str())?;
405
406        match self.accounts.defined_accounts.get(name) {
407            Some(account_tree) => Ok(TxnAccount {
408                atn: account_tree.clone(),
409                comm,
410            }),
411            None => {
412                if let Some(acc_parent) = self.accounts.synthetic_parents.get(name) {
413                    Ok(TxnAccount {
414                        atn: acc_parent.clone(),
415                        comm,
416                    })
417                } else {
418                    let msg = format!("gta: Unknown account: '{name}'");
419                    Err(msg.into())
420                }
421            }
422        }
423    }
424
425    pub(crate) fn get_or_create_txn_account(
426        &mut self,
427        name: &str,
428        commodity: &Arc<Commodity>,
429    ) -> Result<TxnAccount, tackler::Error> {
430        let comm = self.get_or_create_commodity(Some(commodity.name.as_str()))?;
431
432        let strict_mode = self.strict_mode;
433        let atn_opt = self.accounts.defined_accounts.get(name).cloned();
434
435        let atn = if let Some(account_tree) = atn_opt {
436            TxnAccount {
437                atn: account_tree.clone(),
438                comm,
439            }
440        } else {
441            if self.strict_mode {
442                let msg = format!("Unknown account: '{name}'");
443                return Err(msg.into());
444            }
445            let atn = Arc::new(AccountTreeNode::from(name)?);
446            self.accounts
447                .defined_accounts
448                .insert(name.into(), atn.clone());
449            AccountTrees::build_account_tree(&mut self.accounts.defined_accounts, &atn, None)?;
450
451            TxnAccount { atn, comm }
452        };
453        if !strict_mode {
454            // Not strict mode, so we build the (missing) parents
455            // directly into main Chart of Accounts
456            AccountTrees::build_account_tree(&mut self.accounts.defined_accounts, &atn.atn, None)?;
457        }
458
459        Ok(atn)
460    }
461
462    /// # Errors
463    /// Returns reference for commodity, error if it doesn't exist
464    pub fn get_commodity(&self, name: &str) -> Result<Arc<Commodity>, tackler::Error> {
465        if let Some(comm) = self.commodities.names.get(name) {
466            Ok(comm.clone())
467        } else {
468            let msg = format!("Unknown commodity: '{name}'");
469            Err(msg.into())
470        }
471    }
472    pub(crate) fn get_or_create_commodity(
473        &mut self,
474        name: Option<&str>,
475    ) -> Result<Arc<Commodity>, tackler::Error> {
476        Self::inner_get_or_create_commodity(&mut self.commodities, self.strict_mode, name)
477    }
478
479    fn inner_get_or_create_commodity(
480        commodities: &mut Commodities,
481        strict_mode: bool,
482        name: Option<&str>,
483    ) -> Result<Arc<Commodity>, tackler::Error> {
484        if let Some(n) = name {
485            if n.is_empty() {
486                let res = if commodities.permit_empty_commodity {
487                    if let Some(c) = commodities.names.get(n) {
488                        Ok(c.clone())
489                    } else {
490                        let comm = Arc::new(Commodity::default());
491                        commodities.names.insert(n.into(), comm.clone());
492
493                        Ok(comm)
494                    }
495                } else {
496                    let msg = "Empty commodity and 'permit-empty-commodity' is not set".to_string();
497                    Err(msg.into())
498                };
499                return res;
500            }
501            match commodities.names.get(n) {
502                Some(comm) => Ok(comm.clone()),
503                None => {
504                    if strict_mode {
505                        let msg = format!("Unknown commodity: '{n}'");
506                        Err(msg.into())
507                    } else {
508                        let comm = Arc::new(Commodity::from(n.into())?);
509                        commodities.names.insert(n.into(), comm.clone());
510                        Ok(comm)
511                    }
512                }
513            }
514        } else {
515            let comm = Arc::new(Commodity::default());
516            Ok(comm)
517        }
518    }
519
520    pub(crate) fn get_or_create_tag(&mut self, name: &str) -> Result<Arc<Tag>, tackler::Error> {
521        if name.is_empty() {
522            let msg = "Tag name is empty string".to_string();
523            return Err(msg.into());
524        }
525        match self.tags.get(name) {
526            Some(tag) => Ok(tag.clone()),
527            None => {
528                if self.strict_mode {
529                    let msg = format!("Unknown tag: '{name}'");
530                    Err(msg.into())
531                } else {
532                    let tag = Arc::new(Tag::from(name));
533                    self.tags.insert(name.into(), tag.clone());
534                    Ok(tag)
535                }
536            }
537        }
538    }
539
540    #[must_use]
541    pub fn get_price_lookup(&self) -> PriceLookup {
542        self.price_lookup.clone()
543    }
544
545    #[must_use]
546    pub fn input(&self) -> InputSettings {
547        self.input_config.clone()
548    }
549
550    #[allow(clippy::too_many_lines)]
551    fn input_settings(
552        cfg: &Config,
553        storage_overlap: &StorageOverlap,
554    ) -> Result<InputSettings, tackler::Error> {
555        let cfg_input = &cfg.kernel.input;
556
557        // if input_overlap.git.repo => storage git
558        // if input_overlap.fs.path => storage fs
559
560        let storage_target = match storage_overlap.storage_type {
561            Some(storage) => storage,
562            None => cfg_input.storage,
563        };
564
565        let storage_type = if let Some(io) = &storage_overlap.input {
566            match io {
567                InputOverlap::File(f) => {
568                    // This is file based input => no need to configure storage
569                    return Ok(InputSettings::File(FileInput {
570                        path: f.path.clone(),
571                    }));
572                }
573                InputOverlap::Fs(fs) => {
574                    if fs.path.is_none() && storage_target != StorageType::Fs {
575                        let msg = "Conflicting input and storage system arguments. Targeting 'fs', but it's not activated by configuration or by cli options";
576                        return Err(msg.into());
577                    }
578                    StorageType::Fs
579                }
580                InputOverlap::Git(git) => {
581                    if git.repo.is_none() && storage_target != StorageType::Git {
582                        let msg = "Conflicting input and storage system arguments. Targeting 'git', but it's not activated by configuration or by cli options";
583                        return Err(msg.into());
584                    }
585                    StorageType::Git
586                }
587            }
588        } else {
589            storage_target
590        };
591
592        match storage_type {
593            StorageType::Fs => match &cfg_input.fs {
594                Some(fs_cfg) => {
595                    let i = if let Some(InputOverlap::Fs(fs_soi)) = &storage_overlap.input {
596                        // FS: Overlap + Config
597                        let path = fs_soi.path.as_ref().unwrap_or(&fs_cfg.path);
598                        let dir = fs_soi.dir.as_ref().unwrap_or(&fs_cfg.dir);
599                        let ext = fs_soi.ext.as_ref().unwrap_or(&fs_cfg.ext);
600                        FsInput {
601                            path: tackler_rs::get_abs_path(cfg.path(), path)?,
602                            dir: PathBuf::from(dir),
603                            ext: normalize_extension(ext).to_string(),
604                        }
605                    } else {
606                        // FS: No overlap, all info must come from config
607                        let ext = &fs_cfg.ext;
608                        FsInput {
609                            path: tackler_rs::get_abs_path(cfg.path(), fs_cfg.path.as_str())?,
610                            dir: PathBuf::from(&fs_cfg.dir),
611                            ext: normalize_extension(ext).to_string(),
612                        }
613                    };
614                    Ok(InputSettings::Fs(i))
615                }
616                None => {
617                    // FS: No config, all info must come from overlap
618                    if let Some(InputOverlap::Fs(fs_soi)) = &storage_overlap.input {
619                        if let (Some(path), Some(dir), Some(ext)) =
620                            (&fs_soi.path, &fs_soi.dir, &fs_soi.ext)
621                        {
622                            Ok(InputSettings::Fs(FsInput {
623                                path: tackler_rs::get_abs_path(cfg.path(), path)?,
624                                dir: PathBuf::from(dir),
625                                ext: normalize_extension(ext).to_string(),
626                            }))
627                        } else {
628                            let msg = format!(
629                                "Not enough information to configure 'fs' storage: path = '{:?}', dir = '{:?}', ext = '{:?}'",
630                                fs_soi.path, fs_soi.dir, fs_soi.ext
631                            );
632                            Err(msg.into())
633                        }
634                    } else {
635                        Err("Storage type 'fs' is not configured".into())
636                    }
637                }
638            },
639            StorageType::Git => match &cfg_input.git {
640                Some(git_cfg) => {
641                    let i = if let Some(InputOverlap::Git(git_soi)) = &storage_overlap.input {
642                        // GIT: Overlap + Config
643                        let repo = git_soi.repo.as_ref().unwrap_or(&git_cfg.repo);
644                        let dir = git_soi.dir.as_ref().unwrap_or(&git_cfg.dir);
645                        let ext = git_soi.ext.as_ref().unwrap_or(&git_cfg.ext);
646                        // reference is only option via cfg
647                        let cfg_ref = GitInputSelector::Reference(git_cfg.reference.clone());
648                        let git_ref = git_soi.git_ref.as_ref().unwrap_or(&cfg_ref);
649
650                        GitInput {
651                            repo: tackler_rs::get_abs_path(cfg.path(), repo)?,
652                            git_ref: git_ref.clone(),
653                            dir: dir.clone(),
654                            ext: normalize_extension(ext).to_string(),
655                        }
656                    } else {
657                        // GIT: No overlap, all info must come from config
658                        let repo = git_cfg.repo.as_str();
659                        let ext = &git_cfg.ext;
660                        GitInput {
661                            repo: tackler_rs::get_abs_path(cfg.path(), repo)?,
662                            git_ref: GitInputSelector::Reference(git_cfg.reference.clone()),
663                            dir: git_cfg.dir.clone(),
664                            ext: normalize_extension(ext).to_string(),
665                        }
666                    };
667                    Ok(InputSettings::Git(i))
668                }
669                None => {
670                    // GIT: No config, all info must come from overlap
671                    if let Some(InputOverlap::Git(git_soi)) = &storage_overlap.input {
672                        if let (Some(repo), Some(dir), Some(ext), Some(git_ref)) =
673                            (&git_soi.repo, &git_soi.dir, &git_soi.ext, &git_soi.git_ref)
674                        {
675                            Ok(InputSettings::Git(GitInput {
676                                repo: tackler_rs::get_abs_path(cfg.path(), repo)?,
677                                git_ref: git_ref.clone(),
678                                dir: dir.clone(),
679                                ext: normalize_extension(ext).to_string(),
680                            }))
681                        } else {
682                            let msg = format!(
683                                "Not enough information to configure 'git' storage: repo = '{:?}', dir = '{:?}', ext = '{:?}', ref = '{:?}'",
684                                git_soi.repo, git_soi.dir, git_soi.ext, git_soi.git_ref
685                            );
686                            Err(msg.into())
687                        }
688                    } else {
689                        Err("Storage type 'git' is not configured".into())
690                    }
691                }
692            },
693        }
694    }
695}
696
697impl Settings {
698    /// Parse timestamp in tackler-accepted format:
699    ///
700    /// Date (YYYY-MM-DD)
701    /// Date-Time (YYYY-MM-DDTHH:MM:SS[.SSS])
702    /// Date-Time-Zulu (YYYY-MM-DDTHH:MM:SS[.SSS]Z)
703    /// Date-Time-Offset (YYYY-MM-DDTHH:MM:SS[.SSS]+-HH:MM)
704    ///
705    /// Fractional seconds are supported up to nanosecond
706    ///
707    /// # Errors
708    /// Return `Err` timestamp is invalid
709    pub fn parse_timestamp(&mut self, ts: &str) -> Result<Zoned, tackler::Error> {
710        Ok(winnow::Parser::parse(
711            &mut crate::parser::parts::timestamp::parse_timestamp,
712            winnow::Stateful {
713                input: ts,
714                state: self,
715            },
716        )
717        .map_err(|e| e.to_string())?)
718    }
719
720    /// # Errors
721    /// Return `Err` if conversion to zone is not possible
722    pub fn get_offset_datetime(&self, dt: jiff::civil::DateTime) -> Result<Zoned, tackler::Error> {
723        match dt.to_zoned(self.kernel.timestamp.timezone.clone()) {
724            Ok(ts) => Ok(ts),
725            Err(err) => {
726                let msg = format!("time is invalid '{err:?}'");
727                Err(msg.into())
728            }
729        }
730    }
731    /// # Errors
732    /// Return `Err` if conversion to timestamp is not possible
733    pub fn get_offset_date(&self, date: jiff::civil::Date) -> Result<Zoned, tackler::Error> {
734        let ts = date.to_datetime(self.kernel.timestamp.default_time);
735        match ts.to_zoned(self.kernel.timestamp.timezone.clone()) {
736            Ok(ts) => Ok(ts),
737            Err(err) => {
738                let msg = format!("time is invalid '{err:?}'");
739                Err(msg.into())
740            }
741        }
742    }
743
744    #[must_use]
745    pub fn get_report_commodity(&self) -> Option<Arc<Commodity>> {
746        self.report.commodity.clone()
747    }
748
749    #[must_use]
750    pub fn get_report_targets(&self) -> Vec<ReportType> {
751        self.report.targets.clone()
752    }
753
754    #[must_use]
755    pub fn get_export_targets(&self) -> Vec<ExportType> {
756        self.export.targets.clone()
757    }
758
759    #[must_use]
760    fn get_account_selector(&self, acc_sel: &AccountSelectors) -> AccountSelectors {
761        let v = match &self.global_acc_sel {
762            Some(global_acc_sel) => global_acc_sel.clone(),
763            None => acc_sel.clone(),
764        };
765
766        // Turn "" into an empty ("select all") account selector
767        if v.len() == 1 && v[0].is_empty() {
768            Vec::new()
769        } else {
770            v
771        }
772    }
773
774    #[must_use]
775    pub fn get_balance_ras(&self) -> AccountSelectors {
776        self.get_account_selector(&self.report.balance.acc_sel)
777    }
778
779    #[must_use]
780    pub fn get_balance_group_ras(&self) -> AccountSelectors {
781        self.get_account_selector(&self.report.balance_group.acc_sel)
782    }
783
784    #[must_use]
785    pub fn get_register_ras(&self) -> AccountSelectors {
786        self.get_account_selector(&self.report.register.acc_sel)
787    }
788
789    #[must_use]
790    pub fn get_equity_ras(&self) -> AccountSelectors {
791        self.get_account_selector(&self.export.equity.acc_sel)
792    }
793}
794
795#[cfg(test)]
796#[allow(non_snake_case)]
797mod tests {
798    use super::*;
799
800    #[test]
801    fn accounts_strict_false() {
802        let comm = Arc::new(Commodity::default());
803        let mut settings = Settings::default();
804
805        let txntn_1 = settings.get_or_create_txn_account("a:b:c", &comm).unwrap(/*:test:*/);
806        assert_eq!(settings.accounts.defined_accounts.len(), 3);
807
808        assert_eq!(txntn_1.atn.depth, 3);
809        assert_eq!(txntn_1.atn.get_root(), "a");
810        assert_eq!(txntn_1.atn.parent, "a:b");
811        assert_eq!(txntn_1.atn.account, "a:b:c");
812        assert_eq!(txntn_1.atn.get_name(), "c");
813
814        let txntn_2 = settings.get_txn_account("a:b:c", &comm).unwrap(/*:test:*/);
815        assert_eq!(settings.accounts.defined_accounts.len(), 3);
816
817        assert_eq!(txntn_2.atn.depth, 3);
818        assert_eq!(txntn_2.atn.get_root(), "a");
819        assert_eq!(txntn_2.atn.parent, "a:b");
820        assert_eq!(txntn_2.atn.account, "a:b:c");
821        assert_eq!(txntn_2.atn.get_name(), "c");
822
823        let txntn_3 = settings.get_or_create_txn_account("a:b:b-leaf", &comm).unwrap(/*:test:*/);
824        assert_eq!(settings.accounts.defined_accounts.len(), 4);
825
826        assert_eq!(txntn_3.atn.depth, 3);
827        assert_eq!(txntn_3.atn.get_root(), "a");
828        assert_eq!(txntn_3.atn.parent, "a:b");
829        assert_eq!(txntn_3.atn.account, "a:b:b-leaf");
830        assert_eq!(txntn_3.atn.get_name(), "b-leaf");
831    }
832
833    #[test]
834    fn accounts_strict_true() {
835        let comm = Arc::new(Commodity::default());
836        let mut settings = Settings::default();
837        let accounts = vec!["a:b:c".to_string()];
838
839        let acc_trees = AccountTrees::from(&accounts, true).unwrap(/*:test:*/);
840        settings.accounts = acc_trees;
841        settings.strict_mode = true;
842
843        assert_eq!(settings.accounts.defined_accounts.len(), 1);
844        assert_eq!(settings.accounts.synthetic_parents.len(), 2);
845
846        let txntn_1 = settings.get_or_create_txn_account("a:b:c", &comm).unwrap(/*:test:*/);
847        assert_eq!(settings.accounts.defined_accounts.len(), 1);
848        assert_eq!(settings.accounts.synthetic_parents.len(), 2);
849
850        assert_eq!(txntn_1.atn.depth, 3);
851        assert_eq!(txntn_1.atn.get_root(), "a");
852        assert_eq!(txntn_1.atn.parent, "a:b");
853        assert_eq!(txntn_1.atn.account, "a:b:c");
854        assert_eq!(txntn_1.atn.get_name(), "c");
855
856        let txntn_2 = settings.get_txn_account("a:b:c", &comm).unwrap(/*:test:*/);
857        assert_eq!(settings.accounts.defined_accounts.len(), 1);
858        assert_eq!(settings.accounts.synthetic_parents.len(), 2);
859
860        assert_eq!(txntn_2.atn.depth, 3);
861        assert_eq!(txntn_2.atn.get_root(), "a");
862        assert_eq!(txntn_2.atn.parent, "a:b");
863        assert_eq!(txntn_2.atn.account, "a:b:c");
864        assert_eq!(txntn_2.atn.get_name(), "c");
865
866        // Check that it won't create a synthetic account as real one
867        assert!(settings.get_or_create_txn_account("a:b", &comm).is_err());
868        assert_eq!(settings.accounts.defined_accounts.len(), 1);
869        assert_eq!(settings.accounts.synthetic_parents.len(), 2);
870
871        // Check synthetic account
872        let txntn_3 = settings.get_txn_account("a:b", &comm).unwrap(/*:test:*/);
873        assert_eq!(settings.accounts.defined_accounts.len(), 1);
874        assert_eq!(settings.accounts.synthetic_parents.len(), 2);
875
876        assert_eq!(txntn_3.atn.depth, 2);
877        assert_eq!(txntn_3.atn.get_root(), "a");
878        assert_eq!(txntn_3.atn.parent, "a");
879        assert_eq!(txntn_3.atn.account, "a:b");
880        assert_eq!(txntn_3.atn.get_name(), "b");
881
882        // Check synthetic account
883        let txntn_4 = settings.get_txn_account("a", &comm).unwrap(/*:test:*/);
884        assert_eq!(settings.accounts.defined_accounts.len(), 1);
885        assert_eq!(settings.accounts.synthetic_parents.len(), 2);
886
887        assert_eq!(txntn_4.atn.depth, 1);
888        assert_eq!(txntn_4.atn.get_root(), "a");
889        assert_eq!(txntn_4.atn.parent, "");
890        assert_eq!(txntn_4.atn.account, "a");
891        assert_eq!(txntn_4.atn.get_name(), "a");
892    }
893
894    #[test]
895    fn accounts_strict_true_child_first() {
896        let comm = Arc::new(Commodity::default());
897        let mut settings = Settings::default();
898        let accounts = vec!["a:b:c".to_string(), "a:b".to_string(), "a".to_string()];
899
900        let acc_trees = AccountTrees::from(&accounts, true).unwrap(/*:test:*/);
901        settings.accounts = acc_trees;
902        settings.strict_mode = true;
903
904        assert_eq!(settings.accounts.defined_accounts.len(), 3);
905        assert_eq!(settings.accounts.synthetic_parents.len(), 0);
906
907        let txntn_1 = settings.get_or_create_txn_account("a:b:c", &comm).unwrap(/*:test:*/);
908        assert_eq!(settings.accounts.defined_accounts.len(), 3);
909        assert_eq!(settings.accounts.synthetic_parents.len(), 0);
910        assert_eq!(txntn_1.atn.account, "a:b:c");
911
912        let txntn_2 = settings.get_or_create_txn_account("a:b", &comm).unwrap(/*:test:*/);
913        assert_eq!(settings.accounts.defined_accounts.len(), 3);
914        assert_eq!(settings.accounts.synthetic_parents.len(), 0);
915        assert_eq!(txntn_2.atn.account, "a:b");
916
917        let txntn_2 = settings.get_or_create_txn_account("a", &comm).unwrap(/*:test:*/);
918        assert_eq!(settings.accounts.defined_accounts.len(), 3);
919        assert_eq!(settings.accounts.synthetic_parents.len(), 0);
920        assert_eq!(txntn_2.atn.account, "a");
921    }
922
923    #[test]
924    fn accounts_strict_true_gap() {
925        let comm = Arc::new(Commodity::default());
926        let mut settings = Settings::default();
927        let accounts = vec!["a:b:c:d".to_string(), "a:b".to_string(), "a".to_string()];
928
929        let acc_trees = AccountTrees::from(&accounts, true).unwrap(/*:test:*/);
930        settings.accounts = acc_trees;
931        settings.strict_mode = true;
932
933        assert_eq!(settings.accounts.defined_accounts.len(), 3);
934        assert_eq!(settings.accounts.synthetic_parents.len(), 1);
935
936        // Check that it won't create a synthetic account as real one
937        assert!(settings.get_or_create_txn_account("a:b:c", &comm).is_err());
938
939        let txntn_synth = settings.get_txn_account("a:b:c", &comm).unwrap(/*:test:*/);
940        assert_eq!(settings.accounts.defined_accounts.len(), 3);
941        assert_eq!(settings.accounts.synthetic_parents.len(), 1);
942        assert_eq!(txntn_synth.atn.account, "a:b:c");
943
944        let txntn_2 = settings.get_or_create_txn_account("a:b", &comm).unwrap(/*:test:*/);
945        assert_eq!(settings.accounts.defined_accounts.len(), 3);
946        assert_eq!(settings.accounts.synthetic_parents.len(), 1);
947        assert_eq!(txntn_2.atn.account, "a:b");
948
949        let txntn_2 = settings.get_or_create_txn_account("a", &comm).unwrap(/*:test:*/);
950        assert_eq!(settings.accounts.defined_accounts.len(), 3);
951        assert_eq!(settings.accounts.synthetic_parents.len(), 1);
952        assert_eq!(txntn_2.atn.account, "a");
953    }
954}