okane-core 0.21.0

Library to support parsing, emitting and processing Ledger (https://www.ledger-cli.org/) format files.
Documentation
use std::borrow::Borrow;
use std::path::PathBuf;

use crate::{load, syntax};

use super::balance::Balance;
use super::book_keeping::{self, BookKeepError};
use super::context::ReportContext;
use super::error::{self, ReportError};
use super::price_db::PriceRepositoryBuilder;
use super::query::Ledger;
use super::transaction::Transaction;

/// Options to control process behavior.
#[derive(Debug, Default)]
// TODO: non_exhaustive
pub struct ProcessOptions {
    /// Path to the price DB file.
    pub price_db_path: Option<PathBuf>,
}

/// Takes the loader, and gives back the all read transactions.
/// Also returns the computed balance, as a side-artifact.
/// Usually this needs to be reordered, so just returning a `Vec`.
pub fn process<'ctx, L, F>(
    ctx: &mut ReportContext<'ctx>,
    loader: L,
    options: &ProcessOptions,
) -> Result<Ledger<'ctx>, ReportError>
where
    L: Borrow<load::Loader<F>>,
    F: load::FileSystem,
{
    let mut accum = ProcessAccumulator::new();
    loader.borrow().load(|path, pctx, entry| {
        accum.process(ctx, entry).map_err(|berr| {
            ReportError::BookKeep(
                berr,
                error::ErrorContext::new(
                    loader.borrow().error_style().clone(),
                    path.to_owned(),
                    pctx,
                ),
            )
        })
    })?;
    ctx.account_tree.construct(&ctx.accounts);
    if let Some(price_db_path) = options.price_db_path.as_deref() {
        accum
            .price_repos
            .load_price_db(ctx, loader.borrow().filesystem(), price_db_path)?;
    }
    Ok(Ledger {
        arena: ctx.arena,
        transactions: accum.txns,
        date_sorted_txns: None,
        raw_balance: accum.balance,
        price_repos: accum.price_repos.build(),
    })
}

struct ProcessAccumulator<'ctx> {
    balance: Balance<'ctx>,
    txns: Vec<Transaction<'ctx>>,
    price_repos: PriceRepositoryBuilder<'ctx>,
}

impl<'ctx> ProcessAccumulator<'ctx> {
    fn new() -> Self {
        Self {
            balance: Balance::default(),
            txns: Vec::new(),
            price_repos: PriceRepositoryBuilder::default(),
        }
    }

    fn process(
        &mut self,
        ctx: &mut ReportContext<'ctx>,
        entry: &syntax::tracked::LedgerEntry,
    ) -> Result<(), BookKeepError> {
        match &entry.statement {
            syntax::LedgerStatement::Txn(txn) => {
                self.txns.push(book_keeping::add_transaction(
                    ctx,
                    &mut self.price_repos,
                    &mut self.balance,
                    txn,
                )?);
                Ok(())
            }
            syntax::LedgerStatement::Account(account) => process_account(ctx, account),
            syntax::LedgerStatement::Commodity(commodity) => process_commodity(ctx, commodity),
            _ => Ok(()),
        }
    }
}

pub fn process_account<'ctx>(
    ctx: &mut ReportContext<'ctx>,
    account: &syntax::AccountDeclaration<'_>,
) -> Result<(), BookKeepError> {
    let canonical = ctx.accounts.ensure(&account.name);
    for ad in &account.details {
        if let syntax::AccountDetail::Alias(alias) = ad {
            ctx.accounts
                .register_alias(alias, canonical)
                .map_err(|_| BookKeepError::InvalidAccountAlias(alias.to_string()))?;
        }
    }
    Ok(())
}

fn process_commodity<'ctx>(
    ctx: &mut ReportContext<'ctx>,
    commodity: &syntax::CommodityDeclaration<'_>,
) -> Result<(), BookKeepError> {
    let canonical = ctx.commodities.ensure(&commodity.name);
    for cd in &commodity.details {
        match cd {
            syntax::CommodityDetail::Alias(alias) => {
                ctx.commodities
                    .register_alias(alias, canonical)
                    .map_err(|_| BookKeepError::InvalidCommodityAlias(alias.to_string()))?;
            }
            syntax::CommodityDetail::Format(format_amount) => {
                ctx.commodities.set_format(canonical, format_amount.value);
            }
            _ => {}
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {

    use super::*;

    use std::path::Path;

    use assert_matches::assert_matches;
    use bumpalo::Bump;

    use super::super::account::{AccountAggregate, AccountTreeKey};

    #[test]
    fn process_constructs_account_tree() {
        let arena = Bump::new();
        let mut ctx = ReportContext::new(&arena);
        let input =
            Path::new(env!("CARGO_MANIFEST_DIR")).join("../testdata/report/multi_commodity.ledger");

        let _ = process(
            &mut ctx,
            load::new_loader(input),
            &ProcessOptions::default(),
        )
        .unwrap();

        let parent = ctx.account_aggregate("Assets:Banks").unwrap();
        let child1 = ctx.account_aggregate("Assets:Banks:Swiss Bank").unwrap();
        let child2 = ctx.account_aggregate("Assets:Banks:あおによし").unwrap();
        assert_matches!(parent, AccountAggregate::Ancestor(_));
        assert_matches!(child1, AccountAggregate::Account(_));
        assert_matches!(child2, AccountAggregate::Account(_));
        let children: &[_] = &[child1, child2];

        assert_eq!(Some(children), ctx.account_tree.children(parent));
        assert_eq!(
            Some(AccountTreeKey::Descendant(parent)),
            ctx.account_tree.parent(child1)
        );
    }
}