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}
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.clone());
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!("Unknown account: '{name}'");
419                    Err(msg.into())
420                }
421            }
422        }
423    }
424
425    /// Get or create `TxnAccount` by name and commodity
426    ///
427    /// Both name and commodity must be valid name and ID
428    /// e.g. this is function is supposed to be used by parser.
429    pub(crate) fn get_or_create_txn_account(
430        &mut self,
431        name: &str,
432        commodity: &Arc<Commodity>,
433    ) -> Result<TxnAccount, tackler::Error> {
434        let comm = self.get_or_create_commodity(Some(commodity.name.as_str()))?;
435
436        let strict_mode = self.strict_mode;
437        let atn_opt = self.accounts.defined_accounts.get(name).cloned();
438
439        let atn = if let Some(account_tree) = atn_opt {
440            TxnAccount {
441                atn: account_tree.clone(),
442                comm,
443            }
444        } else {
445            if self.strict_mode {
446                let msg = format!("Unknown account: '{name}'");
447                return Err(msg.into());
448            }
449            let atn = Arc::new(AccountTreeNode::unchecked_from(name));
450            self.accounts
451                .defined_accounts
452                .insert(name.into(), atn.clone());
453            AccountTrees::build_account_tree(&mut self.accounts.defined_accounts, &atn, None)?;
454
455            TxnAccount { atn, comm }
456        };
457        if !strict_mode {
458            // Not strict mode, so we build the (missing) parents
459            // directly into main Chart of Accounts
460            AccountTrees::build_account_tree(&mut self.accounts.defined_accounts, &atn.atn, None)?;
461        }
462
463        Ok(atn)
464    }
465
466    /// # Errors
467    /// Returns reference for commodity, error if it doesn't exist
468    pub fn get_commodity(&self, name: &str) -> Result<Arc<Commodity>, tackler::Error> {
469        if let Some(comm) = self.commodities.names.get(name) {
470            Ok(comm.clone())
471        } else {
472            let msg = format!("Unknown commodity: '{name}'");
473            Err(msg.into())
474        }
475    }
476
477    /// Get or create `Commodity` by name
478    ///
479    /// The name must be a valid ID
480    /// e.g. this is function is supposed to be used by parser.
481    pub(crate) fn get_or_create_commodity(
482        &mut self,
483        name: Option<&str>,
484    ) -> Result<Arc<Commodity>, tackler::Error> {
485        Self::inner_get_or_create_commodity(&mut self.commodities, self.strict_mode, name)
486    }
487
488    fn inner_get_or_create_commodity(
489        commodities: &mut Commodities,
490        strict_mode: bool,
491        name: Option<&str>,
492    ) -> Result<Arc<Commodity>, tackler::Error> {
493        if let Some(n) = name {
494            if n.is_empty() {
495                let res = if commodities.permit_empty_commodity {
496                    if let Some(c) = commodities.names.get(n) {
497                        Ok(c.clone())
498                    } else {
499                        let comm = Arc::new(Commodity::default());
500                        commodities.names.insert(n.into(), comm.clone());
501
502                        Ok(comm)
503                    }
504                } else {
505                    let msg = "Empty commodity and 'permit-empty-commodity' is not set".to_string();
506                    Err(msg.into())
507                };
508                return res;
509            }
510            match commodities.names.get(n) {
511                Some(comm) => Ok(comm.clone()),
512                None => {
513                    if strict_mode {
514                        let msg = format!("Unknown commodity: '{n}'");
515                        Err(msg.into())
516                    } else {
517                        let comm = Arc::new(Commodity::unchecked_from(n));
518                        commodities.names.insert(n.into(), comm.clone());
519                        Ok(comm)
520                    }
521                }
522            }
523        } else {
524            let comm = Arc::new(Commodity::default());
525            Ok(comm)
526        }
527    }
528
529    pub(crate) fn get_or_create_tag(&mut self, name: &str) -> Result<Arc<Tag>, tackler::Error> {
530        if name.is_empty() {
531            let msg = "Tag name is empty string".to_string();
532            return Err(msg.into());
533        }
534        match self.tags.get(name) {
535            Some(tag) => Ok(tag.clone()),
536            None => {
537                if self.strict_mode {
538                    let msg = format!("Unknown tag: '{name}'");
539                    Err(msg.into())
540                } else {
541                    let tag = Arc::new(Tag::from(name));
542                    self.tags.insert(name.into(), tag.clone());
543                    Ok(tag)
544                }
545            }
546        }
547    }
548
549    #[must_use]
550    pub fn get_price_lookup(&self) -> PriceLookup {
551        self.price_lookup.clone()
552    }
553
554    #[must_use]
555    pub fn input(&self) -> InputSettings {
556        self.input_config.clone()
557    }
558
559    #[allow(clippy::too_many_lines)]
560    fn input_settings(
561        cfg: &Config,
562        storage_overlap: &StorageOverlap,
563    ) -> Result<InputSettings, tackler::Error> {
564        let cfg_input = &cfg.kernel.input;
565
566        // if input_overlap.git.repo => storage git
567        // if input_overlap.fs.path => storage fs
568
569        let storage_target = match storage_overlap.storage_type {
570            Some(storage) => storage,
571            None => cfg_input.storage,
572        };
573
574        let storage_type = if let Some(io) = &storage_overlap.input {
575            match io {
576                InputOverlap::File(f) => {
577                    // This is file based input => no need to configure storage
578                    return Ok(InputSettings::File(FileInput {
579                        path: f.path.clone(),
580                    }));
581                }
582                InputOverlap::Fs(fs) => {
583                    if fs.path.is_none() && storage_target != StorageType::Fs {
584                        let msg = "Conflicting input and storage system arguments. Targeting 'fs', but it's not activated by configuration or by cli options";
585                        return Err(msg.into());
586                    }
587                    StorageType::Fs
588                }
589                InputOverlap::Git(git) => {
590                    if git.repo.is_none() && storage_target != StorageType::Git {
591                        let msg = "Conflicting input and storage system arguments. Targeting 'git', but it's not activated by configuration or by cli options";
592                        return Err(msg.into());
593                    }
594                    StorageType::Git
595                }
596            }
597        } else {
598            storage_target
599        };
600
601        match storage_type {
602            StorageType::Fs => match &cfg_input.fs {
603                Some(fs_cfg) => {
604                    let i = if let Some(InputOverlap::Fs(fs_soi)) = &storage_overlap.input {
605                        // FS: Overlap + Config
606                        let path = fs_soi.path.as_ref().unwrap_or(&fs_cfg.path);
607                        let dir = fs_soi.dir.as_ref().unwrap_or(&fs_cfg.dir);
608                        let ext = fs_soi.ext.as_ref().unwrap_or(&fs_cfg.ext);
609                        FsInput {
610                            path: tackler_rs::get_abs_path(cfg.path(), path)?,
611                            dir: PathBuf::from(dir),
612                            ext: normalize_extension(ext).to_string(),
613                        }
614                    } else {
615                        // FS: No overlap, all info must come from config
616                        let ext = &fs_cfg.ext;
617                        FsInput {
618                            path: tackler_rs::get_abs_path(cfg.path(), fs_cfg.path.as_str())?,
619                            dir: PathBuf::from(&fs_cfg.dir),
620                            ext: normalize_extension(ext).to_string(),
621                        }
622                    };
623                    Ok(InputSettings::Fs(i))
624                }
625                None => {
626                    // FS: No config, all info must come from overlap
627                    if let Some(InputOverlap::Fs(fs_soi)) = &storage_overlap.input {
628                        if let (Some(path), Some(dir), Some(ext)) =
629                            (&fs_soi.path, &fs_soi.dir, &fs_soi.ext)
630                        {
631                            Ok(InputSettings::Fs(FsInput {
632                                path: tackler_rs::get_abs_path(cfg.path(), path)?,
633                                dir: PathBuf::from(dir),
634                                ext: normalize_extension(ext).to_string(),
635                            }))
636                        } else {
637                            let msg = format!(
638                                "Not enough information to configure 'fs' storage: path = '{:?}', dir = '{:?}', ext = '{:?}'",
639                                fs_soi.path, fs_soi.dir, fs_soi.ext
640                            );
641                            Err(msg.into())
642                        }
643                    } else {
644                        Err("Storage type 'fs' is not configured".into())
645                    }
646                }
647            },
648            StorageType::Git => match &cfg_input.git {
649                Some(git_cfg) => {
650                    let i = if let Some(InputOverlap::Git(git_soi)) = &storage_overlap.input {
651                        // GIT: Overlap + Config
652                        let repo = git_soi.repo.as_ref().unwrap_or(&git_cfg.repo);
653                        let dir = git_soi.dir.as_ref().unwrap_or(&git_cfg.dir);
654                        let ext = git_soi.ext.as_ref().unwrap_or(&git_cfg.ext);
655                        // reference is only option via cfg
656                        let cfg_ref = GitInputSelector::Reference(git_cfg.reference.clone());
657                        let git_ref = git_soi.git_ref.as_ref().unwrap_or(&cfg_ref);
658
659                        GitInput {
660                            repo: tackler_rs::get_abs_path(cfg.path(), repo)?,
661                            git_ref: git_ref.clone(),
662                            dir: dir.clone(),
663                            ext: normalize_extension(ext).to_string(),
664                        }
665                    } else {
666                        // GIT: No overlap, all info must come from config
667                        let repo = git_cfg.repo.as_str();
668                        let ext = &git_cfg.ext;
669                        GitInput {
670                            repo: tackler_rs::get_abs_path(cfg.path(), repo)?,
671                            git_ref: GitInputSelector::Reference(git_cfg.reference.clone()),
672                            dir: git_cfg.dir.clone(),
673                            ext: normalize_extension(ext).to_string(),
674                        }
675                    };
676                    Ok(InputSettings::Git(i))
677                }
678                None => {
679                    // GIT: No config, all info must come from overlap
680                    if let Some(InputOverlap::Git(git_soi)) = &storage_overlap.input {
681                        if let (Some(repo), Some(dir), Some(ext), Some(git_ref)) =
682                            (&git_soi.repo, &git_soi.dir, &git_soi.ext, &git_soi.git_ref)
683                        {
684                            Ok(InputSettings::Git(GitInput {
685                                repo: tackler_rs::get_abs_path(cfg.path(), repo)?,
686                                git_ref: git_ref.clone(),
687                                dir: dir.clone(),
688                                ext: normalize_extension(ext).to_string(),
689                            }))
690                        } else {
691                            let msg = format!(
692                                "Not enough information to configure 'git' storage: repo = '{:?}', dir = '{:?}', ext = '{:?}', ref = '{:?}'",
693                                git_soi.repo, git_soi.dir, git_soi.ext, git_soi.git_ref
694                            );
695                            Err(msg.into())
696                        }
697                    } else {
698                        Err("Storage type 'git' is not configured".into())
699                    }
700                }
701            },
702        }
703    }
704}
705
706impl Settings {
707    /// Parse timestamp in tackler-accepted format:
708    ///
709    /// Date (YYYY-MM-DD)
710    /// Date-Time (YYYY-MM-DDTHH:MM:SS[.SSS])
711    /// Date-Time-Zulu (YYYY-MM-DDTHH:MM:SS[.SSS]Z)
712    /// Date-Time-Offset (YYYY-MM-DDTHH:MM:SS[.SSS]+-HH:MM)
713    ///
714    /// Fractional seconds are supported up to nanosecond
715    ///
716    /// # Errors
717    /// Return `Err` timestamp is invalid
718    pub fn parse_timestamp(&mut self, ts: &str) -> Result<Zoned, tackler::Error> {
719        Ok(winnow::Parser::parse(
720            &mut crate::parser::parts::timestamp::parse_timestamp,
721            winnow::Stateful {
722                input: ts,
723                state: self,
724            },
725        )
726        .map_err(|e| e.to_string())?)
727    }
728
729    /// # Errors
730    /// Return `Err` if conversion to zone is not possible
731    pub fn get_offset_datetime(&self, dt: jiff::civil::DateTime) -> Result<Zoned, tackler::Error> {
732        match dt.to_zoned(self.kernel.timestamp.timezone.clone()) {
733            Ok(ts) => Ok(ts),
734            Err(err) => {
735                let msg = format!("time is invalid '{err:?}'");
736                Err(msg.into())
737            }
738        }
739    }
740    /// # Errors
741    /// Return `Err` if conversion to timestamp is not possible
742    pub fn get_offset_date(&self, date: jiff::civil::Date) -> Result<Zoned, tackler::Error> {
743        let ts = date.to_datetime(self.kernel.timestamp.default_time);
744        match ts.to_zoned(self.kernel.timestamp.timezone.clone()) {
745            Ok(ts) => Ok(ts),
746            Err(err) => {
747                let msg = format!("time is invalid '{err:?}'");
748                Err(msg.into())
749            }
750        }
751    }
752
753    #[must_use]
754    pub fn get_report_commodity(&self) -> Option<Arc<Commodity>> {
755        self.report.commodity.clone()
756    }
757
758    #[must_use]
759    pub fn get_report_targets(&self) -> Vec<ReportType> {
760        self.report.targets.clone()
761    }
762
763    #[must_use]
764    pub fn get_export_targets(&self) -> Vec<ExportType> {
765        self.export.targets.clone()
766    }
767
768    #[must_use]
769    fn get_account_selector(&self, acc_sel: &AccountSelectors) -> AccountSelectors {
770        let v = match &self.global_acc_sel {
771            Some(global_acc_sel) => global_acc_sel.clone(),
772            None => acc_sel.clone(),
773        };
774
775        // Turn "" into an empty ("select all") account selector
776        if v.len() == 1 && v[0].is_empty() {
777            Vec::new()
778        } else {
779            v
780        }
781    }
782
783    #[must_use]
784    pub fn get_balance_ras(&self) -> AccountSelectors {
785        self.get_account_selector(&self.report.balance.acc_sel)
786    }
787
788    #[must_use]
789    pub fn get_balance_group_ras(&self) -> AccountSelectors {
790        self.get_account_selector(&self.report.balance_group.acc_sel)
791    }
792
793    #[must_use]
794    pub fn get_register_ras(&self) -> AccountSelectors {
795        self.get_account_selector(&self.report.register.acc_sel)
796    }
797
798    #[must_use]
799    pub fn get_equity_ras(&self) -> AccountSelectors {
800        self.get_account_selector(&self.export.equity.acc_sel)
801    }
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807
808    #[test]
809    fn accounts_strict_false() {
810        let comm = Arc::new(Commodity::default());
811        let mut settings = Settings::default();
812
813        let txntn_1 = settings.get_or_create_txn_account("a:b:c", &comm).unwrap(/*:test:*/);
814        assert_eq!(settings.accounts.defined_accounts.len(), 3);
815
816        assert_eq!(txntn_1.atn.depth, 3);
817        assert_eq!(txntn_1.atn.get_root(), "a");
818        assert_eq!(txntn_1.atn.parent, "a:b");
819        assert_eq!(txntn_1.atn.account, "a:b:c");
820        assert_eq!(txntn_1.atn.get_name(), "c");
821
822        let txntn_2 = settings.get_txn_account("a:b:c", &comm).unwrap(/*:test:*/);
823        assert_eq!(settings.accounts.defined_accounts.len(), 3);
824
825        assert_eq!(txntn_2.atn.depth, 3);
826        assert_eq!(txntn_2.atn.get_root(), "a");
827        assert_eq!(txntn_2.atn.parent, "a:b");
828        assert_eq!(txntn_2.atn.account, "a:b:c");
829        assert_eq!(txntn_2.atn.get_name(), "c");
830
831        let txntn_3 = settings.get_or_create_txn_account("a:b:b-leaf", &comm).unwrap(/*:test:*/);
832        assert_eq!(settings.accounts.defined_accounts.len(), 4);
833
834        assert_eq!(txntn_3.atn.depth, 3);
835        assert_eq!(txntn_3.atn.get_root(), "a");
836        assert_eq!(txntn_3.atn.parent, "a:b");
837        assert_eq!(txntn_3.atn.account, "a:b:b-leaf");
838        assert_eq!(txntn_3.atn.get_name(), "b-leaf");
839    }
840
841    #[test]
842    fn accounts_strict_true() {
843        let comm = Arc::new(Commodity::default());
844        let mut settings = Settings::default();
845        let accounts = vec!["a:b:c".to_string()];
846
847        let acc_trees = AccountTrees::from(&accounts, true).unwrap(/*:test:*/);
848        settings.accounts = acc_trees;
849        settings.strict_mode = true;
850
851        assert_eq!(settings.accounts.defined_accounts.len(), 1);
852        assert_eq!(settings.accounts.synthetic_parents.len(), 2);
853
854        let txntn_1 = settings.get_or_create_txn_account("a:b:c", &comm).unwrap(/*:test:*/);
855        assert_eq!(settings.accounts.defined_accounts.len(), 1);
856        assert_eq!(settings.accounts.synthetic_parents.len(), 2);
857
858        assert_eq!(txntn_1.atn.depth, 3);
859        assert_eq!(txntn_1.atn.get_root(), "a");
860        assert_eq!(txntn_1.atn.parent, "a:b");
861        assert_eq!(txntn_1.atn.account, "a:b:c");
862        assert_eq!(txntn_1.atn.get_name(), "c");
863
864        let txntn_2 = settings.get_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_2.atn.depth, 3);
869        assert_eq!(txntn_2.atn.get_root(), "a");
870        assert_eq!(txntn_2.atn.parent, "a:b");
871        assert_eq!(txntn_2.atn.account, "a:b:c");
872        assert_eq!(txntn_2.atn.get_name(), "c");
873
874        // Check that it won't create a synthetic account as real one
875        assert!(settings.get_or_create_txn_account("a:b", &comm).is_err());
876        assert_eq!(settings.accounts.defined_accounts.len(), 1);
877        assert_eq!(settings.accounts.synthetic_parents.len(), 2);
878
879        // Check synthetic account
880        let txntn_3 = settings.get_txn_account("a:b", &comm).unwrap(/*:test:*/);
881        assert_eq!(settings.accounts.defined_accounts.len(), 1);
882        assert_eq!(settings.accounts.synthetic_parents.len(), 2);
883
884        assert_eq!(txntn_3.atn.depth, 2);
885        assert_eq!(txntn_3.atn.get_root(), "a");
886        assert_eq!(txntn_3.atn.parent, "a");
887        assert_eq!(txntn_3.atn.account, "a:b");
888        assert_eq!(txntn_3.atn.get_name(), "b");
889
890        // Check synthetic account
891        let txntn_4 = settings.get_txn_account("a", &comm).unwrap(/*:test:*/);
892        assert_eq!(settings.accounts.defined_accounts.len(), 1);
893        assert_eq!(settings.accounts.synthetic_parents.len(), 2);
894
895        assert_eq!(txntn_4.atn.depth, 1);
896        assert_eq!(txntn_4.atn.get_root(), "a");
897        assert_eq!(txntn_4.atn.parent, "");
898        assert_eq!(txntn_4.atn.account, "a");
899        assert_eq!(txntn_4.atn.get_name(), "a");
900    }
901
902    #[test]
903    fn accounts_strict_true_child_first() {
904        let comm = Arc::new(Commodity::default());
905        let mut settings = Settings::default();
906        let accounts = vec!["a:b:c".to_string(), "a:b".to_string(), "a".to_string()];
907
908        let acc_trees = AccountTrees::from(&accounts, true).unwrap(/*:test:*/);
909        settings.accounts = acc_trees;
910        settings.strict_mode = true;
911
912        assert_eq!(settings.accounts.defined_accounts.len(), 3);
913        assert_eq!(settings.accounts.synthetic_parents.len(), 0);
914
915        let txntn_1 = settings.get_or_create_txn_account("a:b:c", &comm).unwrap(/*:test:*/);
916        assert_eq!(settings.accounts.defined_accounts.len(), 3);
917        assert_eq!(settings.accounts.synthetic_parents.len(), 0);
918        assert_eq!(txntn_1.atn.account, "a:b:c");
919
920        let txntn_2 = settings.get_or_create_txn_account("a:b", &comm).unwrap(/*:test:*/);
921        assert_eq!(settings.accounts.defined_accounts.len(), 3);
922        assert_eq!(settings.accounts.synthetic_parents.len(), 0);
923        assert_eq!(txntn_2.atn.account, "a:b");
924
925        let txntn_2 = settings.get_or_create_txn_account("a", &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_2.atn.account, "a");
929    }
930
931    #[test]
932    fn accounts_strict_true_gap() {
933        let comm = Arc::new(Commodity::default());
934        let mut settings = Settings::default();
935        let accounts = vec!["a:b:c:d".to_string(), "a:b".to_string(), "a".to_string()];
936
937        let acc_trees = AccountTrees::from(&accounts, true).unwrap(/*:test:*/);
938        settings.accounts = acc_trees;
939        settings.strict_mode = true;
940
941        assert_eq!(settings.accounts.defined_accounts.len(), 3);
942        assert_eq!(settings.accounts.synthetic_parents.len(), 1);
943
944        // Check that it won't create a synthetic account as real one
945        assert!(settings.get_or_create_txn_account("a:b:c", &comm).is_err());
946
947        let txntn_synth = settings.get_txn_account("a:b:c", &comm).unwrap(/*:test:*/);
948        assert_eq!(settings.accounts.defined_accounts.len(), 3);
949        assert_eq!(settings.accounts.synthetic_parents.len(), 1);
950        assert_eq!(txntn_synth.atn.account, "a:b:c");
951
952        let txntn_2 = settings.get_or_create_txn_account("a:b", &comm).unwrap(/*:test:*/);
953        assert_eq!(settings.accounts.defined_accounts.len(), 3);
954        assert_eq!(settings.accounts.synthetic_parents.len(), 1);
955        assert_eq!(txntn_2.atn.account, "a:b");
956
957        let txntn_2 = settings.get_or_create_txn_account("a", &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_2.atn.account, "a");
961    }
962}