use core::panic;
use std::{
env,
io::{BufRead, BufReader, Read},
path::PathBuf,
str::FromStr,
todo,
};
use chrono::NaiveDate;
use crate::{
amount::Amount,
journal::{Journal, XactIndex},
pool::CommodityIndex,
post::Post,
scanner,
xact::Xact,
};
pub const ISO_DATE_FORMAT: &str = "%Y-%m-%d";
pub const ISO_TIME_FORMAT: &str = "%H:%M:%S";
pub(crate) fn read_into_journal<T: Read>(source: T, journal: &mut Journal) {
let mut parser = Parser::new(source, journal);
parser.parse();
}
pub(crate) fn parse_date(date_str: &str) -> NaiveDate {
NaiveDate::parse_from_str(date_str, ISO_DATE_FORMAT).expect("date parsed")
}
struct Parser<'j, T: Read> {
pub journal: &'j mut Journal,
reader: BufReader<T>,
buffer: String,
}
impl<'j, T: Read> Parser<'j, T> {
pub fn new(source: T, journal: &'j mut Journal) -> Self {
let reader = BufReader::new(source);
let buffer = String::new();
Self {
reader,
buffer,
journal,
}
}
pub fn parse(&mut self) {
loop {
match self.reader.read_line(&mut self.buffer) {
Err(err) => {
println!("Error: {:?}", err);
break;
}
Ok(0) => {
break;
}
Ok(_) => {
match self.read_next_directive() {
Ok(_) => (), Err(err) => {
log::error!("Error: {:?}", err);
println!("Error: {:?}", err);
break;
}
};
}
}
self.buffer.clear();
}
}
fn read_next_directive(&mut self) -> Result<(), String> {
if self.buffer == "\r\n" || self.buffer == "\n" {
return Ok(());
}
match self.buffer.chars().peekable().peek().unwrap() {
';' | '#' | '*' | '|' => {
return Ok(());
}
'-' => {
}
'0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => {
self.xact_directive();
}
' ' | '\t' => {
todo!("complete")
}
c => {
match c {
'P' => {
self.price_xact_directive();
}
c => {
log::warn!("not handled: {:?}", c);
todo!("handle other directives");
}
}
todo!("the rest")
}
}
Ok(())
}
fn general_directive(&self) -> bool {
let mut iter = self.buffer.split_whitespace();
let Some(directive) = iter.next() else { panic!("no directive?") };
let argument = iter.next();
match directive.chars().peekable().peek().unwrap() {
'a' => {
todo!("a");
}
'i' => match self.buffer.as_str() {
"include" => {
self.include_directive(argument.unwrap());
return true;
}
"import" => {
todo!("import directive")
}
_ => (),
},
_ => {
todo!("handle")
}
}
false
}
fn price_xact_directive(&mut self) {
self.journal
.commodity_pool
.parse_price_directive(&self.buffer);
}
fn xact_directive(&mut self) {
let tokens = scanner::tokenize_xact_header(&self.buffer);
let xact = Xact::create(tokens[0], tokens[1], tokens[2], tokens[3]);
let xact_index = self.journal.add_xact(xact);
loop {
self.buffer.clear(); match self.reader.read_line(&mut self.buffer) {
Err(e) => {
println!("Error: {:?}", e);
break;
}
Ok(0) => {
log::debug!("0-length buffer");
break;
}
Ok(_) => {
if self.buffer.is_empty() {
todo!("Check what happens here")
}
match self.buffer.chars().peekable().peek() {
Some(' ') => {
let input = self.buffer.trim_start();
if input.is_empty() {
break;
}
match input.chars().peekable().peek() {
Some(';') => {
todo!("trailing note")
}
_ => {
parse_post(input, xact_index, &mut self.journal);
}
}
}
Some('\r') | Some('\n') => {
break;
}
_ => {
panic!("should not happen")
}
}
}
}
self.buffer.clear();
}
crate::xact::finalize(xact_index, &mut self.journal);
}
fn include_directive(&self, argument: &str) {
let mut filename: PathBuf;
if argument.starts_with('/') || argument.starts_with('\\') || argument.starts_with('~') {
filename = PathBuf::from_str(argument).unwrap();
} else {
filename = env::current_dir().unwrap();
filename.set_file_name(argument);
}
let mut file_found = false;
let parent_path = filename.parent().unwrap();
if parent_path.exists() {
if filename.is_file() {
todo!("read file")
}
}
if !file_found {
panic!("Include file not found");
}
}
}
fn parse_post(input: &str, xact_index: XactIndex, journal: &mut Journal) {
let tokens = scanner::scan_post(input);
let account_index;
{
account_index = journal.register_account(tokens[0]).unwrap();
}
let commodity_index: Option<CommodityIndex>;
{
let symbol = tokens[2];
commodity_index = journal.commodity_pool.find_or_create(symbol);
}
let amount = Amount::parse(tokens[1], commodity_index);
let price_commodity_index: Option<CommodityIndex>;
{
let symbol = tokens[4];
price_commodity_index = journal.commodity_pool.find_or_create(symbol);
}
let cost = Amount::parse(tokens[3], price_commodity_index);
let post_index;
{
let post = Post::new(account_index, xact_index, amount, cost);
post_index = journal.add_post(post);
}
{
let account = journal.accounts.get_mut(account_index).unwrap();
account.post_indices.push(post_index);
}
{
let xact = journal.xacts.get_mut(xact_index).unwrap();
xact.posts.push(post_index);
}
}
#[cfg(test)]
mod tests {
use std::{io::Cursor, todo};
use super::Parser;
use crate::journal::Journal;
fn test_general_directive() {
let source = Cursor::new("include some-file.ledger");
let mut journal = Journal::new();
let parser = Parser::new(source, &mut journal);
parser.general_directive();
todo!("assert")
}
#[test]
fn test_xact_with_space_after() {
let src = r#";
2023-05-05 Payee
Expenses 25 EUR
Assets
"#;
let source = Cursor::new(src);
let mut journal = Journal::new();
let mut parser = Parser::new(source, &mut journal);
parser.parse();
assert_eq!(3, journal.accounts.len());
}
}
#[cfg(test)]
mod full_tests {
use std::io::Cursor;
use crate::{journal::Journal, parser::read_into_journal};
#[test]
fn test_minimal_parsing() {
let input = r#"; Minimal transaction
2023-04-10 Supermarket
Expenses 20
Assets
"#;
let cursor = Cursor::new(input);
let mut journal = Journal::new();
super::read_into_journal(cursor, &mut journal);
assert_eq!(1, journal.xacts.len());
let xact = journal.xacts.first().unwrap();
assert_eq!("Supermarket", xact.payee);
assert_eq!(2, xact.posts.len());
let post1 = &journal.posts[xact.posts[0]];
assert_eq!("Expenses", journal.get_account(post1.account_index).name);
assert_eq!("20", post1.amount.as_ref().unwrap().quantity.to_string());
assert_eq!(None, post1.amount.as_ref().unwrap().commodity_index);
let post2 = &journal.posts[xact.posts[1]];
assert_eq!("Assets", journal.get_account(post2.account_index).name);
}
#[test]
fn test_multiple_currencies_one_xact() {
let input = r#";
2023-05-05 Payee
Assets:Cash EUR -25 EUR
Assets:Cash USD 30 USD
"#;
let cursor = Cursor::new(input);
let mut journal = Journal::new();
read_into_journal(cursor, &mut journal);
assert_eq!(2, journal.commodity_pool.commodities.len());
}
}
#[cfg(test)]
mod parser_tests {
use std::{assert_eq, io::Cursor};
use crate::{
journal::Journal,
parser::{self, read_into_journal},
};
#[test]
fn test_minimal_parser() {
let input = r#"; Minimal transaction
2023-04-10 Supermarket
Expenses 20
Assets
"#;
let cursor = Cursor::new(input);
let mut journal = Journal::new();
parser::read_into_journal(cursor, &mut journal);
assert_eq!(1, journal.xacts.len());
let xact = journal.xacts.first().unwrap();
assert_eq!("Supermarket", xact.payee);
assert_eq!(2, xact.posts.len());
let post1 = &journal.posts[xact.posts[0]];
assert_eq!("Expenses", journal.get_account(post1.account_index).name);
assert_eq!("20", post1.amount.as_ref().unwrap().quantity.to_string());
assert_eq!(None, post1.amount.as_ref().unwrap().commodity_index);
let post2 = &journal.posts[xact.posts[1]];
assert_eq!("Assets", journal.get_account(post2.account_index).name);
}
#[test]
fn test_parse_standard_xact() {
let input = r#"; Standard transaction
2023-04-10 Supermarket
Expenses 20 EUR
Assets
"#;
let cursor = Cursor::new(input);
let mut journal = Journal::new();
super::read_into_journal(cursor, &mut journal);
assert_eq!(1, journal.xacts.len());
if let Some(xact) = journal.xacts.first() {
assert_eq!("Supermarket", xact.payee);
let posts = journal.get_posts(&xact.posts);
assert_eq!(2, posts.len());
let acc1 = journal.get_account(posts[0].account_index);
assert_eq!("Expenses", acc1.name);
let acc2 = journal.get_account(posts[1].account_index);
assert_eq!("Assets", acc2.name);
} else {
assert!(false);
}
}
#[test]
fn test_parse_trade_xact() {
let input = r#"; Standard transaction
2023-04-10 Supermarket
Assets:Investment 20 VEUR @ 10 EUR
Assets
"#;
let cursor = Cursor::new(input);
let mut journal = Journal::new();
read_into_journal(cursor, &mut journal);
let xact = journal.xacts.first().unwrap();
assert_eq!("Supermarket", xact.payee);
let posts = journal.get_posts(&xact.posts);
assert_eq!(2, posts.len());
let p1 = posts[0];
assert_eq!("Investment", journal.get_account(p1.account_index).name);
let Some(a1) = &p1.amount else {panic!()};
assert_eq!("20", a1.quantity.to_string());
let comm1 = journal
.commodity_pool
.commodity_history
.get_commodity(a1.commodity_index.unwrap());
assert_eq!("VEUR", comm1.symbol);
let Some(ref cost1) = p1.cost else { panic!()};
assert_eq!("10", cost1.quantity.to_string());
assert_eq!(
"EUR",
journal
.commodity_pool
.commodity_history
.get_commodity(cost1.commodity_index.unwrap())
.symbol
);
let p2 = posts[1];
assert_eq!("Assets", journal.get_account(p2.account_index).name);
let Some(a2) = &p2.amount else {panic!()};
assert_eq!("-20", a2.quantity.to_string());
let comm2 = journal
.commodity_pool
.commodity_history
.get_commodity(a2.commodity_index.unwrap());
assert_eq!("VEUR", comm2.symbol);
assert!(p2.cost.is_none());
}
#[test]
fn test_parsing_account_tree() {
let input = r#"
2023-05-23 Payee
Assets:Eur -20 EUR
Assets:USD 30 USD
Trading:Eur 20 EUR
Trading:Usd -30 USD
"#;
let cursor = Cursor::new(input);
let mut journal = Journal::new();
read_into_journal(cursor, &mut journal);
assert_eq!(1, journal.xacts.len());
assert_eq!(4, journal.posts.len());
assert_eq!(7, journal.accounts.len());
assert_eq!(2, journal.commodity_pool.commodities.len());
}
}
#[cfg(test)]
mod posting_parsing_tests {
use crate::{journal::Journal, amount::Decimal, utilities::create_date};
#[test]
fn test_parsing_buy_lot() {
let file_path = "tests/lot.ledger";
let mut j = Journal::new();
crate::parse_file(file_path, &mut j);
assert_eq!(1, j.xacts.len());
assert_eq!(4, j.accounts.len());
assert_eq!(2, j.commodity_pool.commodities.len());
assert_eq!(2, j.commodity_pool.commodity_history.graph.node_count());
assert_eq!(1, j.commodity_pool.commodity_history.graph.edge_count());
let price = j.commodity_pool.commodity_history.graph.edge_weight(0.into()).unwrap();
let expected_date = create_date("2023-05-01").unwrap();
assert!(price.contains_key(&expected_date));
let value = price.get(&expected_date).unwrap();
assert_eq!(Decimal::from(12.75), *value);
}
}
#[cfg(test)]
mod amount_parsing_tests {
use crate::{journal::Journal, parser::parse_post, pool::CommodityIndex, xact::Xact, amount::Decimal};
use super::Amount;
fn setup() -> Journal {
let mut journal = Journal::new();
let xact = Xact::create("2023-05-02", "", "Supermarket", "");
journal.add_xact(xact);
journal
}
#[test]
fn test_positive_no_commodity() {
let expected = Amount {
quantity: 20.into(),
commodity_index: None,
};
let actual = Amount::parse("20", None).unwrap();
assert_eq!(expected, actual);
}
#[test]
fn test_negative_no_commodity() {
let actual = Amount::parse("-20", None).unwrap();
let expected = Amount {
quantity: (-20).into(),
commodity_index: None,
};
assert_eq!(expected, actual);
}
#[test]
fn test_pos_w_commodity_separated() {
let expected = Amount {
quantity: 20.into(),
commodity_index: Some(CommodityIndex::new(0)),
};
let mut journal = setup();
parse_post(" Assets 20 EUR", 0, &mut journal);
let post = journal.posts.first().unwrap();
let Some(amount) = &post.amount else { todo!() }; assert_eq!(expected, *amount);
let c = journal
.commodity_pool
.commodity_history
.get_commodity(amount.commodity_index.unwrap());
assert_eq!("EUR", c.symbol);
}
#[test]
fn test_neg_commodity_separated() {
let expected = Amount {
quantity: (-20).into(),
commodity_index: Some(CommodityIndex::new(0)),
};
let mut journal = setup();
parse_post(" Assets -20 EUR", 0, &mut journal);
let post = journal.posts.first().unwrap();
let Some(a) = &post.amount else { panic!() };
assert_eq!(&expected, a);
let commodity = journal
.commodity_pool
.commodity_history
.get_commodity(a.commodity_index.unwrap());
assert_eq!("EUR", commodity.symbol);
}
#[test]
fn test_full_w_commodity_separated() {
let mut journal = setup();
parse_post(" Assets -20000.00 EUR", 0, &mut journal);
let post = journal.posts.first().unwrap();
let Some(ref amount) = post.amount else { panic!()};
assert_eq!("-20000.00", amount.quantity.to_string());
assert_eq!(
"EUR",
journal
.commodity_pool
.commodity_history
.get_commodity(amount.commodity_index.unwrap())
.symbol
);
}
#[test]
fn test_full_commodity_first() {
let mut journal = setup();
parse_post(" Assets A$-20000.00", 0, &mut journal);
let post = journal.posts.first().unwrap();
let Some(ref amount) = post.amount else { panic!()};
assert_eq!("-20000.00", amount.quantity.to_string());
assert_eq!(
"A$",
journal
.commodity_pool
.commodity_history
.get_commodity(amount.commodity_index.unwrap())
.symbol
);
}
#[test]
fn test_quantity_separators() {
let input = "-1000000.00";
let expected = Decimal::from(-1_000_000);
let amount = Amount::parse(input, None);
assert!(amount.is_some());
let actual = amount.unwrap().quantity;
assert_eq!(expected, actual);
}
#[test]
fn test_addition() {
let left = Amount::new(10.into(), None);
let right = Amount::new(15.into(), None);
let actual = left + right;
assert_eq!(Decimal::from(25), actual.quantity);
}
#[test]
fn test_add_assign() {
let mut actual = Amount::new(21.into(), None);
let other = Amount::new(13.into(), None);
actual.add(&other);
assert_eq!(Decimal::from(34), actual.quantity);
}
#[test]
fn test_null_amount() {
let input = " ";
let actual = Amount::parse(input, None);
assert_eq!(None, actual);
}
#[test]
fn test_copy_from_no_commodity() {
let other = Amount::new(10.into(), None);
let actual = Amount::copy_from(&other);
assert_eq!(Decimal::from(10), actual.quantity);
}
}