diff --git a/docs/tep/tep-1015.adoc b/docs/tep/tep-1015.adoc index 4ff54e0..2691ea3 100644 --- a/docs/tep/tep-1015.adoc +++ b/docs/tep/tep-1015.adoc @@ -27,11 +27,6 @@ This TEP does not propose any method for calculating the fixed conversions (e.g. No change would be made to the journal file format. A new file type would be added with a format similar to the default format of Ledger CLI as described https://ledger-cli.org/doc/ledger3.html[here]. ----- -'P' TIMESTAMP AMOUNT COMMODITY '=' AMOUNT COMMODITY OPTIONAL_COMMENT ----- - -COMMENT: This should/could be just simplified ledger format? ---- 'P' TIMESTAMP COMMODITY AMOUNT COMMODITY OPT_COMMENT ---- diff --git a/tackler-core/src/model.rs b/tackler-core/src/model.rs index f003daa..f21ef59 100644 --- a/tackler-core/src/model.rs +++ b/tackler-core/src/model.rs @@ -29,6 +29,7 @@ pub use txn_data::TxnSet; pub(crate) mod account_tree_node; pub(crate) mod balance_tree_node; pub mod posting; +pub mod price_entry; mod register; pub mod transaction; pub mod txn_data; diff --git a/tackler-core/src/model/price_entry.rs b/tackler-core/src/model/price_entry.rs new file mode 100644 index 0000000..370099a --- /dev/null +++ b/tackler-core/src/model/price_entry.rs @@ -0,0 +1,49 @@ +/* + * Copyright 2024-2025 E257.FI and Muhammad Ragib Hasin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +use rust_decimal::Decimal; +use std::{fmt::Write, sync::Arc}; +use time::OffsetDateTime; +use winnow::{seq, PResult, Parser}; + +use crate::parser::parts::timestamp::parse_timestamp; +// use crate::parser::parts::txn_comment::parse_txn_comment; +// use crate::parser::parts::txn_header_code::parse_txn_code; +// use crate::parser::parts::txn_header_desc::parse_txn_description; +// use crate::parser::parts::txn_metadata::{parse_txn_meta, TxnMeta}; +use crate::parser::{from_error, make_semantic_error, Stream}; +use tackler_api::txn_header::{Comments, TxnHeader}; +use winnow::ascii::{line_ending, space1}; +use winnow::combinator::{cut_err, opt, preceded, repeat}; +use winnow::error::{StrContext, StrContextValue}; + +use super::Commodity; + +/// Entry in the price database +#[derive(Debug)] +pub struct PriceEntry { + /// Timestamp with Zone information + pub timestamp: jiff::Zoned, + /// The commodity for which price is being noted + pub base_commodity: Arc, + /// Price of base in _eq_ commodity + pub eq_amount: Decimal, + /// The equivalence commodity in which price is being noted + pub eq_commodity: Arc, + /// Comments + pub comments: Option, +} diff --git a/tackler-core/src/parser.rs b/tackler-core/src/parser.rs index 753c32e..8a96cf2 100644 --- a/tackler-core/src/parser.rs +++ b/tackler-core/src/parser.rs @@ -21,6 +21,7 @@ pub use crate::parser::tackler_txns::GitInputSelector; use winnow::error::{ErrMode, FromExternalError}; mod error; +mod pricedb_parser; mod tackler_parser; mod tackler_txns; diff --git a/tackler-core/src/parser/parts.rs b/tackler-core/src/parser/parts.rs index 95c39b8..c95b0c8 100644 --- a/tackler-core/src/parser/parts.rs +++ b/tackler-core/src/parser/parts.rs @@ -19,6 +19,7 @@ mod comment; pub(crate) mod identifier; pub(crate) mod number; mod posting_value; +pub(super) mod pricedb; pub(crate) mod timestamp; mod txn_comment; mod txn_header; diff --git a/tackler-core/src/parser/parts/comment.rs b/tackler-core/src/parser/parts/comment.rs index f4720af..de3bcde 100644 --- a/tackler-core/src/parser/parts/comment.rs +++ b/tackler-core/src/parser/parts/comment.rs @@ -16,16 +16,21 @@ */ use crate::parser::Stream; -use winnow::ascii::till_line_ending; use winnow::stream::AsChar; use winnow::token::one_of; +use winnow::{ + ascii::till_line_ending, + error::{StrContext, StrContextValue}, +}; use winnow::{seq, PResult, Parser}; pub(crate) fn p_comment<'s>(is: &mut Stream<'s>) -> PResult<&'s str> { let m = seq!( - _: ';', - // this can not be space1 as we must preserve space for equity and identity reports - _: one_of(AsChar::is_space), + _: ( + ';', + // this can not be space1 as we must preserve space for equity and identity reports + one_of(AsChar::is_space) + ).context(StrContext::Expected(StrContextValue::Description("comment begins with a `;` and a space character"))), till_line_ending, ) .parse_next(is)?; diff --git a/tackler-core/src/parser/parts/pricedb.rs b/tackler-core/src/parser/parts/pricedb.rs new file mode 100644 index 0000000..ab07760 --- /dev/null +++ b/tackler-core/src/parser/parts/pricedb.rs @@ -0,0 +1,101 @@ +/* + * Copyright 2024-2025 E257.FI and Muhammad Ragib Hasin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +use winnow::{ + ascii::{line_ending, space0, space1}, + combinator::opt, + error::{StrContext, StrContextValue}, + seq, PResult, Parser, +}; + +use crate::model::price_entry::PriceEntry; +use crate::parser::{from_error, parts::timestamp::parse_timestamp, Stream}; + +use super::{comment::p_comment, identifier::p_identifier, number::p_number}; + +#[allow(clippy::type_complexity)] +pub(crate) fn parse_price_entry(is: &mut Stream<'_>) -> PResult { + let (timestamp, base_commodity, eq_amount, eq_commodity, comments) = seq!( + _: 'P'.context(StrContext::Expected(StrContextValue::Description("price entry starts with `P`"))), + _: space1, + parse_timestamp, + _: space1, + p_identifier + .context(StrContext::Expected(StrContextValue::Description("price entry must have base commodity"))), + _: space1, + p_number + .context(StrContext::Expected(StrContextValue::Description("price entry must have equivalent amount"))), + _: space1, + p_identifier + .context(StrContext::Expected(StrContextValue::Description("price entry must have equivalent commodity"))), + _: space0, + opt(p_comment), + _: line_ending, + ) + .parse_next(is)?; + + let base_commodity = is + .state + .get_or_create_commodity(Some(base_commodity)) + .map_err(|e| from_error(is, &*e))?; + + let eq_commodity = is + .state + .get_or_create_commodity(Some(eq_commodity)) + .map_err(|e| from_error(is, &*e))?; + + let comments = comments.map(String::from); + + Ok(PriceEntry { + timestamp, + base_commodity, + eq_amount, + eq_commodity, + comments, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kernel::Settings; + + #[test] + fn test_parse_price_entry() { + let tests = [ + "P 2024-12-30 XAU 2659.64 USD\n", + "P 2024-12-30T20:21:22 XAU 2659.64 USD ; space\n", + "P 2024-12-30 XAU 2659.64 USD; no space\n", + "P 2024-12-30T20:21:22Z XAU 2659.64 USD\n", + "P 2024-12-30T20:21:22+02:00 XAU 2659.64 USD\n", + "P 2024-12-30T20:21:22.12 XAU 2659.64 USD\n", + ]; + + for s in tests { + let mut settings = Settings::default(); + + let mut is = Stream { + input: s, + state: &mut settings, + }; + + let res = parse_price_entry(&mut is); + + assert!(res.is_ok()); + } + } +} diff --git a/tackler-core/src/parser/parts/txns.rs b/tackler-core/src/parser/parts/txns.rs index 457b04d..0a1c9d9 100644 --- a/tackler-core/src/parser/parts/txns.rs +++ b/tackler-core/src/parser/parts/txns.rs @@ -25,7 +25,7 @@ use winnow::ascii::{line_ending, multispace0, space0}; use winnow::combinator::{cut_err, eof, opt, preceded, repeat, repeat_till}; use winnow::error::StrContext; -fn multispace0_line_ending<'s>(is: &mut Stream<'s>) -> PResult<&'s str> { +pub(crate) fn multispace0_line_ending<'s>(is: &mut Stream<'s>) -> PResult<&'s str> { // space0 can't be multispace0 as it's greedy and eats away the last line ending repeat(1.., (space0, line_ending)) .map(|()| ()) diff --git a/tackler-core/src/parser/pricedb_parser.rs b/tackler-core/src/parser/pricedb_parser.rs new file mode 100644 index 0000000..f31ef99 --- /dev/null +++ b/tackler-core/src/parser/pricedb_parser.rs @@ -0,0 +1,108 @@ +/* + * Copyright 2023-2025 E257.FI and Muhammad Ragib Hasin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +use winnow::{ + combinator::{eof, opt, preceded, repeat_till}, + Parser, +}; + +use crate::kernel::Settings; +use crate::model::price_entry::PriceEntry; +use crate::parser::Stream; + +use super::parts::{pricedb::parse_price_entry, txns::multispace0_line_ending}; + +use std::error::Error; +use std::path::Path; + +pub(crate) fn pricedb_from_str( + input: &mut &str, + settings: &mut Settings, +) -> Result, Box> { + let is = Stream { + input, + state: settings, + }; + + preceded( + opt(multispace0_line_ending), + repeat_till(1.., parse_price_entry, eof), + ) + .parse(is) + .map(|(price_entries, _)| price_entries) + .map_err(|err| err.to_string().into()) + // .map_err(|err| { + // let mut msg = "Failed to process txn input\n".to_string(); + // //let _ = writeln!(msg, "Error: {}", err); + // match err.into_inner() { + // Some(ce) => { + // if let Some(cause) = ce.cause() { + // let _ = writeln!(msg, "Cause:\n{}\n", cause); + // } + // let _ = writeln!(msg, "Error backtrace:"); + // for c in ce.context() { + // let _ = writeln!(msg, " {}", c); + // } + // } + // None => { + // let _ = write!(msg, "No detailed error information available"); + // } + // } + // let i = is.input.lines().next().unwrap_or(is.input); + // let i_err = if i.chars().count() < 1024 { + // i.to_string() + // } else { + // i.chars().take(1024).collect::() + // }; + + // let _ = write!(msg, "Failed input:\n{}\n\n", i_err); + + // msg.into() + // }) +} + +pub(crate) fn pricedb_from_file( + path: &Path, + settings: &mut Settings, +) -> Result, Box> { + let pricedb_str = std::fs::read_to_string(path) + .map_err(|err| format!("Can't open file: '{}' - {}", path.display(), err))?; + + // todo: error log + pricedb_from_str(&mut &*pricedb_str, settings) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kernel::Settings; + + #[test] + fn test_parse_pricedb() { + let test = r#" +P 2024-01-09 XAU 2659.645203 USD +P 2024-01-09 USD 121.306155 BDT +P 2024-01-09 XAG 3652.77663 BDT +"#; + + let mut settings = Settings::default(); + + let res = pricedb_from_str(&mut &*test, &mut settings); + + assert!(res.is_ok()); + } +}