tackler-core 0.19.0

Core components for Tackler accounting engine with native GIT SCM support
Documentation
/*
 * Tackler-NG 2023-2026
 * SPDX-License-Identifier: Apache-2.0
 */

use crate::parser::{is_valid_identifier, is_valid_name};
use crate::tackler;
use std::cmp::Ordering;
use std::fmt::{Display, Formatter};
use std::hash::{Hash, Hasher};
use std::sync::Arc;

#[derive(Debug, Clone, Default, Hash, Eq, Ord, PartialEq, PartialOrd)]
pub struct Commodity {
    pub name: String,
}

impl Display for Commodity {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.name)
    }
}

impl Commodity {
    pub fn is_any(&self) -> bool {
        !self.name.is_empty()
    }
    pub fn is_void(&self) -> bool {
        self.name.is_empty()
    }
}

impl Commodity {
    pub fn from(name: &str) -> Result<Commodity, tackler::Error> {
        match is_valid_identifier(name) {
            Err(e) => {
                let msg = format!("This is not a valid commodity: '{name}', error was: {e}");
                Err(msg.into())
            }
            Ok(_) => Ok(Self::unchecked_from(name)),
        }
    }

    pub fn unchecked_from(name: &str) -> Commodity {
        Commodity {
            name: name.to_string(),
        }
    }
}

#[allow(dead_code)]
#[derive(Debug, Clone, Eq)]
pub(crate) struct AccountTreeNode {
    pub(crate) depth: usize,
    root: String,
    /// parent account (path)
    pub(crate) parent: String,
    /// account as sub-accounts
    parts: Vec<String>,
    /// account of posting (path)
    pub(crate) account: String,
    /// account name (leaf)
    name: String,
}

impl AccountTreeNode {
    pub(crate) fn is_root(&self) -> bool {
        self.depth == 1
    }
}

#[derive(Debug, Clone, Eq)]
pub struct TxnAccount {
    pub(crate) atn: Arc<AccountTreeNode>,
    pub(crate) comm: Arc<Commodity>,
}

impl Hash for TxnAccount {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.atn.account.hash(state);
        self.comm.name.hash(state);
    }
}
impl PartialEq for TxnAccount {
    fn eq(&self, other: &Self) -> bool {
        self.atn == other.atn && self.comm == other.comm
    }
}
impl Ord for TxnAccount {
    fn cmp(&self, other: &Self) -> Ordering {
        match self.comm.cmp(&other.comm) {
            Ordering::Less => Ordering::Less,
            Ordering::Greater => Ordering::Greater,
            Ordering::Equal => self.atn.cmp(&other.atn),
        }
    }
}
impl PartialOrd for TxnAccount {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl TxnAccount {
    pub(crate) fn is_root(&self) -> bool {
        self.atn.is_root()
    }
}

impl Display for AccountTreeNode {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.account)
    }
}

impl Hash for AccountTreeNode {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.account.hash(state);
    }
}

impl PartialEq for AccountTreeNode {
    fn eq(&self, other: &Self) -> bool {
        // depth is used as fast-path checking
        self.depth == other.depth && self.account == other.account
    }
}

impl PartialOrd for AccountTreeNode {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for AccountTreeNode {
    /// Compares 2 account names piecewise by sub-accounts
    /// Basic lexical sort order of account name can't be used because of
    /// numerical accounts will sort wrongly with ':' in the middle
    /// If `account_name_a` is a prefix of `account_name_b` then `account_name_a < account_name_b`
    fn cmp(&self, other: &Self) -> Ordering {
        for (acc_a, acc_b) in self.parts.iter().zip(other.parts.iter()) {
            match acc_a.cmp(acc_b) {
                Ordering::Equal => {}
                ordering => return ordering,
            }
        }
        // So far subcomponents are equal, but which one has more subcomponents?
        self.depth.cmp(&other.depth)
    }
}

#[cfg(test)]
impl AccountTreeNode {
    pub(crate) fn get_root(&self) -> &str {
        self.root.as_str()
    }
    pub(crate) fn get_name(&self) -> &str {
        self.name.as_str()
    }
}

impl AccountTreeNode {
    pub(crate) fn from(account: &str) -> Result<AccountTreeNode, tackler::Error> {
        if let Err(e) = is_valid_name(account) {
            let msg = format!("This is not a valid account name: '{account}', error was: {e}");
            return Err(msg.into());
        }
        Ok(Self::unchecked_from(account))
    }

    /// Create a parent ATN by child ATN
    ///
    /// Child can't be a root account
    ///
    /// Errors
    /// If child is root, then this will error out
    pub(crate) fn make_parent(child: &AccountTreeNode) -> Result<AccountTreeNode, tackler::Error> {
        if child.depth < 2 {
            let msg = format!("can't create parent from root account: '{child}'");
            return Err(msg.into());
        }
        let parts = child.parts[..child.depth - 1].to_vec();
        let depth = parts.len();
        assert_eq!(depth, child.depth - 1);

        let root = child.root.clone();
        let parent = parts[..depth - 1].join(":");
        let account = child.parent.clone();
        let name = parts.last().unwrap(/*:ok: non-empty*/).clone();

        Ok(AccountTreeNode {
            depth,
            root,
            parent,
            parts,
            account,
            name,
        })
    }

    pub(crate) fn unchecked_from(account: &str) -> AccountTreeNode {
        let parts: Vec<&str> = account.split(':').collect();
        let depth = parts.len();
        assert!(depth > 0);

        let root = String::from(parts[0]);
        let acc_parts = parts.iter().map(ToString::to_string).collect();

        let name = parts.last().unwrap(/*:ok: non-empty*/).to_string();

        let parent = parts[..depth - 1].join(":");

        AccountTreeNode {
            depth,
            root,
            parent,
            parts: acc_parts,
            account: account.to_string(),
            name,
        }
    }
}

#[cfg(test)]
#[allow(clippy::bool_assert_comparison)]
mod tests {
    use super::*;

    #[test]
    // test: 270d505b-76f6-4e49-a24c-2fbdfb6e5adf
    fn commodity_ok() {
        let res = Commodity::from("He·bar");
        assert!(res.is_ok());
        let c = res.unwrap(/*:test:*/);
        assert_eq!(c.name, "He·bar");

        assert!(Commodity::from("$").is_ok());
        assert!(Commodity::from("¢").is_ok());
        assert!(Commodity::from("£").is_ok());
        assert!(Commodity::from("¤").is_ok());
        assert!(Commodity::from("¥").is_ok());
    }

    #[test]
    // test: 699aadb1-d1ba-44b6-ae6a-158cf5be13e5
    fn commodity_err() {
        assert!(Commodity::from("123").is_err());
        assert!(Commodity::from("-USD").is_err());
        assert!(Commodity::from("_USD").is_err());
        assert!(Commodity::from("·USD").is_err());
        assert!(Commodity::from("He:bar").is_err());
        assert!(Commodity::from("He bar").is_err());
    }

    /*
    todo: commodity tests as part of AccountTreeNode vs. TxnAccount
    #[test]
    // test: e11d5d26-c149-4d8c-b150-5cb2e2f80608
    fn atn_commodity() {
        let atn = AccountTreeNode::from(
            "a",
            Some(Commodity::from("He·bar".to_string()).unwrap(/*:test:*/)),
        )
        .unwrap(/*:test:*/);

        assert!(!atn.commodity.name.is_empty());
        assert_eq!(atn.commodity.name, "He·bar".to_string());
    }
     */

    #[test]
    fn atn_is_root() {
        let atn_a = TxnAccount {
            atn: Arc::new(AccountTreeNode::from("a")
                .unwrap(/*:test:*/)),
            comm: Arc::new(Commodity::default()),
        };
        let atn_ab = TxnAccount {
            atn: Arc::new(AccountTreeNode::from("a:b")
                .unwrap(/*:test:*/)),
            comm: Arc::new(Commodity::default()),
        };
        let atn_abc = TxnAccount {
            atn: Arc::new(AccountTreeNode::from("a:b:c")
                .unwrap(/*:test:*/)),
            comm: Arc::new(Commodity::default()),
        };
        assert_eq!(atn_a.is_root(), true);
        assert_eq!(atn_ab.is_root(), false);
        assert_eq!(atn_abc.is_root(), false);
    }

    #[test]
    fn atn_ok_display() {
        let atn = AccountTreeNode::from("a:b:c")
        .unwrap(/*:test:*/);

        let atn_str = format!("{atn}");
        assert_eq!(atn_str, String::from("a:b:c"));
    }

    #[test]
    // test: 88c5cb23-5995-4b93-8c26-a3f7374e96d9
    fn atn_root() {
        let atn = AccountTreeNode::from("a").unwrap(/*:test:*/);

        assert_eq!(atn.depth, 1);
        assert_eq!(atn.root, "a");
        assert_eq!(atn.parent, "");
        assert_eq!(atn.account, "a");
        assert_eq!(atn.name, "a");
    }

    #[test]
    // test: fc69f9b2-1faf-425c-87d3-aed63d66171b
    fn atn_two() {
        let atn = AccountTreeNode::from("a:b").unwrap(/*:test:*/);

        assert_eq!(atn.depth, 2);
        assert_eq!(atn.root, "a");
        assert_eq!(atn.parent, "a");
        assert_eq!(atn.account, "a:b");
        assert_eq!(atn.name, "b");
    }

    #[test]
    // test: 38c103d3-4cc7-4af7-86cd-bf24ca37d026
    fn atn_three() {
        let atn = AccountTreeNode::from("a:b:c").unwrap(/*:test:*/);

        assert_eq!(atn.depth, 3);
        assert_eq!(atn.root, "a");
        assert_eq!(atn.parent, "a:b");
        assert_eq!(atn.account, "a:b:c");
        assert_eq!(atn.name, "c");
    }

    #[test]
    // test: 76a6c300-5569-4e1d-a0a1-ae2ee31d919a
    fn atn_more() {
        let atn = AccountTreeNode::from("a:b:c:leaf").unwrap(/*:test:*/);

        assert_eq!(atn.depth, 4);
        assert_eq!(atn.root, "a");
        assert_eq!(atn.parent, "a:b:c");
        assert_eq!(atn.account, "a:b:c:leaf");
        assert_eq!(atn.name, "leaf");
    }

    #[test]
    // test: 55407835-34d8-4de9-a362-4172f0e4d54b
    fn err_empty() {
        let atn = AccountTreeNode::from("");
        assert!(atn.is_err());
    }

    #[test]
    // test: 0609e72f-c509-4b62-950e-fce432122d10
    fn err_empty_sub() {
        // new tests
        assert!(AccountTreeNode::from("a:").is_err());
        assert!(AccountTreeNode::from(":a").is_err());
        assert!(AccountTreeNode::from("a::b").is_err());
        assert!(AccountTreeNode::from("::").is_err());
        // old tests
        assert!(AccountTreeNode::from(":").is_err());
        assert!(AccountTreeNode::from(": :").is_err());
    }

    #[test]
    fn err_invalid_ids_sep_chars() {
        assert!(AccountTreeNode::from("-Err").is_err());
        assert!(AccountTreeNode::from("_Err").is_err());
        assert!(AccountTreeNode::from("·Err").is_err());

        assert!(AccountTreeNode::from("A:-Err").is_err());
        assert!(AccountTreeNode::from("A:_Err").is_err());
        assert!(AccountTreeNode::from("A:·Err").is_err());
    }

    #[test]
    fn err_invalid_ids_spaces() {
        assert!(AccountTreeNode::from(" a").is_err());
        assert!(AccountTreeNode::from("a ").is_err());
        assert!(AccountTreeNode::from(" a ").is_err());

        assert!(AccountTreeNode::from(" a:b:c").is_err());
        assert!(AccountTreeNode::from("a:b:c ").is_err());
        assert!(AccountTreeNode::from(" a:b:c ").is_err());
        assert!(AccountTreeNode::from("a:b c:d").is_err());
    }

    #[test]
    fn err_invalid_ids_numerical() {
        assert!(AccountTreeNode::from("123:Err").is_err());
        assert!(AccountTreeNode::from("-123:Err").is_err());
        assert!(AccountTreeNode::from("1ABC:Err").is_err());
        assert!(AccountTreeNode::from("-1ABC:Err").is_err());
    }
}