Skip to main content

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