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