not sure what the state is, but commit while it's working

This commit is contained in:
Evan Peterson 2025-12-04 13:07:50 -05:00
parent 423bbd8eb4
commit 38fcb89343
Signed by: petersonev
GPG Key ID: 26BC6134519C4FC6
27 changed files with 1795 additions and 100 deletions

View File

@ -11,5 +11,12 @@ nom = "7.1.3"
nom_locate = "4.2.0"
rand = "0.8.5"
ratatui = "0.29.0"
regex = "1.11.1"
rust_decimal = "1.36.0"
rust_decimal_macros = "1.36.0"
[profile.release]
debug = 1
[rust]
debuginfo-level = 1

View File

@ -91,3 +91,13 @@ pub fn combine_amounts(amounts: impl Iterator<Item = Amount>) -> Vec<Amount> {
output_amounts.iter().map(|(&unit_id, &value)| Amount {value, unit_id}).collect()
}
impl PartialOrd for Amount {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
if self.unit_id != other.unit_id {
None
} else {
self.value.partial_cmp(&other.value)
}
}
}

View File

@ -1,9 +1,11 @@
use core::fmt;
#[derive(PartialEq)]
pub struct CoreError {
text: StringData,
}
#[derive(PartialEq)]
enum StringData {
Static(&'static str),
Dynamic(String),

View File

@ -1,11 +1,14 @@
use chrono::NaiveDate;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use super::{Account, Amount, CoreError, Transaction, Unit};
use super::{Account, Amount, CoreError, Price, Transaction, Unit};
#[derive(Debug)]
pub struct Ledger {
accounts: Vec<Account>,
units: Vec<Unit>,
prices: Vec<Price>,
transactions: Vec<Transaction>,
}
@ -14,6 +17,7 @@ impl Ledger {
Ledger {
accounts: Vec::new(),
units: Vec::new(),
prices: Vec::new(),
transactions: Vec::new(),
}
}
@ -27,7 +31,9 @@ impl Ledger {
}
pub fn get_account_by_name(&self, name: &str) -> Option<&Account> {
self.accounts.iter().find(|account| account.get_name() == name)
self.accounts
.iter()
.find(|account| account.get_name() == name)
}
pub fn get_units(&self) -> &Vec<Unit> {
@ -39,13 +45,38 @@ impl Ledger {
}
pub fn get_unit_by_symbol(&self, unit_symbol: &str) -> Option<&Unit> {
self.units.iter().find(|unit| unit.matches_symbol(unit_symbol))
self.units
.iter()
.find(|unit| unit.matches_symbol(unit_symbol))
}
pub fn get_transactions(&self) -> &Vec<Transaction> {
&self.transactions
}
// Assume prices are sorted by date already
// For now only trivial conversions, not multiple conversions
pub fn get_price_on_date(
&self,
date: NaiveDate,
original_unit_id: u32,
new_unit_id: u32,
) -> Option<Decimal> {
let max_pos = self
.prices
.iter()
.position(|p| p.date > date)
.unwrap_or(self.prices.len());
let valid_prices = &self.prices[..max_pos];
let price = valid_prices
.iter()
.rev()
.find(|p| p.unit_id == original_unit_id && p.amount.unit_id == new_unit_id);
price.map(|p| p.amount.value)
}
pub fn round_amount(&self, amount: &Amount) -> Amount {
let mut new_amount = *amount;
let unit = self.get_unit(amount.unit_id);
@ -60,15 +91,15 @@ impl Ledger {
}
pub fn round_amounts(&self, amounts: &[Amount]) -> Vec<Amount> {
amounts.iter().map(|a| self.round_amount(a)).filter(|a| a.value != dec!(0)).collect()
amounts
.iter()
.map(|a| self.round_amount(a))
.filter(|a| a.value != dec!(0))
.collect()
}
pub fn add_account(&mut self, account: Account) -> Result<(), CoreError> {
if self
.accounts
.iter()
.any(|existing_account| existing_account.get_name() == account.get_name())
{
if self.get_account_by_name(&account.get_name()).is_some() {
return Err("Account with the same name already exists".into());
}
@ -100,10 +131,37 @@ impl Ledger {
println!("{:?}", balances);
return Err("Transaction is not balanced".into());
}
for posting in transaction.get_postings() {
if let Some(price_amount) = posting.get_price() {
self.add_price(Price {
amount: *price_amount,
date: transaction.get_date(),
unit_id: posting.get_amount().unit_id,
})?;
} else if let Some(cost_amount) = posting.get_cost() {
self.add_price(Price {
amount: *cost_amount,
date: transaction.get_date(),
unit_id: posting.get_amount().unit_id,
})?;
}
}
self.transactions.push(transaction);
Ok(())
}
pub fn add_price(&mut self, price: Price) -> Result<(), CoreError> {
self.prices.push(price);
Ok(())
}
pub fn sort_prices(&mut self) {
self.prices.sort_by(|a, b| a.date.cmp(&b.date));
}
}
#[cfg(test)]

View File

@ -1,12 +1,14 @@
mod account;
mod amounts;
mod common;
mod errors;
mod ledger;
mod price;
mod transaction;
mod common;
pub use account::*;
pub use amounts::*;
pub use errors::*;
pub use ledger::*;
pub use price::*;
pub use transaction::*;

10
src/core/price.rs Normal file
View File

@ -0,0 +1,10 @@
use chrono::NaiveDate;
use super::Amount;
#[derive(Debug, Clone, Copy)]
pub struct Price {
pub unit_id: u32,
pub date: NaiveDate,
pub amount: Amount,
}

View File

@ -37,6 +37,7 @@ pub struct TransactionDirective {
pub payee: Option<String>,
pub narration: Option<String>,
pub postings: Vec<DirectivePosting>,
pub metadata: Vec<(String, String)>,
}
#[derive(Debug, Clone)]
@ -52,6 +53,7 @@ pub struct BalanceDirective {
#[derive(Debug, PartialEq, Clone)]
pub struct DirectivePosting {
pub date: Option<NaiveDate>,
pub account: String,
pub amount: Option<DirectiveAmount>,
pub cost: Option<DirectiveAmount>,

View File

@ -6,7 +6,16 @@ use crate::{
core::{
Account, Amount, CoreError, Ledger, Posting, Transaction, TransactionFlag, Unit, UnitSymbol,
},
queries::{self, Query},
queries::{
self,
base::{self, DataValue},
functions::{
ComparisonFunction, LogicalFunction, RegexFunction, StringComparisonFunction,
SubAccountFunction,
},
transaction::{AccountField, PostingField, TransactionField},
Query,
},
};
use super::{BalanceDirective, DirectiveAmount, TransactionDirective};
@ -63,17 +72,48 @@ pub fn add_transaction(
Ok(())
}
pub fn check_balance(ledger: &Ledger, balance: &BalanceDirective) -> Result<(), CoreError> {
let accounts = queries::balance(&ledger, &[Query::EndDate(balance.date)]);
pub fn check_balance2(ledger: &Ledger, balance: &BalanceDirective) -> Result<(), CoreError> {
let date_query = ComparisonFunction::new(
"<=",
base::Query::from_field(PostingField::Transaction(TransactionField::Date)),
base::Query::from(DataValue::from(balance.date)),
)
.unwrap();
// let account_fn = |str: &str| {
// str.strip_prefix("abc")
// .map(|n| n.is_empty() || n.starts_with(":"))
// .unwrap_or(false)
// };
// let account_query = StringComparisonFunction::new_func(
// base::Query::from_field(PostingField::Account(AccountField::Name)),
// &account_fn,
// )?;
// let account_regex = format!("^{}($|:.+)", balance.account);
// let account_query = RegexFunction::new(
// base::Query::from_field(PostingField::Account(AccountField::Name)),
// &account_regex,
// )?;
let account_query = SubAccountFunction::new(
balance.account.clone().into(),
base::Query::from_field(PostingField::Account(AccountField::Name)),
);
let accounts = accounts.iter().filter(|(&account_id, val)| {
let account = ledger.get_account(account_id).unwrap();
account.is_under_account(&balance.account)
});
let start = Instant::now();
if accounts.clone().count() == 0 {
let total_query = LogicalFunction::new(
"and",
base::Query::from_fn(date_query),
base::Query::from_fn(account_query),
)
.unwrap();
}
let t2 = Instant::now();
let accounts = queries::balance3(&ledger, &base::Query::from_fn(total_query));
let t3 = Instant::now();
// println!("{:?} {:?}", t2-start, t3-t2);
let mut total_amounts = HashMap::new();
let mut account_count = 0;
@ -108,7 +148,81 @@ pub fn check_balance(ledger: &Ledger, balance: &BalanceDirective) -> Result<(),
let unit = ledger
.get_unit_by_symbol(&balance_amount.unit_symbol)
.ok_or("Unit not found")?;
let value = total_amounts.get(&unit.get_id()).map(|v| *v).unwrap_or(dec!(0));
let value = total_amounts
.get(&unit.get_id())
.map(|v| *v)
.unwrap_or(dec!(0));
// let value = amounts
// .iter()
// .find(|a| a.unit_id == unit.get_id())
// .map(|a| a.value)
// .unwrap_or(dec!(0));
let max_scale = max(value.scale(), balance_amount.value.scale());
let value = value.round_dp(max_scale);
let balance_value = balance_amount.value.round_dp(max_scale);
if value != balance_value {
return Err(format!(
"Balance amount for \"{}\" on {} does not match. Expected {} but got {}",
balance.account, balance.date, balance_value, value
)
.into());
}
}
Ok(())
}
pub fn check_balance(ledger: &Ledger, balance: &BalanceDirective) -> Result<(), CoreError> {
let accounts = queries::balance(&ledger, &[Query::EndDate(balance.date)]);
// let accounts = queries::balance2(&ledger, balance.date);
let accounts = accounts.iter().filter(|(&account_id, val)| {
let account = ledger.get_account(account_id).unwrap();
account.is_under_account(&balance.account)
});
if accounts.clone().count() == 0 {}
let mut total_amounts = HashMap::new();
let mut account_count = 0;
for (_, amounts) in accounts {
account_count += 1;
for amount in amounts {
*total_amounts.entry(amount.unit_id).or_insert(dec!(0)) += amount.value;
}
}
if account_count == 0 {
return Err("No accounts match balance account".into());
}
// let balance_account = ledger
// .get_account_by_name(&balance.account)
// .ok_or("Account not found")?;
// let amounts = accounts
// .get(&balance_account.get_id())
// .map(|v| v.as_slice())
// .unwrap_or(&[]);
// if amounts.len() > balance.amounts.len() {
// return Err("".into());
// } else if amounts.len() < balance.amounts.len() {
// return Err("".into());
// }
for balance_amount in &balance.amounts {
let unit = ledger
.get_unit_by_symbol(&balance_amount.unit_symbol)
.ok_or("Unit not found")?;
let value = total_amounts
.get(&unit.get_id())
.map(|v| *v)
.unwrap_or(dec!(0));
// let value = amounts
// .iter()

View File

@ -1,13 +1,13 @@
mod directives;
mod parser;
mod ledger;
mod parser;
pub use directives::*;
use ledger::{add_transaction, check_balance};
use ledger::{add_transaction, check_balance2};
use parser::parse_directives;
use crate::core::{CoreError, Ledger, Unit};
use std::path::Path;
use std::{path::Path, time::Instant};
#[derive(Debug)]
pub struct Document {
@ -40,9 +40,14 @@ impl Document {
add_transaction(&mut ledger, transaction)?;
}
ledger.sort_prices();
let start = Instant::now();
for balance in &self.directives.balances {
check_balance(&ledger, &balance)?;
check_balance2(&ledger, &balance)?;
}
let end = Instant::now();
println!("time to calculate balance: {:?}", end - start);
Ok(ledger)
// for balance in self.directives.balances {

View File

@ -58,6 +58,22 @@ pub fn empty_lines(input: &str) -> IResult<&str, ()> {
.parse(input)
}
pub fn parse_iso_date(input: &str) -> IResult<&str, NaiveDate> {
let (new_input, (year, _, month, _, day)) = tuple((
date_year,
opt(tag("-")),
date_month,
opt(tag("-")),
date_day,
))
.parse(input)?;
match NaiveDate::from_ymd_opt(year, month, day) {
Some(date) => Ok((new_input, date)),
None => Err(nom::Err::Error(Error::new(input, ErrorKind::Eof))),
}
}
///////////////
// Private //
///////////////
@ -82,22 +98,6 @@ fn date_day(input: &str) -> IResult<&str, u32> {
take_n_digits(input, 2)
}
fn parse_iso_date(input: &str) -> IResult<&str, NaiveDate> {
let (new_input, (year, _, month, _, day)) = tuple((
date_year,
opt(tag("-")),
date_month,
opt(tag("-")),
date_day,
))
.parse(input)?;
match NaiveDate::from_ymd_opt(year, month, day) {
Some(date) => Ok((new_input, date)),
None => Err(nom::Err::Error(Error::new(input, ErrorKind::Eof))),
}
}
const COMMENT_CHARS: &str = ";#";
fn parse_comment(input: &str) -> IResult<&str, &str> {

View File

@ -2,6 +2,7 @@ mod amounts;
mod base_directive;
mod directives;
mod transaction;
mod shared;
use base_directive::{base_directive, empty_lines};
use directives::{specific_directive, Directive};

View File

@ -0,0 +1,48 @@
use nom::{
bytes::complete::tag,
character::complete::space0,
error::ErrorKind,
sequence::{delimited, tuple},
IResult, InputTakeAtPosition, Parser,
};
pub fn metadatum(input: &str) -> IResult<&str, (&str, &str)> {
tuple((
delimited(tag("-"), delimited(space0, key, space0), tag(":")),
value,
))
.map(|v| (v.0, v.1.trim()))
.parse(input)
}
pub fn key(input: &str) -> IResult<&str, &str> {
input.split_at_position1_complete(|item| item == ':', ErrorKind::AlphaNumeric)
}
pub fn value(input: &str) -> IResult<&str, &str> {
input.split_at_position1_complete(|_| false, ErrorKind::AlphaNumeric)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_metadatum() {
assert_eq!(
metadatum("- key: value").unwrap().1,
("key".into(), "value".into())
);
assert_eq!(
metadatum("- key space: value space").unwrap().1,
("key space".into(), "value space".into())
);
}
#[test]
fn parse_metadatum_invalid() {
assert!(metadatum("- key:").is_err());
assert!(metadatum("- : value").is_err());
assert!(metadatum("- : ").is_err());
}
}

View File

@ -1,18 +1,19 @@
use nom::{
branch::alt,
bytes::complete::{is_not, tag},
character::complete::space1,
character::complete::{space0, space1},
combinator::{eof, opt, rest},
error::{Error, ErrorKind},
sequence::{delimited, preceded, tuple},
Err, IResult, Parser,
sequence::{delimited, preceded, terminated, tuple},
Err, IResult, InputTakeAtPosition, Parser,
};
use crate::document::{DirectiveAmount, DirectivePosting, TransactionDirective};
use super::{
amounts::{account, amount},
base_directive::BaseDirective,
base_directive::{parse_iso_date, BaseDirective},
shared::metadatum,
};
// use super::{
@ -52,7 +53,12 @@ pub fn transaction<'a>(
};
let mut postings = Vec::with_capacity(directive.lines.len());
let mut metadata = Vec::new();
for &line in directive.lines.get(1..).unwrap_or(&[]) {
if let Ok(m) = metadatum(line) {
metadata.push((m.1.0.to_string(), m.1.1.to_string()));
continue;
}
let posting = if let Ok(v) = posting(line) {
v
} else {
@ -73,6 +79,7 @@ pub fn transaction<'a>(
payee: payee.map(|p| p.to_string()),
narration: narration.map(|n| n.to_string()),
postings,
metadata,
},
))
}
@ -94,6 +101,7 @@ fn payee_narration(input: &str) -> IResult<&str, (Option<&str>, Option<&str>)> {
fn posting(input: &str) -> IResult<&str, DirectivePosting> {
tuple((
opt(terminated(parse_iso_date, space1)),
account,
opt(tuple((
preceded(space1, amount),
@ -102,7 +110,7 @@ fn posting(input: &str) -> IResult<&str, DirectivePosting> {
))),
eof,
))
.map(|(account, value, _)| {
.map(|(date, account, value, _)| {
let mut amount = None;
let mut cost = None;
let mut price = None;
@ -131,7 +139,13 @@ fn posting(input: &str) -> IResult<&str, DirectivePosting> {
}
}
}
DirectivePosting { account: account.to_string(), amount, cost, price }
DirectivePosting {
date,
account: account.to_string(),
amount,
cost,
price,
}
})
.parse(input)
}
@ -194,6 +208,7 @@ mod tests {
assert_eq!(
posting("Account1 10 SHARE {$100}").unwrap().1,
DirectivePosting {
date: None,
account: "Account1".into(),
amount: Some(DirectiveAmount {
value: dec!(10),
@ -211,6 +226,7 @@ mod tests {
assert_eq!(
posting("Account1 10 SHARE {{1000 USD}}").unwrap().1,
DirectivePosting {
date: None,
account: "Account1".into(),
amount: Some(DirectiveAmount {
value: dec!(10),
@ -232,6 +248,7 @@ mod tests {
assert_eq!(
posting("Account1 10 SHARE @ $100").unwrap().1,
DirectivePosting {
date: None,
account: "Account1".into(),
amount: Some(DirectiveAmount {
value: dec!(10),
@ -249,6 +266,7 @@ mod tests {
assert_eq!(
posting("Account1 10 SHARE @@ 1000 USD").unwrap().1,
DirectivePosting {
date: None,
account: "Account1".into(),
amount: Some(DirectiveAmount {
value: dec!(10),
@ -270,6 +288,7 @@ mod tests {
assert_eq!(
posting("Account1 10 SHARE {$100} @ $110").unwrap().1,
DirectivePosting {
date: None,
account: "Account1".into(),
amount: Some(DirectiveAmount {
value: dec!(10),
@ -293,6 +312,7 @@ mod tests {
.unwrap()
.1,
DirectivePosting {
date: None,
account: "Account1".into(),
amount: Some(DirectiveAmount {
value: dec!(10),
@ -316,6 +336,7 @@ mod tests {
.unwrap()
.1,
DirectivePosting {
date: None,
account: "Account1".into(),
amount: Some(DirectiveAmount {
value: dec!(10),
@ -336,6 +357,34 @@ mod tests {
);
}
#[test]
fn parse_posting_with_date() {
assert_eq!(
posting("2000-01-01 Account1 10 SHARE {$100} @ $110")
.unwrap()
.1,
DirectivePosting {
date: Some(NaiveDate::from_ymd_opt(2000, 01, 01).unwrap()),
account: "Account1".into(),
amount: Some(DirectiveAmount {
value: dec!(10),
unit_symbol: "SHARE".into(),
is_unit_prefix: false
}),
cost: Some(DirectiveAmount {
value: dec!(100),
unit_symbol: "$".into(),
is_unit_prefix: true
}),
price: Some(DirectiveAmount {
value: dec!(110),
unit_symbol: "$".into(),
is_unit_prefix: true
}),
}
);
}
#[test]
fn parse_transaction_postings() {
let directive = BaseDirective {
@ -362,4 +411,25 @@ mod tests {
assert_eq!(transaction.postings[1].account, "Account3");
assert_eq!(transaction.postings[1].amount, None);
}
#[test]
fn parse_transaction_postings_metadata() {
let directive = BaseDirective {
date: NaiveDate::from_ymd_opt(2000, 01, 01),
directive_name: "txn",
lines: vec![
"payee | narration",
"- key: value",
"Account1:Account2 $10.01",
"Account3",
],
};
let transaction = transaction(directive).unwrap().1;
assert_eq!(transaction.metadata.len(), 1);
assert_eq!(transaction.metadata[0], ("key".into(), "value".into()));
assert_eq!(transaction.postings.len(), 2);
assert_eq!(transaction.postings[0].account, "Account1:Account2");
assert_eq!(transaction.postings[1].account, "Account3");
}
}

View File

@ -43,6 +43,7 @@ pub mod queries;
// pub mod create_ledger;
pub mod document;
pub mod output;
mod parser;
// pub struct Account {
// // TODO

View File

@ -41,30 +41,30 @@ pub fn main() -> Result<(), Box<dyn std::error::Error>> {
// ))
// );
let stdout = io::stdout();
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// let stdout = io::stdout();
// let backend = CrosstermBackend::new(stdout);
// let mut terminal = Terminal::new(backend)?;
let line = Line::from(vec![
Span::raw("Hello "),
Span::styled("Hello ", Style::new().fg(Color::Rgb(100, 200, 150))),
Span::styled("World", Style::new().fg(Color::Green).bg(Color::White)),
])
.centered();
let text = Text::from(line);
// let line = Line::from(vec![
// Span::raw("Hello "),
// Span::styled("Hello ", Style::new().fg(Color::Rgb(100, 200, 150))),
// Span::styled("World", Style::new().fg(Color::Green).bg(Color::White)),
// ])
// .centered();
// let text = Text::from(line);
println!("{}", text_to_ansi(&text));
// println!("{}", text_to_ansi(&text));
// println!("{:?}", line.to_string());
// // println!("{:?}", line.to_string());
// terminal.dra
// // terminal.dra
// crossterm::terminal::enable_raw_mode()?;
// // crossterm::terminal::enable_raw_mode()?;
terminal.draw(|f| {
let area = f.area();
f.render_widget(text, area);
})?;
// terminal.draw(|f| {
// let area = f.area();
// f.render_widget(text, area);
// })?;
// PrintStyledContent
@ -88,18 +88,36 @@ pub fn main() -> Result<(), Box<dyn std::error::Error>> {
// println!("{}", text.render(area, buf););
return Ok(());
// return Ok(());
// let document = Document::new(Path::new("data/full/main.ledger")).unwrap();
let t1 = Instant::now();
// let ledger = document.generate_ledger().unwrap();
let document = Document::new(Path::new("data/full/main.ledger")).unwrap();
let t2 = Instant::now();
let ledger = document.generate_ledger().unwrap();
let t3 = Instant::now();
// let balance = queries::balance(
// &ledger,
// &[],
// );
// format_balance(&ledger, &balance);
let balance = queries::balance2(
&ledger,
NaiveDate::from_ymd_opt(2100, 01, 01).unwrap(),
Some("$")
);
let t4 = Instant::now();
format_balance(&ledger, &balance);
let t5 = Instant::now();
println!("{:?} - {:?} - {:?} - {:?}", t2-t1, t3-t2, t4-t3, t5-t4);
// return;
@ -127,4 +145,6 @@ pub fn main() -> Result<(), Box<dyn std::error::Error>> {
// let ledger = create_ledger(&file_data).unwrap();
// println!("{:?}", val);
return Ok(());
}

View File

@ -1,18 +1,45 @@
use crate::core::{Amount, Ledger};
impl Ledger {
pub fn format_amount(&self, amount: &Amount) -> String {
/// Returns formatted string and position of decimal point in string
pub fn format_amount(&self, amount: &Amount) -> (String, usize) {
let unit = self.get_unit(amount.unit_id).unwrap();
let default_symbol = unit.default_symbol();
let amount = self.round_amount(&amount);
if default_symbol.is_prefix {
format!("{}{}", default_symbol.symbol, amount.value)
let sign = if amount.value.is_sign_negative() {
"-"
} else {
""
};
let value = amount.value.abs().to_string();
let mut split = value.split(".");
let mut value = split.next().unwrap()
.as_bytes()
.rchunks(3)
.rev()
.map(std::str::from_utf8)
.collect::<Result<Vec<&str>, _>>()
.unwrap()
.join(",");
let value_decimal_pos = value.len();
if let Some(decimal) = split.next() {
value += ".";
value += decimal;
}
if default_symbol.is_prefix {
let decimal_pos = sign.len() + default_symbol.symbol.len() + value_decimal_pos;
(format!("{}{}{}", sign, default_symbol.symbol, value), decimal_pos)
} else {
let decimal_pos = sign.len() + value_decimal_pos;
if default_symbol.symbol.len() == 1 {
format!("{}{}", amount.value, default_symbol.symbol)
(format!("{}{}{}", sign, value, default_symbol.symbol), decimal_pos)
} else {
format!("{} {}", amount.value, default_symbol.symbol)
(format!("{}{} {}", sign, value, default_symbol.symbol), decimal_pos)
}
}
}

View File

@ -3,7 +3,9 @@ use std::{
collections::{HashMap, HashSet},
};
use crate::core::{combine_amounts, Account, Amount, Ledger};
use ratatui::{style::{Color, Style}, text::{Line, Span, Text}};
use crate::{core::{combine_amounts, Account, Amount, Ledger}, output::cli::tui_to_ansi::text_to_ansi};
#[derive(Debug)]
struct BalanceTree {
@ -12,6 +14,13 @@ struct BalanceTree {
amounts: Option<Vec<Amount>>,
}
#[derive(Debug)]
struct BalanceTreeStr {
name: String,
children: Vec<BalanceTreeStr>,
amounts: Option<Vec<(String, usize)>>,
}
struct AccountInfo<'a> {
account_path: Vec<&'a str>,
amounts: Vec<Amount>,
@ -105,43 +114,130 @@ fn set_tree_totals(tree: &mut BalanceTree) {
tree.amounts = Some(total_amounts);
}
fn print_tree(tree: &BalanceTree, ledger: &Ledger, level: usize, amount_pos: usize) {
let relative_amount_pos = amount_pos - (level*2 + tree.name.len());
let main_line = format!("{}{} {}", " ".repeat(level), tree.name, "".repeat(relative_amount_pos));
let tree_amounts = tree.amounts.as_ref().unwrap().iter().filter(|v| !ledger.round_amount(v).value.is_zero());
const STYLE_LINE: Style = Style::new().fg(Color::LightBlue);
const STYLE_AMOUNT_LINE: Style = Style::new().fg(Color::DarkGray);
const STYLE_ACCOUNT: Style = Style::new().fg(Color::LightBlue);
fn tree_to_text(tree: &BalanceTreeStr, ledger: &Ledger, base_amount_pos: usize, max_decimal_pos: usize) -> Text<'static> {
let mut text = Text::default();
// let tree_amounts = tree.amounts.as_ref().unwrap().iter().filter(|v| !ledger.round_amount(v).value.is_zero());
let tree_amounts = tree.amounts.as_ref().unwrap().iter();
let tree_amounts_count = tree_amounts.clone().count();
for (i, amount) in tree_amounts.enumerate() {
let mut line = String::new();
for (i, (amount, decimal_pos)) in tree_amounts.enumerate() {
let mut line = Line::default();
let amount_padding_count = max_decimal_pos - decimal_pos;
if i == 0 {
line += &main_line;
let amount_pos = base_amount_pos - tree.name.chars().count();
line.push_span(Span::styled(format!("{} ", tree.name), STYLE_ACCOUNT));
let mut line_str = "".repeat(amount_pos);
if tree_amounts_count > 1 {
line += ""
line_str += ""
} else {
line += ""
line_str += ""
}
line_str += &"".repeat(amount_padding_count);
line.push_span(Span::styled(line_str, STYLE_AMOUNT_LINE));
} else {
line += &" ".repeat(amount_pos);
if i == tree_amounts_count - 1 {
line += ""
let line_str = if tree.children.len() > 0 {
""
} else {
line += ""
" "
};
line.push_span(Span::styled(line_str, STYLE_LINE));
let mut line_str = String::new();
line_str += &" ".repeat(base_amount_pos - 2);
if i == tree_amounts_count - 1 {
line_str += "";
line_str += &"".repeat(amount_padding_count);
} else {
line_str += "";
line_str += &" ".repeat(amount_padding_count);
}
line.push_span(Span::styled(line_str, STYLE_AMOUNT_LINE));
}
line.push_span(Span::raw(format!(" {}", amount)));
line += &format!(" {}", ledger.format_amount(amount));
println!("{}", line);
text.push_line(line);
}
// println!("{}{} {} {:?}", " ".repeat(level), tree.name, "-".repeat(relative_amount_pos), tree.amounts);
let mut children: Vec<&BalanceTree> = tree.children.iter().collect();
let mut children: Vec<&BalanceTreeStr> = tree.children.iter().collect();
let children_len = children.len();
children.sort_by(|a, b| a.name.cmp(&b.name));
for child in children {
print_tree(&child, ledger, level + 1, amount_pos);
for (i_c, child) in children.into_iter().enumerate() {
let mut child_text = tree_to_text(&child, ledger, base_amount_pos - 4, max_decimal_pos);
for (i, line) in child_text.lines.into_iter().enumerate() {
let mut whole_line = Line::default();
if i_c == children_len - 1 {
if i == 0 {
whole_line.push_span(Span::styled(" └─ ", STYLE_LINE));
} else {
whole_line.push_span(Span::styled(" ", STYLE_LINE));
}
} else {
if i == 0 {
whole_line.push_span(Span::styled(" ├─ ", STYLE_LINE));
} else {
whole_line.push_span(Span::styled("", STYLE_LINE));
}
}
whole_line.extend(line);
text.push_line(whole_line);
}
}
text
}
fn calculate_max_account_len(tree: &BalanceTree, indent_amount: usize, indent_level: usize) -> usize {
// fn print_tree(tree: &BalanceTree, ledger: &Ledger, level: usize, amount_pos: usize) {
// let relative_amount_pos = amount_pos - (level*2 + tree.name.len());
// let main_line = format!("{}{} {}", " ".repeat(level), tree.name, "─".repeat(relative_amount_pos));
// let tree_amounts = tree.amounts.as_ref().unwrap().iter().filter(|v| !ledger.round_amount(v).value.is_zero());
// let tree_amounts_count = tree_amounts.clone().count();
// for (i, amount) in tree_amounts.enumerate() {
// let mut line = String::new();
// if i == 0 {
// line += &main_line;
// if tree_amounts_count > 1 {
// line += "┬"
// } else {
// line += "─"
// }
// } else {
// line += &" ".repeat(amount_pos);
// if i == tree_amounts_count - 1 {
// line += " └"
// } else {
// line += " │"
// }
// }
// line += &format!(" {}", ledger.format_amount(amount));
// println!("{}", line);
// }
// // println!("{}{} {} {:?}", " ".repeat(level), tree.name, "-".repeat(relative_amount_pos), tree.amounts);
// let mut children: Vec<&BalanceTree> = tree.children.iter().collect();
// children.sort_by(|a, b| a.name.cmp(&b.name));
// for child in children {
// print_tree(&child, ledger, level + 1, amount_pos);
// }
// }
fn balance_tree_to_str_tree(tree: BalanceTree, ledger: &Ledger) -> BalanceTreeStr {
let amounts = tree.amounts.map(|v| v.iter().map(|a| ledger.format_amount(a)).collect());
let children = tree.children.into_iter().map(|c| balance_tree_to_str_tree(c, ledger)).collect();
BalanceTreeStr{amounts, name: tree.name, children}
}
fn calculate_max_account_len(tree: &BalanceTreeStr, indent_amount: usize, indent_level: usize) -> usize {
let current_len = tree.name.len() + indent_amount * indent_level;
let mut max_length = current_len;
@ -153,16 +249,39 @@ fn calculate_max_account_len(tree: &BalanceTree, indent_amount: usize, indent_le
max_length
}
fn calculate_max_decimal_pos(tree: &BalanceTreeStr) -> usize {
let mut max_decimal_pos = 0;
if let Some(amounts) = &tree.amounts {
for (_, decimal_pos) in amounts {
max_decimal_pos = max(max_decimal_pos, *decimal_pos);
}
};
for child in &tree.children {
let child_max = calculate_max_decimal_pos(child);
max_decimal_pos = max(max_decimal_pos, child_max);
}
max_decimal_pos
}
pub fn format_balance(ledger: &Ledger, account_balances: &HashMap<u32, Vec<Amount>>) -> String {
let mut output = String::new();
let mut tree = construct_tree(ledger, account_balances);
set_tree_totals(&mut tree);
let max_account_len = calculate_max_account_len(&tree, 2, 0);
println!("{}", max_account_len);
print_tree(&tree, &ledger, 0, max_account_len + 5);
let str_tree = balance_tree_to_str_tree(tree, &ledger);
let max_account_len = calculate_max_account_len(&str_tree, 4, 0);
let max_decimal_pos = calculate_max_decimal_pos(&str_tree);
let text = tree_to_text(&str_tree, &ledger, max_account_len, max_decimal_pos);
println!("{}", text_to_ansi(&text));
// println!("{}", max_account_len);
// print_tree(&tree, &ledger, 0, max_account_len + 5);
// println!("{:?}", tree);
// let base_account_info: Vec<AccountInfo> = account_balances
@ -275,3 +394,228 @@ pub fn format_balance(ledger: &Ledger, account_balances: &HashMap<u32, Vec<Amoun
// println!("{:?}", account_info[3].account_path);
// }
// }
/*
Assets
Bank
Checking
Savings
Broker
Brokerage
Retirement
Liabilities
Credit Card 1
Credit Card 2
Income
Salary
Capital Gains
Long
Short
Other
Expenses
Taxes
Utilities
Power
Cellular
Rent
Food
Fast Food
Restaurants
Groceries
Entertainment
Equity
Opening Balance
Unrealized Gain
*/
/*
Total ($0.00)
Assets ($10,000.00)
Bank ($2,000.00)
Checking $700.00
Savings $1,300.00
Broker ($8,000.00)
Brokerage $3,000.00
Retirement $5,000.00
Liabilities (-$1,123.11)
Credit Card 1 -$478.20
Credit Card 2 -$644.91
Income (-$10,182.00)
Salary
Capital Gains
Long
Short
Other
Expenses
Taxes
Utilities
Power
Cellular
Rent
Food
Fast Food
Restaurants
Groceries
Entertainment
...
Equity
Opening Balance
Unrealized Gain
*/
/*
Total ...................... ($0.00)
Assets ............. ($10,000.00)
Bank ............. ($2,000.00)
Checking ........ $700.00
Savings ....... $1,300.00
Broker ........... ($8,000.00)
Brokerage ..... $3,000.00
Retirement .... $5,000.00
Liabilities ........ (-$1,123.11)
Credit Card 1 ...... -$478.20
Credit Card 2 ...... -$644.91
Income ............ (-$10,182.00)
Salary
Capital Gains
Long
Short
Other
Expenses
Taxes
Utilities
Power
Cellular
Rent
Food
Fast Food
Restaurants
Groceries
Entertainment
Equity
Opening Balance
Unrealized Gain
*/
/*
Total........................($0.00)
Assets...............($10,000.00)
Bank...............($2,000.00)
Checking..........$700.00
Savings.........$1,300.00
Broker.............($8,000.00)
Brokerage.......$3,000.00
Retirement......$5,000.00
Liabilities..........(-$1,123.11)
Credit Card 1........-$478.20
Credit Card 2........-$644.91
Income..............(-$10,182.00)
Salary
Capital Gains
Long
Short
Other
Expenses
Taxes
Utilities
Power
Cellular
Rent
Food
Fast Food
Restaurants
Groceries
Entertainment
Equity
Opening Balance
Unrealized Gain
*/
/*
Total ($0.00)
Assets ($10,000.00)
Bank ($2,000.00)
Checking $700.00
Savings $1,300.00
Broker ($8,000.00)
Brokerage $3,000.00
Retirement $5,000.00
Liabilities (-$1,123.11)
Credit Card 1 -$478.20
Credit Card 2 -$644.91
Income (-$10,182.00)
Salary
Capital Gains
Long
Short
Other
Expenses
Taxes
Utilities
Power
Cellular
Rent
Food
Fast Food
Restaurants
Groceries
Entertainment
Equity
Opening Balance
Unrealized Gain
*/
/*
Total
Assets
Bank
Checking $700.00
Savings $1,300.00
Broker
Brokerage $3,000.00
Retirement $5,000.00
Liabilities
Credit Card 1 -$478.20
Credit Card 2 -$644.91
Income
Salary
Capital Gains
Long
Short
Other
Expenses
Taxes
Utilities
Power
Cellular
Rent
Food
Fast Food
Restaurants
Groceries
Entertainment
Equity
Opening Balance
Unrealized Gain
*/
/*
Assets 1.00000000 ETH
248.6010 FSCSX
40.979 SmallCapCore
159.122 FSMEX
$25063.25
47.185 FBGRX
260.9010 FSCHX
367.5600 FSRPX
51.910 FNCMX
100.151 VanguardTargetSixty
2.933 VanguardIndexPlus
BofA ----------------- $16922.08
Checking ----------- $5165.22
Savings ------------ $11756.86
Coinbase ------------- 1.00000000 ETH
ESPP ----------------- $1071.00
*/

View File

@ -90,5 +90,5 @@ pub fn text_to_ansi(text: &Text) -> String {
.join("")
})
.collect::<Vec<_>>()
.join("/n")
.join("\n")
}

99
src/parser/mod.rs Normal file
View File

@ -0,0 +1,99 @@
use chrono::NaiveDate;
use nom::{
bytes::complete::{escaped, tag, take_while_m_n},
character::complete::{char, none_of, one_of},
combinator::{opt, recognize},
error::{Error, ErrorKind},
multi::{many0, many1},
sequence::{delimited, terminated, tuple},
AsChar, Err, IResult, Parser,
};
use rust_decimal::Decimal;
pub fn decimal(input: &str) -> IResult<&str, Decimal> {
let (new_input, decimal_str) = recognize(tuple((
opt(one_of("+-")),
opt(number_int),
opt(char('.')),
opt(number_int),
)))
.parse(input)?;
if decimal_str.contains(',') {
match Decimal::from_str_exact(&decimal_str.replace(",", "")) {
Ok(decimal) => Ok((new_input, decimal)),
Err(_) => Err(Err::Error(Error::new(input, ErrorKind::Eof))),
}
} else {
match Decimal::from_str_exact(decimal_str) {
Ok(decimal) => Ok((new_input, decimal)),
Err(_) => Err(Err::Error(Error::new(input, ErrorKind::Eof))),
}
}
}
pub fn number_int(input: &str) -> IResult<&str, &str> {
recognize(many1(terminated(one_of("0123456789"), many0(one_of("_,")))))(input)
}
pub fn parse_iso_date(input: &str) -> IResult<&str, NaiveDate> {
let (new_input, (year, _, month, _, day)) = tuple((
date_year,
opt(tag("-")),
date_month,
opt(tag("-")),
date_day,
))
.parse(input)?;
match NaiveDate::from_ymd_opt(year, month, day) {
Some(date) => Ok((new_input, date)),
None => Err(nom::Err::Error(Error::new(input, ErrorKind::Eof))),
}
}
pub fn quoted_string(input: &str) -> IResult<&str, &str> {
delimited(
tag("\""),
escaped(none_of("\\\""), '\\', tag("\"")),
tag("\""),
)
.parse(input)
}
///////////////
// Private //
///////////////
fn take_n_digits(i: &str, n: usize) -> IResult<&str, u32> {
let (i, digits) = take_while_m_n(n, n, AsChar::is_dec_digit)(i)?;
let res = digits.parse().expect("Invalid ASCII number");
Ok((i, res))
}
fn date_year(input: &str) -> IResult<&str, i32> {
take_n_digits(input, 4).map(|(str, year)| (str, i32::try_from(year).unwrap()))
}
fn date_month(input: &str) -> IResult<&str, u32> {
take_n_digits(input, 2)
}
fn date_day(input: &str) -> IResult<&str, u32> {
take_n_digits(input, 2)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_string() {
assert_eq!(quoted_string("\"test\"").unwrap().1, "test");
assert_eq!(quoted_string("\"te\\\"st\"").unwrap().1, "te\\\"st");
}
}

View File

@ -1,14 +1,139 @@
use std::collections::HashMap;
use std::{collections::HashMap, time::Instant};
use crate::core::{Amount, Ledger};
use chrono::NaiveDate;
use rust_decimal_macros::dec;
use super::{
base::{self, DataValue, Function},
functions::ComparisonFunction,
transaction::{PostingData, PostingField, TransactionField},
};
pub enum Query {
StartDate(NaiveDate),
EndDate(NaiveDate),
}
pub fn balance2(
ledger: &Ledger,
end_date: NaiveDate,
convert_to_unit: Option<&str>,
) -> HashMap<u32, Vec<Amount>> {
let q = ComparisonFunction::new(
"<=",
base::Query::from_field(PostingField::Transaction(TransactionField::Date)),
base::Query::from(DataValue::from(end_date)),
)
.unwrap();
let convert_to_unit = convert_to_unit.map(|u| ledger.get_unit_by_symbol(u).unwrap());
let postings = ledger
.get_transactions()
.iter()
.map(|t| {
t.get_postings().iter().map(|p| PostingData {
ledger,
posting: p,
parent_transaction: t,
})
})
.flatten();
let filtered_postings =
postings.filter(|data| q.evaluate(data).map(|v| bool::from(v)).unwrap_or(false));
let mut accounts = HashMap::new();
for posting_data in filtered_postings {
let posting = posting_data.posting;
let mut amount = *posting.get_amount();
if let Some(new_unit) = convert_to_unit {
if amount.unit_id != new_unit.get_id() {
let price = ledger.get_price_on_date(end_date, amount.unit_id, new_unit.get_id());
if let Some(price) = price {
amount = Amount {
value: amount.value * price,
unit_id: new_unit.get_id(),
};
}
}
}
let account_vals = accounts
.entry(posting.get_account_id())
.or_insert(HashMap::new());
let a = account_vals.entry(amount.unit_id).or_insert(dec!(0));
*a += amount.value;
}
accounts
.iter()
.map(|(&k, v)| {
(
k,
v.into_iter()
.map(|(&unit_id, &value)| Amount { value, unit_id })
.collect(),
)
})
.collect()
}
pub fn balance3(ledger: &Ledger, query: &base::Query<PostingField>) -> HashMap<u32, Vec<Amount>> {
// let q = ComparisonFunction::new(
// "<=",
// base::Query::from_field(PostingField::Transaction(TransactionField::Date)),
// base::Query::from(DataValue::from(end_date)),
// )
// .unwrap();
let t0 = Instant::now();
let postings = ledger
.get_transactions()
.iter()
.map(|t| {
t.get_postings().iter().map(|p| PostingData {
ledger,
posting: p,
parent_transaction: t,
})
})
.flatten();
let t1 = Instant::now();
let filtered_postings =
postings.filter(|data| query.evaluate(data).map(|v| bool::from(v)).unwrap_or(false));
let t2 = Instant::now();
// println!("{:?} {:?}", t1-t0, t2-t1);
let mut accounts = HashMap::new();
for posting_data in filtered_postings {
let posting = posting_data.posting;
let amount = posting.get_amount();
let account_vals = accounts
.entry(posting.get_account_id())
.or_insert(HashMap::new());
let a = account_vals.entry(amount.unit_id).or_insert(dec!(0));
*a += amount.value;
}
accounts
.iter()
.map(|(&k, v)| {
(
k,
v.into_iter()
.map(|(&unit_id, &value)| Amount { value, unit_id })
.collect(),
)
})
.collect()
}
pub fn balance(ledger: &Ledger, query: &[Query]) -> HashMap<u32, Vec<Amount>> {
let relevant_transactions = ledger.get_transactions().iter().filter(|txn| {
query.iter().all(|q| match q {

218
src/queries/base.rs Normal file
View File

@ -0,0 +1,218 @@
use std::collections::HashMap;
use chrono::NaiveDate;
use rust_decimal::{prelude::Zero, Decimal};
use crate::core::{Amount, CoreError};
#[derive(Debug, Clone)]
pub enum StringData<'a> {
Owned(String),
Reference(&'a str)
}
#[derive(Debug, Clone, PartialEq)]
pub enum DataValue<'a> {
Null,
Integer(u32),
Decimal(Decimal),
Boolean(bool),
String(StringData<'a>),
Date(NaiveDate),
Amount(Amount),
List(Vec<DataValue<'a>>),
Map(HashMap<&'static str, DataValue<'a>>),
}
pub enum Query<'a, T> {
Field(T),
Value(DataValue<'a>),
Function(Box<dyn Function<T>>),
}
pub trait Data<T> {
fn get_field(&self, field: &T) -> Result<DataValue, CoreError>;
}
pub trait Function<T> {
fn evaluate(&self, context: &dyn Data<T>) -> Result<DataValue, CoreError>;
}
// impl ConstantValue {
// pub fn to_bool(&self) -> bool {
// match self {
// ConstantValue::Integer(val) => !val.is_zero(),
// ConstantValue::Decimal(val) => !val.is_zero(),
// ConstantValue::Boolean(val) => *val,
// ConstantValue::String(val) => val.is_empty(),
// ConstantValue::Date(_) => true,
// ConstantValue::Amount(val) => !val.value.is_zero(),
// ConstantValue::List(list) => !list.is_empty(),
// ConstantValue::Map(map) => !map.is_empty(),
// }
// }
// }
impl<'a> StringData<'a> {
pub fn as_ref(&'a self) -> &'a str {
match self {
StringData::Owned(val) => val.as_str(),
StringData::Reference(val) => val,
}
}
}
impl<'a> PartialEq for StringData<'a> {
fn eq(&self, other: &Self) -> bool {
let str_self = match self {
StringData::Owned(val) => val.as_str(),
StringData::Reference(val) => val,
};
let str_other = match other {
StringData::Owned(val) => val.as_str(),
StringData::Reference(val) => val,
};
str_self.eq(str_other)
}
}
impl<'a> PartialOrd for StringData<'a> {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
let str_self = match self {
StringData::Owned(val) => val.as_str(),
StringData::Reference(val) => val,
};
let str_other = match other {
StringData::Owned(val) => val.as_str(),
StringData::Reference(val) => val,
};
str_self.partial_cmp(str_other)
}
}
impl<'a> PartialOrd for DataValue<'a> {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
match (self, other) {
(DataValue::Null, DataValue::Null) => Some(std::cmp::Ordering::Equal),
(DataValue::Integer(val1), DataValue::Integer(val2)) => val1.partial_cmp(val2),
(DataValue::Decimal(val1), DataValue::Decimal(val2)) => val1.partial_cmp(val2),
(DataValue::Boolean(val1), DataValue::Boolean(val2)) => val1.partial_cmp(val2),
(DataValue::String(val1), DataValue::String(val2)) => val1.partial_cmp(val2),
(DataValue::Date(val1), DataValue::Date(val2)) => val1.partial_cmp(val2),
(DataValue::Amount(val1), DataValue::Amount(val2)) => val1.partial_cmp(val2),
(DataValue::List(val1), DataValue::List(val2)) => val1.partial_cmp(val2),
_ => None,
}
}
}
impl<'a> From<&'a str> for StringData<'a> {
fn from(value: &'a str) -> Self {
StringData::Reference(value)
}
}
impl<'a> From<String> for StringData<'a> {
fn from(value: String) -> Self {
StringData::Owned(value)
}
}
impl<'a> From<u32> for DataValue<'a> {
fn from(value: u32) -> Self {
DataValue::Integer(value)
}
}
impl<'a> From<Decimal> for DataValue<'a> {
fn from(value: Decimal) -> Self {
DataValue::Decimal(value)
}
}
impl<'a> From<bool> for DataValue<'a> {
fn from(value: bool) -> Self {
DataValue::Boolean(value)
}
}
impl<'a> From<&'a str> for DataValue<'a> {
fn from(value: &'a str) -> Self {
DataValue::String(value.into())
}
}
impl<'a> From<String> for DataValue<'a> {
fn from(value: String) -> Self {
DataValue::String(value.into())
}
}
impl<'a> From<NaiveDate> for DataValue<'a> {
fn from(value: NaiveDate) -> Self {
DataValue::Date(value)
}
}
impl<'a> From<Amount> for DataValue<'a> {
fn from(value: Amount) -> Self {
DataValue::Amount(value)
}
}
impl<'a> From<DataValue<'a>> for bool {
fn from(value: DataValue) -> Self {
match value {
DataValue::Null => false,
DataValue::Integer(val) => !val.is_zero(),
DataValue::Decimal(val) => !val.is_zero(),
DataValue::Boolean(val) => val,
DataValue::String(val) => val.as_ref().is_empty(),
DataValue::Date(_) => true,
DataValue::Amount(val) => !val.value.is_zero(),
DataValue::List(list) => !list.is_empty(),
DataValue::Map(map) => !map.is_empty(),
}
}
}
impl<'a, T> Query<'a, T> {
pub fn evaluate(&self, context: &'a dyn Data<T>) -> Result<DataValue, CoreError> {
match self {
Query::Field(field) => context.get_field(field),
Query::Value(constant) => Ok(constant.clone()),
Query::Function(function) => function.evaluate(context),
}
}
pub fn from_field(field: T) -> Self {
Query::Field(field)
}
pub fn from_fn<F: Function<T> + Sized + 'static>(function: F) -> Self {
Query::Function(Box::new(function))
}
}
impl<'a, T> From<DataValue<'a>> for Query<'a, T> {
fn from(constant: DataValue<'a>) -> Self {
Query::Value(constant)
}
}
// impl<T: Sized + 'static, Field: Sized> Function<Field> for T {
// fn to_value(self) -> Value<T> {
// Value::Function(Box::new(self))
// }
// }
// impl<Field, T: Function<Field>> T {
// }
// impl<Field, T: Function<Field> + Sized + 'static> From<T> for Value<Field> {
// fn from(function: T) -> Self {
// Value::Function(Box::new(function))
// }
// }

294
src/queries/functions.rs Normal file
View File

@ -0,0 +1,294 @@
use std::time::{Duration, Instant};
use crate::core::CoreError;
use regex::Regex;
use super::base::{Data, DataValue, Function, Query, StringData};
#[derive(PartialEq, Debug)]
pub enum ComparisonOperator {
EQ,
NEQ,
GT,
LT,
GTE,
LTE,
}
pub struct ComparisonFunction<'a, Field> {
op: ComparisonOperator,
left: Query<'a, Field>,
right: Query<'a, Field>,
}
pub enum StringComparisonOperator<'a> {
Func(&'a (dyn Fn(&str) -> bool + 'a)),
Regex(Regex),
}
pub struct StringComparisonFunction<'a, Field> {
op: StringComparisonOperator<'a>,
val: Query<'a, Field>,
}
pub struct SubAccountFunction<'a, Field> {
account_name: StringData<'a>,
val: Query<'a, Field>,
}
pub struct RegexFunction<'a, Field> {
left: Query<'a, Field>,
regex: Regex,
}
#[derive(PartialEq, Debug)]
pub enum LogicalOperator {
AND,
OR,
}
pub struct LogicalFunction<'a, Field> {
op: LogicalOperator,
left: Query<'a, Field>,
right: Query<'a, Field>,
}
pub struct NotFunction<'a, Field> {
value: Query<'a, Field>,
}
impl<'a, Field> ComparisonFunction<'a, Field> {
pub fn new(
op: &str,
left: Query<'a, Field>,
right: Query<'a, Field>,
) -> Result<Self, CoreError> {
let op = match op {
"==" => ComparisonOperator::EQ,
"!=" => ComparisonOperator::NEQ,
">" => ComparisonOperator::GT,
"<" => ComparisonOperator::LT,
">=" => ComparisonOperator::GTE,
"<=" => ComparisonOperator::LTE,
_ => return Err("Invalid Operator".into()),
};
Ok(ComparisonFunction { op, left, right })
}
pub fn new_op(
op: ComparisonOperator,
left: Query<'a, Field>,
right: Query<'a, Field>,
) -> Self {
ComparisonFunction { op, left, right }
}
}
impl<'a, Field> SubAccountFunction<'a, Field> {
pub fn new(account: StringData<'a>, val: Query<'a, Field>) -> Self {
SubAccountFunction { account_name: account, val }
}
}
impl<'a, Field> StringComparisonFunction<'a, Field> {
pub fn new_func(
val: Query<'a, Field>,
func: &'a (impl Fn(&str) -> bool + 'a),
) -> Result<Self, CoreError> {
Ok(StringComparisonFunction { val, op: StringComparisonOperator::Func(func) })
}
pub fn new_regex(val: Query<'a, Field>, regex: &str) -> Result<Self, CoreError> {
let regex = Regex::new(regex).map_err(|_| CoreError::from("Unable to parse regex"))?;
Ok(StringComparisonFunction { val, op: StringComparisonOperator::Regex(regex) })
}
}
impl<'a, Field> RegexFunction<'a, Field> {
pub fn new(left: Query<'a, Field>, regex: &str) -> Result<Self, CoreError> {
let regex = Regex::new(regex).map_err(|_| CoreError::from("Unable to parse regex"))?;
Ok(RegexFunction { left, regex })
}
}
impl<'a, Field> Function<Field> for ComparisonFunction<'a, Field> {
fn evaluate(&self, context: &dyn Data<Field>) -> Result<DataValue, CoreError> {
let left = self.left.evaluate(context)?;
let right = self.right.evaluate(context)?;
match self.op {
ComparisonOperator::EQ => Ok(DataValue::Boolean(left == right)),
ComparisonOperator::NEQ => Ok(DataValue::Boolean(left != right)),
ComparisonOperator::GT => Ok(DataValue::Boolean(left > right)),
ComparisonOperator::LT => Ok(DataValue::Boolean(left < right)),
ComparisonOperator::GTE => Ok(DataValue::Boolean(left >= right)),
ComparisonOperator::LTE => Ok(DataValue::Boolean(left <= right)),
}
}
}
impl<'a, Field> Function<Field> for StringComparisonFunction<'a, Field> {
fn evaluate(&self, context: &dyn Data<Field>) -> Result<DataValue, CoreError> {
let val = self.val.evaluate(context)?;
if let DataValue::String(val) = val {
match &self.op {
StringComparisonOperator::Func(func) => Ok(DataValue::Boolean(func(val.as_ref()))),
StringComparisonOperator::Regex(regex) => {
Ok(DataValue::Boolean(regex.is_match(val.as_ref())))
}
}
// Ok(DataValue::Boolean(self.regex.is_match(left.as_ref())))
} else {
Err("Cannot use REGEX operation on non string types".into())
}
// let left = self.left.evaluate(context)?;
// if let DataValue::String(left) = left {
// Ok(DataValue::Boolean(self.regex.is_match(left.as_ref())))
// } else {
// Err("Cannot use REGEX operation on non string types".into())
// }
}
}
impl<'a, Field> Function<Field> for SubAccountFunction<'a, Field> {
fn evaluate(&self, context: &dyn Data<Field>) -> Result<DataValue, CoreError> {
let val = self.val.evaluate(context)?;
if let DataValue::String(val) = val {
Ok(DataValue::Boolean(
val.as_ref()
.strip_prefix(self.account_name.as_ref())
.map(|n| n.is_empty() || n.starts_with(":"))
.unwrap_or(false),
))
} else {
Err("Cannot compare account name on non string types".into())
}
}
}
impl<'a, Field> Function<Field> for RegexFunction<'a, Field> {
fn evaluate(&self, context: &dyn Data<Field>) -> Result<DataValue, CoreError> {
let left = self.left.evaluate(context)?;
if let DataValue::String(left) = left {
Ok(DataValue::Boolean(self.regex.is_match(left.as_ref())))
} else {
Err("Cannot use REGEX operation on non string types".into())
}
}
}
impl<'a, Field> LogicalFunction<'a, Field> {
pub fn new(
op: &str,
left: Query<'a, Field>,
right: Query<'a, Field>,
) -> Result<Self, CoreError> {
if op.eq_ignore_ascii_case("and") {
Ok(LogicalFunction { op: LogicalOperator::AND, left, right })
} else if op.eq_ignore_ascii_case("or") {
Ok(LogicalFunction { op: LogicalOperator::OR, left, right })
} else {
Err("Invalid logical operator".into())
}
}
}
impl<'a, Field> NotFunction<'a, Field> {
pub fn new(value: Query<'a, Field>) -> Self {
NotFunction { value }
}
}
impl<'a, Field> Function<Field> for LogicalFunction<'a, Field> {
fn evaluate(&self, context: &dyn Data<Field>) -> Result<DataValue, CoreError> {
// More verbose to try and avoid doing right side computation
let value: bool = match self.op {
LogicalOperator::AND => {
if !bool::from(self.left.evaluate(context)?) {
false
} else {
self.right.evaluate(context)?.into()
}
}
LogicalOperator::OR => {
if bool::from(self.left.evaluate(context)?) {
true
} else {
self.right.evaluate(context)?.into()
}
}
};
Ok(DataValue::Boolean(value))
// let left = self.left.evaluate(context)?.into();
// let right = self.right.evaluate(context)?.into();
// match self.op {
// LogicalOperator::AND => Ok(DataValue::Boolean(left && right)),
// LogicalOperator::OR => Ok(DataValue::Boolean(left || right)),
// }
}
}
impl<'a, Field> Function<Field> for NotFunction<'a, Field> {
fn evaluate(&self, context: &dyn Data<Field>) -> Result<DataValue, CoreError> {
let value: bool = self.value.evaluate(context)?.into();
Ok(DataValue::Boolean(!value))
}
}
// Tests section
#[cfg(test)]
mod tests {
use super::*;
enum TestField {}
struct TestData {}
impl<TestField> Data<TestField> for TestData {
fn get_field(&self, _: &TestField) -> Result<DataValue, CoreError> {
Err("".into())
}
}
#[test]
fn comparison_function_evaluate() {
let value1: DataValue = 5.into();
let value2: DataValue = 10.into();
let context = TestData {};
let comparison_function =
ComparisonFunction::<TestField>::new(">", value1.into(), value2.into()).unwrap();
assert_eq!(comparison_function.op, ComparisonOperator::GT);
assert_eq!(comparison_function.evaluate(&context), Ok(false.into()));
let value = Query::from_fn(comparison_function);
assert_eq!(value.evaluate(&context), Ok(false.into()));
}
#[test]
fn logical_function_evaluate() {
let value1: DataValue = 1.into();
let value2: DataValue = false.into();
let context = TestData {};
let logical_function_and =
LogicalFunction::<TestField>::new("and", value1.clone().into(), value2.clone().into())
.unwrap();
assert_eq!(logical_function_and.op, LogicalOperator::AND);
assert_eq!(logical_function_and.evaluate(&context), Ok(false.into()));
let logical_function_or =
LogicalFunction::<TestField>::new("or", value1.into(), value2.into()).unwrap();
assert_eq!(logical_function_or.op, LogicalOperator::OR);
assert_eq!(logical_function_or.evaluate(&context), Ok(true.into()));
}
}

View File

@ -1,3 +1,9 @@
mod balance;
mod postings;
pub mod base;
pub mod functions;
pub mod parser;
pub mod transaction;
pub use balance::*;
pub use postings::*;

View File

@ -0,0 +1,142 @@
use nom::{
branch::alt, bytes::complete::{tag, tag_no_case, take_until}, character::complete::space0, error::{Error, ErrorKind}, multi::fold_many0, sequence::{delimited, preceded, tuple}, AsChar, IResult, InputTakeAtPosition, Parser
};
use crate::{
parser::{decimal, parse_iso_date, quoted_string},
queries::{
base::{DataValue, Query},
functions::{ComparisonFunction, ComparisonOperator, LogicalFunction},
},
};
pub trait ParseField: Sized {
fn parse(input: &str) -> Option<Self>;
}
// fn query<'a, Field: ParseField + 'static>(
// input: &'a str,
// ) -> IResult<&'a str, Query<'a, Field>> {
// // TODO: uncommented, is this right?
// delimited(
// space0,
// alt((
// parenthesis,
// value.map(|v| v.into()),
// field.map(|v| Query::from_field(v)),
// comparison_function::<Field>.map(|v| Query::from_fn(v)),
// logical_function::<Field>.map(|v| Query::from_fn(v)),
// )),
// space0,
// )
// .parse(input)
// }
fn value<'a>(input: &'a str) -> IResult<&'a str, DataValue<'a>> {
alt((
tag_no_case("null").map(|_| DataValue::Null),
tag_no_case("true").map(|_| DataValue::Boolean(true)),
tag_no_case("false").map(|_| DataValue::Boolean(false)),
parse_iso_date.map(|v| v.into()),
decimal.map(|v| v.into()),
quoted_string.map(|v| v.into()),
))
.parse(input)
}
fn field<'a, Field: ParseField>(input: &str) -> IResult<&str, Field> {
input
.split_at_position1_complete(
|item| !item.is_alphanum() || item != '.',
ErrorKind::AlphaNumeric,
)
.and_then(|v| {
Field::parse(v.1)
.map(|f| (v.0, f))
.ok_or(nom::Err::Error(Error::new(input, ErrorKind::Eof)))
})
}
// fn parenthesis<'a, Field: ParseField + 'static>(
// input: &'a str,
// ) -> IResult<&'a str, Query<'a, Field>> {
// delimited(tag("("), query, tag(")")).parse(input)
// }
// fn query_factor<'a, Field: ParseField + 'static>(input: &'a str) -> IResult<&'a str, Query<'a, Field>> {
// delimited(
// space0,
// alt((
// value.map(|v| v.into()),
// field.map(|v| Query::from_field(v)),
// parenthesis,
// )),
// space0,
// )
// .parse(input)
// }
// fn query_comparison<'a, Field: ParseField + 'static>(
// input: &'a str,
// ) -> IResult<&'a str, Query<'a, Field>> {
// let (input, initial) = query_factor(input)?;
// let op = alt((
// tag("=").map(|_| ComparisonOperator::EQ),
// tag("!=").map(|_| ComparisonOperator::NEQ),
// tag(">").map(|_| ComparisonOperator::GT),
// tag("<").map(|_| ComparisonOperator::LT),
// tag(">=").map(|_| ComparisonOperator::GTE),
// tag("<=").map(|_| ComparisonOperator::LTE),
// ));
// loop {
// }
// fold_many0(tuple((op, query_factor)), move || initial, |acc, (op, val)| {
// Query::from_fn(ComparisonFunction::new_op(op, acc, val))
// }).parse(input)
// }
// fn query_logical_and<'a, Field: ParseField>(
// input: &str,
// ) -> IResult<&'a str, Query<'a, Field>> {
// let (input_next, lhs) = query_factor(input)?;
// fold_many0(preceded(tag_no_case("and"), query_factor), init, g)
// // loop {
// // let rhs_result = tuple((
// // alt((tag_no_case("and"), tag_no_case("or"))),
// // query_factor,
// // )).parse(input_next);
// // if let Ok(rhs_result) = rhs_result {
// // input_next = rhs_result.0;
// // } else {
// // break;
// // }
// // };
// // tuple(take_until(alt((tag("")))))
// }
// fn logical_function<'a, Field: ParseField>(
// input: &str,
// ) -> IResult<&str, LogicalFunction<'a, Field>> {
// let lhs =
// }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_string() {
assert_eq!(quoted_string("\"test\"").unwrap().1, "test");
assert_eq!(quoted_string("\"te\\\"st\"").unwrap().1, "te\\\"st");
}
}

View File

@ -0,0 +1 @@
mod functions;

11
src/queries/postings.rs Normal file
View File

@ -0,0 +1,11 @@
use crate::core::Ledger;
pub struct PostingQuery {
}
pub fn query_postings(ledger: &Ledger, query: PostingQuery) {
}

View File

@ -0,0 +1,78 @@
use crate::core::{CoreError, Ledger, Posting, Transaction};
use super::base::{Data, DataValue};
pub enum AccountField {
Name,
OpenDate,
CloseDate,
}
pub enum PostingField {
Transaction(TransactionField),
Account(AccountField),
Amount,
Cost,
Price,
}
pub enum TransactionField {
Date,
Flag,
Payee,
Narration,
}
pub struct PostingData<'a> {
pub posting: &'a Posting,
pub parent_transaction: &'a Transaction,
pub ledger: &'a Ledger,
}
impl<'a> Data<PostingField> for PostingData<'a> {
fn get_field(&self, field: &PostingField) -> Result<DataValue, CoreError> {
match field {
PostingField::Transaction(transaction_field) => get_transaction_value(transaction_field, &self.parent_transaction),
PostingField::Account(account_field) => {
let account = self
.ledger
.get_account(self.posting.get_account_id())
.ok_or_else(|| CoreError::from("Unable to find account"))?;
match account_field {
AccountField::Name => Ok(account.get_name().as_str().into()),
AccountField::OpenDate => Ok(account
.get_open_date()
.map(|v| v.into())
.unwrap_or(DataValue::Null)),
AccountField::CloseDate => Ok(account
.get_close_date()
.map(|v| v.into())
.unwrap_or(DataValue::Null)),
}
}
PostingField::Amount => todo!(),
PostingField::Cost => todo!(),
PostingField::Price => todo!(),
}
}
}
fn get_transaction_value<'a>(
field: &TransactionField,
transaction: &Transaction,
) -> Result<DataValue<'a>, CoreError> {
match field {
TransactionField::Date => Ok(transaction.get_date().into()),
TransactionField::Flag => Ok(char::from(transaction.get_flag()).to_string().into()),
TransactionField::Payee => Ok(transaction
.get_payee()
.clone()
.map(|v| v.into())
.unwrap_or(DataValue::Null)),
TransactionField::Narration => Ok(transaction
.get_narration()
.clone()
.map(|v| v.into())
.unwrap_or(DataValue::Null)),
}
}